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 && (
+
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 && (
+
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' && (
+
+ {/*
+ 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 && (
+
{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;