fix(studio): fold the stops list and put a picture into the gallery it lands in

The stops list was the longest thing in the travel panel by a distance: a
fortnight is fourteen rows, and it pushed the maps and the country tiles
below the fold on every journey. It is a folding section now, closed to
begin with, with the count of what still counts in its head, so the closed
section answers the question anyone opens it for. The rows are one line
each, the day loses its weekday, the country appears only on a journey with
more than one, and the switch is a mark rather than a word, which is what
lets the place name fit.

A picture uploaded onto an entry also goes into the gallery, because that is
where the row lives. The entry upload only answers with the entry's view of
it, so the store derives the gallery row it did not get and the content
browser shows a new picture without the journey being fetched again.
This commit is contained in:
Maurice
2026-09-03 09:57:43 +02:00
committed by Maurice
parent 6cbefd23e8
commit b383230928
4 changed files with 143 additions and 40 deletions
@@ -36,18 +36,26 @@ import ToggleSwitch from '../Settings/ToggleSwitch'
* remembered: coming back to an element and finding its panel folded up
* differently from last time is worse than opening one group again.
*/
export function Section({ label, children, defaultOpen = true, hint }: {
export function Section({ label, children, defaultOpen = true, hint, badge }: {
label: string
children: ReactNode
defaultOpen?: boolean
/** A line under the label, for a group whose name cannot carry the whole idea. */
hint?: string
/**
* A figure beside the label, for a group worth reading while it is folded.
* The stops list is the case: how many of them count is the thing you came
* to check, and opening a section to read one number is a section that
* cannot usefully be closed.
*/
badge?: string
}) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className={`st-section is-fold ${open ? '' : 'is-folded'}`}>
<button type="button" className="st-section-head" onClick={() => setOpen(!open)} aria-expanded={open}>
<span className="st-section-label">{label}</span>
{badge && <span className="st-section-badge">{badge}</span>}
<ChevronDown size={13} className="st-section-caret" />
</button>
{open && (
@@ -1,9 +1,10 @@
import { useEffect, useRef, useState } from 'react'
import { Check, RouteOff } from 'lucide-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 { Section } from './StudioControls'
import { TravelPreview } from './TravelPreview'
import { formatBookCoords } from './entryText'
import { routeFor } from './travelRefresh'
@@ -238,6 +239,20 @@ export function StudioTravelPanel({
}
return a.label.localeCompare(b.label)
})
const excludedCount = stops.filter(s => s.excluded).length
/** Whether the country is worth a badge, or the same word on every row. */
const manyCountries = stats.countries.length > 1
/**
* The day of a stop, without the weekday.
*
* `formatDate` is the journal's format and carries "Tue," because a journal
* entry is about a day somebody lived. A row here is about a leg of a route,
* and it has 236px to hold a place name in as well: the weekday costs a
* third of the badge and answers nothing anybody asks of this list.
*/
const stopDay = (iso: string) =>
new Date(`${iso}T00:00:00Z`).toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
const toggleStop = async (entryId: number, excluded: boolean) => {
setPending(prev => new Set(prev).add(entryId))
@@ -458,42 +473,73 @@ export function StudioTravelPanel({
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>
<Section
label={t('journey.studio.stops')}
hint={t('journey.studio.stopsHint')}
/*
* Folded to begin with, and saying how many count while it is.
*
* A fortnight is fourteen rows, which is longer than everything
* else in this panel put together, and most books never need to
* touch it. The figure in the head is what anyone opening it came
* to check anyway, so the closed section still answers the
* question and the maps below stay in reach.
*/
defaultOpen={false}
badge={`${stops.length - excludedCount}/${stops.length}`}
>
<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' : ''}`
const chip = `st-chip is-pick is-icon ${on ? 'is-on' : ''}`
/*
* A mark rather than the word.
*
* "Counts" and "Left out" are different lengths, so a column of
* them jitters, and together they cost a third of a row that
* has a place name to fit. The tick and the crossed-out route
* say the same thing at a glance, and the word is still there
* for anyone who hovers or reads with a screen reader.
*/
const mark = on ? <Check size={13} strokeWidth={2.4} /> : <RouteOff size={13} strokeWidth={2.2} />
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>
{/*
One line: the day, then the name, then the switch. The
name and the day were stacked, which doubled the height
of a list whose whole job is being read down.
*/}
{stop.date && <span className="st-badge">{stopDay(stop.date)}</span>}
<span className="st-stop-label">{stop.label || t('journey.studio.untitled')}</span>
{/*
The country only where there is more than one to tell
apart. On a journey inside one country it is the same
word fourteen times, in the row that has least room.
*/}
{manyCountries && stop.country && (
<span className="st-badge is-quiet">{countryName(stop.country)}</span>
)}
{canEdit && entryId != null ? (
<button type="button"
className={chip}
title={`${state}: ${t('journey.studio.stopToggle')}`}
aria-label={t('journey.studio.stopToggle')}
aria-pressed={on}
disabled={pending.has(entryId)}
onClick={() => void toggleStop(entryId, !stop.excluded)}
>
{state}
{mark}
</button>
) : (
<span className={chip}>{state}</span>
<span className={chip} title={state} aria-label={state}>{mark}</span>
)}
</li>
)
})}
</ul>
</div>
</Section>
)}
<div className="st-section">
+31 -17
View File
@@ -969,6 +969,15 @@
margin-bottom: 10px;
}
.trek-dash .st-section-head .st-section-label { margin-bottom: 0; }
/* A figure in the head, for a section worth reading while it is folded. It
sits against the caret rather than the label, so the labels stay aligned. */
.trek-dash .st-section-badge {
margin-left: auto;
font-size: 10.5px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--ink-3);
}
.trek-dash .st-section-caret { flex: none; opacity: 0.5; transition: transform .18s, opacity .14s; }
.trek-dash .st-section-head:hover .st-section-caret { opacity: 1; }
.trek-dash .st-section.is-folded .st-section-caret { transform: rotate(-90deg); }
@@ -1936,34 +1945,37 @@
* 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.
* figure below reads. One line per stop, the day first and the switch last, so
* the eye runs down the right-hand edge 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.
*
* A row is a row rather than a card: fourteen bordered boxes down a 236px
* panel is a wall, and what the list needs is to be read quickly, not to have
* each of its entries framed. The separators do the same work for a fifth of
* the height.
*/
.trek-dash .st-stops {
display: flex;
flex-direction: column;
gap: 4px;
margin: 4px 0 0;
margin: 2px 0 0;
padding: 0;
list-style: none;
}
.trek-dash .st-stop {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
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);
padding: 4px 0;
border-bottom: 1px solid var(--line);
transition: opacity .16s;
}
.trek-dash .st-stop:last-child { border-bottom: 0; }
.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;
flex: 1 1 auto;
min-width: 0;
font-size: 12px;
font-weight: 600;
letter-spacing: -0.01em;
@@ -1972,11 +1984,13 @@
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; }
.trek-dash .st-stop > .st-badge { flex: none; }
.trek-dash .st-stop > .st-badge.is-quiet { flex: 0 1 auto; }
/* The switch, as a mark: a square the size of the badges beside it. The
padding is reset because `is-pick` sets its own further down the file. */
.trek-dash .st-stop > .st-chip.is-icon { flex: none; width: 22px; height: 22px; padding: 0; }
.trek-dash .st-stop > .st-chip:not(.is-on) { color: var(--ink-3); }
/* 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; }
+42 -7
View File
@@ -44,7 +44,7 @@ const stats = (over: Partial<JourneyStats> = {}): JourneyStats => ({
type Toggle = (entryId: number, excluded: boolean) => Promise<boolean>
function open(journeyStats: JourneyStats, over: { canEdit?: boolean; onToggleStop?: Toggle } = {}) {
function open(journeyStats: JourneyStats, over: { canEdit?: boolean; onToggleStop?: Toggle; folded?: boolean } = {}) {
const onToggleStop = over.onToggleStop ?? vi.fn(async () => true)
render(
<StudioTravelPanel
@@ -52,12 +52,19 @@ function open(journeyStats: JourneyStats, over: { canEdit?: boolean; onToggleSto
canEdit={over.canEdit ?? true} onToggleStop={onToggleStop}
/>,
)
return { onToggleStop }
// The section opens folded, since a fortnight of rows is longer than the
// rest of the panel together. Every case below is about those rows, so
// unfold unless the case is about the fold itself.
const head = document.querySelector('.st-section-head')
if (head && !over.folded) fireEvent.click(head)
return { onToggleStop, head }
}
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' })
/** The switch of a row reads its state from the title, since it draws a mark. */
const stateOf = (row: Element) => row.querySelector('.st-chip')?.getAttribute('title') ?? ''
beforeEach(() => {
useStudioStore.getState().load({
@@ -78,9 +85,9 @@ describe('what the stops section lists', () => {
const [keflavik, reykjavik] = rows()
expect(keflavik.className).toContain('is-off')
expect(keflavik.textContent).toContain('journey.studio.stopOff')
expect(stateOf(keflavik)).toContain('journey.studio.stopOff')
expect(reykjavik.className).not.toContain('is-off')
expect(reykjavik.textContent).toContain('journey.studio.stopOn')
expect(stateOf(reykjavik)).toContain('journey.studio.stopOn')
})
it('puts an undated stop last', () => {
@@ -89,14 +96,42 @@ describe('what the stops section lists', () => {
expect(labels()).toEqual(['Reykjavík', 'Akureyri', 'Somewhere'])
})
it('names the country in the reader\'s language, and only for a stop that has one', () => {
/*
* The country is the first thing a 236px row gives up, and on a journey
* inside one country it was the same word on every line anyway.
*/
it('leaves the country off a journey that only went to one', () => {
open(stats())
expect(rows().map(r => r.querySelector('.st-badge.is-quiet'))).toEqual([null, null, null])
})
it('names the country in the reader\'s language once there is more than one', () => {
open(stats({
countries: [
{ code: 'IS', name: 'Iceland', places: 2, firstVisit: '2026-06-02' },
{ code: 'DK', name: 'Denmark', places: 1, firstVisit: '2026-06-10' },
],
}))
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'])
})
/*
* The fold is the section's own contract: it costs one line when closed and
* still answers the question anyone opens it for, which is how many stops
* are counting.
*/
it('starts folded, with the count of what still counts in its head', () => {
const { head } = open(stats(), { folded: true })
expect(rows()).toHaveLength(0)
expect(head).toHaveAttribute('aria-expanded', 'false')
expect(head!.querySelector('.st-section-badge')?.textContent).toBe('2/3')
})
it('is left out entirely when the journey has no stops at all', () => {
open(stats({ points: [], excluded: [] }))
@@ -147,7 +182,7 @@ describe('the switch', () => {
expect(switches()).toHaveLength(0)
expect(rows()).toHaveLength(3)
expect(rows()[1].textContent).toContain('journey.studio.stopOn')
expect(stateOf(rows()[1])).toBe('journey.studio.stopOn')
})
it('is a label for a stop taken from a trip place, which has no entry to switch', () => {
@@ -162,6 +197,6 @@ describe('the switch', () => {
expect(labels()).toEqual(['Vík', 'Akureyri'])
expect(switches()).toHaveLength(1)
expect(rows()[0].querySelector('button')).toBeNull()
expect(rows()[0].textContent).toContain('journey.studio.stopOn')
expect(stateOf(rows()[0])).toBe('journey.studio.stopOn')
})
})