feat(studio): filter photos by entry, upload from Studio, and switch stops off

The content browser gets a filter over the journey's pictures: all of them,
the ones no entry holds, or one entry at a time, with the day and the place
under each name. An entry card in the Entries tab reaches its own pictures
the same way. Pictures can now come in without leaving Studio, into the
entry the filter names or into the gallery, from the panel or dropped
straight onto the page, and a photo element can fill its page or the whole
spread in one press.

Stops can be taken out of the arithmetic. A journal entry carries a
stats_excluded flag; an entry that has it is off the route, out of the
distance, out of the countries and not a step, while staying in the
journal. Studio lists every stop in the travel panel with a switch, the
entry editor carries the same switch on desktop and phone, and the entry
card says when a day is off the route.
This commit is contained in:
Maurice
2026-09-03 09:46:57 +02:00
committed by Maurice
parent 23401fcdb9
commit 6cbefd23e8
62 changed files with 2933 additions and 116 deletions
@@ -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', () => {
@@ -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}
</span>
)}
{/* 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 && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide">
<RouteOff size={10} />
{t('journey.entry.offRoute')}
</span>
)}
</div>
{/* Menu top-right */}
@@ -110,6 +118,11 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
<Clock size={10} /> {entry.entry_time}
</span>
)}
{entry.stats_excluded && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500">
<RouteOff size={10} /> {t('journey.entry.offRoute')}
</span>
)}
</div>
{!readOnly && (
<div className="relative">
@@ -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()
})
})
@@ -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<ReturnType<typeof setTimeout> | 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<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
const [cons, setCons] = useState<string[]>(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,
</div>
</div>
{/* 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 && (
<div className="flex items-center gap-3 px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl">
<div className="flex-1 min-w-0">
<div className="text-[12px] font-semibold text-zinc-900 dark:text-white">{t('journey.editor.statsExcluded')}</div>
<div className="text-[11px] leading-snug text-zinc-500 mt-0.5">{t('journey.editor.statsExcludedHint')}</div>
</div>
<ToggleSwitch on={statsExcluded} onToggle={() => setStatsExcluded(v => !v)} label={t('journey.editor.statsExcluded')} />
</div>
)}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.editor.mood')}</label>
<div className="flex flex-wrap gap-2">
+39 -4
View File
@@ -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<string | null>(null)
/** A file drag from outside the browser is over the sheet. */
const [fileOver, setFileOver] = useState(false)
const [editing, setEditing] = useState<string | null>(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 && (
<div className="st-file-veil" aria-hidden>
<span>{fileDropLabel}</span>
</div>
)}
<div
className="st-sheet"
style={{
@@ -191,6 +191,34 @@ export function StudioInspector({
const live = (patch: Partial<BookElement>) => 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 (
<aside className="st-panel st-inspector">
{/*
@@ -696,6 +724,28 @@ export function StudioInspector({
<ChevronsDown size={14} />
</button>
</div>
{/*
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' && (
<div className="st-row" style={{ marginTop: 6 }}>
<button type="button" className="st-chip" onClick={() => fill('page')} title={t('journey.studio.fillHint')}>
{t('journey.studio.fillPage')}
</button>
{spread.role === 'inner' && (
<button type="button" className="st-chip" onClick={() => fill('spread')} title={t('journey.studio.fillHint')}>
{t('journey.studio.fillSpread')}
</button>
)}
</div>
)}
</Section>
</div>
</aside>
@@ -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}
/>
<Workbench s={s} bookView={bookView} />
<StudioInspector
@@ -431,7 +434,20 @@ function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
dropLabel={s.t('journey.studio.dropPhotoHere')}
cursors={s.cursors}
onCursor={(x, y) => 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 && (
<div className="st-upload-veil" role="status" aria-live="polite">
{s.t('journey.studio.uploading', { done: s.canvasUpload.done, total: s.canvasUpload.total })}
</div>
)}
</div>
<div className="st-zoom">
+368 -32
View File
@@ -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<number, string>
}
/** The translator, with the parameters some of the strings here take. */
export type StudioT = (k: string, params?: Record<string, string | number>) => 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<boolean>
}) {
const [section, setSection] = useState<Section>('pages')
@@ -113,9 +131,21 @@ export function StudioSidebar({
<aside className="st-panel st-side">
{section === 'pages' && <PagesPanel page={page} pxPerMm={pxPerMm} bookView={bookView} t={t} />}
{section === 'content' && <ContentPanel source={source} page={page} t={t} locale={locale} />}
{section === 'content' && (
<ContentPanel source={source} page={page} t={t} locale={locale} canEdit={canEdit} onUpload={onUpload} />
)}
{section === 'elements' && <StudioElementsPanel page={page} t={t} />}
{section === 'travel' && <StudioTravelPanel page={page} stats={stats} path={path} t={t} locale={locale} />}
{section === 'travel' && (
<StudioTravelPanel
page={page}
stats={stats}
path={path}
t={t}
locale={locale}
canEdit={canEdit}
onToggleStop={onToggleStop}
/>
)}
{section === 'templates' && <TemplatesPanel page={page} pxPerMm={pxPerMm} t={t} onOpenContent={() => setSection('content')} />}
</aside>
</>
@@ -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<PhotoFilter>({ 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<HTMLDivElement>(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<HTMLInputElement>(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<HTMLDivElement>) => {
if (!e.dataTransfer.types.includes('Files')) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
setDropOver(true)
},
onDragLeave: (e: React.DragEvent<HTMLDivElement>) => {
// 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<HTMLDivElement>) => {
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({
</button>
</div>
<div className="st-panel-scroll">
{tab === 'photos' ? (
<div className="st-photo-grid">
{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' && (
<div className="st-filter-row">
<div className="st-picker" ref={menuBox}>
<span className={`st-chip st-filter-chip ${filter.kind !== 'all' ? 'is-on' : ''}`}>
<button type="button"
key={p.photoId}
className="st-photo-cell"
onClick={() => dropPhoto(p.photoId)}
title={t('journey.studio.addToPage')}
draggable
onDragStart={e => {
// HTML5 drag is the right tool for exactly this shape of
// interaction — one item, from a list, onto a target — and it
// gives us the thumbnail as the drag image for free. The
// canvas uses pointer events instead, because free transform
// needs a live position that a drop event cannot provide.
e.dataTransfer.setData('application/x-trek-photo', String(p.photoId))
e.dataTransfer.effectAllowed = 'copy'
}}
onClick={() => setMenuOpen(o => !o)}
aria-haspopup="menu"
aria-expanded={menuOpen}
title={t('journey.studio.filterPhotos')}
>
<img src={photoSrc(p.photoId, false)} alt="" loading="lazy" draggable={false} />
<span className="st-filter-name">{filterLabel}</span>
<em>{byFilter.length}</em>
<ChevronDown size={12} style={{ opacity: .5 }} />
</button>
))}
{!photos.length && (
<p className="st-hint">{t(q ? 'journey.studio.noMatches' : 'journey.studio.noPhotos')}</p>
{filter.kind !== 'all' && (
<button type="button" onClick={() => setFilter({ kind: 'all' })} aria-label={t('common.clear')}>
<X size={12} />
</button>
)}
</span>
{menuOpen && (
<div className="st-menu" role="menu">
<FilterItem
on={filter.kind === 'all'}
label={t('journey.studio.filterAll')}
count={source.photos.length}
onPick={() => pick({ kind: 'all' })}
/>
<FilterItem
on={filter.kind === 'loose'}
label={t('journey.studio.filterLoose')}
count={looseCount}
onPick={() => pick({ kind: 'loose' })}
/>
{withPhotos.length > 0 && <div className="st-menu-sep" />}
{withPhotos.map(e => (
<FilterItem
key={e.id}
on={filter.kind === 'entry' && filter.id === e.id}
label={nameOf(e)}
// The day, and the place when the title took the top line.
dim={[e.date ? formatDate(e.date, locale) ?? e.date : null, e.title ? e.location : null]
.filter(Boolean).join(', ')}
count={e.photoIds.length}
onPick={() => pick({ kind: 'entry', id: e.id })}
/>
))}
</div>
)}
</div>
{offerThisPage && (
<button type="button" className="st-chip" onClick={() => setFilter({ kind: 'entry', id: pageEntry.id })}>
{t('journey.studio.filterThisPage')}
</button>
)}
</div>
)}
<div className={`st-panel-scroll ${dropOver ? 'is-drop-over' : ''}`} {...dropZone}>
{tab === 'photos' ? (
<>
{canEdit && (
<p className="st-hint st-upload-where">
{t(filter.kind === 'entry' ? 'journey.studio.uploadToEntry' : 'journey.studio.uploadToGallery')}
</p>
)}
<div className="st-photo-grid">
{/*
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 && (
<button type="button"
className="st-photo-cell is-upload"
onClick={() => fileInput.current?.click()}
disabled={!!upload}
title={t('journey.studio.uploadHint')}
>
<Upload size={18} strokeWidth={1.6} />
<span>
{upload
? t('journey.studio.uploading', { done: upload.done, total: upload.total })
: t('journey.studio.uploadPhotos')}
</span>
</button>
)}
{photos.map(p => (
<button type="button"
key={p.photoId}
className="st-photo-cell"
onClick={() => dropPhoto(p.photoId)}
title={t('journey.studio.addToPage')}
draggable
onDragStart={e => {
// HTML5 drag is the right tool for exactly this shape of
// interaction (one item, from a list, onto a target) and it
// gives us the thumbnail as the drag image for free. The
// canvas uses pointer events instead, because free transform
// needs a live position that a drop event cannot provide.
e.dataTransfer.setData('application/x-trek-photo', String(p.photoId))
e.dataTransfer.effectAllowed = 'copy'
}}
>
<img src={photoSrc(p.photoId, false)} alt="" loading="lazy" draggable={false} />
</button>
))}
{!photos.length && <p className="st-hint">{t(emptyKey)}</p>}
</div>
{canEdit && (
<input
ref={fileInput}
type="file"
multiple
accept="image/*,.heic,.heif"
hidden
onChange={e => {
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)
}}
/>
)}
</>
) : (
<div className="st-entries">
{entries.map(e => (
@@ -556,6 +851,22 @@ function ContentPanel({
<em>{e.pros.length + e.cons.length}</em>
</button>
)}
{/*
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 && (
<button type="button"
className="st-chip"
onClick={() => { setFilter({ kind: 'entry', id: e.id }); setTab('photos') }}
>
<Camera size={12} />
{t('journey.studio.entryPhotos')}
<em>{e.photoIds.length}</em>
</button>
)}
</div>
</div>
))}
@@ -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 (
<button type="button"
role="menuitemradio"
aria-checked={on}
className={`st-menu-item ${on ? 'is-active' : ''}`}
onClick={onPick}
>
<span className="st-menu-text">
<span className="st-menu-name">{label}</span>
{dim && <span className="st-menu-dim">{dim}</span>}
</span>
<span className="st-menu-dim st-filter-count">{count}</span>
{on && <Check size={14} />}
</button>
)
}
/**
* Layouts for the current spread.
*
@@ -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<boolean>
}) {
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<Set<number>>(() => 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({
</div>
</div>
{/*
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 && (
<div className="st-section">
<div className="st-section-label">{t('journey.studio.stops')}</div>
<p className="st-hint">{t('journey.studio.stopsHint')}</p>
<ul className="st-stops">
{stops.map((stop, i) => {
const entryId = stop.entryId
const on = !stop.excluded
const state = t(on ? 'journey.studio.stopOn' : 'journey.studio.stopOff')
const chip = `st-chip is-pick ${on ? 'is-on' : ''}`
return (
<li key={entryId ?? `place-${i}`} className={`st-stop ${on ? '' : 'is-off'}`}>
<div className="st-stop-body">
<span className="st-stop-label">{stop.label || t('journey.studio.untitled')}</span>
<span className="st-stop-meta">
{stop.date && <span className="st-badge">{formatDate(stop.date, locale)}</span>}
{stop.country && <span className="st-badge is-quiet">{countryName(stop.country)}</span>}
</span>
</div>
{canEdit && entryId != null ? (
<button type="button"
className={chip}
aria-label={t('journey.studio.stopToggle')}
aria-pressed={on}
disabled={pending.has(entryId)}
onClick={() => void toggleStop(entryId, !stop.excluded)}
>
{state}
</button>
) : (
<span className={chip}>{state}</span>
)}
</li>
)
})}
</ul>
</div>
)}
<div className="st-section">
<div className="st-section-label">{t('journey.studio.routeMap')}</div>
{stats.points.length ? (
@@ -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<StudioUploadOutcome>
/**
* 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<StudioUploadOutcome> {
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,
}
}
@@ -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
<span className="whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-2 py-[2px] font-geist text-[0.5625rem] font-bold text-m-muted">
{dateLabel}
</span>
{/* Switched off the printed route (#2064). Shrinks before the date does. */}
{entry.stats_excluded && (
<span className="inline-flex min-w-0 items-center gap-[3px] overflow-hidden truncate whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-2 py-[2px] font-geist text-[0.5625rem] font-bold text-m-muted">
<RouteOff size={9} strokeWidth={2.4} className="flex-none" />
<span className="truncate">{t('journey.entry.offRoute')}</span>
</span>
)}
<span className="ml-auto flex gap-1">
{mood && (
<span
@@ -3,6 +3,7 @@ import { localIsoDate } from '../../../utils/localDate'
import { Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle } from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MToggle from '../../components/MToggle'
import { useTranslation } from '../../../i18n'
import { useToast } from '../../../components/shared/Toast'
import CustomTimePicker from '../../../components/shared/CustomTimePicker'
@@ -70,6 +71,7 @@ export default function MJourneyEntrySheet({
const locationTimerRef = useRef<ReturnType<typeof setTimeout> | 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<string[]>(entry.pros_cons?.pros ?? [])
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons ?? [])
const [tags, setTags] = useState<string[]>(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 && <div className="mt-[5px] font-geist text-[0.65625rem] text-[color:var(--m-st-danger)]">{locationError}</div>}
</div>
{/* 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 && (
<div className={`mt-3 flex items-center gap-3 px-3 py-[10px] ${fieldShell}`}>
<div className="min-w-0 flex-1">
<div className="text-[0.75rem] font-semibold text-m-ink">{t('journey.editor.statsExcluded')}</div>
<div className="mt-[2px] font-geist text-[0.65625rem] leading-[1.4] text-m-muted">{t('journey.editor.statsExcludedHint')}</div>
</div>
<MToggle checked={statsExcluded} onChange={setStatsExcluded} disabled={readOnly} ariaLabel={t('journey.editor.statsExcluded')} />
</div>
)}
{/* Mood */}
{!captureOnly && <>
<div className={`${eyebrow} mb-[6px] mt-3`}>{t('journey.editor.mood')}</div>
@@ -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<number, number[]> = {}
// 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<number, string> = {}
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<number, string> = {}
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<boolean> => {
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<StudioUploader>(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,
+20 -1
View File
@@ -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<JourneyState>((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,
+233
View File
@@ -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;
}
@@ -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(<MJourneyEntryCard entry={makeEntry({ stats_excluded: true })} number={1} onClick={() => {}} />);
expect(screen.getByText('Off route')).toBeInTheDocument();
});
it('FE-MOB-JRN-007: carries no such mark on an ordinary entry', () => {
render(<MJourneyEntryCard entry={makeEntry()} number={1} onClick={() => {}} />);
expect(screen.queryByText('Off route')).not.toBeInTheDocument();
});
});
@@ -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();
@@ -28,10 +28,10 @@ const stats = (over: Partial<JourneyStats> = {}): 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 })
})
@@ -39,10 +39,11 @@ const stats = (over: Partial<JourneyStats> = {}): 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,
@@ -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>): 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(
<StudioSidebar
page={page}
pxPerMm={96 / 25.4}
bookView={false}
source={src}
stats={null}
path={[]}
t={(k: string) => 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<StudioUploader>(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)))
})
})
@@ -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> = {}): 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'))
+76 -1
View File
@@ -48,7 +48,7 @@ const setup = (folios: Partial<BookPageNumbers> = {}): 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', () => {
+4 -1
View File
@@ -246,7 +246,10 @@ describe('placing a map from the panel', () => {
})
const { getByTitle, unmount } = render(
<StudioTravelPanel page={page} stats={journeyStats} path={[]} t={k => k} locale="en" />,
<StudioTravelPanel
page={page} stats={journeyStats} path={[]} t={k => k} locale="en"
canEdit={false} onToggleStop={async () => true}
/>,
)
fireEvent.click(getByTitle('journey.studio.mapStyle.minimal'))
await vi.waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalled())
+5 -4
View File
@@ -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',
}
@@ -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',
}
@@ -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> = {}): 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> = {}): 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<boolean>
function open(journeyStats: JourneyStats, over: { canEdit?: boolean; onToggleStop?: Toggle } = {}) {
const onToggleStop = over.onToggleStop ?? vi.fn(async () => true)
render(
<StudioTravelPanel
page={page} stats={journeyStats} path={[]} t={k => 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<boolean>(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')
})
})
+21
View File
@@ -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) {
@@ -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);
+10 -3
View File
@@ -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<JourneyEntry, 'tags' | 'pros_cons'> {
export interface JourneyEntryWire extends Omit<JourneyEntry, 'tags' | 'pros_cons' | 'stats_excluded'> {
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,
};
}
+16 -1
View File
@@ -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<string, string>;
/** 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,
};
+4 -3
View File
@@ -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,
) {
+6
View File
@@ -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;
}
+25 -1
View File
@@ -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
+6 -3
View File
@@ -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;
@@ -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) => {
@@ -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);
@@ -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']);
});
});
+26 -2
View File
@@ -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,
});
+26
View File
@@ -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<typeof journeyStatsPointSchema>;
/**
* 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<typeof journeyStatsExcludedStopSchema>;
/**
* 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(),
+28
View File
@@ -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;
+30
View File
@@ -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;
+29
View File
@@ -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;
+29
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+29
View File
@@ -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;
+30
View File
@@ -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;
+29
View File
@@ -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;
+30
View File
@@ -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;
+30
View File
@@ -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;
+28
View File
@@ -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;
+28
View File
@@ -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;
+30
View File
@@ -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;
+29
View File
@@ -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;
+30
View File
@@ -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;
+29
View File
@@ -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;
+29
View File
@@ -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;
+30
View File
@@ -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;
+29
View File
@@ -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;
+27
View File
@@ -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;
+27
View File
@@ -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;