mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user