From 6cbefd23e8f760c934f1a6bcbaf88b107d05cc65 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 3 Sep 2026 09:46:57 +0200 Subject: [PATCH] feat(studio): filter photos by entry, upload from Studio, and switch stops off The content browser gets a filter over the journey's pictures: all of them, the ones no entry holds, or one entry at a time, with the day and the place under each name. An entry card in the Entries tab reaches its own pictures the same way. Pictures can now come in without leaving Studio, into the entry the filter names or into the gallery, from the panel or dropped straight onto the page, and a photo element can fill its page or the whole spread in one press. Stops can be taken out of the arithmetic. A journal entry carries a stats_excluded flag; an entry that has it is off the route, out of the distance, out of the countries and not a step, while staying in the journal. Studio lists every stop in the travel panel with a switch, the entry editor carries the same switch on desktop and phone, and the entry card says when a day is off the route. --- .../JourneyDetailPageEntryCard.test.tsx | 17 + .../Journey/JourneyDetailPageEntryCard.tsx | 15 +- .../JourneyDetailPageEntryEditor.test.tsx | 63 +++ .../Journey/JourneyDetailPageEntryEditor.tsx | 21 + client/src/components/Studio/StudioCanvas.tsx | 43 +- .../src/components/Studio/StudioInspector.tsx | 50 +++ client/src/components/Studio/StudioShell.tsx | 16 + .../src/components/Studio/StudioSidebar.tsx | 400 ++++++++++++++++-- .../components/Studio/StudioTravelPanel.tsx | 117 ++++- client/src/components/Studio/studioUpload.ts | 54 +++ .../screens/journey/MJourneyEntryCard.tsx | 9 +- .../screens/journey/MJourneyEntrySheet.tsx | 21 + .../pages/journeyStudio/useJourneyStudio.ts | 198 +++++++-- client/src/store/journeyStore.ts | 21 +- client/src/styles/studio.css | 233 ++++++++++ .../mobile/journey/MJourneyEntryCard.test.tsx | 11 + .../journey/MJourneyEntrySheet.test.tsx | 46 ++ .../unit/studio/autoLayout.contract.test.ts | 10 +- .../unit/studio/autoLayout.travel.test.ts | 5 +- .../tests/unit/studio/contentPhotos.test.tsx | 263 ++++++++++++ client/tests/unit/studio/entryChips.test.tsx | 8 + client/tests/unit/studio/inspector.test.tsx | 77 +++- client/tests/unit/studio/mapRoads.test.tsx | 5 +- client/tests/unit/studio/mapScope.test.ts | 9 +- .../tests/unit/studio/travelRefresh.test.ts | 5 +- client/tests/unit/studio/travelStops.test.tsx | 167 ++++++++ server/src/db/migrations.ts | 21 + .../nest/journey/journey-domain.service.ts | 72 +++- server/src/nest/journey/journey-entry-row.ts | 13 +- server/src/nest/journey/journey-stats.ts | 17 +- server/src/nest/journey/journey.mcp.ts | 7 +- server/src/types.ts | 6 + server/tests/e2e/journey.e2e.test.ts | 26 +- server/tests/helpers/factories.ts | 9 +- server/tests/unit/mcp/tools-journey.test.ts | 53 +++ .../unit/nest/journey-domain.service.test.ts | 59 +++ .../unit/nest/journey-stats.service.test.ts | 158 ++++++- server/tests/unit/nest/journey-stats.test.ts | 28 +- shared/src/book/journey-stats.schema.ts | 26 ++ shared/src/i18n/ar/journey.ts | 28 ++ shared/src/i18n/br/journey.ts | 30 ++ shared/src/i18n/ca/journey.ts | 29 ++ shared/src/i18n/cs/journey.ts | 29 ++ shared/src/i18n/de/journey.ts | 30 ++ shared/src/i18n/en/journey.ts | 30 ++ shared/src/i18n/es/journey.ts | 30 ++ shared/src/i18n/fr/journey.ts | 29 ++ shared/src/i18n/gr/journey.ts | 30 ++ shared/src/i18n/hu/journey.ts | 29 ++ shared/src/i18n/id/journey.ts | 30 ++ shared/src/i18n/it/journey.ts | 30 ++ shared/src/i18n/ja/journey.ts | 28 ++ shared/src/i18n/ko/journey.ts | 28 ++ shared/src/i18n/nl/journey.ts | 30 ++ shared/src/i18n/pl/journey.ts | 29 ++ shared/src/i18n/ru/journey.ts | 30 ++ shared/src/i18n/sv/journey.ts | 29 ++ shared/src/i18n/tr/journey.ts | 29 ++ shared/src/i18n/uk/journey.ts | 30 ++ shared/src/i18n/vi/journey.ts | 29 ++ shared/src/i18n/zh-TW/journey.ts | 27 ++ shared/src/i18n/zh/journey.ts | 27 ++ 62 files changed, 2933 insertions(+), 116 deletions(-) create mode 100644 client/src/components/Studio/studioUpload.ts create mode 100644 client/tests/unit/studio/contentPhotos.test.tsx create mode 100644 client/tests/unit/studio/travelStops.test.tsx diff --git a/client/src/components/Journey/JourneyDetailPageEntryCard.test.tsx b/client/src/components/Journey/JourneyDetailPageEntryCard.test.tsx index fad61473d..3d4c1383e 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryCard.test.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryCard.test.tsx @@ -189,6 +189,23 @@ describe('EntryCard', () => { expect(await screen.findByRole('link', { name: 'https://book.example' })).toBeInTheDocument() }) + + // #2064: a stop switched off the route says so on its card, so a journal + // that reads fine still shows which of its days the printed map skips. + it('FE-JRN-CARD-018: marks an entry left out of the route, on the header and on the hero', () => { + const { unmount } = mountCard(buildEntry({ stats_excluded: true })) + expect(screen.getByText('Off route')).toBeInTheDocument() + unmount() + + mountCard(buildEntry({ stats_excluded: true, photos: [buildPhoto(100)] })) + expect(screen.getByText('Off route')).toBeInTheDocument() + }) + + it('FE-JRN-CARD-019: carries no such mark on an ordinary entry', () => { + mountCard(buildEntry({ photos: [buildPhoto(100)] })) + + expect(screen.queryByText('Off route')).not.toBeInTheDocument() + }) }) describe('SkeletonCard', () => { diff --git a/client/src/components/Journey/JourneyDetailPageEntryCard.tsx b/client/src/components/Journey/JourneyDetailPageEntryCard.tsx index 0bad18349..4bdc1a323 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryCard.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryCard.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect } from 'react' import { createPortal } from 'react-dom' -import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus } from 'lucide-react' +import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus, RouteOff } from 'lucide-react' import { formatLocationName } from '../../utils/formatters' import { useTranslation } from '../../i18n' import { pluginsApi } from '../../api/client' @@ -68,6 +68,14 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: { {entry.entry_time} )} + {/* Switched off the route (#2064): the day is still in the journal, + the printed map and the distance skip it, and the card says so. */} + {entry.stats_excluded && ( + + + {t('journey.entry.offRoute')} + + )} {/* Menu top-right */} @@ -110,6 +118,11 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: { {entry.entry_time} )} + {entry.stats_excluded && ( + + {t('journey.entry.offRoute')} + + )} {!readOnly && (
diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx index b560134e1..141e966df 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx @@ -965,4 +965,67 @@ describe('EntryEditor', () => { expect(library[0]).toHaveAttribute('multiple') expect(camera[0]).not.toHaveAttribute('multiple') }) + + // #2064: a home airport written up as an entry is a stop on the printed + // route. The editor offers the same switch the Studio panel has, but only + // to an entry that is (or was) a stop: one without a point never counted, + // and a new entry is not on the route yet. + it('FE-JRN-EDITOR-051: offers to leave a located entry out of the route and saves it', async () => { + const user = userEvent.setup() + const { onSave } = mountEditor(buildEntry({ + id: 10, location_name: 'Keflavík', location_lat: 63.98, location_lng: -22.6, + })) + + const toggle = screen.getByRole('button', { name: 'Leave out of the route' }) + expect(toggle).toHaveAttribute('aria-pressed', 'false') + await user.click(toggle) + expect(toggle).toHaveAttribute('aria-pressed', 'true') + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(onSave).toHaveBeenCalled()) + expect(onSave.mock.calls[0][0]).toMatchObject({ stats_excluded: true }) + }) + + it('FE-JRN-EDITOR-052: keeps the switch away from a new entry, point or no point', () => { + mountEditor(buildEntry({ location_lat: 63.98, location_lng: -22.6 })) + + expect(screen.queryByRole('button', { name: 'Leave out of the route' })).not.toBeInTheDocument() + }) + + it('FE-JRN-EDITOR-053: keeps the switch away from an entry without a point, and sends nothing for it', async () => { + const user = userEvent.setup() + const { onSave } = mountEditor(buildEntry({ id: 10 })) + + expect(screen.queryByRole('button', { name: 'Leave out of the route' })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(onSave).toHaveBeenCalled()) + expect(onSave.mock.calls[0][0].stats_excluded).toBeUndefined() + }) + + it('FE-JRN-EDITOR-054: still offers the switch to a left-out entry, so it can be put back', async () => { + const user = userEvent.setup() + const { onSave } = mountEditor(buildEntry({ id: 10, stats_excluded: true })) + + const toggle = screen.getByRole('button', { name: 'Leave out of the route' }) + expect(toggle).toHaveAttribute('aria-pressed', 'true') + await user.click(toggle) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(onSave).toHaveBeenCalled()) + expect(onSave.mock.calls[0][0]).toMatchObject({ stats_excluded: false }) + }) + + it('FE-JRN-EDITOR-055: flipping the switch is a change worth warning about', async () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) + const user = userEvent.setup() + const { onClose } = mountEditor(buildEntry({ id: 10, location_lat: 63.98, location_lng: -22.6 })) + + await user.click(screen.getByRole('button', { name: 'Leave out of the route' })) + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(confirmSpy).toHaveBeenCalledWith('You have unsaved changes. Discard them?') + expect(onClose).not.toHaveBeenCalled() + confirmSpy.mockRestore() + }) }) diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx index d504a30bc..a7d3e4668 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx @@ -14,6 +14,7 @@ import { photoUrl, isValidGeoPoint, geoOnceErrorKey } from '../../pages/journeyD import MarkdownToolbar from './MarkdownToolbar' import { DatePicker } from './JourneyDetailPageDatePicker' import CustomTimePicker from '../shared/CustomTimePicker' +import ToggleSwitch from '../Settings/ToggleSwitch' import { ProviderPicker, type ProviderPhotoGroup } from './JourneyDetailPageProviderPicker' type PendingProviderGroup = ProviderPhotoGroup & { provider: string } @@ -48,6 +49,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, const locationTimerRef = useRef | null>(null) const [mood, setMood] = useState(entry.mood || '') const [weather, setWeather] = useState(entry.weather || '') + const [statsExcluded, setStatsExcluded] = useState(entry.stats_excluded ?? false) const [pros, setPros] = useState(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : ['']) const [cons, setCons] = useState(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : ['']) const [saving, setSaving] = useState(false) @@ -87,6 +89,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, (locationLng ?? null) !== (entry.location_lng ?? null) || mood !== (entry.mood || '') || weather !== (entry.weather || '') || + statsExcluded !== (entry.stats_excluded ?? false) || pros.filter(p => p.trim()).join('\n') !== originalPros || cons.filter(c => c.trim()).join('\n') !== originalCons || pendingFiles.length > 0 || @@ -146,6 +149,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, location_name: locationName || null, location_lat: locationLat, location_lng: locationLng, + stats_excluded: offersStatsToggle ? statsExcluded : undefined, mood: mood || null, weather: weather || null, pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) }, @@ -214,6 +218,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, ? { lat: locationLat!, lng: locationLng!, name: locationName || undefined } : null + // The route switch belongs to an entry that is a stop, or was one: an entry + // without a point was never on the route, and a new one is not on it yet. + const offersStatsToggle = entry.id > 0 && (contextLocation != null || !!entry.stats_excluded) + const handleUseCurrentLocation = async () => { if (locating) return setLocating(true) @@ -695,6 +703,19 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
+ {/* Every located entry is a stop on the route Studio prints, the home + airport included. This is the entry's own way off it, the same + switch the Studio travel panel offers (#2064). */} + {offersStatsToggle && ( +
+
+
{t('journey.editor.statsExcluded')}
+
{t('journey.editor.statsExcludedHint')}
+
+ setStatsExcluded(v => !v)} label={t('journey.editor.statsExcluded')} /> +
+ )} +
diff --git a/client/src/components/Studio/StudioCanvas.tsx b/client/src/components/Studio/StudioCanvas.tsx index 5c0007d3e..11363b63d 100644 --- a/client/src/components/Studio/StudioCanvas.tsx +++ b/client/src/components/Studio/StudioCanvas.tsx @@ -65,6 +65,8 @@ export function StudioCanvas({ dropLabel, cursors, onCursor, + onDropFiles, + fileDropLabel, }: { spread: BookSpread | null spreadIndex: number @@ -73,6 +75,14 @@ export function StudioCanvas({ pxPerMm: number bookView: boolean dropLabel: string + /** + * Files from outside the browser, let go over the sheet. Absent for a + * viewer, and then a file drag is left to the browser, which opens it. + * `targetFrameId` is the photo frame under the pointer, when there is one. + */ + onDropFiles?: (files: File[], at: { x: number; y: number }, targetFrameId: string | null) => void + /** What the veil says while a file drag is over the sheet. */ + fileDropLabel?: string /** The other editors' pointers, on this spread and elsewhere. */ cursors?: PeerCursor[] /** Where this one is, in the spread's millimetres. Null once it leaves. */ @@ -87,6 +97,8 @@ export function StudioCanvas({ const commit = useStudioStore(s => s.commit) const addElement = useStudioStore(s => s.addElement) const [dropTarget, setDropTarget] = useState(null) + /** A file drag from outside the browser is over the sheet. */ + const [fileOver, setFileOver] = useState(false) const [editing, setEditing] = useState(null) /** Where on the spread, in millimetres, a drop landed. */ @@ -166,22 +178,34 @@ export function StudioCanvas({ onPointerUp={finish} onPointerCancel={finish} onDragOver={e => { - if (!e.dataTransfer.types.includes('application/x-trek-photo')) return + const fromPanel = e.dataTransfer.types.includes('application/x-trek-photo') + // A file drag is only claimed when somebody is there to take it; for a + // viewer the browser keeps its default and opens the picture itself. + const fromDisk = !!onDropFiles && e.dataTransfer.types.includes('Files') + if (!fromPanel && !fromDisk) return e.preventDefault() e.dataTransfer.dropEffect = 'copy' const p = pointInMm(e) setDropTarget(frameUnder(p.x, p.y)?.id ?? null) + setFileOver(fromDisk && !fromPanel) }} - onDragLeave={() => setDropTarget(null)} + onDragLeave={() => { setDropTarget(null); setFileOver(false) }} onDrop={e => { const raw = e.dataTransfer.getData('application/x-trek-photo') - if (!raw) return + const files = Array.from(e.dataTransfer.files ?? []) + if (!raw && !(files.length && onDropFiles)) return e.preventDefault() setDropTarget(null) + setFileOver(false) + + const p = pointInMm(e) + if (!raw) { + onDropFiles!(files, p, frameUnder(p.x, p.y)?.id ?? null) + return + } const photoId = Number(raw) if (!Number.isFinite(photoId)) return - const p = pointInMm(e) const target = frameUnder(p.x, p.y) if (target) { // Dropped onto a frame: fill it. That is the whole point of an empty @@ -211,6 +235,17 @@ export function StudioCanvas({ select([id]) }} > + {/* + Over the stage rather than inside the sheet, which is scaled: at 40% + a label drawn on the page would be unreadable. It says what letting go + will do, because a page is not an obvious drop target for a file the + way an upload box is. + */} + {fileOver && fileDropLabel && ( +
+ {fileDropLabel} +
+ )}
) => update(spreadIndex, el.id, patch) + /** + * Stretch the picture over the page it is on, or over both pages, bleed + * included. + * + * One commit rather than `set` followed by `raise`: the frame, the rotation + * put back to zero (a turned picture leaves bare corners), the cover crop and + * the move to the back are one press, and one press comes back with one + * undo. Which page is the one under the frame's centre; a cover has only + * the one. + */ + const fill = (scope: 'page' | 'spread') => commit(d => ({ + ...d, + spreads: d.spreads.map((sp, i) => { + if (i !== spreadIndex) return sp + const bleed = page.bleed + const onRight = sp.role === 'inner' && scope === 'page' + && el.frame.x + el.frame.w / 2 >= page.pageWidth + const frame = { + x: onRight ? page.pageWidth - bleed : -bleed, + y: -bleed, + w: (scope === 'spread' ? 2 : 1) * page.pageWidth + 2 * bleed, + h: page.pageHeight + 2 * bleed, + } + const filled = { ...el, frame, rotation: 0, fit: 'cover' } as BookElement + return { ...sp, elements: [filled, ...sp.elements.filter(e => e.id !== el.id)] } + }), + })) + return (
+ {/* + The picture as the page. + + A full-bleed photograph is the commonest layout in any photo book + and the hardest to make by hand: the frame has to reach exactly to + the bleed on every side, and a millimetre short leaves a white + hairline on the trimmed page. So it is one press. It goes to the + back as well, since a page-sized picture in front of everything is + a page with nothing else on it. + */} + {one && el.kind === 'photo' && ( +
+ + {spread.role === 'inner' && ( + + )} +
+ )}
diff --git a/client/src/components/Studio/StudioShell.tsx b/client/src/components/Studio/StudioShell.tsx index 20dd4ce41..dd25d9f65 100644 --- a/client/src/components/Studio/StudioShell.tsx +++ b/client/src/components/Studio/StudioShell.tsx @@ -89,6 +89,9 @@ export default function StudioShell() { path={s.path} t={s.t} locale={s.locale} + canEdit={s.canEdit} + onUpload={s.uploadPhotos} + onToggleStop={s.setStopExcluded} /> s.moveCursor(s.activeSpread, x, y)} + onDropFiles={s.canEdit ? s.dropFiles : undefined} + fileDropLabel={s.t('journey.studio.dropFilesHere')} /> + {/* + Pictures dropped on the sheet are on their way. The page keeps + rendering underneath; the veil only says that something is happening + where the pointer let go, so the second or two before the frame + appears does not read as the drop having been ignored. + */} + {s.canvasUpload && ( +
+ {s.t('journey.studio.uploading', { done: s.canvasUpload.done, total: s.canvasUpload.total })} +
+ )}
diff --git a/client/src/components/Studio/StudioSidebar.tsx b/client/src/components/Studio/StudioSidebar.tsx index f852046b1..44ab7e916 100644 --- a/client/src/components/Studio/StudioSidebar.tsx +++ b/client/src/components/Studio/StudioSidebar.tsx @@ -1,9 +1,13 @@ -import { useMemo, useRef, useState } from 'react' -import { ChevronDown, ChevronUp, Compass, Copy, FileDown, Files, ImageIcon, LayoutTemplate, Plus, Search, Shapes, Trash2, X } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Camera, Check, ChevronDown, ChevronUp, Compass, Copy, FileDown, Files, ImageIcon, LayoutTemplate, + Plus, Search, Shapes, Trash2, Upload, X, +} from 'lucide-react' import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared' import { useElementSize } from '../../hooks/useElementSize' import { useStudioStore } from '../../store/studioStore' import { formatDate } from '../../utils/formatters' +import { useToast } from '../shared/Toast' import { SpreadFold, SpreadView } from './SpreadView' import { photoSrc } from './bookRender' import { formatBookCoords, formatBookDate, type CoordFormat } from './entryText' @@ -14,6 +18,7 @@ import { PanelHead as Head } from './StudioPanelHead' import { StudioElementsPanel } from './StudioElementsPanel' import { StudioTravelPanel } from './StudioTravelPanel' import { MAX_SPREAD_FILE_BYTES, importSpread } from './spreadFile' +import type { StudioUploader } from './studioUpload' /** * The left side of Studio: a narrow rail of sections and one wide panel showing @@ -42,12 +47,18 @@ export interface JourneySource { weather: string | null pros: string[] cons: string[] + /** The pictures attached to this entry, as trek_photos ids. */ + photoIds: number[] }[] - photos: { photoId: number; caption?: string | null }[] + /** `entryIds` is empty for a picture that sits only in the gallery. */ + photos: { photoId: number; caption?: string | null; entryIds: number[] }[] /** photoId to the words of the entry it belongs to, lower-cased. */ photoEntries: Record } +/** The translator, with the parameters some of the strings here take. */ +export type StudioT = (k: string, params?: Record) => string + const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}` /** @@ -72,6 +83,7 @@ const THUMB_CHROME = (2 + 3) * 2 export function StudioSidebar({ page, pxPerMm, bookView, source, stats, path, t, locale, + canEdit, onUpload, onToggleStop, }: { page: BookPageSetup pxPerMm: number @@ -81,8 +93,14 @@ export function StudioSidebar({ stats: JourneyStats | null /** The roads the trip took, for a map that draws them. Empty when it has none. */ path: [number, number][][] - t: (k: string) => string + t: StudioT locale: string + /** False for a viewer: no upload, no switching stops, nothing that writes. */ + canEdit: boolean + /** Pictures into the gallery, or into one entry. See studioUpload.ts. */ + onUpload: StudioUploader + /** Switch a stop on or off in the figures. Resolves false when it did not land. */ + onToggleStop: (entryId: number, excluded: boolean) => Promise }) { const [section, setSection] = useState
('pages') @@ -113,9 +131,21 @@ export function StudioSidebar({ @@ -290,18 +320,46 @@ function PagesPanel({ ) } +/** + * Which of the journey's pictures the browser shows. + * + * `recent` holds ids rather than a flag on the photo: what just arrived is a + * fact about this sitting, not about the picture, and the journey store has no + * reason to remember it. + */ +type PhotoFilter = + | { kind: 'all' } + | { kind: 'entry'; id: number } + | { kind: 'loose' } + | { kind: 'recent'; photoIds: number[] } + /** * The journey's own material. * * This is what separates a book maker from a drawing program: the pictures and * the words are already written, and the job is putting them on pages. Clicking * an item drops it on the current spread, centred, at a sensible size. + * + * Pictures can also come in here. A book is where you notice the one that + * never made it into the journal, and going back to the journey to add it, then + * finding the page again, was the one round trip Studio still asked for. What + * comes in goes through the journey store like any other upload, so the journal + * sees it the moment the panel does. */ function ContentPanel({ - source, page, t, locale, -}: { source: JourneySource; page: BookPageSetup; t: (k: string) => string; locale: string }) { + source, page, t, locale, canEdit, onUpload, +}: { + source: JourneySource + page: BookPageSetup + t: StudioT + locale: string + canEdit: boolean + onUpload: StudioUploader +}) { const [tab, setTab] = useState<'photos' | 'text'>('photos') const [query, setQuery] = useState('') + const [filter, setFilter] = useState({ kind: 'all' }) + const toast = useToast() /* * Filtering, not a search index: a journey holds tens or hundreds of items, and @@ -316,16 +374,144 @@ function ContentPanel({ [e.title, e.story, e.location, ...e.pros, ...e.cons] .some(v => v && v.toLowerCase().includes(q))) : source.entries + + /* + * The filter narrows first and the words second, so an entry filter with a + * search typed into it means "these pictures, with these words". Which entry + * a picture belongs to is read off the journal's own junction rather than + * off its words, which is what lets "not in an entry" mean exactly that. + */ + const byFilter = (() => { + switch (filter.kind) { + case 'entry': return source.photos.filter(p => p.entryIds.includes(filter.id)) + case 'loose': return source.photos.filter(p => !p.entryIds.length) + case 'recent': { + // In the order they were sent, which is the order they were chosen in, + // not wherever the gallery sorted them. + const byId = new Map(source.photos.map(p => [p.photoId, p] as const)) + return filter.photoIds.flatMap(id => { const p = byId.get(id); return p ? [p] : [] }) + } + default: return source.photos + } + })() const photos = q - ? source.photos.filter(p => + ? byFilter.filter(p => (p.caption && p.caption.toLowerCase().includes(q)) || (source.photoEntries[p.photoId] || '').includes(q)) - : source.photos + : byFilter + + const looseCount = source.photos.filter(p => !p.entryIds.length).length + const withPhotos = source.entries.filter(e => e.photoIds.length > 0) + const nameOf = (e: { title: string | null; location: string | null } | undefined) => + e?.title || e?.location || t('journey.studio.untitled') + const filterLabel = filter.kind === 'loose' ? t('journey.studio.filterLoose') + : filter.kind === 'recent' ? t('journey.studio.filterRecent') + : filter.kind === 'entry' ? nameOf(source.entries.find(e => e.id === filter.id)) + : t('journey.studio.filterAll') + + /* + * Why the grid is empty, which is four different answers. "No photos" on a + * journey with three hundred of them, because the filter is on a day with + * none, reads as the pictures having gone. + */ + const emptyKey = !source.photos.length ? 'journey.studio.noPhotos' + : byFilter.length ? 'journey.studio.noMatches' + : filter.kind === 'entry' ? 'journey.studio.noEntryPhotos' + : filter.kind === 'loose' ? 'journey.studio.noLoosePhotos' + : 'journey.studio.noMatches' + + const [menuOpen, setMenuOpen] = useState(false) + const menuBox = useRef(null) + useEffect(() => { + if (!menuOpen) return + const onDown = (e: PointerEvent) => { + if (!menuBox.current?.contains(e.target as Node)) setMenuOpen(false) + } + const onKey = (e: KeyboardEvent) => { + // Swallowed, as the format picker does it: the shell's Escape closes Studio. + if (e.key === 'Escape') { e.stopPropagation(); setMenuOpen(false) } + } + document.addEventListener('pointerdown', onDown) + document.addEventListener('keydown', onKey, true) + return () => { + document.removeEventListener('pointerdown', onDown) + document.removeEventListener('keydown', onKey, true) + } + }, [menuOpen]) + const pick = (f: PhotoFilter) => { setFilter(f); setMenuOpen(false) } + const addElement = useStudioStore(s => s.addElement) const active = useStudioStore(s => s.activeSpread) const doc = useStudioStore(s => s.doc) const spread = doc?.spreads[active] + /* + * The page on the sheet belongs to a day, so its pictures are one press + * away. Offered only while the filter is not already there: a chip for the + * state you are in is a chip that does nothing. + */ + const pageEntry = spread?.entryId != null ? withPhotos.find(e => e.id === spread.entryId) : undefined + const offerThisPage = pageEntry && !(filter.kind === 'entry' && filter.id === pageEntry.id) + + const fileInput = useRef(null) + const [upload, setUpload] = useState<{ done: number; total: number } | null>(null) + /* A ref as well as the state: two drops in one second would both read the + state before either had set it. */ + const sending = useRef(false) + const [dropOver, setDropOver] = useState(false) + + /** + * Into the entry the browser is filtered to, otherwise into the gallery. The + * filter is the one thing on screen that names an entry, so it is also the + * one thing that can say where a picture belongs; the line under it says so + * before the press. Afterwards the new pictures get a filter of their own, + * because the gallery sorts them to its end, and twelve pictures that arrive + * out of sight read as twelve that did not arrive. + */ + const send = async (files: File[]) => { + if (sending.current || !files.length) return + sending.current = true + setUpload({ done: 0, total: files.length }) + try { + const entryId = filter.kind === 'entry' ? filter.id : null + const sent = await onUpload(files, entryId, p => setUpload({ done: p.done, total: p.total })) + if (sent.photoIds.length) { + setFilter({ kind: 'recent', photoIds: sent.photoIds }) + setQuery('') + toast.success(t('journey.photosUploaded', { count: sent.photoIds.length })) + } + } finally { + sending.current = false + setUpload(null) + } + } + + /* + * The whole scrolling area takes a drop, not just the dashed cell: a drag + * from the desktop arrives with the pointer wherever it arrives, and a target + * seventy pixels square is one you miss. Only a file drag is claimed; the + * panel's own photo drags carry another type and go to the sheet. + */ + const dropZone = canEdit && tab === 'photos' ? { + onDragOver: (e: React.DragEvent) => { + if (!e.dataTransfer.types.includes('Files')) return + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + setDropOver(true) + }, + onDragLeave: (e: React.DragEvent) => { + // Moving onto a child of the box is not leaving the box. + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropOver(false) + }, + onDrop: (e: React.DragEvent) => { + const files = Array.from(e.dataTransfer.files ?? []) + if (!files.length) return + e.preventDefault() + setDropOver(false) + void send(files) + }, + } : {} + const centre = (w: number, h: number) => { const W = spread && spread.role !== 'inner' ? page.pageWidth : page.pageWidth * 2 return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h } @@ -444,33 +630,142 @@ function ContentPanel({
-
- {tab === 'photos' ? ( -
- {photos.map(p => ( + {/* + Which entry, as a row of its own under the tabs rather than a control + inside the search box: the search answers "which words" and this + answers "which day", and the two combine. The chip opens the same + popover the bar uses for formats, hung from its left edge because the + panel has no room to the right of it. + */} + {tab === 'photos' && ( +
+
+ - ))} - {!photos.length && ( -

{t(q ? 'journey.studio.noMatches' : 'journey.studio.noPhotos')}

+ {filter.kind !== 'all' && ( + + )} +
+ + {menuOpen && ( +
+ pick({ kind: 'all' })} + /> + pick({ kind: 'loose' })} + /> + {withPhotos.length > 0 &&
} + {withPhotos.map(e => ( + pick({ kind: 'entry', id: e.id })} + /> + ))} +
)}
+ + {offerThisPage && ( + + )} +
+ )} + +
+ {tab === 'photos' ? ( + <> + {canEdit && ( +

+ {t(filter.kind === 'entry' ? 'journey.studio.uploadToEntry' : 'journey.studio.uploadToGallery')} +

+ )} +
+ {/* + The first cell rather than a button above the grid: it is the + size and shape of what it produces, and it stays where the eye + already is. Disabled, not hidden, while a batch is on its way, + so the count has somewhere to be read. + */} + {canEdit && ( + + )} + {photos.map(p => ( + + ))} + {!photos.length &&

{t(emptyKey)}

} +
+ {canEdit && ( + { + const files = Array.from(e.target.files ?? []) + // Cleared straight away, as the spread import does, so the + // same pictures can be chosen twice in a row. + e.target.value = '' + void send(files) + }} + /> + )} + ) : (
{entries.map(e => ( @@ -556,6 +851,22 @@ function ContentPanel({ {e.pros.length + e.cons.length} )} + {/* + Not a drop like the others: this one takes you to the + entry's pictures. The words of a day and its pictures were + two tabs apart with nothing joining them, and the day is + how anyone thinks of the pictures. + */} + {e.photoIds.length > 0 && ( + + )}
))} @@ -567,6 +878,31 @@ function ContentPanel({ ) } +/** One line of the photo filter: a name, a count, and a tick on the one in force. */ +function FilterItem({ on, label, dim, count, onPick }: { + on: boolean + label: string + dim?: string + count: number + onPick: () => void +}) { + return ( + + ) +} + /** * Layouts for the current spread. * diff --git a/client/src/components/Studio/StudioTravelPanel.tsx b/client/src/components/Studio/StudioTravelPanel.tsx index d0cbf9a64..a77ec7e0e 100644 --- a/client/src/components/Studio/StudioTravelPanel.tsx +++ b/client/src/components/Studio/StudioTravelPanel.tsx @@ -1,7 +1,8 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import type { BookElement, BookMetric, BookPageSetup, JourneyStats } from '@trek/shared' import { BOOK_METRICS } from '@trek/shared' import { useStudioStore } from '../../store/studioStore' +import { formatDate } from '../../utils/formatters' import { PanelHead } from './StudioPanelHead' import { TravelPreview } from './TravelPreview' import { formatBookCoords } from './entryText' @@ -38,6 +39,21 @@ import { fetchRoads } from './roadRoute' const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}` +/** + * One row of the stops list: a point of the route or a stop switched off. + * + * `entryId` is null for a stop the server took straight from a trip's places, + * which is what a journey has before anyone wrote an entry. Such a stop has no + * switch, because there is no entry to put it on. + */ +type StopRow = { + entryId: number | null + label: string + date: string | null + country: string | null + excluded: boolean +} + /* * How many cards share a row. * @@ -80,7 +96,7 @@ function Card({ label, onClick, children, recommended = false, title }: { } export function StudioTravelPanel({ - page, stats, path, t, locale, + page, stats, path, t, locale, canEdit, onToggleStop, }: { page: BookPageSetup stats: JourneyStats | null @@ -88,6 +104,10 @@ export function StudioTravelPanel({ path: [number, number][][] t: (k: string) => string locale: string + /** False for a viewer: the stops are still listed, none of them can be switched. */ + canEdit: boolean + /** Switch one stop on or off in the figures. Resolves false when it did not land. */ + onToggleStop: (entryId: number, excluded: boolean) => Promise }) { const addElement = useStudioStore(s => s.addElement) const updateElement = useStudioStore(s => s.updateElement) @@ -109,6 +129,17 @@ export function StudioTravelPanel({ roadJobs.current.clear() }, []) + /* + * The stops whose switch is on its way to the server. + * + * No optimistic flip: which stop is furthest and which countries remain is + * the server's sum, so the row reads its state from the stats the hook + * fetches once the change has landed. Until then the chip is only held, + * so a second click cannot send the opposite of what the first one is + * still delivering. + */ + const [pending, setPending] = useState>(() => new Set()) + const centre = (w: number, h: number) => { const W = single ? page.pageWidth : page.pageWidth * 2 return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h } @@ -183,6 +214,42 @@ export function StudioTravelPanel({ /** The linked trips that actually put stops on the route. */ const tripsWithRoute = (stats.trips ?? []).filter(t => t.points > 0) + /* + * The route as rows, counting and left-out stops together. + * + * Every point is listed, so the list reads as the route. The left-out + * stops sit among the counting ones in date order rather than in a list of + * their own, because the question the section answers is "which of these + * count?", and that is only answerable with all of them in one sequence. + * Undated stops go last, then by name. + */ + const stops: StopRow[] = [ + ...stats.points.map(p => ({ + entryId: p.entryId, label: p.label, date: p.date, country: p.country, excluded: false, + })), + ...(stats.excluded ?? []).map(x => ({ + entryId: x.entryId, label: x.label, date: x.date, country: null, excluded: true, + })), + ].sort((a, b) => { + if (a.date !== b.date) { + if (a.date == null) return 1 + if (b.date == null) return -1 + return a.date < b.date ? -1 : 1 + } + return a.label.localeCompare(b.label) + }) + + const toggleStop = async (entryId: number, excluded: boolean) => { + setPending(prev => new Set(prev).add(entryId)) + // A refusal is the hook's to report; here it only frees the chip again. + await onToggleStop(entryId, excluded) + setPending(prev => { + const next = new Set(prev) + next.delete(entryId) + return next + }) + } + /* Satellite first: the recommendation should not be the second thing read. */ const imagery = sources .filter(s => s.id === 'relief' || s.id === 'satellite') @@ -383,6 +450,52 @@ export function StudioTravelPanel({
+ {/* + The switch is here, beside the maps and the figures it changes, and + not only in the entry editor: the moment somebody notices the home + airport on the printed route is the moment they are looking at this + panel. The rows carry no preview because they are not things to + place; they are the input every tile below reads. + */} + {stops.length > 0 && ( +
+
{t('journey.studio.stops')}
+

{t('journey.studio.stopsHint')}

+
    + {stops.map((stop, i) => { + const entryId = stop.entryId + const on = !stop.excluded + const state = t(on ? 'journey.studio.stopOn' : 'journey.studio.stopOff') + const chip = `st-chip is-pick ${on ? 'is-on' : ''}` + return ( +
  • +
    + {stop.label || t('journey.studio.untitled')} + + {stop.date && {formatDate(stop.date, locale)}} + {stop.country && {countryName(stop.country)}} + +
    + {canEdit && entryId != null ? ( + + ) : ( + {state} + )} +
  • + ) + })} +
+
+ )} +
{t('journey.studio.routeMap')}
{stats.points.length ? ( diff --git a/client/src/components/Studio/studioUpload.ts b/client/src/components/Studio/studioUpload.ts new file mode 100644 index 000000000..471751603 --- /dev/null +++ b/client/src/components/Studio/studioUpload.ts @@ -0,0 +1,54 @@ +import { useJourneyStore } from '../../store/journeyStore' +import { normalizeImageFiles } from '../../utils/convertHeic' +import { isVideoFile } from '../../utils/videoPoster' +import type { UploadProgress } from '../../utils/uploadQueue' + +export interface StudioUploadOutcome { + /** trek_photos ids of what arrived, in the order it was sent. */ + photoIds: number[] + failed: number + /** Left out before anything was sent, see below. */ + skippedVideos: number +} + +export type StudioUploader = ( + files: File[], + entryId: number | null, + onProgress?: (p: UploadProgress) => void, +) => Promise + +/** + * Pictures into the journey without leaving Studio. + * + * Into an entry when one is named, into the gallery otherwise. The journey + * store is what changes either way, so the content browser sees the new + * pictures the way it sees any other; there is no second list to keep in + * step. HEIC is converted first, exactly as the journal's own upload does it. + * + * Videos are left out rather than refused. A drop that carries a clip usually + * carries pictures too, and failing the lot over one file would teach the + * wrong lesson; a book holds pictures, and the count of what was skipped goes + * back so the panel can say so. + */ +export async function uploadStudioPhotos( + journeyId: number, + files: File[], + entryId: number | null, + onProgress?: (p: UploadProgress) => void, +): Promise { + const images = files.filter(f => !isVideoFile(f)) + const skippedVideos = files.length - images.length + if (!images.length) return { photoIds: [], failed: 0, skippedVideos } + + const normalized = await normalizeImageFiles(images) + const store = useJourneyStore.getState() + const sent = entryId != null + ? await store.uploadPhotos(entryId, normalized, { onProgress }) + : await store.uploadGalleryPhotos(journeyId, normalized, { onProgress }) + + return { + photoIds: sent.succeeded.map(p => p.photo_id), + failed: sent.failed.length, + skippedVideos, + } +} diff --git a/client/src/mobile/screens/journey/MJourneyEntryCard.tsx b/client/src/mobile/screens/journey/MJourneyEntryCard.tsx index e12ac6e61..01ba2d8f1 100644 --- a/client/src/mobile/screens/journey/MJourneyEntryCard.tsx +++ b/client/src/mobile/screens/journey/MJourneyEntryCard.tsx @@ -1,4 +1,4 @@ -import { MapPin } from 'lucide-react' +import { MapPin, RouteOff } from 'lucide-react' import { useTranslation } from '../../../i18n' import { formatLocationName } from '../../../utils/formatters' import { stripMarkdown } from '../../../components/Journey/stripMarkdown' @@ -59,6 +59,13 @@ export default function MJourneyEntryCard({ entry, number, onClick }: MJourneyEn {dateLabel} + {/* Switched off the printed route (#2064). Shrinks before the date does. */} + {entry.stats_excluded && ( + + + {t('journey.entry.offRoute')} + + )} {mood && ( | null>(null) const [mood, setMood] = useState(entry.mood || '') const [weather, setWeather] = useState(entry.weather || '') + const [statsExcluded, setStatsExcluded] = useState(entry.stats_excluded ?? false) const [pros, setPros] = useState(entry.pros_cons?.pros ?? []) const [cons, setCons] = useState(entry.pros_cons?.cons ?? []) const [tags, setTags] = useState(entry.tags ?? []) @@ -144,6 +146,10 @@ export default function MJourneyEntrySheet({ ? { lat: locationLat!, lng: locationLng!, name: locationName || undefined } : null + // The route switch belongs to an entry that is a stop, or was one: an entry + // without a point was never on the route, and a new one is not on it yet. + const offersStatsToggle = entry.id > 0 && (contextLocation != null || !!entry.stats_excluded) + useEffect(() => { if (!quickCapture || readOnly || entry.location_lat != null || entry.location_lng != null) return @@ -184,6 +190,7 @@ export default function MJourneyEntrySheet({ locationName !== (entry.location_name || '') || mood !== (entry.mood || '') || weather !== (entry.weather || '') || + statsExcluded !== (entry.stats_excluded ?? false) || pros.filter(p => p.trim()).join('\n') !== (entry.pros_cons?.pros ?? []).join('\n') || cons.filter(c => c.trim()).join('\n') !== (entry.pros_cons?.cons ?? []).join('\n') || tags.join('\n') !== (entry.tags ?? []).join('\n') || @@ -207,6 +214,7 @@ export default function MJourneyEntrySheet({ location_name: locationName || null, location_lat: locationLat, location_lng: locationLng, + stats_excluded: offersStatsToggle ? statsExcluded : undefined, mood: mood || null, weather: weather || null, tags: tags.filter(tag => tag.trim()), @@ -792,6 +800,19 @@ export default function MJourneyEntrySheet({ {locationError &&
{locationError}
}
+ {/* Off the route: the same switch the desktop editor and the Studio + travel panel offer (#2064). Read-only keeps it visible but locked, + like the date and the mood above and below it. */} + {offersStatsToggle && ( +
+
+
{t('journey.editor.statsExcluded')}
+
{t('journey.editor.statsExcludedHint')}
+
+ +
+ )} + {/* Mood */} {!captureOnly && <>
{t('journey.editor.mood')}
diff --git a/client/src/pages/journeyStudio/useJourneyStudio.ts b/client/src/pages/journeyStudio/useJourneyStudio.ts index fe560577a..f120c1d0a 100644 --- a/client/src/pages/journeyStudio/useJourneyStudio.ts +++ b/client/src/pages/journeyStudio/useJourneyStudio.ts @@ -1,10 +1,12 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useLocation, useNavigate, useParams } from 'react-router' -import type { BookPageNumbers, JourneyStats } from '@trek/shared' +import type { BookElement, BookPageNumbers, JourneyStats } from '@trek/shared' import { bookPageSetupSchema } from '@trek/shared' import { journeyApi } from '../../api/client' import { useJourneyStore, type GalleryPhoto, type JourneyEntry, type JourneyPhoto } from '../../store/journeyStore' import { useStudioStore } from '../../store/studioStore' +import { useToast } from '../../components/shared/Toast' +import { uploadStudioPhotos, type StudioUploader } from '../../components/Studio/studioUpload' import { useBookStore } from '../../components/Studio/useBookStore' import { useBookPresence } from '../../components/Studio/useBookPresence' import { useTranslation } from '../../i18n' @@ -167,45 +169,54 @@ export function useJourneyStudio() { const journey = current && current.id === journeyId ? current : null /** The journey's own material, for the content browser. */ - const source = useMemo(() => ({ - entries: (journey?.entries || []) - .filter((e: JourneyEntry) => !!( - e.title || e.story || e.location_name || e.mood || e.weather - || e.pros_cons?.pros?.length || e.pros_cons?.cons?.length - // A stop that was only checked into still has two things a page can - // use: the day it happened and where on earth it was. - || e.entry_date || (e.location_lat != null && e.location_lng != null) - )) - .map((e: JourneyEntry) => ({ - id: e.id, - title: e.title ?? null, - story: e.story ?? null, - location: e.location_name ?? null, - date: e.entry_date ?? null, - lat: e.location_lat ?? null, - lng: e.location_lng ?? null, - // What the entry recorded beyond its story: how the day felt, what the - // weather did, and what was worth and not worth it. - mood: e.mood ?? null, - weather: e.weather ?? null, - pros: e.pros_cons?.pros?.filter(Boolean) ?? [], - cons: e.pros_cons?.cons?.filter(Boolean) ?? [], - })), - photos: (journey?.gallery || []).map((p: GalleryPhoto) => ({ - photoId: p.photo_id, - caption: p.caption ?? null, - })), - // Which entry a photo hangs on, so a search for a place finds its pictures + const source = useMemo(() => { + const entries = (journey?.entries || []) as JourneyEntry[] + // Which entries a picture hangs on. Usually one; the junction allows more, + // and a picture linked to two days belongs to both of their pages. + const entriesByPhoto: Record = {} + // And the words of that entry, so a search for a place finds its pictures // even though a picture carries no words of its own. - photoEntries: (() => { - const map: Record = {} - for (const e of (journey?.entries || []) as JourneyEntry[]) { - const words = [e.title, e.location_name].filter(Boolean).join(' ').toLowerCase() - for (const p of e.photos || []) map[p.photo_id] = words + const photoEntries: Record = {} + for (const e of entries) { + const words = [e.title, e.location_name].filter(Boolean).join(' ').toLowerCase() + for (const p of e.photos || []) { + ;(entriesByPhoto[p.photo_id] ||= []).push(e.id) + photoEntries[p.photo_id] = words } - return map - })(), - }), [journey]) + } + return { + entries: entries + .filter((e: JourneyEntry) => !!( + e.title || e.story || e.location_name || e.mood || e.weather + || e.pros_cons?.pros?.length || e.pros_cons?.cons?.length + // A stop that was only checked into still has two things a page can + // use: the day it happened and where on earth it was. + || e.entry_date || (e.location_lat != null && e.location_lng != null) + )) + .map((e: JourneyEntry) => ({ + id: e.id, + title: e.title ?? null, + story: e.story ?? null, + location: e.location_name ?? null, + date: e.entry_date ?? null, + lat: e.location_lat ?? null, + lng: e.location_lng ?? null, + // What the entry recorded beyond its story: how the day felt, what the + // weather did, and what was worth and not worth it. + mood: e.mood ?? null, + weather: e.weather ?? null, + pros: e.pros_cons?.pros?.filter(Boolean) ?? [], + cons: e.pros_cons?.cons?.filter(Boolean) ?? [], + photoIds: (e.photos || []).map((p: JourneyPhoto) => p.photo_id), + })), + photos: (journey?.gallery || []).map((p: GalleryPhoto) => ({ + photoId: p.photo_id, + caption: p.caption ?? null, + entryIds: entriesByPhoto[p.photo_id] ?? [], + })), + photoEntries, + } + }, [journey]) useEffect(() => { if (!Number.isFinite(journeyId)) return @@ -629,6 +640,110 @@ export function useJourneyStudio() { }, [selection, select, close, undo, redo]) + /* + * A viewer may open the book and change nothing: not the pages, not the + * journey behind them. The server refuses either anyway; this keeps the + * controls that would only ever answer with a 403 off the screen. + */ + const canEdit = (journey?.my_role ?? 'owner') !== 'viewer' + const toast = useToast() + + /** + * Switch a stop on or off, and read the figures again. + * + * The switch lives on the journal entry, so it goes through the journey + * store like any other edit and the timeline sees it too. The figures are + * fetched again rather than adjusted here: which stop is furthest, and which + * countries remain, is the server's sum to do. The frozen layout input is + * patched as well, so a relayout after the change draws the route the change + * produced. True when both landed, false (with the toast shown) when not. + */ + const setStopExcluded = useCallback(async (entryId: number, excluded: boolean): Promise => { + try { + await useJourneyStore.getState().updateEntry(entryId, { stats_excluded: excluded }) + const fresh = await journeyApi.stats(journeyId) + setStats(fresh) + if (autoInput.current) autoInput.current = { ...autoInput.current, stats: fresh } + return true + } catch { + toast.error(t('journey.studio.stopsFailed')) + return false + } + }, [journeyId, toast, t]) + + /** + * Pictures into the journey without leaving Studio. + * + * What went wrong is said here, once, so neither caller has to: the content + * browser and the page both hand files in, and both would otherwise grow the + * same three toasts. + */ + const uploadPhotos = useCallback(async (files, entryId, onProgress) => { + try { + const outcome = await uploadStudioPhotos(journeyId, files, entryId, onProgress) + if (outcome.failed > 0) toast.error(t('journey.photosUploadFailed')) + if (outcome.skippedVideos > 0) toast.info(t('journey.studio.videosSkipped', { count: outcome.skippedVideos })) + return outcome + } catch { + toast.error(t('journey.photosUploadFailed')) + return { photoIds: [], failed: files.length, skippedVideos: 0 } + } + }, [journeyId, toast, t]) + + /** How far a drop onto the page has got, for the veil over the sheet. */ + const [canvasUpload, setCanvasUpload] = useState<{ done: number; total: number } | null>(null) + + /** + * Pictures dropped straight on the page. + * + * Into the gallery, never into an entry: the page may belong to one, but a + * texture dropped as a background is not a photograph of that day, and a + * wrong guess here would show up in the journal. The content browser is + * where a picture goes into an entry on purpose. + * + * Placed where they were let go, the first into the frame under the + * pointer when there is one, the rest fanned out from the point, as a + * single undo step, so a drop of five is taken back with one press. + */ + const dropFiles = useCallback(async (files: File[], at: { x: number; y: number }, targetFrameId: string | null) => { + if (!canEdit || !files.length) return + const spreadIndex = useStudioStore.getState().activeSpread + setCanvasUpload({ done: 0, total: files.length }) + let photoIds: number[] = [] + try { + photoIds = (await uploadPhotos(files, null, p => setCanvasUpload({ done: p.done, total: p.total }))).photoIds + } finally { + setCanvasUpload(null) + } + if (!photoIds.length) return + + const placed: string[] = [] + commit(d => { + const sp = d.spreads[spreadIndex] + if (!sp) return d + const w = Math.min(d.page.pageWidth, d.page.pageHeight) * 0.5 + const h = w * 0.72 + const target = targetFrameId ? sp.elements.find(el => el.id === targetFrameId && el.kind === 'photo') : null + const filled = target + ? sp.elements.map(el => (el.id === target.id ? { ...el, photoId: photoIds[0] } : el)) + : sp.elements + if (target) placed.push(target.id) + const fresh = (target ? photoIds.slice(1) : photoIds).map((photoId, i) => ({ + id: `p-${Math.random().toString(36).slice(2, 9)}`, kind: 'photo', + frame: { x: at.x - w / 2 + i * 6, y: at.y - h / 2 + i * 6, w, h }, + rotation: 0, opacity: 1, locked: false, + photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none', + mask: null, frameStyle: 'none', + } as BookElement)) + placed.push(...fresh.map(el => el.id)) + return { + ...d, + spreads: d.spreads.map((s, i) => (i === spreadIndex ? { ...s, elements: [...filled, ...fresh] } : s)), + } + }) + select(placed) + }, [canEdit, uploadPhotos, commit, select]) + const coverUrl = journey?.cover_image ? (journey.cover_image.startsWith('/uploads/') ? journey.cover_image : `/uploads/${journey.cover_image}`) : null @@ -650,6 +765,13 @@ export function useJourneyStudio() { /** The travelled way, ready to freeze into a map element. */ path, + /** Whether this user may change the book and the journey behind it. */ + canEdit, + setStopExcluded, + uploadPhotos, + dropFiles, + canvasUpload, + doc, page, preset, diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts index bde04a5ed..2c128b52e 100644 --- a/client/src/store/journeyStore.ts +++ b/client/src/store/journeyStore.ts @@ -46,6 +46,9 @@ export interface JourneyEntry { pros_cons?: { pros: string[]; cons: string[] } | null visibility: string sort_order: number + // Switched off by hand: the stop stays in the journal but is left out of the + // route, the distance and the countries that Studio prints. + stats_excluded?: boolean photos: JourneyPhoto[] created_at: number updated_at: number @@ -120,6 +123,7 @@ export interface JourneyDetail extends Journey { contributors: JourneyContributor[] stats: { entries: number; photos: number; places: number } hide_skeletons?: boolean + my_role?: 'owner' | 'editor' | 'viewer' } interface JourneyState { @@ -263,9 +267,24 @@ export const useJourneyStore = create((set, get) => ({ fd.append('photos', file) const data = await journeyApi.uploadPhotos(entryId, fd, opts) const photos: JourneyPhoto[] = data.photos || [] - const gallery: GalleryPhoto[] = data.gallery || [] set(s => { if (!s.current) return s + const journeyId = s.current.id + const known = new Set((s.current.gallery || []).map(p => p.id)) + /* + * A picture put on an entry is in the gallery too, because that is + * where the row lives: the entry only holds a link to it. The route + * answers with the entry's view of the row and, when it has one, the + * gallery's; the derived rows below are for the case where it does + * not, so that a picture added here shows up wherever the gallery is + * read without waiting for the journey to be fetched again. + */ + const gallery: GalleryPhoto[] = [ + ...(data.gallery || []), + ...photos + .filter(p => !known.has(p.id) && !(data.gallery || []).some(g => g.id === p.id)) + .map(({ entry_id: _entry, ...rest }) => ({ ...rest, journey_id: journeyId })), + ] return { current: { ...s.current, diff --git a/client/src/styles/studio.css b/client/src/styles/studio.css index 5159bead9..f69be9b15 100644 --- a/client/src/styles/studio.css +++ b/client/src/styles/studio.css @@ -1929,3 +1929,236 @@ margin: 0 2px; background: var(--line-2); } + +/* stops */ + +/* + * The route as rows, in the Travel panel. + * + * Not tiles: a stop is not something to place, it is the input every map and + * figure below reads. A quiet row with the name, the day and the country, and + * the switch on the right, so the eye runs down the switches to find the one + * that is off. A left-out row is dimmed as a whole rather than struck through: + * it is still a day of the journal, just not a leg of the route. + */ +.trek-dash .st-stops { + display: flex; + flex-direction: column; + gap: 4px; + margin: 4px 0 0; + padding: 0; + list-style: none; +} +.trek-dash .st-stop { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 7px 8px 7px 10px; + border-radius: var(--r-sm); + background: color-mix(in oklch, var(--surface) 70%, transparent); + border: 1px solid var(--line); + transition: opacity .16s; +} +.trek-dash .st-stop.is-off { opacity: .55; } +.trek-dash .st-stop-body { flex: 1 1 auto; min-width: 0; } +.trek-dash .st-stop-label { + display: block; + font-size: 12px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.trek-dash .st-stop-meta { display: flex; align-items: center; gap: 5px; margin-top: 3px; min-width: 0; } +/* The day keeps its width, as on the entry cards; the country gives way. */ +.trek-dash .st-stop-meta > .st-badge { flex: none; } +.trek-dash .st-stop-meta > .st-badge.is-quiet { flex: 0 1 auto; } +.trek-dash .st-stop > .st-chip { flex: none; height: 24px; font-size: 11px; } +/* The same chip as a plain label: a viewer's, or a stop with no entry to switch. */ +.trek-dash .st-stop > span.st-chip { pointer-events: none; } + +/* content filter + upload */ +/* + * Which pictures the browser shows, and the way pictures get in (#2064). + * + * The filter chip is a `.st-chip` with two buttons inside it: the name, which + * opens the popover, and the cross, which clears. One button would have to be + * both, and a chip whose whole face means "open" cannot also mean "off". The + * popover is the bar's format menu, hung from the left edge instead of the + * right: the side panel is 236px wide and the menu would otherwise open into + * the rail. + */ +/* The row the filter sits in. It takes the panel's full width, so the entry + currently in force reads as a heading over the grid rather than as one + control among several, and a long entry name has somewhere to go. The + quick chip wraps under it. */ +.trek-dash .st-filter-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 5px; + padding: 0 12px 9px; + flex: none; + min-width: 0; +} +.trek-dash .st-filter-row .st-picker { flex: 1 0 100%; min-width: 0; } +.trek-dash .st-filter-chip { + display: flex; + width: 100%; + padding: 0; + min-width: 0; +} +.trek-dash .st-filter-chip > button { + display: inline-flex; + align-items: center; + gap: 5px; + height: 100%; + min-width: 0; + padding: 0 9px 0 10px; + border-radius: inherit; + color: inherit; +} +/* The name takes the slack; the count and the chevron stay at the right edge. */ +.trek-dash .st-filter-chip > button:first-child { flex: 1; justify-content: flex-start; } +.trek-dash .st-filter-chip > button + button { padding: 0 8px 0 3px; margin-left: -5px; } +.trek-dash .st-filter-chip > button:hover { background: color-mix(in oklch, var(--ink) 7%, transparent); } +.trek-dash .st-filter-chip.is-on > button:hover { background: color-mix(in oklch, var(--accent-text, #fff) 14%, transparent); } +.trek-dash .st-filter-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; +} +/* The count, set like the one in the tabs: a footnote, not part of the name. */ +.trek-dash .st-filter-chip em { + font-style: normal; + font-size: 10.5px; + font-weight: 600; + font-variant-numeric: tabular-nums; + opacity: .7; +} +/* Hung under the chip and as wide as it: a menu narrower than the control that + opens it reads as belonging to something else. */ +.trek-dash .st-filter-row .st-menu { + left: 0; + right: 0; + min-width: 0; + max-width: none; +} +.trek-dash .st-filter-row .st-menu-item { padding: 7px 10px; gap: 8px; } +.trek-dash .st-filter-row .st-menu-name, +.trek-dash .st-filter-row .st-menu-dim { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.trek-dash .st-filter-count { flex: none; } + +/* Where the next pictures go. Under the filter because the filter decides it. */ +.trek-dash .st-upload-where { padding: 0 2px 8px; font-size: 11.5px; } + +/* + * The upload cell: the first square of the grid, drawn as an outline so it + * reads as a place for a picture rather than as one. Disabled while a batch + * is on its way, in the same grey, because a target that goes lighter looks + * like it has stopped taking files rather than like it is busy. + */ +.trek-dash .st-photo-cell.is-upload { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + padding: 6px; + background: transparent; + border: 1px dashed var(--line-2); + color: var(--ink-3); + font-size: 10.5px; + font-weight: 600; + line-height: 1.25; + text-align: center; + transition: border-color .16s, color .16s, background .16s, transform .14s ease-out, box-shadow .14s; +} +.trek-dash .st-photo-cell.is-upload span { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; +} +.trek-dash .st-photo-cell.is-upload:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); + background: color-mix(in oklch, var(--accent) 6%, transparent); +} +.trek-dash .st-photo-cell.is-upload:disabled { + cursor: default; + transform: none; + box-shadow: none; +} + +/* A file drag over the panel: an inner frame, and the cell lights with it. */ +.trek-dash .st-panel-scroll.is-drop-over { + box-shadow: inset 0 0 0 2px var(--accent); + border-radius: var(--r-sm); +} +.trek-dash .st-panel-scroll.is-drop-over .st-photo-cell.is-upload { + border-color: var(--accent); + color: var(--accent); +} + +/* + * Pictures dropped on the sheet, on their way. A pill above the zoom bar + * rather than a veil over the page: the page keeps rendering, and the only + * question in those two seconds is whether the drop was taken. + */ +.trek-dash .st-upload-veil { + position: absolute; + left: 50%; + bottom: 70px; + z-index: 6; + transform: translateX(-50%); + padding: 7px 14px; + border-radius: 999px; + background: var(--surface); + border: 1px solid var(--line-2); + box-shadow: var(--sh-md); + color: var(--ink-2); + font-size: 12px; + font-weight: 500; + font-variant-numeric: tabular-nums; + white-space: nowrap; + pointer-events: none; +} + +/* + * A file drag over the stage. Over the whole stage rather than inside the + * scaled sheet, so the label is one size at every zoom, and above the handles + * and the peers' pointers, none of which matter while a file is in the air. + */ +.trek-dash .st-file-veil { + position: absolute; + inset: 0; + z-index: 9; + display: grid; + place-items: center; + border: 2px dashed var(--accent); + border-radius: 4px; + background: color-mix(in oklch, var(--accent) 10%, transparent); + pointer-events: none; +} +.trek-dash .st-file-veil span { + padding: 8px 16px; + border-radius: 999px; + background: var(--surface); + border: 1px solid var(--line-2); + box-shadow: var(--sh-md); + color: var(--ink); + font-size: 12.5px; + font-weight: 600; + white-space: nowrap; +} diff --git a/client/tests/unit/mobile/journey/MJourneyEntryCard.test.tsx b/client/tests/unit/mobile/journey/MJourneyEntryCard.test.tsx index fa4eee899..a23868d33 100644 --- a/client/tests/unit/mobile/journey/MJourneyEntryCard.test.tsx +++ b/client/tests/unit/mobile/journey/MJourneyEntryCard.test.tsx @@ -43,4 +43,15 @@ describe('MJourneyEntryCard', () => { fireEvent.click(screen.getByText('Sunrise Pier')); expect(onClick).toHaveBeenCalledTimes(1); }); + + // #2064: a stop switched off the printed route says so beside its date. + it('FE-MOB-JRN-006: marks an entry left out of the route', () => { + render( {}} />); + expect(screen.getByText('Off route')).toBeInTheDocument(); + }); + + it('FE-MOB-JRN-007: carries no such mark on an ordinary entry', () => { + render( {}} />); + expect(screen.queryByText('Off route')).not.toBeInTheDocument(); + }); }); diff --git a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx index a6c878221..cd52c918a 100644 --- a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx +++ b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx @@ -935,6 +935,52 @@ describe('MJourneyEntrySheet read-only', () => { }); }); +// FE-MOB-JENTRY-051 to FE-MOB-JENTRY-054: leaving a stop off the printed route +// (#2064), the phone sheet's half of the switch the desktop editor has. + +describe('MJourneyEntrySheet route switch', () => { + it('FE-MOB-JENTRY-051: offers to leave a located entry out of the route and saves it', async () => { + const user = userEvent.setup(); + const { onSave } = mountSheet(buildEntry({ + id: 5, location_name: 'Keflavík', location_lat: 63.98, location_lng: -22.6, + })); + + const toggle = screen.getByRole('switch', { name: 'Leave out of the route' }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + await user.click(toggle); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0]).toMatchObject({ stats_excluded: true }); + }); + + it('FE-MOB-JENTRY-052: keeps the switch away from a new entry, point or no point', () => { + mountSheet(buildEntry({ location_lat: 63.98, location_lng: -22.6 })); + + expect(screen.queryByRole('switch', { name: 'Leave out of the route' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-JENTRY-053: keeps the switch away from an entry without a point, and sends nothing for it', async () => { + const user = userEvent.setup(); + const { onSave } = mountSheet(buildEntry({ id: 5 })); + + expect(screen.queryByRole('switch', { name: 'Leave out of the route' })).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].stats_excluded).toBeUndefined(); + }); + + it('FE-MOB-JENTRY-054: shows a left-out entry\'s switch to a viewer, locked', () => { + mountSheet(buildEntry({ id: 5, stats_excluded: true }), { readOnly: true }); + + const toggle = screen.getByRole('switch', { name: 'Leave out of the route' }); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + expect(toggle).toBeDisabled(); + }); +}); + describe('MJourneyEntrySheet quick capture edge cases', () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/client/tests/unit/studio/autoLayout.contract.test.ts b/client/tests/unit/studio/autoLayout.contract.test.ts index ca417bcc2..a9186d564 100644 --- a/client/tests/unit/studio/autoLayout.contract.test.ts +++ b/client/tests/unit/studio/autoLayout.contract.test.ts @@ -28,10 +28,10 @@ const stats = (over: Partial = {}): JourneyStats => ({ journeyId: 6, distance: 1_189_000, days: 14, steps: 14, photos: 57, places: 21, furthest: 408_000, countries: [{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' }], points: [ - { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null }, - { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null }, + { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null, entryId: null }, + { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null, entryId: null }, ], - trips: [], start: '2026-06-02', end: '2026-06-15', + trips: [], excluded: [], start: '2026-06-02', end: '2026-06-15', ...over, }) @@ -56,8 +56,8 @@ describe('a laid-out book is a book the server will take', () => { it('when a stop carries a place name longer than a map label may print', () => { const points = [ - { lat: 64.14, lng: -21.94, label: long(300), date: '2026-06-02', country: 'IS', tripId: null, photoId: null }, - { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null }, + { lat: 64.14, lng: -21.94, label: long(300), date: '2026-06-02', country: 'IS', tripId: null, photoId: null, entryId: null }, + { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null, entryId: null }, ] expect(savable(buildBook(input({ stats: stats({ points }) })))).toEqual({ ok: true }) }) diff --git a/client/tests/unit/studio/autoLayout.travel.test.ts b/client/tests/unit/studio/autoLayout.travel.test.ts index f369fa26b..194d6c20e 100644 --- a/client/tests/unit/studio/autoLayout.travel.test.ts +++ b/client/tests/unit/studio/autoLayout.travel.test.ts @@ -39,10 +39,11 @@ const stats = (over: Partial = {}): JourneyStats => ({ furthest: 408_000, countries: [{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' }], points: [ - { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null }, - { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null }, + { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null, entryId: null }, + { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null, entryId: null }, ], trips: [], + excluded: [], start: '2026-06-02', end: '2026-06-15', ...over, diff --git a/client/tests/unit/studio/contentPhotos.test.tsx b/client/tests/unit/studio/contentPhotos.test.tsx new file mode 100644 index 000000000..71ab1211a --- /dev/null +++ b/client/tests/unit/studio/contentPhotos.test.tsx @@ -0,0 +1,263 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { BookDocument, BookPageSetup } from '@trek/shared' +import { bookPageSetupSchema, normalizeBookDocument } from '@trek/shared' +import { fireEvent, render, screen, waitFor, within } from '../../helpers/render' +import { StudioSidebar, type JourneySource } from '../../../src/components/Studio/StudioSidebar' +import type { StudioUploader } from '../../../src/components/Studio/studioUpload' +import { useStudioStore } from '../../../src/store/studioStore' + +/** + * Content > Photos, narrowed to a day, and the upload cell (#2064). + * + * A journey of three hundred pictures showed them as one grid, so laying out + * the page for one day meant scrolling past every other day to find its + * pictures. What these check is that the browser can be narrowed to one + * entry, to the pictures no entry claims, and to what just arrived; that the + * search still applies on top; and that a picture uploaded here lands where + * the panel says it will, the entry the filter names or the gallery when it + * names none. + */ + +const page: BookPageSetup = bookPageSetupSchema.parse({ + preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5, +}) + +type Entry = JourneySource['entries'][number] + +const entry = (over: Partial): Entry => ({ + id: 1, title: 'Reykjavik', story: null, location: null, date: '2026-06-01', + lat: null, lng: null, mood: null, weather: null, pros: [], cons: [], photoIds: [], + ...over, +}) + +/** Two days with pictures, one written day without, and one picture only the gallery holds. */ +const source: JourneySource = { + entries: [ + entry({ id: 1, title: 'Reykjavik', date: '2026-06-01', photoIds: [11, 12] }), + entry({ id: 2, title: 'Vik', location: 'Vik i Myrdal', date: '2026-06-02', photoIds: [13] }), + entry({ id: 3, title: 'A written day', date: '2026-06-03' }), + ], + photos: [ + { photoId: 11, caption: null, entryIds: [1] }, + { photoId: 12, caption: 'Harbour', entryIds: [1] }, + { photoId: 13, caption: null, entryIds: [2] }, + { photoId: 14, caption: 'Loose', entryIds: [] }, + ], + photoEntries: { 11: 'reykjavik', 12: 'reykjavik', 13: 'vik vik i myrdal' }, +} + +/** One inner spread, tied to a day or to none. */ +const book = (entryId: number | null = null): BookDocument => normalizeBookDocument({ + version: 1, + title: 'Iceland', + page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 }, + spreads: [{ id: 'sp1', role: 'inner', entryId, background: null, elements: [], parked: [] }], +}) + +const nothing: StudioUploader = async () => ({ photoIds: [], failed: 0, skippedVideos: 0 }) + +beforeEach(() => { + useStudioStore.getState().load(book()) +}) + +/** Render the sidebar and open Content, which lands on the Photos tab. */ +function openContent(src = source, over: { canEdit?: boolean; onUpload?: StudioUploader } = {}) { + const view = render( + k} + locale="en-US" + canEdit={over.canEdit ?? true} + onUpload={over.onUpload ?? nothing} + onToggleStop={async () => true} + />, + ) + fireEvent.click(screen.getByLabelText('journey.studio.content')) + return view +} + +/** The pictures the grid shows now, by id, in grid order. */ +const shown = () => [...document.querySelectorAll('.st-photo-cell:not(.is-upload) img')] + .map(img => Number(img.getAttribute('src')!.match(/\/photos\/(\d+)\//)![1])) + +const openMenu = () => fireEvent.click(screen.getByTitle('journey.studio.filterPhotos')) + +const chipLabel = () => document.querySelector('.st-filter-name')!.textContent + +const picture = (name: string) => new File(['x'], name, { type: 'image/jpeg' }) + +describe('the filter menu', () => { + it('lists every entry that has pictures, with how many', () => { + openContent() + openMenu() + + const items = screen.getAllByRole('menuitemradio').map(b => b.textContent) + expect(items[0]).toContain('journey.studio.filterAll') + expect(items[0]).toContain('4') + expect(items[1]).toContain('journey.studio.filterLoose') + expect(items[1]).toContain('1') + expect(items.find(s => s!.includes('Reykjavik'))).toContain('2') + expect(items.find(s => s!.includes('Vik i Myrdal'))).toContain('1') + // A day with words and no pictures is not a filter worth offering. + expect(items.some(s => s!.includes('A written day'))).toBe(false) + }) + + it('narrows the grid to one day, and the cross widens it again', () => { + openContent() + openMenu() + fireEvent.click(screen.getByRole('menuitemradio', { name: /Reykjavik/ })) + + expect(shown()).toEqual([11, 12]) + expect(chipLabel()).toBe('Reykjavik') + + fireEvent.click(screen.getByLabelText('common.clear')) + + expect(shown()).toEqual([11, 12, 13, 14]) + expect(chipLabel()).toBe('journey.studio.filterAll') + }) + + it('shows only the pictures no entry claims', () => { + openContent() + openMenu() + fireEvent.click(screen.getByRole('menuitemradio', { name: /journey\.studio\.filterLoose/ })) + + expect(shown()).toEqual([14]) + }) + + it('combines with the search rather than replacing it', () => { + openContent() + openMenu() + fireEvent.click(screen.getByRole('menuitemradio', { name: /Reykjavik/ })) + fireEvent.change(screen.getByLabelText('journey.studio.searchContent'), { target: { value: 'harbour' } }) + + expect(shown()).toEqual([12]) + }) + + /* Four reasons for an empty grid, each said in its own words. */ + it('says why the grid is empty', () => { + openContent() + openMenu() + fireEvent.click(screen.getByRole('menuitemradio', { name: /Reykjavik/ })) + fireEvent.change(screen.getByLabelText('journey.studio.searchContent'), { target: { value: 'myrdal' } }) + expect(screen.getByText('journey.studio.noMatches')).toBeInTheDocument() + }) + + it('says so when every picture belongs to a day', () => { + openContent({ ...source, photos: source.photos.filter(p => p.entryIds.length) }) + openMenu() + fireEvent.click(screen.getByRole('menuitemradio', { name: /journey\.studio\.filterLoose/ })) + + expect(shown()).toEqual([]) + expect(screen.getByText('journey.studio.noLoosePhotos')).toBeInTheDocument() + }) +}) + +describe('the way in from an entry', () => { + it('opens the pictures of the day from its card', () => { + openContent() + fireEvent.click(screen.getByRole('button', { name: /journey\.studio\.entries/ })) + + const card = screen.getByText('Reykjavik').closest('.st-entry') as HTMLElement + fireEvent.click(within(card).getByText('journey.studio.entryPhotos')) + + expect(shown()).toEqual([11, 12]) + expect(chipLabel()).toBe('Reykjavik') + }) + + it('offers no such chip on a day without pictures', () => { + openContent() + fireEvent.click(screen.getByRole('button', { name: /journey\.studio\.entries/ })) + + const card = screen.getByText('A written day').closest('.st-entry') as HTMLElement + expect(within(card).queryByText('journey.studio.entryPhotos')).toBeNull() + }) + + it('offers the page\'s own day while the filter is not on it', () => { + useStudioStore.getState().load(book(1)) + openContent() + + fireEvent.click(screen.getByText('journey.studio.filterThisPage')) + + expect(shown()).toEqual([11, 12]) + expect(screen.queryByText('journey.studio.filterThisPage')).toBeNull() + }) + + it('offers nothing for a page tied to a day without pictures', () => { + useStudioStore.getState().load(book(3)) + openContent() + expect(screen.queryByText('journey.studio.filterThisPage')).toBeNull() + }) +}) + +describe('the upload cell', () => { + it('is not there for a viewer', () => { + openContent(source, { canEdit: false }) + + expect(document.querySelector('.st-photo-cell.is-upload')).toBeNull() + expect(document.querySelector('input[type="file"]')).toBeNull() + expect(screen.queryByText('journey.studio.uploadToGallery')).toBeNull() + }) + + it('sends into the gallery when no day is chosen', async () => { + const onUpload = vi.fn(nothing) + openContent(source, { onUpload }) + expect(screen.getByText('journey.studio.uploadToGallery')).toBeInTheDocument() + + const file = picture('a.jpg') + fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files: [file] } }) + + await waitFor(() => expect(onUpload).toHaveBeenCalledWith([file], null, expect.any(Function))) + }) + + it('sends into the day the filter names', async () => { + const onUpload = vi.fn(nothing) + openContent(source, { onUpload }) + openMenu() + fireEvent.click(screen.getByRole('menuitemradio', { name: /Reykjavik/ })) + expect(screen.getByText('journey.studio.uploadToEntry')).toBeInTheDocument() + + const file = picture('b.jpg') + fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files: [file] } }) + + await waitFor(() => expect(onUpload).toHaveBeenCalledWith([file], 1, expect.any(Function))) + }) + + /* + * The gallery sorts what arrives to its end, out of sight under three + * hundred pictures; the filter is what brings it into view. The source + * here already holds the new id, as the store would once the upload landed. + */ + it('shows what just arrived once it has', async () => { + const onUpload = vi.fn(async () => ({ photoIds: [99], failed: 0, skippedVideos: 0 })) + const arrived: JourneySource = { + ...source, + photos: [...source.photos, { photoId: 99, caption: null, entryIds: [] }], + } + openContent(arrived, { onUpload }) + fireEvent.change(screen.getByLabelText('journey.studio.searchContent'), { target: { value: 'harbour' } }) + + fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files: [picture('c.jpg')] } }) + + await waitFor(() => expect(chipLabel()).toBe('journey.studio.filterRecent')) + expect(shown()).toEqual([99]) + // The search is let go of too, or the new picture would be hidden by it. + expect((screen.getByLabelText('journey.studio.searchContent') as HTMLInputElement).value).toBe('') + }) + + it('takes a drop anywhere on the panel', async () => { + const onUpload = vi.fn(nothing) + openContent(source, { onUpload }) + + const file = picture('d.jpg') + fireEvent.drop(document.querySelector('.st-panel-scroll')!, { + dataTransfer: { files: [file], types: ['Files'] }, + }) + + await waitFor(() => expect(onUpload).toHaveBeenCalledWith([file], null, expect.any(Function))) + }) +}) diff --git a/client/tests/unit/studio/entryChips.test.tsx b/client/tests/unit/studio/entryChips.test.tsx index 6c4afec59..d8a1f02e0 100644 --- a/client/tests/unit/studio/entryChips.test.tsx +++ b/client/tests/unit/studio/entryChips.test.tsx @@ -3,6 +3,7 @@ import type { BookDocument, BookPageSetup, BookTextElement } from '@trek/shared' import { bookPageSetupSchema, normalizeBookDocument } from '@trek/shared' import { fireEvent, render, screen } from '../../helpers/render' import { StudioSidebar, type JourneySource } from '../../../src/components/Studio/StudioSidebar' +import type { StudioUploader } from '../../../src/components/Studio/studioUpload' import { useStudioStore } from '../../../src/store/studioStore' /** @@ -39,11 +40,15 @@ const entry = (over: Partial = {}): Entry => ({ weather: null, pros: [], cons: [], + photoIds: [], ...over, }) const sourceOf = (...entries: Entry[]): JourneySource => ({ entries, photos: [], photoEntries: {} }) +/** Nothing ever arrives through these; the chips under test place text. */ +const noUpload: StudioUploader = async () => ({ photoIds: [], failed: 0, skippedVideos: 0 }) + /** One empty inner spread, parsed rather than cast, so the store holds a real document. */ const emptyBook = (): BookDocument => normalizeBookDocument({ version: 1, @@ -74,6 +79,9 @@ function openEntries(source: JourneySource) { path={[]} t={(k: string) => k} locale="en-US" + canEdit + onUpload={noUpload} + onToggleStop={async () => true} />, ) fireEvent.click(screen.getByLabelText('journey.studio.content')) diff --git a/client/tests/unit/studio/inspector.test.tsx b/client/tests/unit/studio/inspector.test.tsx index 1a8cee512..f56f0ac1f 100644 --- a/client/tests/unit/studio/inspector.test.tsx +++ b/client/tests/unit/studio/inspector.test.tsx @@ -48,7 +48,7 @@ const setup = (folios: Partial = {}): BookPageSetup => bookPage const source: JourneySource = { entries: [{ id: 7, title: 'Bergen', story: null, location: 'Bergen', date: '2026-05-01', - lat: 60.3913, lng: 5.3221, mood: null, weather: null, pros: [], cons: [], + lat: 60.3913, lng: 5.3221, mood: null, weather: null, pros: [], cons: [], photoIds: [], }], photos: [], photoEntries: {}, @@ -337,6 +337,81 @@ describe('a photo frame', () => { }) }) +/* + * The picture as the page (#2064). What is pinned is the geometry: the frame + * must reach the bleed on all four sides, or the trimmed page shows a white + * hairline, and the picture must end up behind everything else, or it covers + * the page it was meant to carry. + */ +describe('filling a page with a picture', () => { + /** The chips live in Arrange, which starts folded like the frame's numbers. */ + function fillChip(c: HTMLElement, label: string): HTMLElement | undefined { + open(c, 'journey.studio.arrange') + return [...c.querySelectorAll('.st-chip')].find(b => b.textContent === label) as HTMLElement | undefined + } + + it('reaches the bleed on every side and goes behind everything else', () => { + load([shape(), photo({ photoId: 12, rotation: 15, fit: 'contain' })], ['p1']) + const { container } = draw() + + fireEvent.click(fillChip(container, 'journey.studio.fillPage')!) + + expect(elements().map(e => e.id)).toEqual(['p1', 's1']) + expect(el(0)).toMatchObject({ + frame: { x: -3, y: -3, w: 216, h: 216 }, rotation: 0, fit: 'cover', + }) + }) + + /* The frame, the crop and the move to the back are one press, so they are + one undo; three steps for one press would take three presses back. */ + it('is a single undo step', () => { + load([shape(), photo({ photoId: 12 })], ['p1']) + const { container } = draw() + + fireEvent.click(fillChip(container, 'journey.studio.fillPage')!) + + expect(useStudioStore.getState().past).toHaveLength(1) + }) + + it('fills the right-hand page when the frame sits on it', () => { + load([photo({ photoId: 12, frame: { x: 300, y: 30, w: 60, h: 40 } })]) + const { container } = draw() + + fireEvent.click(fillChip(container, 'journey.studio.fillPage')!) + + expect(el().frame).toEqual({ x: 207, y: -3, w: 216, h: 216 }) + }) + + it('fills both pages when asked for the spread', () => { + load([photo({ photoId: 12, frame: { x: 300, y: 30, w: 60, h: 40 } })]) + const { container } = draw() + + fireEvent.click(fillChip(container, 'journey.studio.fillSpread')!) + + expect(el().frame).toEqual({ x: -3, y: -3, w: 426, h: 216 }) + }) + + it('offers no spread on a cover, which is one page', () => { + useStudioStore.getState().load({ + version: 1, title: 'T', page: setup(), spreads: [ + { id: 'sp1', role: 'cover', background: null, elements: [photo({ photoId: 12 })], parked: [], entryId: null }, + ], + } as BookDocument) + useStudioStore.getState().select(['p1']) + const { container } = draw() + + expect(fillChip(container, 'journey.studio.fillSpread')).toBeUndefined() + fireEvent.click(fillChip(container, 'journey.studio.fillPage')!) + expect(el().frame).toEqual({ x: -3, y: -3, w: 216, h: 216 }) + }) + + it('is not offered for anything but a picture', () => { + load([shape()]) + const { container } = draw() + expect(fillChip(container, 'journey.studio.fillPage')).toBeUndefined() + }) +}) + // ── Shape ────────────────────────────────────────────────────────────────── describe('a shape', () => { diff --git a/client/tests/unit/studio/mapRoads.test.tsx b/client/tests/unit/studio/mapRoads.test.tsx index 86016242e..2c1dc02c9 100644 --- a/client/tests/unit/studio/mapRoads.test.tsx +++ b/client/tests/unit/studio/mapRoads.test.tsx @@ -246,7 +246,10 @@ describe('placing a map from the panel', () => { }) const { getByTitle, unmount } = render( - k} locale="en" />, + k} locale="en" + canEdit={false} onToggleStop={async () => true} + />, ) fireEvent.click(getByTitle('journey.studio.mapStyle.minimal')) await vi.waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalled()) diff --git a/client/tests/unit/studio/mapScope.test.ts b/client/tests/unit/studio/mapScope.test.ts index 88ef1e3c0..153a4ea59 100644 --- a/client/tests/unit/studio/mapScope.test.ts +++ b/client/tests/unit/studio/mapScope.test.ts @@ -33,15 +33,16 @@ const stats: JourneyStats = { { code: 'PT', name: 'Portugal', places: 2, firstVisit: '2026-09-04' }, ], points: [ - { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: ICELAND, photoId: null }, - { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: ICELAND, photoId: null }, - { lat: 38.72, lng: -9.14, label: 'Lisboa', date: '2026-09-04', country: 'PT', tripId: PORTUGAL, photoId: null }, - { lat: 41.15, lng: -8.61, label: 'Porto', date: '2026-09-08', country: 'PT', tripId: PORTUGAL, photoId: null }, + { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: ICELAND, photoId: null, entryId: null }, + { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: ICELAND, photoId: null, entryId: null }, + { lat: 38.72, lng: -9.14, label: 'Lisboa', date: '2026-09-04', country: 'PT', tripId: PORTUGAL, photoId: null, entryId: null }, + { lat: 41.15, lng: -8.61, label: 'Porto', date: '2026-09-08', country: 'PT', tripId: PORTUGAL, photoId: null, entryId: null }, ], trips: [ { id: ICELAND, title: 'Iceland', start: '2026-06-02', end: '2026-06-14', points: 2 }, { id: PORTUGAL, title: 'Portugal', start: '2026-09-04', end: '2026-09-12', points: 2 }, ], + excluded: [], start: '2026-06-02', end: '2026-09-12', } diff --git a/client/tests/unit/studio/travelRefresh.test.ts b/client/tests/unit/studio/travelRefresh.test.ts index 9133054b3..815c62650 100644 --- a/client/tests/unit/studio/travelRefresh.test.ts +++ b/client/tests/unit/studio/travelRefresh.test.ts @@ -23,10 +23,11 @@ const stats: JourneyStats = { { code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' }, ], points: [ - { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null }, - { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null }, + { lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null, entryId: null }, + { lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null, entryId: null }, ], trips: [], + excluded: [], start: '2026-06-02', end: '2026-06-15', } diff --git a/client/tests/unit/studio/travelStops.test.tsx b/client/tests/unit/studio/travelStops.test.tsx new file mode 100644 index 000000000..1829914ba --- /dev/null +++ b/client/tests/unit/studio/travelStops.test.tsx @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { BookDocument, BookPageSetup, JourneyStats, JourneyStatsPoint } from '@trek/shared' +import { bookPageSetupSchema } from '@trek/shared' +import { act, fireEvent, render, screen } from '../../helpers/render' +import { StudioTravelPanel } from '../../../src/components/Studio/StudioTravelPanel' +import { useStudioStore } from '../../../src/store/studioStore' + +/** + * Switching a stop off the route, from the Travel panel (#2064). + * + * The home airport is the case: written up as an entry so the journal has a + * first day, it lands on the printed map and adds a leg to the distance that + * nobody thinks of as the trip. The panel lists every stop with a switch, and + * what these pin is the part that would rot quietly: the left-out stop stays + * in the list among the others, in date order, because the only way to put a + * stop back is to be able to see it; the switch asks for exactly the opposite + * of what the row shows; and a viewer, or a stop the server took from a trip + * place rather than from an entry, gets the label and no button. + * + * Nothing here is optimistic. The row reads the stats it was given, and the + * hook behind `onToggleStop` fetches fresh ones; the only state the panel + * keeps is which chips are held while an answer is on its way. + */ + +const page: BookPageSetup = bookPageSetupSchema.parse({ preset: 'square-210' }) + +const point = (over: Partial = {}): JourneyStatsPoint => ({ + lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', + tripId: null, photoId: null, entryId: 1, + ...over, +}) + +const stats = (over: Partial = {}): JourneyStats => ({ + journeyId: 6, distance: 1_189_000, days: 14, steps: 2, photos: 0, places: 3, furthest: 408_000, + countries: [{ code: 'IS', name: 'Iceland', places: 2, firstVisit: '2026-06-02' }], + points: [ + point(), + point({ lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', entryId: 2 }), + ], + excluded: [{ entryId: 3, label: 'Keflavík', date: '2026-06-01' }], + trips: [], start: '2026-06-02', end: '2026-06-15', + ...over, +}) + +type Toggle = (entryId: number, excluded: boolean) => Promise + +function open(journeyStats: JourneyStats, over: { canEdit?: boolean; onToggleStop?: Toggle } = {}) { + const onToggleStop = over.onToggleStop ?? vi.fn(async () => true) + render( + k} locale="en" + canEdit={over.canEdit ?? true} onToggleStop={onToggleStop} + />, + ) + return { onToggleStop } +} + +const rows = () => Array.from(document.querySelectorAll('.st-stop')) +const labels = () => rows().map(r => r.querySelector('.st-stop-label')?.textContent) +const switches = () => screen.queryAllByRole('button', { name: 'journey.studio.stopToggle' }) + +beforeEach(() => { + useStudioStore.getState().load({ + version: 1, title: 'T', page, + spreads: [{ id: 's1', role: 'inner', background: null, elements: [], parked: [], entryId: null }], + } as unknown as BookDocument) +}) + +describe('what the stops section lists', () => { + it('puts the left-out stop among the counting ones, in date order', () => { + open(stats()) + + expect(labels()).toEqual(['Keflavík', 'Reykjavík', 'Akureyri']) + }) + + it('dims the left-out row and says which way each switch stands', () => { + open(stats()) + + const [keflavik, reykjavik] = rows() + expect(keflavik.className).toContain('is-off') + expect(keflavik.textContent).toContain('journey.studio.stopOff') + expect(reykjavik.className).not.toContain('is-off') + expect(reykjavik.textContent).toContain('journey.studio.stopOn') + }) + + it('puts an undated stop last', () => { + open(stats({ excluded: [{ entryId: 3, label: 'Somewhere', date: null }] })) + + expect(labels()).toEqual(['Reykjavík', 'Akureyri', 'Somewhere']) + }) + + it('names the country in the reader\'s language, and only for a stop that has one', () => { + open(stats()) + + const quiet = rows().map(r => r.querySelector('.st-badge.is-quiet')?.textContent ?? null) + // The left-out stop carries no country: it is not on the route to have one. + expect(quiet).toEqual([null, 'Iceland', 'Iceland']) + }) + + it('is left out entirely when the journey has no stops at all', () => { + open(stats({ points: [], excluded: [] })) + + expect(screen.queryByText('journey.studio.stops')).toBeNull() + expect(screen.getByText('journey.studio.noRoute')).toBeInTheDocument() + }) +}) + +describe('the switch', () => { + it('asks to leave a counting stop out', () => { + const { onToggleStop } = open(stats()) + + fireEvent.click(switches()[1]) + + expect(onToggleStop).toHaveBeenCalledWith(1, true) + }) + + it('asks to count a left-out stop again', () => { + const { onToggleStop } = open(stats()) + + fireEvent.click(switches()[0]) + + expect(onToggleStop).toHaveBeenCalledWith(3, false) + }) + + /* + * The row does not flip on its own: the figures are the server's sum and + * arrive with the next stats. What the panel owes the user in the meantime + * is a chip that cannot be clicked twice, and one that comes free again + * whether the change landed or not, since a refusal is toasted elsewhere. + */ + it('holds the chip until the answer comes back, then frees it', async () => { + let settle: (ok: boolean) => void = () => {} + const onToggleStop = vi.fn(() => new Promise(resolve => { settle = resolve })) + open(stats(), { onToggleStop }) + + fireEvent.click(switches()[1]) + expect(switches()[1]).toBeDisabled() + // One stop in flight does not lock its neighbours. + expect(switches()[0]).toBeEnabled() + + await act(async () => { settle(false) }) + expect(switches()[1]).toBeEnabled() + }) + + it('is a label, not a button, for a viewer', () => { + open(stats(), { canEdit: false }) + + expect(switches()).toHaveLength(0) + expect(rows()).toHaveLength(3) + expect(rows()[1].textContent).toContain('journey.studio.stopOn') + }) + + it('is a label for a stop taken from a trip place, which has no entry to switch', () => { + open(stats({ + points: [ + point({ entryId: null, label: 'Vík' }), + point({ lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', entryId: 2 }), + ], + excluded: [], + })) + + expect(labels()).toEqual(['Vík', 'Akureyri']) + expect(switches()).toHaveLength(1) + expect(rows()[0].querySelector('button')).toBeNull() + expect(rows()[0].textContent).toContain('journey.studio.stopOn') + }) +}) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 087046333..0970d732c 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -4236,6 +4236,27 @@ function runMigrations(db: Database.Database): void { db.exec("ALTER TABLE plugin_actions ADD COLUMN scope TEXT NOT NULL DEFAULT 'user'"); } }, + /** + * A journal entry that is not a stop (discussion #2064). + * + * Studio draws its route and prints its distance from every entry that + * carries coordinates, and that is right until the journal starts at the + * home airport: the night before the flight, the stopover, the place the + * trip was planned from all become stops, and the distance counts the legs + * to and from them. The traveller knows which of those are the journey and + * which are the way there, so the switch sits on the entry. The entry stays + * in the journal; it is only left out of the arithmetic. + * + * DEFAULT 0: every existing entry keeps counting, which is what it did. + * + * Appended LAST: the array is index-addressed against schema_version. + */ + () => { + const cols = db.prepare("SELECT name FROM pragma_table_info('journey_entries')").all() as Array<{ name: string }>; + if (!cols.some((c) => c.name === 'stats_excluded')) { + db.exec('ALTER TABLE journey_entries ADD COLUMN stats_excluded INTEGER NOT NULL DEFAULT 0'); + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/journey/journey-domain.service.ts b/server/src/nest/journey/journey-domain.service.ts index e58d943e2..1b6a20df1 100644 --- a/server/src/nest/journey/journey-domain.service.ts +++ b/server/src/nest/journey/journey-domain.service.ts @@ -888,19 +888,27 @@ export class JourneyDomainService { * `getCountryFromCoords`, which is the same answer computed rather than * remembered. That import is a plain function from a module built to have no * DI and no Nest edges, so it costs this domain no coupling to Atlas. + * + * ── Stops switched off ───────────────────────────────────────────────── + * + * An entry marked `stats_excluded` is in the journal and in nothing here: + * not on the route, not in the distance, not a step, not a country + * (discussion #2064). It is still named, under `excluded`, because the only + * way to switch a stop back on is to be able to see it. */ journeyStats(journeyId: number, userId: number): JourneyStats | null { if (!this.canAccessJourney(journeyId, userId)) return null; const entryRows = this.db.prepare(` - SELECT id, title, location_name, location_lat, location_lng, entry_date, source_trip_id + SELECT id, title, location_name, location_lat, location_lng, entry_date, source_trip_id, + source_place_id, stats_excluded FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC `).all(journeyId) as { id: number; title: string | null; location_name: string | null; location_lat: number | null; location_lng: number | null; entry_date: string | null; - source_trip_id: number | null; + source_trip_id: number | null; source_place_id: number | null; stats_excluded: number; }[]; /* @@ -1006,8 +1014,33 @@ export class JourneyDomainService { return getCountryFromCoords(lat, lng); }; - const fromEntries: StatsInputPoint[] = entryRows - .filter(e => Number.isFinite(e.location_lat) && Number.isFinite(e.location_lng)) + /* + * The switch is honoured before anything is measured, on both routes. + * + * Only an entry with coordinates is listed under `excluded`: one without + * a place was never a stop, and offering to switch it back on would offer + * a stop that does not exist. It still stops being a step, because the + * traveller said it does not count. + * + * The fallback has to honour it too. A skeleton is the entry TREK derives + * from a trip place, so switching the skeleton off and then drawing the + * place it came from would put the same stop straight back on the map + * under its other name. Hence the set of places to leave out. + */ + const hasCoords = (e: { location_lat: number | null; location_lng: number | null }) => + Number.isFinite(e.location_lat) && Number.isFinite(e.location_lng); + const counting = entryRows.filter(e => !e.stats_excluded); + const excluded = entryRows + .filter(e => e.stats_excluded && hasCoords(e)) + .map(e => ({ entryId: e.id, label: e.title || e.location_name || '', date: e.entry_date ?? null })); + const excludedPlaceIds = new Set( + entryRows + .filter(e => e.stats_excluded && e.source_place_id != null) + .map(e => e.source_place_id as number), + ); + + const fromEntries: StatsInputPoint[] = counting + .filter(hasCoords) .map(e => ({ lat: e.location_lat as number, lng: e.location_lng as number, @@ -1016,12 +1049,24 @@ export class JourneyDomainService { country: countryAt(e.location_lat as number, e.location_lng as number), tripId: e.source_trip_id ?? null, photoId: photoByEntry.get(e.id) ?? null, + entryId: e.id, })); - const points: StatsInputPoint[] = fromEntries.length + /* + * Which source wins is decided before the switches, not after. + * + * `fromEntries.length` was the test, and with a switch in the world it + * answers the wrong question: a journey whose every stop has been switched + * off has no entry points left, so the fallback would draw the trip's + * places instead and put the whole route back under other names. The + * journal is the source as soon as one entry carries a point, whether or + * not it still counts. + */ + const journalHasPoints = entryRows.some(hasCoords); + const points: StatsInputPoint[] = journalHasPoints ? fromEntries : placeRows - .filter(p => Number.isFinite(p.lat) && Number.isFinite(p.lng)) + .filter(p => !excludedPlaceIds.has(p.id) && Number.isFinite(p.lat) && Number.isFinite(p.lng)) .map(p => ({ lat: p.lat as number, lng: p.lng as number, @@ -1034,6 +1079,8 @@ export class JourneyDomainService { // smuggled into a field the book will print as one of the journey's // own pictures. photoId: null, + // No entry to switch off, so a panel lists it and offers no switch. + entryId: null, })); /* @@ -1051,9 +1098,11 @@ export class JourneyDomainService { return computeJourneyStats({ journeyId, points, - entries: entryRows.length, + entries: counting.length, photos: photoCount?.n ?? 0, - places: placeCount?.n ?? 0, + // A place whose skeleton was switched off is not a place the journey + // went to, whichever route drew it. + places: (placeCount?.n ?? 0) - placeRows.filter(p => excludedPlaceIds.has(p.id)).length, tripDates, countryNames: countryNamesFor(points), trips: tripRows.map(t => ({ @@ -1063,6 +1112,7 @@ export class JourneyDomainService { end: t.end, points: perTrip.get(t.id) ?? 0, })), + excluded, }); } @@ -1182,6 +1232,7 @@ export class JourneyDomainService { pros_cons: { pros: string[]; cons: string[] }; visibility: string; sort_order: number; + stats_excluded: boolean; }>, sid?: string, ): JourneyEntryWire | null { @@ -1210,6 +1261,7 @@ export class JourneyDomainService { 'pros_cons', 'visibility', 'sort_order', + 'stats_excluded', ]); for (const [key, val] of Object.entries(data)) { @@ -1221,6 +1273,10 @@ export class JourneyDomainService { } else if (key === 'pros_cons') { fields.push('pros_cons = ?'); values.push(val && typeof val === 'object' ? JSON.stringify(val) : val); + } else if (key === 'stats_excluded') { + // INTEGER column, and better-sqlite3 refuses to bind a boolean. + fields.push('stats_excluded = ?'); + values.push(val ? 1 : 0); } else { fields.push(`${key} = ?`); values.push(val); diff --git a/server/src/nest/journey/journey-entry-row.ts b/server/src/nest/journey/journey-entry-row.ts index 0ce00035f..2f77855d8 100644 --- a/server/src/nest/journey/journey-entry-row.ts +++ b/server/src/nest/journey/journey-entry-row.ts @@ -20,18 +20,25 @@ import type { JourneyEntry } from '../../types'; * breaks on it today — the journey page reloads on any event and ignores the * payload — but a payload that contradicts what the read paths answer is a trap * for the first listener that decides to trust it. + * + * `stats_excluded` is the same gap one column over: an INTEGER holding 0 or 1 + * that the wire carries as a boolean (discussion #2064). It crosses here for + * the reason the JSON does, so that a client comparing it with `=== true` is + * comparing against something that can be true on every path. */ -export interface JourneyEntryWire extends Omit { +export interface JourneyEntryWire extends Omit { tags: string[]; pros_cons: { pros: string[]; cons: string[] } | null; + stats_excluded: boolean; } -/** Decode a row's JSON columns. The one place that knows they are JSON. */ +/** Decode a row's JSON columns and its flag. The one place that knows how they are stored. */ export function decodeEntryRow(row: JourneyEntry): JourneyEntryWire { - const { tags, pros_cons, ...rest } = row; + const { tags, pros_cons, stats_excluded, ...rest } = row; return { ...rest, tags: tags ? JSON.parse(tags) : [], pros_cons: pros_cons ? JSON.parse(pros_cons) : null, + stats_excluded: !!stats_excluded, }; } diff --git a/server/src/nest/journey/journey-stats.ts b/server/src/nest/journey/journey-stats.ts index 98d5ac9d4..e6b0f0426 100644 --- a/server/src/nest/journey/journey-stats.ts +++ b/server/src/nest/journey/journey-stats.ts @@ -1,4 +1,6 @@ -import type { JourneyStats, JourneyStatsCountry, JourneyStatsPoint, JourneyStatsTrip } from '@trek/shared'; +import type { + JourneyStats, JourneyStatsCountry, JourneyStatsExcludedStop, JourneyStatsPoint, JourneyStatsTrip, +} from '@trek/shared'; /** * What a journey adds up to — the arithmetic, with no database and no Nest. @@ -27,6 +29,8 @@ export interface StatsInputPoint { tripId?: number | null; /** One photograph from this stop, for a map that marks it with a picture. */ photoId?: number | null; + /** The journal entry this stop was read from; null or absent for a trip place. */ + entryId?: number | null; } const rad = (deg: number) => (deg * Math.PI) / 180; @@ -166,6 +170,15 @@ export interface StatsInput { countryNames: Record; /** The trips themselves, so a book can print a map of one of them. */ trips?: JourneyStatsTrip[]; + /** + * Stops switched off by hand (discussion #2064). + * + * Not in `points`, and that is the whole arrangement: the service takes + * them out before anything here is measured, so nothing in this module has + * to know which stop is which. The list only rides along so that one + * answer holds both halves, the route and what was left off it. + */ + excluded?: JourneyStatsExcludedStop[]; } /** @@ -206,6 +219,7 @@ export function computeJourneyStats(input: StatsInput): JourneyStats { country: p.country, tripId: p.tripId ?? null, photoId: p.photoId ?? null, + entryId: p.entryId ?? null, })), /* * Counted after thinning, not before. @@ -219,6 +233,7 @@ export function computeJourneyStats(input: StatsInput): JourneyStats { ...t, points: points.filter(p => p.tripId === t.id).length, })), + excluded: input.excluded ?? [], start, end, }; diff --git a/server/src/nest/journey/journey.mcp.ts b/server/src/nest/journey/journey.mcp.ts index 49222cdbb..47d3b99be 100644 --- a/server/src/nest/journey/journey.mcp.ts +++ b/server/src/nest/journey/journey.mcp.ts @@ -143,7 +143,7 @@ export class JourneyMcp { @Tool({ name: 'get_journey_stats', - description: 'What a journey adds up to: distance travelled in metres, calendar days spanned, countries in visit order, the furthest point reached, and entry, photo and place counts. Prefer this over get_journey whenever the question is about totals, since the stats get_journey carries are three counts and nothing else.', + description: 'What a journey adds up to: distance travelled in metres, calendar days spanned, countries in visit order, the furthest point reached, and entry, photo and place counts. Stops the traveller switched off with update_journey_entry count towards none of those and are listed under excluded instead. Prefer this over get_journey whenever the question is about totals, since the stats get_journey carries are three counts and nothing else.', inputSchema: { journeyId: z.number().int().positive(), include_route: z.boolean().optional().describe('Also return the route itself, up to 400 stops with coordinates. Off by default: the totals and the country list do not need it.'), @@ -348,7 +348,7 @@ export class JourneyMcp { @Tool({ name: 'update_journey_entry', - description: 'Update an existing journey entry: its text, date, place, coordinates, weather, tags, verdict or visibility. Fields left out keep their value; pass null to clear one. To move an entry within its day use reorder_journey_entries rather than setting sort_order here.', + description: 'Update an existing journey entry: its text, date, place, coordinates, weather, tags, verdict, visibility, or whether it counts as a stop. Fields left out keep their value; pass null to clear one. To move an entry within its day use reorder_journey_entries rather than setting sort_order here.', inputSchema: { entryId: z.number().int().positive(), title: z.string().max(300).nullable().optional(), @@ -365,6 +365,7 @@ export class JourneyMcp { visibility: ENTRY_VISIBILITY.optional(), type: ENTRY_TYPE.optional().describe('Promote a trip-derived "skeleton" to a real "entry" once it has been written up'), sort_order: z.number().int().min(0).optional(), + stats_excluded: z.boolean().optional().describe('True leaves the entry in the journal but takes it off the journey route and out of its distance, countries and step count (see get_journey_stats); false puts it back. For the home airport, a stopover, the place the trip was planned from'), }, annotations: TOOL_ANNOTATIONS_WRITE, when: journeyAddonOn, @@ -376,7 +377,7 @@ export class JourneyMcp { entry_time?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; mood?: string | null; weather?: string | null; tags?: string[] | null; pros_cons?: { pros: string[]; cons: string[] } | null; - visibility?: EntryVisibility; type?: EntryType; sort_order?: number; + visibility?: EntryVisibility; type?: EntryType; sort_order?: number; stats_excluded?: boolean; }, ctx: McpContext, ) { diff --git a/server/src/types.ts b/server/src/types.ts index d0b383291..aed7164d6 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -382,6 +382,12 @@ export interface JourneyEntry { pros_cons?: string | null; visibility: 'private' | 'shared' | 'public'; sort_order: number; + /** + * 0/1 as the row holds it. Switched on, the entry stays in the journal but + * is left out of the route and the figures Studio prints (discussion #2064). + * The wire carries a boolean; journey-entry-row.ts is where the two meet. + */ + stats_excluded: number; created_at: number; updated_at: number; } diff --git a/server/tests/e2e/journey.e2e.test.ts b/server/tests/e2e/journey.e2e.test.ts index 8e049a89b..954ed2b2d 100644 --- a/server/tests/e2e/journey.e2e.test.ts +++ b/server/tests/e2e/journey.e2e.test.ts @@ -49,7 +49,7 @@ vi.mock('../../src/nest/memories/photo-resolver.service', async (importOriginal) const { jsvc } = vi.hoisted(() => ({ jsvc: { listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn(), - journeyStats: vi.fn(), + journeyStats: vi.fn(), updateEntry: vi.fn(), }, })); import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service'; @@ -153,6 +153,30 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => { expect(res.body).toEqual({ error: 'Journey not found' }); }); + /* + * The stop switch (discussion #2064). The entry body is a loose contract, so + * what e2e pins is that the boolean reaches the service as it was sent, + * through the addon gate and the auth guard, and that the entry comes back + * bare rather than in an envelope. + */ + it('PATCH entries/:entryId hands stats_excluded to the service as the boolean it was sent', async () => { + jsvc.updateEntry.mockReturnValue({ id: 3, journey_id: 9, stats_excluded: true }); + const res = await request(server) + .patch('/api/journeys/entries/3') + .set('Cookie', sessionCookie(1)) + .send({ stats_excluded: true }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ id: 3, journey_id: 9, stats_excluded: true }); + const [entryId, userId, body] = jsvc.updateEntry.mock.calls[0]; + expect([entryId, userId]).toEqual([3, 1]); + expect(body).toEqual({ stats_excluded: true }); + }); + + it('401 for PATCH entries/:entryId without a session', async () => { + const res = await request(server).patch('/api/journeys/entries/3').send({ stats_excluded: true }); + expect(res.status).toBe(401); + }); + /* * The journey figures TREK Studio prints (#1973). Read-only and derived, so * what e2e adds over the controller unit test is that the addon gate and the diff --git a/server/tests/helpers/factories.ts b/server/tests/helpers/factories.ts index 51596001b..6407bd9d1 100644 --- a/server/tests/helpers/factories.ts +++ b/server/tests/helpers/factories.ts @@ -703,18 +703,20 @@ export interface TestJourneyEntry { entry_date: string; title: string | null; story: string | null; + /** 0/1, as the column holds it. */ + stats_excluded: number; } export function createJourneyEntry( db: Database.Database, journeyId: number, authorId: number, - overrides: Partial<{ type: string; entry_date: string; title: string; story: string; location_name: string; mood: string; weather: string }> = {} + overrides: Partial<{ type: string; entry_date: string; title: string; story: string; location_name: string; mood: string; weather: string; stats_excluded: number }> = {} ): TestJourneyEntry { const now = Date.now(); const result = db.prepare(` - INSERT INTO journey_entries (journey_id, author_id, type, entry_date, title, story, location_name, mood, weather, visibility, sort_order, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'private', 0, ?, ?) + INSERT INTO journey_entries (journey_id, author_id, type, entry_date, title, story, location_name, mood, weather, stats_excluded, visibility, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'private', 0, ?, ?) `).run( journeyId, authorId, overrides.type ?? 'entry', @@ -724,6 +726,7 @@ export function createJourneyEntry( overrides.location_name ?? null, overrides.mood ?? null, overrides.weather ?? null, + overrides.stats_excluded ?? 0, now, now ); return db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(result.lastInsertRowid) as TestJourneyEntry; diff --git a/server/tests/unit/mcp/tools-journey.test.ts b/server/tests/unit/mcp/tools-journey.test.ts index 5f2d63f5c..cdabc0b06 100644 --- a/server/tests/unit/mcp/tools-journey.test.ts +++ b/server/tests/unit/mcp/tools-journey.test.ts @@ -619,6 +619,59 @@ describe('journey entry fields', () => { }); }); + /* + * The stop switch (discussion #2064). The REST route takes it, so the tool + * does too, and get_journey_stats is where the effect shows. + */ + it('update_journey_entry switches a stop out of the figures and back', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const journey = await seedJourney(h); + const airport = await seedEntry(h, journey.id, { + title: 'Keflavík', entry_date: '2026-06-01', location_lat: 63.98, location_lng: -22.62, + }); + await seedEntry(h, journey.id, { + title: 'Vík', entry_date: '2026-06-03', location_lat: 63.42, location_lng: -19.01, + }); + + const data = parseToolResult(await h.client.callTool({ + name: 'update_journey_entry', arguments: { entryId: airport.id, stats_excluded: true }, + })) as any; + expect(entryRow(airport.id).stats_excluded).toBe(1); + // The echo carries the boolean, as the REST answer does. + expect(data.entry.stats_excluded).toBe(true); + + const off = parseToolResult(await h.client.callTool({ + name: 'get_journey_stats', arguments: { journeyId: journey.id, include_route: true }, + })) as any; + expect(off.stats.points.map((p: any) => p.label)).toEqual(['Vík']); + expect(off.stats.steps).toBe(1); + expect(off.stats.excluded).toEqual([{ entryId: airport.id, label: 'Keflavík', date: '2026-06-01' }]); + + await h.client.callTool({ + name: 'update_journey_entry', arguments: { entryId: airport.id, stats_excluded: false }, + }); + const on = parseToolResult(await h.client.callTool({ + name: 'get_journey_stats', arguments: { journeyId: journey.id }, + })) as any; + expect(entryRow(airport.id).stats_excluded).toBe(0); + expect(on.stats.steps).toBe(2); + expect(on.stats.excluded).toEqual([]); + }); + }); + + it('update_journey_entry refuses a stats_excluded that is not a boolean', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const journey = await seedJourney(h); + const entry = await seedEntry(h, journey.id); + expect((await h.client.callTool({ + name: 'update_journey_entry', arguments: { entryId: entry.id, stats_excluded: 'yes' }, + })).isError).toBe(true); + expect(entryRow(entry.id).stats_excluded).toBe(0); + }); + }); + it('update_journey_entry refuses an unknown visibility and an out-of-range longitude', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { diff --git a/server/tests/unit/nest/journey-domain.service.test.ts b/server/tests/unit/nest/journey-domain.service.test.ts index e7fe2afee..f0ee58146 100644 --- a/server/tests/unit/nest/journey-domain.service.test.ts +++ b/server/tests/unit/nest/journey-domain.service.test.ts @@ -518,6 +518,25 @@ describe('listEntries', () => { expect(result).toBeNull(); }); + + /* + * The stop switch reads back as a boolean on every path (discussion #2064). + * The column holds 0 or 1; a client handed the integer on one read and the + * boolean on another could compare against neither. + */ + it('answers stats_excluded as a boolean, on the list and on the full journey alike', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const off = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', stats_excluded: 1 }); + const on = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-02' }); + + const listed = svc.listEntries(journey.id, user.id)!; + expect(listed.find(e => e.id === off.id)!.stats_excluded).toBe(true); + expect(listed.find(e => e.id === on.id)!.stats_excluded).toBe(false); + + const full = svc.getJourneyFull(journey.id, user.id)!; + expect(full.entries.map(e => e.stats_excluded)).toEqual([true, false]); + }); }); describe('createEntry', () => { @@ -667,6 +686,46 @@ describe('updateEntry', () => { } }); + /* + * The stop switch (discussion #2064). The column is an INTEGER, the wire is + * a boolean and the client compares with `=== true`, so the answer has to + * be the boolean on the way out as well as the integer on the way in, and + * the broadcast has to say the same thing the answer does. + */ + it('switches a stop off and back on, and answers with the flag as a boolean', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); + const flagOf = (id: number) => + (testDb.prepare('SELECT stats_excluded FROM journey_entries WHERE id = ?').get(id) as { stats_excluded: number }).stats_excluded; + + const spy = vi.spyOn(RealtimeService.prototype, 'broadcastToUser').mockImplementation(() => {}); + try { + const off = svc.updateEntry(entry.id, user.id, { stats_excluded: true }); + expect(off!.stats_excluded).toBe(true); + expect(flagOf(entry.id)).toBe(1); + const payload = spy.mock.calls.at(-1)?.[1] as { type: string; entry: { stats_excluded: unknown } }; + expect(payload.entry.stats_excluded).toBe(true); + + const on = svc.updateEntry(entry.id, user.id, { stats_excluded: false }); + expect(on!.stats_excluded).toBe(false); + expect(flagOf(entry.id)).toBe(0); + } finally { + spy.mockRestore(); + } + }); + + it('a viewer cannot switch a stop off', () => { + const { user: owner } = createUser(testDb); + const { user: viewer } = createUser(testDb); + const journey = createJourney(testDb, owner.id); + addJourneyContributor(testDb, journey.id, viewer.id, 'viewer'); + const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' }); + + expect(svc.updateEntry(entry.id, viewer.id, { stats_excluded: true })).toBeNull(); + expect(svc.listEntries(journey.id, owner.id)![0].stats_excluded).toBe(false); + }); + it('JOURNEY-SVC-034: returns null for non-existent entry', () => { const { user } = createUser(testDb); diff --git a/server/tests/unit/nest/journey-stats.service.test.ts b/server/tests/unit/nest/journey-stats.service.test.ts index 26d2e4407..2478923bf 100644 --- a/server/tests/unit/nest/journey-stats.service.test.ts +++ b/server/tests/unit/nest/journey-stats.service.test.ts @@ -76,7 +76,7 @@ function placeEntry( authorId: number, lat: number, lng: number, - overrides: { title?: string; entry_date?: string } = {}, + overrides: { title?: string; entry_date?: string; stats_excluded?: number } = {}, ) { const entry = createJourneyEntry(testDb, journeyId, authorId, overrides); testDb @@ -356,3 +356,159 @@ describe('journeyStats totals', () => { expect(svc.journeyStats(journey.id, helper.id)).not.toBeNull(); }); }); + +/* + * The stop somebody switched off (discussion #2064): the home airport, the + * stopover, the place the trip was planned from. It stays in the journal and + * leaves every figure, and it is named under `excluded` so that it can be + * switched back on. + */ +describe('journeyStats excluded stops', () => { + it('leaves a switched-off entry out of the route, the distance and the countries', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + placeEntry(journey.id, user.id, 52.52, 13.4, { title: 'Berlin, the airport', entry_date: '2026-06-01', stats_excluded: 1 }); + placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' }); + placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'Akureyri', entry_date: '2026-06-06' }); + + const stats = svc.journeyStats(journey.id, user.id)!; + + expect(stats.points.map(p => p.label)).toEqual(['Reykjavík', 'Akureyri']); + // Reykjavík to Akureyri and nothing else; the leg in from Berlin is gone. + expect(stats.distance).toBeLessThan(300_000); + expect(stats.countries.map(c => c.code)).toEqual(['IS']); + expect(stats.start).toBe('2026-06-02'); + expect(stats.days).toBe(5); + }); + + it('names the switched-off stop under excluded so it can be switched back on', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const airport = placeEntry(journey.id, user.id, 63.98, -22.62, { title: 'Keflavík', entry_date: '2026-06-01', stats_excluded: 1 }); + placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' }); + + const stats = svc.journeyStats(journey.id, user.id)!; + + expect(stats.excluded).toEqual([{ entryId: airport.id, label: 'Keflavík', date: '2026-06-01' }]); + }); + + it('does not count a switched-off entry as a step', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + placeEntry(journey.id, user.id, 63.98, -22.62, { entry_date: '2026-06-01', stats_excluded: 1 }); + placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); + + expect(svc.journeyStats(journey.id, user.id)!.steps).toBe(1); + }); + + /* An entry without a place was never a stop, so there is nothing to offer back. */ + it('lists nothing under excluded for a switched-off entry without coordinates', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + createJourneyEntry(testDb, journey.id, user.id, { title: 'a thought', entry_date: '2026-06-03', stats_excluded: 1 }); + placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); + + const stats = svc.journeyStats(journey.id, user.id)!; + + expect(stats.excluded).toEqual([]); + expect(stats.steps).toBe(1); + }); + + it('carries the entry on every stop read from the entries', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const a = placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); + const b = placeEntry(journey.id, user.id, 65.68, -18.12, { entry_date: '2026-06-06' }); + + expect(svc.journeyStats(journey.id, user.id)!.points.map(p => p.entryId)).toEqual([a.id, b.id]); + }); + + it('drops the place behind a switched-off skeleton from the place count', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const trip = createTrip(testDb, user.id); + linkTripToJourney(testDb, journey.id, trip.id); + const airport = createPlace(testDb, trip.id, { name: 'Keflavík', lat: 63.98, lng: -22.62 }); + const town = createPlace(testDb, trip.id, { name: 'Vík', lat: 63.42, lng: -19.01 }); + createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-01' }).id, airport.id); + createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-02' }).id, town.id); + svc.syncTripPlaces(journey.id, trip.id, user.id); + testDb + .prepare('UPDATE journey_entries SET stats_excluded = 1 WHERE journey_id = ? AND source_place_id = ?') + .run(journey.id, airport.id); + + const stats = svc.journeyStats(journey.id, user.id)!; + + // The town's skeleton still draws the route, so this is the entries path. + expect(stats.points.map(p => p.label)).toEqual(['Vík']); + expect(stats.excluded.map(s => s.label)).toEqual(['Keflavík']); + expect(stats.places).toBe(1); + }); + + /* + * The fallback honours the switch too. A skeleton is the entry TREK derives + * from a trip place; switching it off and then drawing the place it came + * from would put the same stop straight back on the map by its other name. + */ + it('leaves the place behind a switched-off skeleton out of the fallback route', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const trip = createTrip(testDb, user.id); + linkTripToJourney(testDb, journey.id, trip.id); + const airport = createPlace(testDb, trip.id, { name: 'Keflavík', lat: 63.98, lng: -22.62 }); + createPlace(testDb, trip.id, { name: 'Vík', lat: 63.42, lng: -19.01 }); + // Only the airport is on a day, so only the airport gets a skeleton; the + // town stays a bare trip place that the fallback is there to draw. + createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-01' }).id, airport.id); + svc.syncTripPlaces(journey.id, trip.id, user.id); + // Switched off, and its point cleared by hand afterwards, which is what + // sends the journey down the fallback while the skeleton still names the + // place it came from. + testDb + .prepare(` + UPDATE journey_entries + SET stats_excluded = 1, location_lat = NULL, location_lng = NULL + WHERE journey_id = ? AND source_place_id = ? + `) + .run(journey.id, airport.id); + + const stats = svc.journeyStats(journey.id, user.id)!; + + expect(stats.points.map(p => p.label)).toEqual(['Vík']); + expect(stats.points[0].entryId).toBeNull(); + expect(stats.steps).toBe(0); + expect(stats.places).toBe(1); + }); + + /* + * The pair of cases that decide which source a journey reads from, now that + * a stop can be taken out of one of them. + */ + it('draws no route at all when every stop of the journal is switched off', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const trip = createTrip(testDb, user.id); + linkTripToJourney(testDb, journey.id, trip.id); + createPlace(testDb, trip.id, { name: 'a trip place', lat: 48.85, lng: 2.35 }); + placeEntry(journey.id, user.id, 52.52, 13.4, { title: 'Berlin, the airport', entry_date: '2026-06-01', stats_excluded: 1 }); + + const stats = svc.journeyStats(journey.id, user.id)!; + + expect(stats.points).toEqual([]); + expect(stats.distance).toBe(0); + expect(stats.excluded.map(s => s.label)).toEqual(['Berlin, the airport']); + }); + + it('still falls back to the trip places for a journal that never had a point', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const trip = createTrip(testDb, user.id); + linkTripToJourney(testDb, journey.id, trip.id); + createPlace(testDb, trip.id, { name: 'a trip place', lat: 48.85, lng: 2.35 }); + createJourneyEntry(testDb, journey.id, user.id, { title: 'written, not placed', stats_excluded: 1 }); + + const stats = svc.journeyStats(journey.id, user.id)!; + + expect(stats.points.map(p => p.label)).toEqual(['a trip place']); + }); +}); diff --git a/server/tests/unit/nest/journey-stats.test.ts b/server/tests/unit/nest/journey-stats.test.ts index d3c394d1b..610f87a4d 100644 --- a/server/tests/unit/nest/journey-stats.test.ts +++ b/server/tests/unit/nest/journey-stats.test.ts @@ -331,11 +331,34 @@ describe('computeJourneyStats', () => { expect(stats.points[0]).toEqual({ // `tripId` is null when the stop came from an entry nobody linked to a // trip, which is every stop here — the field is what lets a book print a - // map of one trip out of several. - lat: 64, lng: -22, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null, + // map of one trip out of several. `entryId` is null the same way: the + // fixture names no entry, so there is none to switch off. + lat: 64, lng: -22, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null, entryId: null, }); }); + it('keeps the entry on each stop, and null for a stop that came from a trip place', () => { + const stats = computeJourneyStats({ + ...base, + points: [point({ lat: 64, lng: -22, entryId: 12 }), point({ lat: 65, lng: -18 })], + }); + expect(stats.points.map(p => p.entryId)).toEqual([12, null]); + }); + + /* + * The stops switched off by hand ride through untouched (discussion #2064). + * The service takes them out of `points` before anything here is measured, + * so all this has to do is hand the list back beside the route it was left + * off, and hand back nothing when it was given nothing. + */ + it('carries the excluded stops through beside the route', () => { + const excluded = [{ entryId: 4, label: 'Keflavík', date: '2026-06-01' }]; + const stats = computeJourneyStats({ ...base, points: [point({ lat: 64, lng: -22 })], excluded }); + expect(stats.excluded).toEqual(excluded); + expect(stats.points).toHaveLength(1); + expect(computeJourneyStats({ ...base, points: [] }).excluded).toEqual([]); + }); + it('names the countries from the map it was given', () => { const stats = computeJourneyStats({ ...base, @@ -361,6 +384,7 @@ describe('computeJourneyStats', () => { countries: [], points: [], trips: [], + excluded: [], start: null, end: null, }); diff --git a/shared/src/book/journey-stats.schema.ts b/shared/src/book/journey-stats.schema.ts index 79059a548..bc75d0528 100644 --- a/shared/src/book/journey-stats.schema.ts +++ b/shared/src/book/journey-stats.schema.ts @@ -55,9 +55,33 @@ export const journeyStatsPointSchema = z.object({ * photographed honestly is. */ photoId: z.number().int().positive().nullable().default(null), + /** + * The journal entry this stop was read from, when it was one. + * + * A stop can be switched off (see `excluded` below) and the switch lives + * on the entry, so a panel that offers it needs to know which entry a point + * came from. Null for a stop taken from a trip place directly, which is the + * fallback route a journey gets before anyone has written an entry. + */ + entryId: z.number().int().positive().nullable().default(null), }); export type JourneyStatsPoint = z.infer; +/** + * A stop that was left out. + * + * The home airport, the stopover, the place the trip was planned from: a + * journal entry can be marked as not counting, and then it is not on the + * route, not in the distance and not in the countries. It is still listed + * here, because the only way to put a stop back is to be able to see it. + */ +export const journeyStatsExcludedStopSchema = z.object({ + entryId: z.number().int().positive(), + label: z.string(), + date: z.string().nullable(), +}); +export type JourneyStatsExcludedStop = z.infer; + /** * The trips behind a journey, named. * @@ -111,6 +135,8 @@ export const journeyStatsSchema = z.object({ points: z.array(journeyStatsPointSchema).max(400), /** The trips this journey is made of, for a map of one of them. */ trips: z.array(journeyStatsTripSchema).max(200).default([]), + /** Stops switched off by hand. Not in `points` and not in any figure. */ + excluded: z.array(journeyStatsExcludedStopSchema).max(400).default([]), /** ISO dates, null when nothing in the journey is dated. */ start: z.string().nullable(), end: z.string().nullable(), diff --git a/shared/src/i18n/ar/journey.ts b/shared/src/i18n/ar/journey.ts index 509cbe28b..0e85cbd1a 100644 --- a/shared/src/i18n/ar/journey.ts +++ b/shared/src/i18n/ar/journey.ts @@ -595,5 +595,33 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'تدوير لليسار', 'journey.studio.rotateRight': 'تدوير لليمين', 'journey.studio.saveReadOnly': 'للقراءة فقط، لا يتم الحفظ', + 'journey.studio.filterPhotos': 'تصفية الصور', + 'journey.studio.filterAll': 'كل الصور', + 'journey.studio.filterLoose': 'ليست ضمن إدخال', + 'journey.studio.filterRecent': 'رُفعت للتو', + 'journey.studio.filterThisPage': 'هذه الصفحة', + 'journey.studio.entryPhotos': 'الصور', + 'journey.studio.noEntryPhotos': 'لا توجد صور في هذا الإدخال بعد.', + 'journey.studio.noLoosePhotos': 'كل صورة هنا تنتمي إلى إدخال.', + 'journey.studio.uploadPhotos': 'رفع الصور', + 'journey.studio.uploadHint': 'أفلت الصور هنا أو انقر لاختيارها', + 'journey.studio.uploadToEntry': 'ستُضاف الصور الجديدة إلى هذا الإدخال', + 'journey.studio.uploadToGallery': 'ستُضاف الصور الجديدة إلى المعرض', + 'journey.studio.uploading': 'جارٍ رفع {done} من {total}', + 'journey.studio.dropFilesHere': 'أفلت لإضافة الصور', + 'journey.studio.videosSkipped': 'تم تجاهل {count} من مقاطع الفيديو. الكتاب يحتوي على صور فقط.', + 'journey.studio.fillPage': 'ملء الصفحة', + 'journey.studio.fillSpread': 'ملء الصفحتين المتقابلتين', + 'journey.studio.fillHint': 'يمدّ الصورة على الصفحة كاملة بما في ذلك الفيض، ويضعها خلف كل شيء آخر.', + 'journey.studio.stops': 'المحطات', + 'journey.studio.stopsHint': 'كل محطة تُحتسب ضمن المسار والمسافة والدول. أوقف واحدة لاستبعادها، مثل مطار بلدك.', + 'journey.studio.stopOn': 'محتسبة', + 'journey.studio.stopOff': 'مستبعدة', + 'journey.studio.stopToggle': 'احتساب هذه المحطة', + 'journey.studio.stopsFailed': 'تعذّر تغيير المحطة.', + 'journey.editor.statsExcluded': 'استبعاد من المسار', + 'journey.editor.statsExcludedHint': + 'تبقى المحطة في اليوميات لكنها لا تُحتسب ضمن المسافة أو الدول أو الخريطة في Studio.', + 'journey.entry.offRoute': 'خارج المسار', }; export default journey; diff --git a/shared/src/i18n/br/journey.ts b/shared/src/i18n/br/journey.ts index 8770cbd2d..dccac328a 100644 --- a/shared/src/i18n/br/journey.ts +++ b/shared/src/i18n/br/journey.ts @@ -596,5 +596,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Girar à esquerda', 'journey.studio.rotateRight': 'Girar à direita', 'journey.studio.saveReadOnly': 'Somente leitura, nada é salvo', + 'journey.studio.filterPhotos': 'Filtrar fotos', + 'journey.studio.filterAll': 'Todas as fotos', + 'journey.studio.filterLoose': 'Sem entrada', + 'journey.studio.filterRecent': 'Recém-enviadas', + 'journey.studio.filterThisPage': 'Esta página', + 'journey.studio.entryPhotos': 'Fotos', + 'journey.studio.noEntryPhotos': 'Esta entrada ainda não tem fotos.', + 'journey.studio.noLoosePhotos': 'Todas as fotos aqui pertencem a uma entrada.', + 'journey.studio.uploadPhotos': 'Enviar fotos', + 'journey.studio.uploadHint': 'Solte imagens aqui ou clique para escolher', + 'journey.studio.uploadToEntry': 'As novas imagens vão para esta entrada', + 'journey.studio.uploadToGallery': 'As novas imagens vão para a galeria', + 'journey.studio.uploading': 'Enviando {done} de {total}', + 'journey.studio.dropFilesHere': 'Solte para adicionar as imagens', + 'journey.studio.videosSkipped': '{count} vídeos ficaram de fora. Um livro contém imagens.', + 'journey.studio.fillPage': 'Preencher página', + 'journey.studio.fillSpread': 'Preencher página dupla', + 'journey.studio.fillHint': + 'Estica a imagem por toda a página, incluindo a sangria, e a envia para trás de todo o resto.', + 'journey.studio.stops': 'Paradas', + 'journey.studio.stopsHint': + 'Cada parada conta para a rota, a distância e os países. Desligue uma para deixá-la de fora, por exemplo o seu aeroporto de origem.', + 'journey.studio.stopOn': 'Conta', + 'journey.studio.stopOff': 'De fora', + 'journey.studio.stopToggle': 'Contar esta parada', + 'journey.studio.stopsFailed': 'Não foi possível alterar a parada.', + 'journey.editor.statsExcluded': 'Deixar fora da rota', + 'journey.editor.statsExcludedHint': + 'A parada continua no diário, mas não conta para a distância, os países nem o mapa no Studio.', + 'journey.entry.offRoute': 'Fora da rota', }; export default journey; diff --git a/shared/src/i18n/ca/journey.ts b/shared/src/i18n/ca/journey.ts index 12b589206..a6de506f6 100644 --- a/shared/src/i18n/ca/journey.ts +++ b/shared/src/i18n/ca/journey.ts @@ -596,5 +596,34 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': "Gira cap a l'esquerra", 'journey.studio.rotateRight': 'Gira cap a la dreta', 'journey.studio.saveReadOnly': 'Només lectura, no es desa', + 'journey.studio.filterPhotos': 'Filtra les fotos', + 'journey.studio.filterAll': 'Totes les fotos', + 'journey.studio.filterLoose': 'Sense entrada', + 'journey.studio.filterRecent': 'Acabades de pujar', + 'journey.studio.filterThisPage': 'Aquesta pàgina', + 'journey.studio.entryPhotos': 'Fotos', + 'journey.studio.noEntryPhotos': 'Aquesta entrada encara no té fotos.', + 'journey.studio.noLoosePhotos': "Totes les fotos d'aquí pertanyen a una entrada.", + 'journey.studio.uploadPhotos': 'Puja fotos', + 'journey.studio.uploadHint': 'Deixa anar imatges aquí o fes clic per triar-ne', + 'journey.studio.uploadToEntry': 'Les imatges noves aniran a aquesta entrada', + 'journey.studio.uploadToGallery': 'Les imatges noves aniran a la galeria', + 'journey.studio.uploading': 'Pujant {done} de {total}', + 'journey.studio.dropFilesHere': 'Deixa anar per afegir les imatges', + 'journey.studio.videosSkipped': "S'han deixat fora {count} vídeos. Un llibre conté imatges.", + 'journey.studio.fillPage': 'Omple la pàgina', + 'journey.studio.fillSpread': 'Omple la doble pàgina', + 'journey.studio.fillHint': 'Estira la imatge per tota la pàgina, sagnat inclòs, i la posa darrere de tota la resta.', + 'journey.studio.stops': 'Parades', + 'journey.studio.stopsHint': + "Cada parada compta per a la ruta, la distància i els països. Desactiva'n una per deixar-la fora, per exemple el teu aeroport d'origen.", + 'journey.studio.stopOn': 'Compta', + 'journey.studio.stopOff': 'Exclosa', + 'journey.studio.stopToggle': 'Compta aquesta parada', + 'journey.studio.stopsFailed': "No s'ha pogut canviar la parada.", + 'journey.editor.statsExcluded': 'Deixa fora de la ruta', + 'journey.editor.statsExcludedHint': + 'La parada es manté al diari, però no compta per a la distància, els països ni el mapa de Studio.', + 'journey.entry.offRoute': 'Fora de ruta', }; export default journey; diff --git a/shared/src/i18n/cs/journey.ts b/shared/src/i18n/cs/journey.ts index 6908698f3..c76e7372d 100644 --- a/shared/src/i18n/cs/journey.ts +++ b/shared/src/i18n/cs/journey.ts @@ -596,5 +596,34 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Otočit doleva', 'journey.studio.rotateRight': 'Otočit doprava', 'journey.studio.saveReadOnly': 'Jen ke čtení, neukládá se', + 'journey.studio.filterPhotos': 'Filtrovat fotografie', + 'journey.studio.filterAll': 'Všechny fotografie', + 'journey.studio.filterLoose': 'Bez záznamu', + 'journey.studio.filterRecent': 'Právě nahrané', + 'journey.studio.filterThisPage': 'Tato stránka', + 'journey.studio.entryPhotos': 'Fotografie', + 'journey.studio.noEntryPhotos': 'Tento záznam zatím nemá žádné fotografie.', + 'journey.studio.noLoosePhotos': 'Každá fotografie zde patří k nějakému záznamu.', + 'journey.studio.uploadPhotos': 'Nahrát fotografie', + 'journey.studio.uploadHint': 'Přetáhněte sem obrázky nebo klikněte a vyberte je', + 'journey.studio.uploadToEntry': 'Nové obrázky půjdou do tohoto záznamu', + 'journey.studio.uploadToGallery': 'Nové obrázky půjdou do galerie', + 'journey.studio.uploading': 'Nahrává se {done} z {total}', + 'journey.studio.dropFilesHere': 'Pusťte pro přidání obrázků', + 'journey.studio.videosSkipped': '{count} videí bylo vynecháno. Kniha obsahuje obrázky.', + 'journey.studio.fillPage': 'Vyplnit stránku', + 'journey.studio.fillSpread': 'Vyplnit dvoustranu', + 'journey.studio.fillHint': 'Roztáhne obrázek přes celou stránku včetně spadávky a umístí ho za vše ostatní.', + 'journey.studio.stops': 'Zastávky', + 'journey.studio.stopsHint': + 'Každá zastávka se počítá do trasy, vzdálenosti a zemí. Vypněte ji, pokud ji chcete vynechat, třeba domovské letiště.', + 'journey.studio.stopOn': 'Počítá se', + 'journey.studio.stopOff': 'Vynechána', + 'journey.studio.stopToggle': 'Počítat tuto zastávku', + 'journey.studio.stopsFailed': 'Zastávku se nepodařilo změnit.', + 'journey.editor.statsExcluded': 'Vynechat z trasy', + 'journey.editor.statsExcludedHint': + 'Zastávka zůstane v deníku, ale nepočítá se do vzdálenosti, zemí ani do mapy v Studio.', + 'journey.entry.offRoute': 'Mimo trasu', }; export default journey; diff --git a/shared/src/i18n/de/journey.ts b/shared/src/i18n/de/journey.ts index 707670fcd..78b8db400 100644 --- a/shared/src/i18n/de/journey.ts +++ b/shared/src/i18n/de/journey.ts @@ -596,5 +596,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Nach links drehen', 'journey.studio.rotateRight': 'Nach rechts drehen', 'journey.studio.saveReadOnly': 'Nur lesen, nichts wird gespeichert', + 'journey.studio.filterPhotos': 'Fotos filtern', + 'journey.studio.filterAll': 'Alle Fotos', + 'journey.studio.filterLoose': 'Keinem Eintrag zugeordnet', + 'journey.studio.filterRecent': 'Gerade hochgeladen', + 'journey.studio.filterThisPage': 'Diese Seite', + 'journey.studio.entryPhotos': 'Fotos', + 'journey.studio.noEntryPhotos': 'Dieser Eintrag hat noch keine Fotos.', + 'journey.studio.noLoosePhotos': 'Jedes Foto hier gehört zu einem Eintrag.', + 'journey.studio.uploadPhotos': 'Fotos hochladen', + 'journey.studio.uploadHint': 'Bilder hier ablegen oder klicken, um welche auszuwählen', + 'journey.studio.uploadToEntry': 'Neue Bilder landen in diesem Eintrag', + 'journey.studio.uploadToGallery': 'Neue Bilder landen in der Galerie', + 'journey.studio.uploading': 'Lade {done} von {total} hoch', + 'journey.studio.dropFilesHere': 'Loslassen, um die Bilder hinzuzufügen', + 'journey.studio.videosSkipped': '{count} Videos wurden ausgelassen. Ein Buch enthält Bilder.', + 'journey.studio.fillPage': 'Seite füllen', + 'journey.studio.fillSpread': 'Doppelseite füllen', + 'journey.studio.fillHint': + 'Zieht das Bild über die ganze Seite, Anschnitt inklusive, und legt es hinter alles andere.', + 'journey.studio.stops': 'Stationen', + 'journey.studio.stopsHint': + 'Jede Station zählt für Route, Distanz und Länder. Schalte eine aus, um sie auszulassen, zum Beispiel deinen Heimatflughafen.', + 'journey.studio.stopOn': 'Zählt', + 'journey.studio.stopOff': 'Ausgelassen', + 'journey.studio.stopToggle': 'Diese Station zählen', + 'journey.studio.stopsFailed': 'Die Station konnte nicht geändert werden.', + 'journey.editor.statsExcluded': 'Aus der Route herausnehmen', + 'journey.editor.statsExcludedHint': + 'Die Station bleibt im Tagebuch, zählt aber nicht für Distanz, Länder oder die Karte im Studio.', + 'journey.entry.offRoute': 'Nicht auf der Route', }; export default journey; diff --git a/shared/src/i18n/en/journey.ts b/shared/src/i18n/en/journey.ts index 8b4d1e02d..e302aa350 100644 --- a/shared/src/i18n/en/journey.ts +++ b/shared/src/i18n/en/journey.ts @@ -596,5 +596,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Rotate left', 'journey.studio.rotateRight': 'Rotate right', 'journey.studio.saveReadOnly': 'Read-only, not saved', + 'journey.studio.filterPhotos': 'Filter photos', + 'journey.studio.filterAll': 'All photos', + 'journey.studio.filterLoose': 'Not in an entry', + 'journey.studio.filterRecent': 'Just uploaded', + 'journey.studio.filterThisPage': 'This page', + 'journey.studio.entryPhotos': 'Photos', + 'journey.studio.noEntryPhotos': 'This entry has no photos yet.', + 'journey.studio.noLoosePhotos': 'Every photo here belongs to an entry.', + 'journey.studio.uploadPhotos': 'Upload photos', + 'journey.studio.uploadHint': 'Drop pictures here, or click to choose some', + 'journey.studio.uploadToEntry': 'New pictures go into this entry', + 'journey.studio.uploadToGallery': 'New pictures go into the gallery', + 'journey.studio.uploading': 'Uploading {done} of {total}', + 'journey.studio.dropFilesHere': 'Drop to add the pictures', + 'journey.studio.videosSkipped': '{count} videos were left out. A book holds pictures.', + 'journey.studio.fillPage': 'Fill page', + 'journey.studio.fillSpread': 'Fill spread', + 'journey.studio.fillHint': + 'Stretches the picture over the whole page, bleed included, and sends it behind everything else.', + 'journey.studio.stops': 'Stops', + 'journey.studio.stopsHint': + 'Every stop counts toward the route, the distance and the countries. Switch one off to leave it out, your home airport for instance.', + 'journey.studio.stopOn': 'Counts', + 'journey.studio.stopOff': 'Left out', + 'journey.studio.stopToggle': 'Count this stop', + 'journey.studio.stopsFailed': 'The stop could not be changed.', + 'journey.editor.statsExcluded': 'Leave out of the route', + 'journey.editor.statsExcludedHint': + 'The stop stays in the journal but does not count toward the distance, the countries or the map in Studio.', + 'journey.entry.offRoute': 'Off route', }; export default journey; diff --git a/shared/src/i18n/es/journey.ts b/shared/src/i18n/es/journey.ts index f6e4735fc..6dcaf4011 100644 --- a/shared/src/i18n/es/journey.ts +++ b/shared/src/i18n/es/journey.ts @@ -596,5 +596,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Girar a la izquierda', 'journey.studio.rotateRight': 'Girar a la derecha', 'journey.studio.saveReadOnly': 'Solo lectura, no se guarda', + 'journey.studio.filterPhotos': 'Filtrar fotos', + 'journey.studio.filterAll': 'Todas las fotos', + 'journey.studio.filterLoose': 'Sin entrada', + 'journey.studio.filterRecent': 'Recién subidas', + 'journey.studio.filterThisPage': 'Esta página', + 'journey.studio.entryPhotos': 'Fotos', + 'journey.studio.noEntryPhotos': 'Esta entrada aún no tiene fotos.', + 'journey.studio.noLoosePhotos': 'Todas las fotos de aquí pertenecen a una entrada.', + 'journey.studio.uploadPhotos': 'Subir fotos', + 'journey.studio.uploadHint': 'Suelta imágenes aquí o haz clic para elegirlas', + 'journey.studio.uploadToEntry': 'Las imágenes nuevas irán a esta entrada', + 'journey.studio.uploadToGallery': 'Las imágenes nuevas irán a la galería', + 'journey.studio.uploading': 'Subiendo {done} de {total}', + 'journey.studio.dropFilesHere': 'Suelta para añadir las imágenes', + 'journey.studio.videosSkipped': '{count} vídeos se han dejado fuera. Un libro contiene imágenes.', + 'journey.studio.fillPage': 'Llenar página', + 'journey.studio.fillSpread': 'Llenar doble página', + 'journey.studio.fillHint': + 'Estira la imagen por toda la página, sangrado incluido, y la envía detrás de todo lo demás.', + 'journey.studio.stops': 'Paradas', + 'journey.studio.stopsHint': + 'Cada parada cuenta para la ruta, la distancia y los países. Desactiva una para dejarla fuera, por ejemplo tu aeropuerto de origen.', + 'journey.studio.stopOn': 'Cuenta', + 'journey.studio.stopOff': 'Excluida', + 'journey.studio.stopToggle': 'Contar esta parada', + 'journey.studio.stopsFailed': 'No se pudo cambiar la parada.', + 'journey.editor.statsExcluded': 'Dejar fuera de la ruta', + 'journey.editor.statsExcludedHint': + 'La parada sigue en el diario, pero no cuenta para la distancia, los países ni el mapa en Studio.', + 'journey.entry.offRoute': 'Fuera de ruta', }; export default journey; diff --git a/shared/src/i18n/fr/journey.ts b/shared/src/i18n/fr/journey.ts index dc677b5f9..023963891 100644 --- a/shared/src/i18n/fr/journey.ts +++ b/shared/src/i18n/fr/journey.ts @@ -597,5 +597,34 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Pivoter à gauche', 'journey.studio.rotateRight': 'Pivoter à droite', 'journey.studio.saveReadOnly': "Lecture seule, rien n'est enregistré", + 'journey.studio.filterPhotos': 'Filtrer les photos', + 'journey.studio.filterAll': 'Toutes les photos', + 'journey.studio.filterLoose': 'Sans entrée', + 'journey.studio.filterRecent': 'Tout juste téléversées', + 'journey.studio.filterThisPage': 'Cette page', + 'journey.studio.entryPhotos': 'Photos', + 'journey.studio.noEntryPhotos': "Cette entrée n'a pas encore de photos.", + 'journey.studio.noLoosePhotos': 'Chaque photo ici appartient à une entrée.', + 'journey.studio.uploadPhotos': 'Téléverser des photos', + 'journey.studio.uploadHint': 'Déposez des images ici ou cliquez pour en choisir', + 'journey.studio.uploadToEntry': 'Les nouvelles images iront dans cette entrée', + 'journey.studio.uploadToGallery': 'Les nouvelles images iront dans la galerie', + 'journey.studio.uploading': 'Téléversement de {done} sur {total}', + 'journey.studio.dropFilesHere': 'Relâchez pour ajouter les images', + 'journey.studio.videosSkipped': '{count} vidéos ont été laissées de côté. Un livre contient des images.', + 'journey.studio.fillPage': 'Remplir la page', + 'journey.studio.fillSpread': 'Remplir la double page', + 'journey.studio.fillHint': "Étire l'image sur toute la page, fond perdu compris, et la place derrière tout le reste.", + 'journey.studio.stops': 'Étapes', + 'journey.studio.stopsHint': + "Chaque étape compte pour l'itinéraire, la distance et les pays. Désactivez-en une pour l'exclure, votre aéroport de départ par exemple.", + 'journey.studio.stopOn': 'Comptée', + 'journey.studio.stopOff': 'Exclue', + 'journey.studio.stopToggle': 'Compter cette étape', + 'journey.studio.stopsFailed': "L'étape n'a pas pu être modifiée.", + 'journey.editor.statsExcluded': "Exclure de l'itinéraire", + 'journey.editor.statsExcludedHint': + "L'étape reste dans le journal mais ne compte ni pour la distance, ni pour les pays, ni pour la carte dans Studio.", + 'journey.entry.offRoute': 'Hors itinéraire', }; export default journey; diff --git a/shared/src/i18n/gr/journey.ts b/shared/src/i18n/gr/journey.ts index 5fb69eca6..cfce69bbf 100644 --- a/shared/src/i18n/gr/journey.ts +++ b/shared/src/i18n/gr/journey.ts @@ -598,5 +598,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Περιστροφή αριστερά', 'journey.studio.rotateRight': 'Περιστροφή δεξιά', 'journey.studio.saveReadOnly': 'Μόνο για ανάγνωση, δεν αποθηκεύεται', + 'journey.studio.filterPhotos': 'Φιλτράρισμα φωτογραφιών', + 'journey.studio.filterAll': 'Όλες οι φωτογραφίες', + 'journey.studio.filterLoose': 'Χωρίς καταχώρηση', + 'journey.studio.filterRecent': 'Μόλις μεταφορτώθηκαν', + 'journey.studio.filterThisPage': 'Αυτή η σελίδα', + 'journey.studio.entryPhotos': 'Φωτογραφίες', + 'journey.studio.noEntryPhotos': 'Αυτή η καταχώρηση δεν έχει ακόμη φωτογραφίες.', + 'journey.studio.noLoosePhotos': 'Κάθε φωτογραφία εδώ ανήκει σε μια καταχώρηση.', + 'journey.studio.uploadPhotos': 'Μεταφόρτωση φωτογραφιών', + 'journey.studio.uploadHint': 'Αφήστε εικόνες εδώ ή κάντε κλικ για να επιλέξετε', + 'journey.studio.uploadToEntry': 'Οι νέες εικόνες θα μπουν σε αυτή την καταχώρηση', + 'journey.studio.uploadToGallery': 'Οι νέες εικόνες θα μπουν στη συλλογή', + 'journey.studio.uploading': 'Μεταφόρτωση {done} από {total}', + 'journey.studio.dropFilesHere': 'Αφήστε για να προσθέσετε τις εικόνες', + 'journey.studio.videosSkipped': '{count} βίντεο παραλείφθηκαν. Ένα βιβλίο περιέχει εικόνες.', + 'journey.studio.fillPage': 'Γέμισμα σελίδας', + 'journey.studio.fillSpread': 'Γέμισμα δισέλιδου', + 'journey.studio.fillHint': + 'Απλώνει την εικόνα σε όλη τη σελίδα, μαζί με τη διαφυγή, και τη στέλνει πίσω από όλα τα υπόλοιπα.', + 'journey.studio.stops': 'Στάσεις', + 'journey.studio.stopsHint': + 'Κάθε στάση μετράει στη διαδρομή, την απόσταση και τις χώρες. Απενεργοποιήστε μία για να την εξαιρέσετε, για παράδειγμα το αεροδρόμιο της πόλης σας.', + 'journey.studio.stopOn': 'Μετράει', + 'journey.studio.stopOff': 'Εξαιρείται', + 'journey.studio.stopToggle': 'Να μετράει αυτή η στάση', + 'journey.studio.stopsFailed': 'Η στάση δεν μπόρεσε να αλλάξει.', + 'journey.editor.statsExcluded': 'Εξαίρεση από τη διαδρομή', + 'journey.editor.statsExcludedHint': + 'Η στάση παραμένει στο ημερολόγιο, αλλά δεν μετράει στην απόσταση, τις χώρες ή τον χάρτη στο Studio.', + 'journey.entry.offRoute': 'Εκτός διαδρομής', }; export default journey; diff --git a/shared/src/i18n/hu/journey.ts b/shared/src/i18n/hu/journey.ts index cac0082d0..858115e0f 100644 --- a/shared/src/i18n/hu/journey.ts +++ b/shared/src/i18n/hu/journey.ts @@ -596,5 +596,34 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Forgatás balra', 'journey.studio.rotateRight': 'Forgatás jobbra', 'journey.studio.saveReadOnly': 'Csak olvasható, nem mentődik', + 'journey.studio.filterPhotos': 'Fotók szűrése', + 'journey.studio.filterAll': 'Összes fotó', + 'journey.studio.filterLoose': 'Nincs bejegyzésben', + 'journey.studio.filterRecent': 'Most feltöltve', + 'journey.studio.filterThisPage': 'Ez az oldal', + 'journey.studio.entryPhotos': 'Fotók', + 'journey.studio.noEntryPhotos': 'Ehhez a bejegyzéshez még nincs fotó.', + 'journey.studio.noLoosePhotos': 'Itt minden fotó egy bejegyzéshez tartozik.', + 'journey.studio.uploadPhotos': 'Fotók feltöltése', + 'journey.studio.uploadHint': 'Húzd ide a képeket, vagy kattints a kiválasztáshoz', + 'journey.studio.uploadToEntry': 'Az új képek ebbe a bejegyzésbe kerülnek', + 'journey.studio.uploadToGallery': 'Az új képek a galériába kerülnek', + 'journey.studio.uploading': 'Feltöltés: {done} / {total}', + 'journey.studio.dropFilesHere': 'Engedd el a képek hozzáadásához', + 'journey.studio.videosSkipped': '{count} videó kimaradt. A könyvbe képek kerülnek.', + 'journey.studio.fillPage': 'Oldal kitöltése', + 'journey.studio.fillSpread': 'Oldalpár kitöltése', + 'journey.studio.fillHint': 'A képet a teljes oldalra nyújtja, kifutóval együtt, és minden más mögé teszi.', + 'journey.studio.stops': 'Megállók', + 'journey.studio.stopsHint': + 'Minden megálló beleszámít az útvonalba, a távolságba és az országokba. Kapcsolj ki egyet, ha ki szeretnéd hagyni, például az otthoni repülőteret.', + 'journey.studio.stopOn': 'Beleszámít', + 'journey.studio.stopOff': 'Kihagyva', + 'journey.studio.stopToggle': 'Ez a megálló számítson', + 'journey.studio.stopsFailed': 'A megállót nem sikerült módosítani.', + 'journey.editor.statsExcluded': 'Kihagyás az útvonalból', + 'journey.editor.statsExcludedHint': + 'A megálló a naplóban marad, de nem számít bele a távolságba, az országokba és a Studio térképébe.', + 'journey.entry.offRoute': 'Útvonalon kívül', }; export default journey; diff --git a/shared/src/i18n/id/journey.ts b/shared/src/i18n/id/journey.ts index e75bb909b..126eb709e 100644 --- a/shared/src/i18n/id/journey.ts +++ b/shared/src/i18n/id/journey.ts @@ -595,5 +595,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Putar ke kiri', 'journey.studio.rotateRight': 'Putar ke kanan', 'journey.studio.saveReadOnly': 'Hanya baca, tidak disimpan', + 'journey.studio.filterPhotos': 'Filter foto', + 'journey.studio.filterAll': 'Semua foto', + 'journey.studio.filterLoose': 'Tidak dalam entri', + 'journey.studio.filterRecent': 'Baru diunggah', + 'journey.studio.filterThisPage': 'Halaman ini', + 'journey.studio.entryPhotos': 'Foto', + 'journey.studio.noEntryPhotos': 'Entri ini belum punya foto.', + 'journey.studio.noLoosePhotos': 'Semua foto di sini milik sebuah entri.', + 'journey.studio.uploadPhotos': 'Unggah foto', + 'journey.studio.uploadHint': 'Letakkan gambar di sini atau klik untuk memilih', + 'journey.studio.uploadToEntry': 'Gambar baru akan masuk ke entri ini', + 'journey.studio.uploadToGallery': 'Gambar baru akan masuk ke galeri', + 'journey.studio.uploading': 'Mengunggah {done} dari {total}', + 'journey.studio.dropFilesHere': 'Lepaskan untuk menambahkan gambar', + 'journey.studio.videosSkipped': '{count} video dilewati. Buku berisi gambar.', + 'journey.studio.fillPage': 'Isi halaman', + 'journey.studio.fillSpread': 'Isi halaman ganda', + 'journey.studio.fillHint': + 'Merentangkan gambar ke seluruh halaman, termasuk bleed, dan menaruhnya di belakang semua yang lain.', + 'journey.studio.stops': 'Perhentian', + 'journey.studio.stopsHint': + 'Setiap perhentian dihitung untuk rute, jarak, dan negara. Matikan salah satunya untuk mengecualikannya, misalnya bandara asal Anda.', + 'journey.studio.stopOn': 'Dihitung', + 'journey.studio.stopOff': 'Dikecualikan', + 'journey.studio.stopToggle': 'Hitung perhentian ini', + 'journey.studio.stopsFailed': 'Perhentian tidak dapat diubah.', + 'journey.editor.statsExcluded': 'Kecualikan dari rute', + 'journey.editor.statsExcludedHint': + 'Perhentian tetap ada di jurnal tetapi tidak dihitung untuk jarak, negara, atau peta di Studio.', + 'journey.entry.offRoute': 'Di luar rute', }; export default journey; diff --git a/shared/src/i18n/it/journey.ts b/shared/src/i18n/it/journey.ts index 50ad25e35..4f954ef0b 100644 --- a/shared/src/i18n/it/journey.ts +++ b/shared/src/i18n/it/journey.ts @@ -596,5 +596,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Ruota a sinistra', 'journey.studio.rotateRight': 'Ruota a destra', 'journey.studio.saveReadOnly': 'Sola lettura, non si salva', + 'journey.studio.filterPhotos': 'Filtra foto', + 'journey.studio.filterAll': 'Tutte le foto', + 'journey.studio.filterLoose': 'Senza voce', + 'journey.studio.filterRecent': 'Appena caricate', + 'journey.studio.filterThisPage': 'Questa pagina', + 'journey.studio.entryPhotos': 'Foto', + 'journey.studio.noEntryPhotos': 'Questa voce non ha ancora foto.', + 'journey.studio.noLoosePhotos': 'Ogni foto qui appartiene a una voce.', + 'journey.studio.uploadPhotos': 'Carica foto', + 'journey.studio.uploadHint': 'Trascina qui le immagini o fai clic per sceglierle', + 'journey.studio.uploadToEntry': 'Le nuove immagini andranno in questa voce', + 'journey.studio.uploadToGallery': 'Le nuove immagini andranno nella galleria', + 'journey.studio.uploading': 'Caricamento {done} di {total}', + 'journey.studio.dropFilesHere': 'Rilascia per aggiungere le immagini', + 'journey.studio.videosSkipped': '{count} video sono stati esclusi. Un libro contiene immagini.', + 'journey.studio.fillPage': 'Riempi pagina', + 'journey.studio.fillSpread': 'Riempi doppia pagina', + 'journey.studio.fillHint': + "Estende l'immagine su tutta la pagina, abbondanza inclusa, e la manda dietro a tutto il resto.", + 'journey.studio.stops': 'Tappe', + 'journey.studio.stopsHint': + 'Ogni tappa conta per il percorso, la distanza e i paesi. Disattivane una per escluderla, ad esempio il tuo aeroporto di casa.', + 'journey.studio.stopOn': 'Conta', + 'journey.studio.stopOff': 'Esclusa', + 'journey.studio.stopToggle': 'Conta questa tappa', + 'journey.studio.stopsFailed': 'Non è stato possibile modificare la tappa.', + 'journey.editor.statsExcluded': 'Escludi dal percorso', + 'journey.editor.statsExcludedHint': + 'La tappa resta nel diario ma non conta per la distanza, i paesi o la mappa in Studio.', + 'journey.entry.offRoute': 'Fuori percorso', }; export default journey; diff --git a/shared/src/i18n/ja/journey.ts b/shared/src/i18n/ja/journey.ts index 17a0252ab..6b7c2de89 100644 --- a/shared/src/i18n/ja/journey.ts +++ b/shared/src/i18n/ja/journey.ts @@ -597,5 +597,33 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': '左に回転', 'journey.studio.rotateRight': '右に回転', 'journey.studio.saveReadOnly': '閲覧のみ・保存されません', + 'journey.studio.filterPhotos': '写真を絞り込む', + 'journey.studio.filterAll': 'すべての写真', + 'journey.studio.filterLoose': 'エントリーに属さない', + 'journey.studio.filterRecent': 'アップロードしたばかり', + 'journey.studio.filterThisPage': 'このページ', + 'journey.studio.entryPhotos': '写真', + 'journey.studio.noEntryPhotos': 'このエントリーにはまだ写真がありません。', + 'journey.studio.noLoosePhotos': 'ここにある写真はすべてエントリーに属しています。', + 'journey.studio.uploadPhotos': '写真をアップロード', + 'journey.studio.uploadHint': 'ここに画像をドロップするか、クリックして選択してください', + 'journey.studio.uploadToEntry': '新しい画像はこのエントリーに追加されます', + 'journey.studio.uploadToGallery': '新しい画像はギャラリーに追加されます', + 'journey.studio.uploading': '{total}枚中{done}枚をアップロード中', + 'journey.studio.dropFilesHere': 'ドロップして画像を追加', + 'journey.studio.videosSkipped': '{count}本の動画は除外されました。本に入るのは画像だけです。', + 'journey.studio.fillPage': 'ページいっぱいに', + 'journey.studio.fillSpread': '見開きいっぱいに', + 'journey.studio.fillHint': '塗り足しを含めてページ全体に画像を広げ、ほかのすべての背面に送ります。', + 'journey.studio.stops': '立ち寄り地', + 'journey.studio.stopsHint': + 'すべての立ち寄り地はルート、距離、国の集計に含まれます。除外したい場合はオフにしてください。たとえば出発地の空港などです。', + 'journey.studio.stopOn': '集計に含む', + 'journey.studio.stopOff': '除外', + 'journey.studio.stopToggle': 'この立ち寄り地を集計に含める', + 'journey.studio.stopsFailed': '立ち寄り地を変更できませんでした。', + 'journey.editor.statsExcluded': 'ルートから除外', + 'journey.editor.statsExcludedHint': '立ち寄り地は日記に残りますが、Studio の距離、国、地図には含まれません。', + 'journey.entry.offRoute': 'ルート外', }; export default journey; diff --git a/shared/src/i18n/ko/journey.ts b/shared/src/i18n/ko/journey.ts index a5e8296c6..163d04dc8 100644 --- a/shared/src/i18n/ko/journey.ts +++ b/shared/src/i18n/ko/journey.ts @@ -596,5 +596,33 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': '왼쪽으로 회전', 'journey.studio.rotateRight': '오른쪽으로 회전', 'journey.studio.saveReadOnly': '읽기 전용, 저장되지 않음', + 'journey.studio.filterPhotos': '사진 필터', + 'journey.studio.filterAll': '모든 사진', + 'journey.studio.filterLoose': '항목에 없음', + 'journey.studio.filterRecent': '방금 업로드됨', + 'journey.studio.filterThisPage': '이 페이지', + 'journey.studio.entryPhotos': '사진', + 'journey.studio.noEntryPhotos': '이 항목에는 아직 사진이 없습니다.', + 'journey.studio.noLoosePhotos': '여기 있는 사진은 모두 항목에 속해 있습니다.', + 'journey.studio.uploadPhotos': '사진 업로드', + 'journey.studio.uploadHint': '여기에 이미지를 놓거나 클릭해서 선택하세요', + 'journey.studio.uploadToEntry': '새 이미지는 이 항목에 추가됩니다', + 'journey.studio.uploadToGallery': '새 이미지는 갤러리에 추가됩니다', + 'journey.studio.uploading': '{total}장 중 {done}장 업로드 중', + 'journey.studio.dropFilesHere': '놓아서 이미지 추가', + 'journey.studio.videosSkipped': '동영상 {count}개는 제외되었습니다. 책에는 이미지만 들어갑니다.', + 'journey.studio.fillPage': '페이지 채우기', + 'journey.studio.fillSpread': '펼침면 채우기', + 'journey.studio.fillHint': '도련을 포함해 페이지 전체에 이미지를 펼치고 다른 모든 요소의 뒤로 보냅니다.', + 'journey.studio.stops': '경유지', + 'journey.studio.stopsHint': + '모든 경유지는 경로, 거리, 국가에 포함됩니다. 제외하려면 끄세요. 예를 들어 출발 공항이 있습니다.', + 'journey.studio.stopOn': '포함', + 'journey.studio.stopOff': '제외됨', + 'journey.studio.stopToggle': '이 경유지 포함', + 'journey.studio.stopsFailed': '경유지를 변경할 수 없습니다.', + 'journey.editor.statsExcluded': '경로에서 제외', + 'journey.editor.statsExcludedHint': '경유지는 일기에 남지만 Studio의 거리, 국가, 지도에는 포함되지 않습니다.', + 'journey.entry.offRoute': '경로 외', }; export default journey; diff --git a/shared/src/i18n/nl/journey.ts b/shared/src/i18n/nl/journey.ts index 4d19a21d9..0dfe8e363 100644 --- a/shared/src/i18n/nl/journey.ts +++ b/shared/src/i18n/nl/journey.ts @@ -597,5 +597,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Naar links draaien', 'journey.studio.rotateRight': 'Naar rechts draaien', 'journey.studio.saveReadOnly': 'Alleen lezen, wordt niet opgeslagen', + 'journey.studio.filterPhotos': "Foto's filteren", + 'journey.studio.filterAll': "Alle foto's", + 'journey.studio.filterLoose': 'Niet in een vermelding', + 'journey.studio.filterRecent': 'Net geüpload', + 'journey.studio.filterThisPage': 'Deze pagina', + 'journey.studio.entryPhotos': "Foto's", + 'journey.studio.noEntryPhotos': "Deze vermelding heeft nog geen foto's.", + 'journey.studio.noLoosePhotos': 'Elke foto hier hoort bij een vermelding.', + 'journey.studio.uploadPhotos': "Foto's uploaden", + 'journey.studio.uploadHint': 'Sleep afbeeldingen hierheen of klik om ze te kiezen', + 'journey.studio.uploadToEntry': 'Nieuwe afbeeldingen komen in deze vermelding', + 'journey.studio.uploadToGallery': 'Nieuwe afbeeldingen komen in de galerij', + 'journey.studio.uploading': '{done} van {total} wordt geüpload', + 'journey.studio.dropFilesHere': 'Laat los om de afbeeldingen toe te voegen', + 'journey.studio.videosSkipped': "{count} video's zijn overgeslagen. Een boek bevat afbeeldingen.", + 'journey.studio.fillPage': 'Pagina vullen', + 'journey.studio.fillSpread': 'Dubbele pagina vullen', + 'journey.studio.fillHint': + 'Rekt de afbeelding uit over de hele pagina, afloop inbegrepen, en zet haar achter al het andere.', + 'journey.studio.stops': 'Stops', + 'journey.studio.stopsHint': + 'Elke stop telt mee voor de route, de afstand en de landen. Zet er een uit om hem weg te laten, bijvoorbeeld je thuisluchthaven.', + 'journey.studio.stopOn': 'Telt mee', + 'journey.studio.stopOff': 'Weggelaten', + 'journey.studio.stopToggle': 'Deze stop meetellen', + 'journey.studio.stopsFailed': 'De stop kon niet worden gewijzigd.', + 'journey.editor.statsExcluded': 'Weglaten uit de route', + 'journey.editor.statsExcludedHint': + 'De stop blijft in het dagboek maar telt niet mee voor de afstand, de landen of de kaart in Studio.', + 'journey.entry.offRoute': 'Buiten de route', }; export default journey; diff --git a/shared/src/i18n/pl/journey.ts b/shared/src/i18n/pl/journey.ts index 1eb198e51..ef51d73c4 100644 --- a/shared/src/i18n/pl/journey.ts +++ b/shared/src/i18n/pl/journey.ts @@ -596,5 +596,34 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Obróć w lewo', 'journey.studio.rotateRight': 'Obróć w prawo', 'journey.studio.saveReadOnly': 'Tylko do odczytu, nie zapisuje się', + 'journey.studio.filterPhotos': 'Filtruj zdjęcia', + 'journey.studio.filterAll': 'Wszystkie zdjęcia', + 'journey.studio.filterLoose': 'Bez wpisu', + 'journey.studio.filterRecent': 'Właśnie przesłane', + 'journey.studio.filterThisPage': 'Ta strona', + 'journey.studio.entryPhotos': 'Zdjęcia', + 'journey.studio.noEntryPhotos': 'Ten wpis nie ma jeszcze zdjęć.', + 'journey.studio.noLoosePhotos': 'Każde zdjęcie tutaj należy do jakiegoś wpisu.', + 'journey.studio.uploadPhotos': 'Prześlij zdjęcia', + 'journey.studio.uploadHint': 'Upuść obrazy tutaj lub kliknij, aby je wybrać', + 'journey.studio.uploadToEntry': 'Nowe obrazy trafią do tego wpisu', + 'journey.studio.uploadToGallery': 'Nowe obrazy trafią do galerii', + 'journey.studio.uploading': 'Przesyłanie {done} z {total}', + 'journey.studio.dropFilesHere': 'Upuść, aby dodać obrazy', + 'journey.studio.videosSkipped': '{count} filmów pominięto. Książka zawiera obrazy.', + 'journey.studio.fillPage': 'Wypełnij stronę', + 'journey.studio.fillSpread': 'Wypełnij rozkładówkę', + 'journey.studio.fillHint': 'Rozciąga obraz na całą stronę, łącznie ze spadem, i umieszcza go za wszystkim innym.', + 'journey.studio.stops': 'Przystanki', + 'journey.studio.stopsHint': + 'Każdy przystanek liczy się do trasy, dystansu i krajów. Wyłącz któryś, aby go pominąć, na przykład swoje lotnisko domowe.', + 'journey.studio.stopOn': 'Liczy się', + 'journey.studio.stopOff': 'Pominięty', + 'journey.studio.stopToggle': 'Liczyć ten przystanek', + 'journey.studio.stopsFailed': 'Nie udało się zmienić przystanku.', + 'journey.editor.statsExcluded': 'Pomiń w trasie', + 'journey.editor.statsExcludedHint': + 'Przystanek zostaje w dzienniku, ale nie liczy się do dystansu, krajów ani mapy w Studio.', + 'journey.entry.offRoute': 'Poza trasą', }; export default journey; diff --git a/shared/src/i18n/ru/journey.ts b/shared/src/i18n/ru/journey.ts index 6233c812c..47ef71dd2 100644 --- a/shared/src/i18n/ru/journey.ts +++ b/shared/src/i18n/ru/journey.ts @@ -596,5 +596,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Повернуть влево', 'journey.studio.rotateRight': 'Повернуть вправо', 'journey.studio.saveReadOnly': 'Только чтение, не сохраняется', + 'journey.studio.filterPhotos': 'Фильтр фото', + 'journey.studio.filterAll': 'Все фото', + 'journey.studio.filterLoose': 'Без записи', + 'journey.studio.filterRecent': 'Только что загружены', + 'journey.studio.filterThisPage': 'Эта страница', + 'journey.studio.entryPhotos': 'Фото', + 'journey.studio.noEntryPhotos': 'В этой записи пока нет фото.', + 'journey.studio.noLoosePhotos': 'Каждое фото здесь относится к какой-то записи.', + 'journey.studio.uploadPhotos': 'Загрузить фото', + 'journey.studio.uploadHint': 'Перетащите изображения сюда или нажмите, чтобы выбрать', + 'journey.studio.uploadToEntry': 'Новые изображения попадут в эту запись', + 'journey.studio.uploadToGallery': 'Новые изображения попадут в галерею', + 'journey.studio.uploading': 'Загрузка {done} из {total}', + 'journey.studio.dropFilesHere': 'Отпустите, чтобы добавить изображения', + 'journey.studio.videosSkipped': 'Видео пропущено: {count}. В книге только изображения.', + 'journey.studio.fillPage': 'Заполнить страницу', + 'journey.studio.fillSpread': 'Заполнить разворот', + 'journey.studio.fillHint': + 'Растягивает изображение на всю страницу, включая вылет, и помещает его позади всего остального.', + 'journey.studio.stops': 'Остановки', + 'journey.studio.stopsHint': + 'Каждая остановка учитывается в маршруте, расстоянии и странах. Отключите остановку, чтобы исключить её, например домашний аэропорт.', + 'journey.studio.stopOn': 'Учитывается', + 'journey.studio.stopOff': 'Исключена', + 'journey.studio.stopToggle': 'Учитывать эту остановку', + 'journey.studio.stopsFailed': 'Не удалось изменить остановку.', + 'journey.editor.statsExcluded': 'Исключить из маршрута', + 'journey.editor.statsExcludedHint': + 'Остановка остаётся в дневнике, но не учитывается в расстоянии, странах и на карте в Studio.', + 'journey.entry.offRoute': 'Вне маршрута', }; export default journey; diff --git a/shared/src/i18n/sv/journey.ts b/shared/src/i18n/sv/journey.ts index 928892095..b4c68b50d 100644 --- a/shared/src/i18n/sv/journey.ts +++ b/shared/src/i18n/sv/journey.ts @@ -596,5 +596,34 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Rotera åt vänster', 'journey.studio.rotateRight': 'Rotera åt höger', 'journey.studio.saveReadOnly': 'Skrivskyddad, sparas inte', + 'journey.studio.filterPhotos': 'Filtrera foton', + 'journey.studio.filterAll': 'Alla foton', + 'journey.studio.filterLoose': 'Inte i något inlägg', + 'journey.studio.filterRecent': 'Nyss uppladdade', + 'journey.studio.filterThisPage': 'Den här sidan', + 'journey.studio.entryPhotos': 'Foton', + 'journey.studio.noEntryPhotos': 'Det här inlägget har inga foton än.', + 'journey.studio.noLoosePhotos': 'Alla foton här hör till ett inlägg.', + 'journey.studio.uploadPhotos': 'Ladda upp foton', + 'journey.studio.uploadHint': 'Släpp bilder här eller klicka för att välja', + 'journey.studio.uploadToEntry': 'Nya bilder hamnar i det här inlägget', + 'journey.studio.uploadToGallery': 'Nya bilder hamnar i galleriet', + 'journey.studio.uploading': 'Laddar upp {done} av {total}', + 'journey.studio.dropFilesHere': 'Släpp för att lägga till bilderna', + 'journey.studio.videosSkipped': '{count} videor lämnades utanför. En bok rymmer bilder.', + 'journey.studio.fillPage': 'Fyll sidan', + 'journey.studio.fillSpread': 'Fyll uppslaget', + 'journey.studio.fillHint': 'Sträcker ut bilden över hela sidan, utfall inräknat, och lägger den bakom allt annat.', + 'journey.studio.stops': 'Stopp', + 'journey.studio.stopsHint': + 'Varje stopp räknas med i rutten, sträckan och länderna. Stäng av ett för att utelämna det, till exempel din hemmaflygplats.', + 'journey.studio.stopOn': 'Räknas', + 'journey.studio.stopOff': 'Utelämnat', + 'journey.studio.stopToggle': 'Räkna med det här stoppet', + 'journey.studio.stopsFailed': 'Stoppet kunde inte ändras.', + 'journey.editor.statsExcluded': 'Utelämna från rutten', + 'journey.editor.statsExcludedHint': + 'Stoppet finns kvar i dagboken men räknas inte med i sträckan, länderna eller kartan i Studio.', + 'journey.entry.offRoute': 'Utanför rutten', }; export default journey; diff --git a/shared/src/i18n/tr/journey.ts b/shared/src/i18n/tr/journey.ts index 36b10be6b..e3b59143c 100644 --- a/shared/src/i18n/tr/journey.ts +++ b/shared/src/i18n/tr/journey.ts @@ -596,5 +596,34 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Sola döndür', 'journey.studio.rotateRight': 'Sağa döndür', 'journey.studio.saveReadOnly': 'Salt okunur, kaydedilmiyor', + 'journey.studio.filterPhotos': 'Fotoğrafları filtrele', + 'journey.studio.filterAll': 'Tüm fotoğraflar', + 'journey.studio.filterLoose': 'Bir kayıtta değil', + 'journey.studio.filterRecent': 'Az önce yüklendi', + 'journey.studio.filterThisPage': 'Bu sayfa', + 'journey.studio.entryPhotos': 'Fotoğraflar', + 'journey.studio.noEntryPhotos': 'Bu kaydın henüz fotoğrafı yok.', + 'journey.studio.noLoosePhotos': 'Buradaki her fotoğraf bir kayda ait.', + 'journey.studio.uploadPhotos': 'Fotoğraf yükle', + 'journey.studio.uploadHint': 'Resimleri buraya bırakın veya seçmek için tıklayın', + 'journey.studio.uploadToEntry': 'Yeni resimler bu kayda eklenir', + 'journey.studio.uploadToGallery': 'Yeni resimler galeriye eklenir', + 'journey.studio.uploading': '{total} resimden {done} tanesi yükleniyor', + 'journey.studio.dropFilesHere': 'Resimleri eklemek için bırakın', + 'journey.studio.videosSkipped': '{count} video dışarıda bırakıldı. Kitapta resimler yer alır.', + 'journey.studio.fillPage': 'Sayfayı doldur', + 'journey.studio.fillSpread': 'Çift sayfayı doldur', + 'journey.studio.fillHint': 'Resmi taşma dahil tüm sayfaya yayar ve diğer her şeyin arkasına gönderir.', + 'journey.studio.stops': 'Duraklar', + 'journey.studio.stopsHint': + 'Her durak rotaya, mesafeye ve ülkelere dahil edilir. Dışarıda bırakmak için birini kapatın, örneğin kalkış havalimanınızı.', + 'journey.studio.stopOn': 'Sayılıyor', + 'journey.studio.stopOff': 'Dışarıda', + 'journey.studio.stopToggle': 'Bu durağı say', + 'journey.studio.stopsFailed': 'Durak değiştirilemedi.', + 'journey.editor.statsExcluded': 'Rotanın dışında bırak', + 'journey.editor.statsExcludedHint': + 'Durak günlükte kalır ama mesafeye, ülkelere veya Studio içindeki haritaya dahil edilmez.', + 'journey.entry.offRoute': 'Rota dışı', }; export default journey; diff --git a/shared/src/i18n/uk/journey.ts b/shared/src/i18n/uk/journey.ts index 3d17643ce..235688519 100644 --- a/shared/src/i18n/uk/journey.ts +++ b/shared/src/i18n/uk/journey.ts @@ -597,5 +597,35 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Повернути ліворуч', 'journey.studio.rotateRight': 'Повернути праворуч', 'journey.studio.saveReadOnly': 'Лише для читання, не зберігається', + 'journey.studio.filterPhotos': 'Фільтр фото', + 'journey.studio.filterAll': 'Усі фото', + 'journey.studio.filterLoose': 'Без запису', + 'journey.studio.filterRecent': 'Щойно завантажені', + 'journey.studio.filterThisPage': 'Ця сторінка', + 'journey.studio.entryPhotos': 'Фото', + 'journey.studio.noEntryPhotos': 'У цьому записі ще немає фото.', + 'journey.studio.noLoosePhotos': 'Кожне фото тут належить до якогось запису.', + 'journey.studio.uploadPhotos': 'Завантажити фото', + 'journey.studio.uploadHint': 'Перетягніть зображення сюди або натисніть, щоб вибрати', + 'journey.studio.uploadToEntry': 'Нові зображення потраплять у цей запис', + 'journey.studio.uploadToGallery': 'Нові зображення потраплять у галерею', + 'journey.studio.uploading': 'Завантаження {done} з {total}', + 'journey.studio.dropFilesHere': 'Відпустіть, щоб додати зображення', + 'journey.studio.videosSkipped': 'Пропущено відео: {count}. У книзі лише зображення.', + 'journey.studio.fillPage': 'Заповнити сторінку', + 'journey.studio.fillSpread': 'Заповнити розворот', + 'journey.studio.fillHint': + 'Розтягує зображення на всю сторінку, включно з вильотом, і розміщує його позаду всього іншого.', + 'journey.studio.stops': 'Зупинки', + 'journey.studio.stopsHint': + 'Кожна зупинка враховується в маршруті, відстані та країнах. Вимкніть зупинку, щоб виключити її, наприклад домашній аеропорт.', + 'journey.studio.stopOn': 'Враховується', + 'journey.studio.stopOff': 'Виключена', + 'journey.studio.stopToggle': 'Враховувати цю зупинку', + 'journey.studio.stopsFailed': 'Не вдалося змінити зупинку.', + 'journey.editor.statsExcluded': 'Виключити з маршруту', + 'journey.editor.statsExcludedHint': + 'Зупинка залишається в щоденнику, але не враховується у відстані, країнах і на карті в Studio.', + 'journey.entry.offRoute': 'Поза маршрутом', }; export default journey; diff --git a/shared/src/i18n/vi/journey.ts b/shared/src/i18n/vi/journey.ts index e50a6eb28..5b360ec9f 100644 --- a/shared/src/i18n/vi/journey.ts +++ b/shared/src/i18n/vi/journey.ts @@ -596,5 +596,34 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': 'Xoay trái', 'journey.studio.rotateRight': 'Xoay phải', 'journey.studio.saveReadOnly': 'Chỉ đọc, không lưu', + 'journey.studio.filterPhotos': 'Lọc ảnh', + 'journey.studio.filterAll': 'Tất cả ảnh', + 'journey.studio.filterLoose': 'Không thuộc mục nào', + 'journey.studio.filterRecent': 'Vừa tải lên', + 'journey.studio.filterThisPage': 'Trang này', + 'journey.studio.entryPhotos': 'Ảnh', + 'journey.studio.noEntryPhotos': 'Mục này chưa có ảnh.', + 'journey.studio.noLoosePhotos': 'Mọi ảnh ở đây đều thuộc về một mục.', + 'journey.studio.uploadPhotos': 'Tải ảnh lên', + 'journey.studio.uploadHint': 'Thả ảnh vào đây hoặc nhấp để chọn', + 'journey.studio.uploadToEntry': 'Ảnh mới sẽ được đưa vào mục này', + 'journey.studio.uploadToGallery': 'Ảnh mới sẽ được đưa vào thư viện', + 'journey.studio.uploading': 'Đang tải lên {done} trên {total}', + 'journey.studio.dropFilesHere': 'Thả để thêm ảnh', + 'journey.studio.videosSkipped': 'Đã bỏ qua {count} video. Sách chỉ chứa ảnh.', + 'journey.studio.fillPage': 'Phủ kín trang', + 'journey.studio.fillSpread': 'Phủ kín trang đôi', + 'journey.studio.fillHint': 'Kéo giãn ảnh phủ kín cả trang, bao gồm cả tràn lề, và đưa ảnh ra sau mọi thứ khác.', + 'journey.studio.stops': 'Điểm dừng', + 'journey.studio.stopsHint': + 'Mỗi điểm dừng đều được tính vào lộ trình, quãng đường và các quốc gia. Tắt một điểm để loại nó ra, chẳng hạn sân bay nhà của bạn.', + 'journey.studio.stopOn': 'Được tính', + 'journey.studio.stopOff': 'Đã loại ra', + 'journey.studio.stopToggle': 'Tính điểm dừng này', + 'journey.studio.stopsFailed': 'Không thể thay đổi điểm dừng.', + 'journey.editor.statsExcluded': 'Loại khỏi lộ trình', + 'journey.editor.statsExcludedHint': + 'Điểm dừng vẫn nằm trong nhật ký nhưng không được tính vào quãng đường, các quốc gia hay bản đồ trong Studio.', + 'journey.entry.offRoute': 'Ngoài lộ trình', }; export default journey; diff --git a/shared/src/i18n/zh-TW/journey.ts b/shared/src/i18n/zh-TW/journey.ts index 98226cd1b..f4aac669d 100644 --- a/shared/src/i18n/zh-TW/journey.ts +++ b/shared/src/i18n/zh-TW/journey.ts @@ -594,5 +594,32 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': '向左旋轉', 'journey.studio.rotateRight': '向右旋轉', 'journey.studio.saveReadOnly': '唯讀,不會儲存', + 'journey.studio.filterPhotos': '篩選照片', + 'journey.studio.filterAll': '全部照片', + 'journey.studio.filterLoose': '不屬於任何條目', + 'journey.studio.filterRecent': '剛剛上傳', + 'journey.studio.filterThisPage': '目前頁面', + 'journey.studio.entryPhotos': '照片', + 'journey.studio.noEntryPhotos': '這個條目還沒有照片。', + 'journey.studio.noLoosePhotos': '這裡的每張照片都屬於某個條目。', + 'journey.studio.uploadPhotos': '上傳照片', + 'journey.studio.uploadHint': '將圖片拖放到這裡,或點擊選擇', + 'journey.studio.uploadToEntry': '新圖片會加入這個條目', + 'journey.studio.uploadToGallery': '新圖片會加入圖庫', + 'journey.studio.uploading': '正在上傳第 {done} 張,共 {total} 張', + 'journey.studio.dropFilesHere': '放開以加入圖片', + 'journey.studio.videosSkipped': '已略過 {count} 部影片。書裡只放圖片。', + 'journey.studio.fillPage': '填滿頁面', + 'journey.studio.fillSpread': '填滿跨頁', + 'journey.studio.fillHint': '將圖片延展至整頁(含出血),並置於所有內容之後。', + 'journey.studio.stops': '停留點', + 'journey.studio.stopsHint': '每個停留點都會計入路線、距離和國家。關閉某個停留點即可將其排除,例如你的出發機場。', + 'journey.studio.stopOn': '計入', + 'journey.studio.stopOff': '已排除', + 'journey.studio.stopToggle': '計入此停留點', + 'journey.studio.stopsFailed': '無法變更該停留點。', + 'journey.editor.statsExcluded': '不計入路線', + 'journey.editor.statsExcludedHint': '該停留點仍保留在日誌中,但不計入 Studio 中的距離、國家和地圖。', + 'journey.entry.offRoute': '不在路線內', }; export default journey; diff --git a/shared/src/i18n/zh/journey.ts b/shared/src/i18n/zh/journey.ts index 0116330e5..ea6902bda 100644 --- a/shared/src/i18n/zh/journey.ts +++ b/shared/src/i18n/zh/journey.ts @@ -594,5 +594,32 @@ const journey: TranslationStrings = { 'journey.studio.rotateLeft': '向左旋转', 'journey.studio.rotateRight': '向右旋转', 'journey.studio.saveReadOnly': '只读,不会保存', + 'journey.studio.filterPhotos': '筛选照片', + 'journey.studio.filterAll': '全部照片', + 'journey.studio.filterLoose': '不属于任何条目', + 'journey.studio.filterRecent': '刚刚上传', + 'journey.studio.filterThisPage': '当前页', + 'journey.studio.entryPhotos': '照片', + 'journey.studio.noEntryPhotos': '这个条目还没有照片。', + 'journey.studio.noLoosePhotos': '这里的每张照片都属于某个条目。', + 'journey.studio.uploadPhotos': '上传照片', + 'journey.studio.uploadHint': '将图片拖放到这里,或点击选择', + 'journey.studio.uploadToEntry': '新图片会加入这个条目', + 'journey.studio.uploadToGallery': '新图片会加入图库', + 'journey.studio.uploading': '正在上传第 {done} 张,共 {total} 张', + 'journey.studio.dropFilesHere': '松开以添加图片', + 'journey.studio.videosSkipped': '已跳过 {count} 个视频。书里只放图片。', + 'journey.studio.fillPage': '铺满页面', + 'journey.studio.fillSpread': '铺满跨页', + 'journey.studio.fillHint': '将图片拉伸至整页(含出血),并置于所有内容之后。', + 'journey.studio.stops': '停留点', + 'journey.studio.stopsHint': '每个停留点都会计入路线、距离和国家。关掉某个停留点即可将其排除,例如你的出发机场。', + 'journey.studio.stopOn': '计入', + 'journey.studio.stopOff': '已排除', + 'journey.studio.stopToggle': '计入此停留点', + 'journey.studio.stopsFailed': '无法更改该停留点。', + 'journey.editor.statsExcluded': '不计入路线', + 'journey.editor.statsExcludedHint': '该停留点仍保留在日志中,但不计入 Studio 中的距离、国家和地图。', + 'journey.entry.offRoute': '不在路线内', }; export default journey;