feat(dialogs): Vacay, Collections, Atlas and Journey in the new dialog look

Every popup of the four pages moves to the shared dialog frame the trip
uses: head band with its icon, the scrolling body, the footer with its
actions, and ConfirmDialog for the questions.

- Vacay: settings, removing a year (now says so when it fails), the
  fusion request, inviting a person, a person's colour and sharing the
  calendar.
- Collections: new and edited lists, adding a place, importing a file and
  a trip's places, sharing, labels and assigning them, copying to a trip,
  moving or duplicating, saving a trip's places and deleting a list. The
  stylesheet of the old import view goes.
- Atlas: the country popup with the flag in its head, its choices as rows
  and the wishlist step; Dawarich's Atlas dialog and its accept dialog on
  the desktop.
- Journey: creating a journey, its settings, linking a trip, inviting a
  contributor, the entry editor (discarding asks in a dialog) and the
  photo provider picker; the entry card's menu is the shared one.

The phone keeps its own sheets: where a dialog is shared with it
(Dawarich, the contributor invite, the provider picker) only the desktop
frame changes.
This commit is contained in:
Maurice
2026-09-28 16:52:32 +02:00
parent 9064ee57b7
commit acd003ea67
46 changed files with 3338 additions and 2803 deletions
@@ -1,6 +1,7 @@
import React, { useMemo, useRef, useState } from 'react'
import { Upload, FileJson, Loader2, MapPin, Tag, AlertCircle, Route, Info, Plus, FolderInput, Check, Search, Bookmark } from 'lucide-react'
import Modal from '../shared/Modal'
import React, { useId, useMemo, useRef, useState } from 'react'
import { Upload, FileDown, FileJson, Loader2, MapPin, Tag, AlertCircle, Route, Info, Plus, FolderInput, Check, Search, Bookmark } from 'lucide-react'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { EditorField, INPUT } from '../shared/dialogParts'
import { MAX_COLLECTION_FILE_PLACES, type Collection, type CollectionFile } from '@trek/shared'
import type { TranslationFn } from '../../types'
import { getApiErrorMessage } from '../../types'
@@ -36,6 +37,18 @@ const ERROR_KEYS: Record<CollectionFileError, string> = {
'too-many-places': 'collections.file.errorTooManyPlaces',
}
/**
* The square that stands for a list: its colour as a wash, the icon in the
* colour itself. A list without one is drawn in the default the lists rail uses.
*/
function swatch(color?: string | null): React.CSSProperties {
const tone = color || '#6366f1' // theme-lint-disable: a list's own colour, and the default one without a colour is drawn in
return { background: `color-mix(in srgb, ${tone} 14%, transparent)`, color: tone }
}
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5'
const HINT = 'm-0 leading-normal text-content-faint'
/** What a GPX held besides its places, said before anything is imported. */
function GpxNotes({ leftovers, placeCount, t }: { leftovers: GpxLeftovers; placeCount: number; t: TranslationFn }) {
const notes = [
@@ -45,10 +58,10 @@ function GpxNotes({ leftovers, placeCount, t }: { leftovers: GpxLeftovers; place
].filter(Boolean)
if (notes.length === 0) return null
return (
<ul className="flex flex-col gap-1">
<ul className="m-0 flex list-none flex-col gap-1 p-0">
{notes.map(note => (
<li key={note as string} className="flex items-start gap-2 text-[12px] text-content-muted">
<Info size={13} className="shrink-0 mt-0.5 text-content-faint" />
<li key={note as string} className="flex items-start gap-2 text-content-muted" style={fs(12, 'body')}>
<Info size={13} className="mt-0.5 flex-none text-content-faint" />
<span>{note}</span>
</li>
))}
@@ -69,17 +82,17 @@ function TargetCard({ active, icon: Icon, title, hint, onClick }: {
type="button"
onClick={onClick}
aria-pressed={active}
className={`relative flex flex-col items-start gap-1.5 px-3 py-3 rounded-xl border text-left transition-colors ${
active ? 'border-accent bg-surface-hover' : 'border-edge hover:bg-surface-hover'
className={`relative flex flex-col items-start gap-1.5 rounded-[14px] border p-3 text-left transition-colors ${
active ? 'border-accent bg-surface-card shadow-sm' : 'border-edge-faint bg-surface-secondary hover:bg-surface-card'
}`}
>
<span className={`w-8 h-8 rounded-lg flex items-center justify-center ${active ? 'bg-accent text-accent-text' : 'bg-surface-input text-content-muted'}`}>
<span className={`grid h-8 w-8 place-items-center rounded-[10px] ${active ? 'bg-accent text-accent-text' : 'bg-surface-card text-content-muted shadow-sm'}`}>
<Icon size={15} />
</span>
<span className="text-[13px] font-semibold text-content">{title}</span>
<span className="text-[11.5px] text-content-faint leading-snug">{hint}</span>
<span className="font-semibold text-content" style={fs(13, 'body')}>{title}</span>
<span className="leading-snug text-content-faint" style={fs(11.5)}>{hint}</span>
{active && (
<span className="absolute top-2 right-2 w-4 h-4 rounded-full bg-accent text-accent-text flex items-center justify-center">
<span className="absolute right-2 top-2 grid h-4 w-4 place-items-center rounded-full bg-accent text-accent-text">
<Check size={11} strokeWidth={3} />
</span>
)}
@@ -104,16 +117,17 @@ function ListChoice({ lists, selectedId, onSelect, t }: {
<div className="flex flex-col gap-2">
{lists.length > 5 && (
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
aria-label={t('collections.file.searchLists')}
placeholder={t('collections.file.searchLists')}
className="w-full pl-8 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
className={`${INPUT} pl-8`}
/>
</div>
)}
<div className="flex flex-col gap-1 max-h-[34vh] overflow-y-auto -mx-1 px-1">
<div className={`${ROWS} max-h-[34vh] overflow-y-auto`}>
{shown.map(list => {
const active = list.id === selectedId
return (
@@ -122,23 +136,21 @@ function ListChoice({ lists, selectedId, onSelect, t }: {
type="button"
onClick={() => onSelect(list.id)}
aria-pressed={active}
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl border text-left transition-colors ${
active ? 'border-accent bg-surface-hover' : 'border-edge bg-surface-card hover:bg-surface-hover'
}`}
className={`flex min-h-[46px] items-center gap-3 rounded-[10px] px-2.5 py-2 text-left ${active ? 'bg-surface-card shadow-sm' : 'hover:bg-surface-card'}`}
>
<span className="w-9 h-9 min-w-[36px] rounded-lg flex items-center justify-center shrink-0 text-white" style={{ background: list.color || '#6366f1' }}>
<span className="grid h-8 w-8 flex-none place-items-center rounded-[9px]" style={swatch(list.color)}>
<Bookmark size={15} />
</span>
<span className="flex-1 min-w-0">
<span className="block text-[13px] font-semibold text-content truncate">{list.name}</span>
<span className="block text-[11.5px] text-content-faint">{t('collections.placeCount', { count: list.place_count ?? 0 })}</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(13, 'body')}>{list.name}</span>
<span className="block text-content-faint" style={fs(11.5)}>{t('collections.placeCount', { count: list.place_count ?? 0 })}</span>
</span>
{active && <Check size={16} className="shrink-0 text-accent" />}
{active && <Check size={16} className="flex-none text-accent-on" />}
</button>
)
})}
{shown.length === 0 && (
<p className="text-center text-[13px] text-content-faint py-6">{t('collections.noOtherLists')}</p>
<p className="m-0 py-6 text-center text-content-faint" style={fs(13, 'body')}>{t('collections.noOtherLists')}</p>
)}
</div>
</div>
@@ -169,6 +181,8 @@ export default function ImportCollectionModal({
onImport, onImportInto, lists, defaultListId, onReadGpx, onClose, t,
}: ImportCollectionModalProps): React.ReactElement {
const inputRef = useRef<HTMLInputElement>(null)
const titleId = useId()
const nameId = useId()
const [file, setFile] = useState<CollectionFile | null>(null)
const [leftovers, setLeftovers] = useState<GpxLeftovers | null>(null)
const [name, setName] = useState('')
@@ -243,131 +257,148 @@ export default function ImportCollectionModal({
const nothingToImport = !!leftovers && !!file && file.places.length === 0
return (
<Modal
isOpen
<DialogShell
onClose={onClose}
title={t('collections.file.importTitle')}
size="sm"
footer={
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg text-[13px] font-medium text-content-muted hover:bg-surface-hover transition-colors">
{t('common.cancel')}
</button>
<button
type="button"
onClick={submit}
disabled={!file || busy || nothingToImport || (intoExisting ? listId == null : !name.trim())}
className="px-4 py-2 rounded-lg text-[13px] font-semibold bg-accent text-accent-text disabled:opacity-50 transition-opacity inline-flex items-center gap-2"
>
{busy && <Loader2 size={14} className="animate-spin" />}
{intoExisting ? t('collections.file.confirmInto') : t('collections.file.confirm')}
</button>
</div>
}
>
<div className="flex flex-col gap-3">
<input
ref={inputRef}
type="file"
accept=".json,application/json,.gpx,application/gpx+xml"
className="hidden"
onChange={e => { void take(e.target.files?.[0]); e.target.value = '' }}
labelledBy={titleId}
width="narrow"
// The body grows once a file is read; the top edge stays where it was.
align="top"
header={(
<DialogHeader
tile={<DialogTile><FileDown size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={titleId}
onClose={onClose}
title={t('collections.file.importTitle')}
/>
{!file ? (
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
disabled={reading}
aria-busy={reading}
className={`flex flex-col items-center justify-center gap-2 px-4 py-10 rounded-xl border border-dashed transition-colors ${
dragging ? 'border-accent bg-surface-hover' : 'border-edge hover:bg-surface-hover'
}`}
)}
footer={(
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton
variant="primary"
onClick={() => void submit()}
disabled={!file || busy || nothingToImport || (intoExisting ? listId == null : !name.trim())}
icon={busy ? <Loader2 size={14} className="animate-spin" /> : undefined}
>
{reading ? <Loader2 size={22} className="text-content-faint animate-spin" /> : <Upload size={22} className="text-content-faint" />}
<span className="text-[13px] font-medium text-content">
{reading ? t('collections.file.reading') : t('collections.file.choose')}
</span>
<span className="text-[11.5px] text-content-faint">{COLLECTION_FILE_EXTENSION} · {COLLECTION_GPX_EXTENSION}</span>
</button>
) : (
<>
<div className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-card">
<span className="w-9 h-9 min-w-[36px] rounded-lg flex items-center justify-center shrink-0 text-white" style={{ background: file.color || '#6366f1' }}>
{leftovers ? <Route size={15} /> : <FileJson size={15} />}
{intoExisting ? t('collections.file.confirmInto') : t('collections.file.confirm')}
</DialogButton>
</DialogFooter>
)}
>
<input
ref={inputRef}
type="file"
accept=".json,application/json,.gpx,application/gpx+xml"
className="hidden"
onChange={e => { void take(e.target.files?.[0]); e.target.value = '' }}
/>
{!file ? (
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
disabled={reading}
aria-busy={reading}
className={`flex min-h-[132px] w-full flex-col items-center justify-center gap-1.5 rounded-[14px] border-2 border-dashed p-4 transition-colors ${
dragging ? 'border-accent bg-surface-tertiary' : 'border-edge bg-surface-secondary hover:border-content-faint'
}`}
>
{reading
? <Loader2 size={20} className="animate-spin text-content-faint" />
: <Upload size={20} strokeWidth={1.8} className="text-content-faint" />}
<span className="font-semibold text-content" style={fs(13, 'body')}>
{reading ? t('collections.file.reading') : t('collections.file.choose')}
</span>
<span className="flex items-center gap-1.5 text-content-faint" style={fs(11.5)}><span>{COLLECTION_FILE_EXTENSION}</span><span>{COLLECTION_GPX_EXTENSION}</span></span>
</button>
) : (
<>
{/* What the file holds, read before anything is imported. */}
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-secondary p-2.5">
<span className="grid h-9 w-9 flex-none place-items-center rounded-[10px]" style={swatch(file.color)}>
{leftovers ? <Route size={16} /> : <FileJson size={16} />}
</span>
<span className="flex-1 min-w-0">
<span className="block text-[13px] font-semibold text-content truncate">{file.name}</span>
<span className="flex items-center gap-3 text-[11.5px] text-content-faint">
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(13, 'body')}>{file.name}</span>
<span className="flex items-center gap-3 text-content-faint" style={fs(11.5)}>
<span className="inline-flex items-center gap-1"><MapPin size={11} />{t('collections.placeCount', { count: file.places.length })}</span>
{file.labels && file.labels.length > 0 && (
<span className="inline-flex items-center gap-1"><Tag size={11} />{t('collections.file.labelCount', { count: file.labels.length })}</span>
)}
</span>
</span>
<button type="button" onClick={() => inputRef.current?.click()} className="text-[12px] font-medium text-accent shrink-0 hover:underline">
<button
type="button"
onClick={() => inputRef.current?.click()}
className="flex-none rounded-[8px] bg-surface-card px-2.5 py-1 font-semibold text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary"
style={fs(12, 'body')}
>
{t('collections.file.change')}
</button>
</div>
{file.description && (
<p className="text-[12px] text-content-muted whitespace-pre-wrap">{file.description}</p>
<p className="m-0 whitespace-pre-wrap text-content-muted" style={fs(12, 'body')}>{file.description}</p>
)}
{leftovers && <GpxNotes leftovers={leftovers} placeCount={file.places.length} t={t} />}
</div>
{canChoose && (
<div className="grid grid-cols-2 gap-2">
<TargetCard
active={target === 'new'}
icon={Plus}
title={t('collections.file.targetNew')}
hint={t('collections.file.targetNewHint')}
onClick={() => setTarget('new')}
/>
<TargetCard
active={intoExisting}
icon={FolderInput}
title={t('collections.file.targetExisting')}
hint={t('collections.file.targetExistingHint')}
onClick={() => setTarget('existing')}
/>
</div>
)}
{canChoose && (
<div className="grid grid-cols-2 gap-2">
<TargetCard
active={target === 'new'}
icon={Plus}
title={t('collections.file.targetNew')}
hint={t('collections.file.targetNewHint')}
onClick={() => setTarget('new')}
/>
<TargetCard
active={intoExisting}
icon={FolderInput}
title={t('collections.file.targetExisting')}
hint={t('collections.file.targetExistingHint')}
onClick={() => setTarget('existing')}
/>
</div>
)}
{intoExisting ? (
<>
<ListChoice lists={targets} selectedId={listId} onSelect={setListId} t={t} />
<p className="text-[11.5px] text-content-faint">{t('collections.file.intoHint')}</p>
</>
) : (
<>
<label className="flex flex-col gap-1.5">
<span className="text-[12px] font-medium text-content-muted">{t('collections.listName')}</span>
<input
value={name}
onChange={e => setName(e.target.value)}
maxLength={120}
className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
</label>
{intoExisting ? (
<div className="flex flex-col gap-2">
<ListChoice lists={targets} selectedId={listId} onSelect={setListId} t={t} />
<p className={HINT} style={fs(11.5)}>{t('collections.file.intoHint')}</p>
</div>
) : (
<EditorField
label={t('collections.listName')}
htmlFor={nameId}
// Ratings and members are a TREK file's business; a GPX never had any.
hint={leftovers ? undefined : t('collections.file.hint')}
>
<input
id={nameId}
value={name}
onChange={e => setName(e.target.value)}
maxLength={120}
className={INPUT}
/>
</EditorField>
)}
</>
)}
{!leftovers && <p className="text-[11.5px] text-content-faint">{t('collections.file.hint')}</p>}
</>
)}
</>
)}
{errorText && (
<p className="flex items-start gap-2 text-[12px] text-danger">
<AlertCircle size={13} className="shrink-0 mt-0.5" />
<span>{errorText}</span>
</p>
)}
</div>
</Modal>
{errorText && (
<div role="alert" className="flex items-start gap-2 rounded-[12px] bg-danger-soft px-3 py-2 text-danger" style={fs(12, 'body')}>
<AlertCircle size={13} className="mt-0.5 flex-none" />
<span>{errorText}</span>
</div>
)}
</DialogShell>
)
}