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
+2 -4
View File
@@ -240,8 +240,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
// The dialog's close button has no label of its own: it is the button in the header next to the title.
target: p => settings(p).locator('h2').locator('xpath=..').getByRole('button').first(),
target: p => settings(p).getByRole('button', { name: 'Close' }),
act: async p => { await closeSettings(p); await settle(p) },
},
],
@@ -272,8 +271,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
// The dialog's close button has no label of its own: it is the button in the header next to the title.
target: p => settings(p).locator('h2').locator('xpath=..').getByRole('button').first(),
target: p => settings(p).getByRole('button', { name: 'Close' }),
act: async p => { await closeSettings(p); await settle(p) },
},
],
@@ -2,7 +2,7 @@
import React from 'react'
import type { Mock } from 'vitest'
import { http, HttpResponse } from 'msw'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render'
import { server } from '../../../tests/helpers/msw/server'
import type { Category } from '@trek/shared'
import { useTranslation } from '../../i18n/TranslationContext'
@@ -68,6 +68,11 @@ function typeName(value: string): void {
fireEvent.change(screen.getByPlaceholderText('Name'), { target: { value } })
}
/** The results panel's own close button; the dialog's head band has one of the same name. */
function dismissResultsButton(): HTMLElement {
return within(screen.getByRole('group', { name: 'Search' })).getByRole('button', { name: 'Close' })
}
describe('AddPlaceToCollectionModal', () => {
beforeEach(() => {
addToast = vi.fn<AddToast>(() => 0)
@@ -151,7 +156,7 @@ describe('AddPlaceToCollectionModal', () => {
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
await screen.findByText('Kissa Sakaiki')
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
fireEvent.click(dismissResultsButton())
expect(screen.queryByText('Kissa Sakaiki')).not.toBeInTheDocument()
})
@@ -182,7 +187,7 @@ describe('AddPlaceToCollectionModal', () => {
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
await screen.findByText('No places found')
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
fireEvent.click(dismissResultsButton())
expect(screen.queryByText('No places found')).not.toBeInTheDocument()
})
@@ -1,11 +1,13 @@
import React, { useEffect, useRef, useState } from 'react'
import React, { useEffect, useId, useRef, useState } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { Search, MapPin, Plus, Loader2, Link2, Trash2, Check, X } from 'lucide-react'
import Modal from '../shared/Modal'
import { Bookmark, Search, MapPin, MapPinned, Loader2, Trash2, Check, X } from 'lucide-react'
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { AddRowButton, EditorField, GRID_2, INPUT, PANEL, Segmented, TEXTAREA } from '../shared/dialogParts'
import { NumericInput } from '../shared/NumericInput'
import MarkdownToolbar from '../Journey/MarkdownToolbar'
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
import { Tooltip } from '../shared/Tooltip'
import { mapsApi } from '../../api/client'
import { collectionsApi } from '../../api/collections'
import { getCategoryIcon } from '../shared/categoryIcons'
@@ -20,6 +22,19 @@ type MapsPlace = Record<string, unknown>
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : typeof v === 'string' && v !== '' ? Number(v) : undefined)
/**
* The search row, pinned to the top of the scrolling body. The dialog stays open
* after an add, and the row used to sit above the viewport once the form was
* scrolled, so it looked like the button had vanished (#1921). The negative
* margins let its background cover the body's padding while it is stuck.
*/
const STICKY_SEARCH = 'sticky top-0 z-20 -mx-6 -mt-5 bg-surface-card px-6 pb-1 pt-5'
/** A chip of the category row; the picked one wears the category's own colour. */
const CHIP = 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 font-semibold'
const CHIP_OFF = 'border-edge-faint bg-surface-card text-content-muted hover:text-content'
const CHIP_NONE_ON = 'border-content bg-surface-card text-content'
const ROW_ACTION = 'grid h-8 w-8 flex-none place-items-center rounded-[9px] text-content-faint hover:bg-surface-secondary'
interface AddPlaceToCollectionModalProps {
isOpen: boolean
collectionId: number
@@ -31,10 +46,11 @@ interface AddPlaceToCollectionModalProps {
}
/**
* Add a place to the current list — everything in one view: a search field that
* fills in the location when a result is picked, plus name / category / status /
* markdown description / links, all editable together before saving. Stays open
* after each add so several places can be added in a row.
* Add a place to the current list, everything in one view: the name typed into
* the head band, a search that fills it and the location in when a result is
* picked, plus status, category, a markdown description and links, all editable
* together before saving. Stays open after each add so several places can be
* added in a row.
*/
export default function AddPlaceToCollectionModal({ isOpen, collectionId, collectionName, categories, onClose, onAdded, t }: AddPlaceToCollectionModalProps): React.ReactElement {
const { language } = useTranslation()
@@ -60,6 +76,8 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
const [saving, setSaving] = useState(false)
const descRef = useRef<HTMLTextAreaElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const labelId = useId()
const fieldId = useId()
const reset = () => { setQuery(''); setResults([]); setNoResults(false); setPicked(null); setName(''); setAddress(''); setLat(''); setLng(''); setCategoryId(null); setDescription(''); setLinks([]); setStatus('idea') }
useEffect(() => { if (!isOpen) reset() }, [isOpen])
@@ -141,157 +159,202 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
const match = text.match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/)
if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) }
}
const coordInputClass = 'w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent'
const header = (
<DialogHeader
tile={<DialogTile><MapPinned size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
eyebrow={t('collections.addPlace')}
// Typed into the band, as in the planner's place dialog. It takes no focus
// on opening: the dialog starts at the search, which fills the name in.
titleInput={{ value: name, onChange: setName, label: t('common.name'), placeholder: t('common.name') }}
sub={<><Bookmark size={11} strokeWidth={2.2} className="mr-1 inline-block align-[-1px]" />{collectionName}</>}
/>
)
const footer = (
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton
variant="primary"
onClick={() => void save()}
disabled={saving || !name.trim()}
icon={saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} strokeWidth={2.2} />}
>
{t('common.add')}
</DialogButton>
</DialogFooter>
)
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={t('collections.addPlace')}
size="md"
footer={
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">{t('common.cancel')}</button>
<button type="button" onClick={save} disabled={saving || !name.trim()} className="px-3 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-1.5">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {t('common.add')}
</button>
</div>
}
>
<div className="flex flex-col gap-4">
{/* Search — picking a result fills the location below. Pinned to the top of
the scrolling dialog body: the dialog stays open after an add, and the
search row used to sit above the viewport once the form was scrolled,
so it looked like the button had vanished (#1921). The negative margins
let its background cover the body padding while it is stuck. */}
<div className="sticky top-0 z-20 -mx-6 -mt-6 -mb-4 px-6 pt-6 pb-4 bg-surface-card">
<div className="relative">
<div className="flex gap-2">
<div className="relative flex-1">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" />
<input
autoFocus
ref={searchRef}
value={query}
onChange={e => { setQuery(e.target.value); setNoResults(false) }}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); search() } }}
placeholder={t('collections.addPlaceSearch')}
className="w-full pl-9 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent"
/>
</div>
<button type="button" onClick={search} disabled={!query.trim() || searching} className="px-4 py-2 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-2">
{searching ? <Loader2 size={15} className="animate-spin" /> : <Search size={15} />}
{t('common.search')}
</button>
<DialogShell open={isOpen} onClose={onClose} labelledBy={labelId} width="detail" align="top" header={header} footer={footer}>
{/* Search: picking a result fills the name and the location below. */}
<div className={STICKY_SEARCH}>
<div className="relative">
<div className="flex items-stretch gap-2">
<div className="relative min-w-0 flex-1">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<input
autoFocus
ref={searchRef}
type="text"
value={query}
onChange={e => { setQuery(e.target.value); setNoResults(false) }}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); search() } }}
aria-label={t('collections.addPlaceSearch')}
placeholder={t('collections.addPlaceSearch')}
className={`${INPUT} pl-8`}
/>
</div>
{(results.length > 0 || noResults) && (
<div className="absolute z-20 left-0 right-0 mt-1.5 max-h-[280px] overflow-y-auto rounded-xl border border-edge bg-surface-card shadow-lg p-1.5 flex flex-col gap-1">
<div className="flex items-center justify-between px-2 py-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-content-faint">{t('common.search')}</span>
<button type="button" onClick={dismissResults} className="p-1 rounded-md text-content-faint hover:text-content hover:bg-surface-hover" aria-label={t('common.close')}><X size={13} /></button>
</div>
{noResults ? (
<div className="px-2.5 py-3 text-center text-[12.5px] text-content-faint">{t('planner.noPlacesFound')}</div>
) : results.map((r, i) => (
<button key={i} type="button" onClick={() => pick(r)} className="flex items-center gap-3 px-2.5 py-2 rounded-lg text-left hover:bg-surface-hover transition-colors">
<div className="w-8 h-8 min-w-[32px] rounded-lg bg-surface-secondary flex items-center justify-center text-content-faint shrink-0"><MapPin size={15} /></div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[13px] font-semibold text-content truncate">{str(r.name)}</span>
{str(r.address) && <span className="text-[11.5px] text-content-faint truncate">{str(r.address)}</span>}
</div>
<DialogButton
variant="primary"
onClick={() => void search()}
disabled={!query.trim() || searching}
icon={searching ? <Loader2 size={14} className="animate-spin" /> : <Search size={14} strokeWidth={2.2} />}
>
{t('common.search')}
</DialogButton>
</div>
{(results.length > 0 || noResults) && (
<div
role="group"
aria-label={t('common.search')}
className="absolute left-0 right-0 z-20 mt-1.5 flex max-h-[280px] flex-col gap-0.5 overflow-y-auto rounded-[12px] border border-edge-faint bg-surface-card p-1 shadow-dropdown"
>
<div className="flex items-center justify-between py-0.5 pl-2.5 pr-0.5">
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>{t('common.search')}</span>
<Tooltip label={t('common.close')}>
<button type="button" onClick={dismissResults} aria-label={t('common.close')}
className="grid h-7 w-7 place-items-center rounded-[8px] text-content-faint hover:bg-surface-hover hover:text-content">
<X size={13} />
</button>
))}
</Tooltip>
</div>
)}
</div>
{noResults ? (
<div className="px-2.5 py-3 text-center text-content-faint" style={fs(12.5, 'body')}>{t('planner.noPlacesFound')}</div>
) : results.map((r, i) => (
<button key={i} type="button" onClick={() => pick(r)} className="flex w-full items-center gap-2.5 rounded-[8px] px-2.5 py-2 text-left hover:bg-surface-hover">
<span className="grid h-8 w-8 flex-none place-items-center rounded-[9px] bg-surface-secondary text-content-faint"><MapPin size={15} /></span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(13, 'body')}>{str(r.name)}</span>
{str(r.address) && <span className="block truncate text-content-faint" style={fs(11.5)}>{str(r.address)}</span>}
</span>
</button>
))}
</div>
)}
</div>
</div>
{/* Name */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('common.name')}</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder={t('common.name')} className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent" />
{/* Where it is: filled in by a picked result, but also typeable, so a place
can be added by GPS alone (#1435). */}
<div className={PANEL}>
<EditorField label={t('places.formAddress')} htmlFor={`${fieldId}-address`}>
<input
id={`${fieldId}-address`}
type="text"
value={address}
onChange={e => setAddress(e.target.value)}
placeholder={t('places.formAddressPlaceholder')}
className={INPUT}
/>
</EditorField>
<div role="group" aria-label={t('collections.coordinates')} className={GRID_2}>
<EditorField label={t('places.formLatLabel')} htmlFor={`${fieldId}-lat`}>
<NumericInput id={`${fieldId}-lat`} mode="signed" value={lat} onValueChange={setLat} onPaste={coordPaste} placeholder={t('places.formLat')} className={INPUT} />
</EditorField>
<EditorField label={t('places.formLngLabel')} htmlFor={`${fieldId}-lng`}>
<NumericInput id={`${fieldId}-lng`} mode="signed" value={lng} onValueChange={setLng} onPaste={coordPaste} placeholder={t('places.formLng')} className={INPUT} />
</EditorField>
</div>
</div>
{/* Address + coordinates — editable so a place can be added by GPS alone */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('places.formAddress')}</label>
<input value={address} onChange={e => setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className={coordInputClass} />
<div className="grid grid-cols-2 gap-2 mt-2">
<NumericInput mode="signed" value={lat} onValueChange={setLat} onPaste={coordPaste} placeholder={t('places.formLat')} className={coordInputClass} />
<NumericInput mode="signed" value={lng} onValueChange={setLng} onPaste={coordPaste} placeholder={t('places.formLng')} className={coordInputClass} />
</div>
</div>
<EditorField label={t('mobileCollections.status')}>
<Segmented<CollectionStatus>
label={t('mobileCollections.status')}
value={status}
onChange={setStatus}
options={STATUS_ORDER.map(s => {
const Icon = STATUS_META[s].icon
return { value: s, label: t(STATUS_META[s].labelKey), icon: <Icon size={13} style={{ color: STATUS_META[s].color }} /> }
})}
/>
</EditorField>
{/* Status */}
<div>
<div className="flex flex-wrap gap-1.5">
{STATUS_ORDER.map(s => {
const Icon = STATUS_META[s].icon
const on = status === s
{categories.length > 0 && (
<EditorField label={t('collections.category')}>
<div role="group" aria-label={t('collections.category')} className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => setCategoryId(null)}
aria-pressed={categoryId == null}
className={`${CHIP} ${categoryId == null ? CHIP_NONE_ON : CHIP_OFF}`}
style={fs(12, 'body')}
>
{t('collections.noCategory')}
</button>
{categories.map(cat => {
const Icon = getCategoryIcon(cat.icon ?? undefined)
const on = categoryId === cat.id
const col = cat.color || '#6366f1' // theme-lint-disable: the category's own colour, and the default one without a colour is drawn in
return (
<button key={s} type="button" onClick={() => setStatus(s)} className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-semibold border transition-colors ${on ? 'bg-inverse text-inverse-text border-transparent' : 'bg-surface-card text-content-secondary border-edge hover:bg-surface-hover'}`}>
<Icon size={13} style={{ color: on ? undefined : STATUS_META[s].color }} /> {t(STATUS_META[s].labelKey)}
<button
key={cat.id}
type="button"
onClick={() => setCategoryId(cat.id)}
aria-pressed={on}
className={`${CHIP} ${CHIP_OFF}`}
style={{ ...fs(12, 'body'), ...(on ? { color: col, background: `color-mix(in oklch, ${col} 15%, transparent)`, borderColor: `color-mix(in oklch, ${col} 40%, transparent)` } : {}) }}
>
<Icon size={13} /> {cat.name}
</button>
)
})}
</div>
</div>
</EditorField>
)}
{/* Category */}
{categories.length > 0 && (
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.category')}</label>
<div className="flex flex-wrap gap-1.5">
<button type="button" onClick={() => setCategoryId(null)} className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-colors ${categoryId == null ? 'bg-inverse text-inverse-text border-transparent' : 'bg-surface-card text-content-secondary border-edge hover:bg-surface-hover'}`}>
{t('collections.noCategory')}
</button>
{categories.map(cat => {
const Icon = getCategoryIcon(cat.icon ?? undefined)
const on = categoryId === cat.id
const col = cat.color || '#6366f1'
return (
<button
key={cat.id}
type="button"
onClick={() => setCategoryId(cat.id)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-medium border transition-colors bg-surface-card border-edge hover:bg-surface-hover"
style={on ? { color: col, background: `color-mix(in oklch, ${col} 15%, transparent)`, borderColor: `color-mix(in oklch, ${col} 40%, transparent)` } : undefined}
>
<Icon size={13} /> {cat.name}
</button>
)
})}
</div>
<DialogSection
label={<label htmlFor={`${fieldId}-description`}>{t('collections.description')}</label>}
action={<NoteFormatToolbar textareaRef={descRef} onChange={setDescription} compact customTooltips />}
>
<textarea
id={`${fieldId}-description`}
ref={descRef}
value={description}
onChange={e => setDescription(e.target.value)}
rows={3}
placeholder={t('collections.descriptionPlaceholder')}
className={`${TEXTAREA} resize-y`}
/>
{description.trim() && (
<div className="collab-note-md mt-2 text-content-secondary" style={fs(13, 'body')}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{description}</Markdown>
</div>
)}
</DialogSection>
{/* Description */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.description')}</label>
<MarkdownToolbar textareaRef={descRef} onUpdate={setDescription} />
<textarea ref={descRef} value={description} onChange={e => setDescription(e.target.value)} rows={3} placeholder={t('collections.descriptionPlaceholder')} className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent resize-y" />
{description.trim() && (
<div className="collab-note-md mt-2 text-[13px] text-content-secondary"><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{description}</Markdown></div>
)}
</div>
{/* Links */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.links')}</label>
<div className="flex flex-col gap-2">
{links.map((l, i) => (
<div key={i} className="flex items-center gap-2">
<input value={l.label ?? ''} onChange={e => setLink(i, { label: e.target.value })} placeholder={t('collections.linkLabel')} className="w-28 shrink-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent" />
<input value={l.url} onChange={e => setLink(i, { url: e.target.value })} placeholder="https://…" className="flex-1 min-w-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent" />
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} className="p-1.5 rounded-md text-content-faint hover:text-danger hover:bg-danger-soft" aria-label={t('common.delete')}><Trash2 size={14} /></button>
<DialogSection label={t('collections.links')}>
<div className="flex flex-col gap-2">
{links.map((l, i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-28 flex-none">
<input value={l.label ?? ''} onChange={e => setLink(i, { label: e.target.value })} placeholder={t('collections.linkLabel')} className={INPUT} />
</div>
))}
<button type="button" onClick={() => setLinks([...links, { url: '' }])} className="inline-flex items-center gap-1.5 self-start px-2.5 py-1.5 rounded-lg border border-dashed border-edge text-content-secondary text-[12.5px] font-medium hover:bg-surface-hover">
<Plus size={14} /> <Link2 size={13} /> {t('collections.addLink')}
</button>
</div>
<input value={l.url} onChange={e => setLink(i, { url: e.target.value })} placeholder="https://…" className={`${INPUT} flex-1`} />
<Tooltip label={t('common.delete')}>
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} aria-label={t('common.delete')} className={`${ROW_ACTION} hover:text-danger`}>
<Trash2 size={14} />
</button>
</Tooltip>
</div>
))}
<AddRowButton onClick={() => setLinks([...links, { url: '' }])}>{t('collections.addLink')}</AddRowButton>
</div>
</div>
</Modal>
</DialogSection>
</DialogShell>
)
}
@@ -65,10 +65,10 @@ describe('BulkAssignLabelModal', () => {
const berlinRow = screen.getByRole('button', { name: 'Berlin' });
await user.click(berlinRow);
expect(berlinRow).toHaveClass('border-accent');
expect(berlinRow).toHaveAttribute('aria-pressed', 'true');
await user.click(berlinRow);
expect(berlinRow).not.toHaveClass('border-accent');
expect(berlinRow).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: /^Assign label$/ })).toBeDisabled();
});
@@ -83,7 +83,7 @@ describe('BulkAssignLabelModal', () => {
await waitFor(() => expect(props.onAssign).toHaveBeenCalledWith([2, 1]));
// Selection resets so the modal is ready for the next batch.
await waitFor(() => expect(screen.getByRole('button', { name: /^Assign label$/ })).toBeDisabled());
expect(screen.getByRole('button', { name: 'Berlin' })).not.toHaveClass('border-accent');
expect(screen.getByRole('button', { name: 'Berlin' })).toHaveAttribute('aria-pressed', 'false');
});
it('FE-COMP-BULKLABEL-006: a second click while the assign is pending is ignored', async () => {
@@ -1,6 +1,6 @@
import React, { useState } from 'react'
import React, { useId, useState } from 'react'
import { Check, Loader2, Settings2, Tags } from 'lucide-react'
import Modal from '../shared/Modal'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import type { CollectionLabel } from '@trek/shared'
import type { TranslationFn } from '../../types'
@@ -24,6 +24,7 @@ interface BulkAssignLabelModalProps {
export default function BulkAssignLabelModal({ isOpen, labels, count, onAssign, onManage, onClose, t }: BulkAssignLabelModalProps): React.ReactElement {
const [picked, setPicked] = useState<number[]>([])
const [busy, setBusy] = useState(false)
const labelId = useId()
const toggle = (id: number) => setPicked(picked.includes(id) ? picked.filter(x => x !== id) : [...picked, id])
@@ -39,54 +40,66 @@ export default function BulkAssignLabelModal({ isOpen, labels, count, onAssign,
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.assignN', { count })} size="lg">
<DialogShell
open={isOpen}
onClose={onClose}
labelledBy={labelId}
width="narrow"
header={(
<DialogHeader
tile={<DialogTile><Tags size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={t('collections.labels.assignN', { count })}
/>
)}
footer={labels.length === 0 ? undefined : (
<DialogFooter>
<DialogButton onClick={onManage} icon={<Settings2 size={14} strokeWidth={2.2} />}>{t('collections.labels.manage')}</DialogButton>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton
variant="primary"
onClick={assign}
disabled={picked.length === 0 || busy}
icon={busy ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} strokeWidth={2.2} />}
>
{t('collections.labels.assign')}
</DialogButton>
</DialogFooter>
)}
>
{labels.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<Tags size={26} className="text-content-faint" />
<p className="text-[13px] text-content-faint">{t('collections.labels.emptyHint')}</p>
<button type="button" onClick={onManage} className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-edge text-[13px] text-content hover:bg-surface-hover">
<Settings2 size={14} /> {t('collections.labels.manage')}
</button>
<div className="flex flex-col items-center px-4 py-6 text-center">
<div className="mb-3 grid h-11 w-11 place-items-center rounded-2xl bg-surface-secondary text-content-faint">
<Tags size={20} />
</div>
<p className="m-0 mb-4 text-content-faint" style={fs(13, 'body')}>{t('collections.labels.emptyHint')}</p>
<DialogButton variant="primary" onClick={onManage} icon={<Settings2 size={14} strokeWidth={2.2} />}>{t('collections.labels.manage')}</DialogButton>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1 max-h-[46vh] overflow-y-auto -mx-1 px-1">
{labels.map(l => {
const on = picked.includes(l.id)
return (
<button
key={l.id}
type="button"
onClick={() => toggle(l.id)}
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-xl border text-left transition-colors ${on ? 'border-accent bg-accent/10' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
>
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: l.color || '#6366f1' }} />
<span className="flex-1 min-w-0 text-[13px] font-medium text-content truncate">{l.name}</span>
{on && <Check size={15} className="text-accent shrink-0" />}
</button>
)
})}
</div>
{/* nowrap on all three: the labels are long in several locales and used to
wrap into two lines inside the buttons before the dialog got wider. */}
<div className="flex justify-end gap-2 pt-2 border-t border-edge">
<button type="button" onClick={onManage} className="mr-auto flex items-center gap-1.5 px-3 py-2 rounded-lg text-[13px] text-content-secondary whitespace-nowrap hover:bg-surface-hover">
<Settings2 size={14} /> {t('collections.labels.manage')}
</button>
<button type="button" onClick={onClose} className="px-3 py-2 rounded-lg border border-edge text-content-secondary text-[13px] whitespace-nowrap hover:bg-surface-hover">
{t('common.cancel')}
</button>
<button
type="button"
onClick={assign}
disabled={picked.length === 0 || busy}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-accent-text text-[13px] font-semibold whitespace-nowrap hover:opacity-90 disabled:opacity-50"
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {t('collections.labels.assign')}
</button>
</div>
<div className="flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5">
{labels.map(l => {
const on = picked.includes(l.id)
return (
<button
key={l.id}
type="button"
aria-pressed={on}
onClick={() => toggle(l.id)}
className={`flex min-h-[44px] items-center gap-3 rounded-[10px] px-3 py-2 text-left ${on ? 'bg-surface-card shadow-sm' : 'hover:bg-surface-card'}`}
>
<span className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: l.color || '#6366f1' }} /* theme-lint-disable: the label's own colour, and the first swatch of the label manager for one without */ />
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>{l.name}</span>
<span className={`grid h-6 w-6 flex-none place-items-center rounded-[8px] ${on ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-transparent'}`}>
<Check size={13} />
</span>
</button>
)
})}
</div>
)}
</Modal>
</DialogShell>
)
}
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'
import React, { useEffect, useId, useMemo, useState } from 'react'
import { Search, MapPin, Loader2, Copy, CalendarDays } from 'lucide-react'
import Modal from '../shared/Modal'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { INPUT } from '../shared/dialogParts'
import { useToast } from '../shared/Toast'
import { tripsApi } from '../../api/client'
import { getApiErrorMessage } from '../../utils/apiError'
@@ -35,6 +36,7 @@ interface CopyToTripModalProps {
export default function CopyToTripModal({ isOpen, onClose, placeIds, onCopy, t }: CopyToTripModalProps): React.ReactElement | null {
const toast = useToast()
const { language } = useTranslation()
const labelId = useId()
const [trips, setTrips] = useState<TripOption[]>([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
@@ -90,59 +92,75 @@ export default function CopyToTripModal({ isOpen, onClose, placeIds, onCopy, t }
}
return (
<Modal
isOpen
<DialogShell
onClose={onClose}
title={placeIds.length > 1 ? t('collections.copyN', { count: placeIds.length }) : t('collections.copyToTripTitle')}
size="sm"
labelledBy={labelId}
width="narrow"
// The list shrinks while the search narrows it; a pinned top edge keeps the field still.
align="top"
header={(
<DialogHeader
tile={<DialogTile><Copy size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={placeIds.length > 1 ? t('collections.copyN', { count: placeIds.length }) : t('collections.copyToTripTitle')}
/>
)}
footer={(
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
</DialogFooter>
)}
>
<div className="flex flex-col gap-3">
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.copyToTripSearch')}
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"
/>
</div>
{loading ? (
<div className="flex items-center justify-center py-10 text-content-faint">
<Loader2 size={20} className="animate-spin" />
</div>
) : filtered.length === 0 ? (
<p className="text-center text-[13px] text-content-faint py-10">{t('collections.noTrips')}</p>
) : (
<div className="flex flex-col gap-1 max-h-[50vh] overflow-y-auto -mx-1 px-1">
{filtered.map(trip => {
const busy = busyTripId === trip.id
return (
<button
key={trip.id}
type="button"
onClick={() => handleCopy(trip.id)}
disabled={busy}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-card text-left hover:bg-surface-hover transition-colors disabled:opacity-60"
>
<span className="w-9 h-9 rounded-lg bg-surface-secondary flex items-center justify-center shrink-0 overflow-hidden text-content-faint">
{trip.cover_image ? <img src={trip.cover_image} alt="" className="w-full h-full object-cover" /> : <MapPin size={15} />}
</span>
<span className="flex-1 min-w-0">
<span className="block text-[13px] font-medium text-content truncate">{trip.title}</span>
{dateRange(trip) && (
<span className="flex items-center gap-1 text-[11.5px] text-content-faint truncate">
<CalendarDays size={11} className="shrink-0" /> {dateRange(trip)}
</span>
)}
</span>
{busy ? <Loader2 size={15} className="animate-spin text-content-faint shrink-0" /> : <Copy size={15} className="text-content-faint shrink-0" />}
</button>
)
})}
</div>
)}
<div className="relative">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.copyToTripSearch')}
aria-label={t('collections.copyToTripSearch')}
className={`${INPUT} pl-8`}
/>
</div>
</Modal>
{loading ? (
<div className="flex items-center justify-center py-8 text-content-faint">
<Loader2 size={20} className="animate-spin" />
</div>
) : filtered.length === 0 ? (
<p className="m-0 rounded-[12px] bg-surface-secondary px-4 py-6 text-center text-content-faint" style={fs(12.5, 'body')}>{t('collections.noTrips')}</p>
) : (
<div className="flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5">
{filtered.map(trip => {
const busy = busyTripId === trip.id
const range = dateRange(trip)
return (
<button
key={trip.id}
type="button"
onClick={() => handleCopy(trip.id)}
disabled={busy}
className="flex min-h-[52px] items-center gap-3 rounded-[10px] px-2.5 py-2 text-left hover:bg-surface-card disabled:opacity-60"
>
<span className="grid h-9 w-9 flex-none place-items-center overflow-hidden rounded-[9px] bg-surface-tertiary text-content-faint">
{trip.cover_image ? <img src={trip.cover_image} alt="" className="h-full w-full object-cover" /> : <MapPin size={15} />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(13, 'body')}>{trip.title}</span>
{range && (
<span className="mt-0.5 flex items-center gap-1 truncate text-content-faint" style={fs(11.5)}>
<CalendarDays size={11} className="flex-none" /> {range}
</span>
)}
</span>
{busy ? <Loader2 size={15} className="flex-none animate-spin text-content-faint" /> : <Copy size={15} className="flex-none text-content-faint" />}
</button>
)
})}
</div>
)}
</DialogShell>
)
}
@@ -154,7 +154,8 @@ describe('ImportCollectionModal with a GPX (#2301)', () => {
it('FE-COMP-COLLIMPORT-011: says which files it takes', () => {
renderModal();
expect(screen.getByText('.trekcollection.json · .gpx')).toBeInTheDocument();
expect(screen.getByText('.trekcollection.json')).toBeInTheDocument();
expect(screen.getByText('.gpx')).toBeInTheDocument();
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(input.accept).toContain('.gpx');
});
@@ -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>
)
}
@@ -1,7 +1,7 @@
// FE-COMP-COLIMPORT-001 to FE-COMP-COLIMPORT-007
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -65,11 +65,12 @@ describe('ImportFromTripModal', () => {
it('FE-COMP-COLIMPORT-001: lists the trips with their place count and a localized date range', async () => {
render(<Harness />);
await waitFor(() => expect(screen.getByText('Rome 2026')).toBeInTheDocument());
// The count is a compact badge; the full wording stays as its title.
expect(screen.getByTitle('3 places')).toHaveTextContent('3');
// The count is a compact badge; the full wording is in its tooltip.
fireEvent.mouseEnter(screen.getByText('3'));
expect(await screen.findByRole('tooltip')).toHaveTextContent('3 places');
// Formatted through the shared formatter, never the raw ISO string.
const dateBadge = screen.getByTitle(/May/);
expect(dateBadge.textContent).toMatch(/May 1.*–.*May 8/);
const dateBadge = screen.getByText(/May/);
expect(dateBadge.textContent).toMatch(/May 1.*→.*May 8/);
expect(dateBadge.textContent).not.toMatch(/2026-05-01/);
expect(screen.getByText('Lisbon')).toBeInTheDocument();
});
@@ -1,6 +1,8 @@
import React, { useEffect, useMemo, useState } from 'react'
import { ArrowLeft, Calendar, Check, ChevronRight, Loader2, MapPin, Map as MapIcon, Search, Sparkles } from 'lucide-react'
import Modal from '../shared/Modal'
import React, { useEffect, useId, useMemo, useState } from 'react'
import { ArrowLeft, Calendar, Check, ChevronRight, ListPlus, Loader2, MapPin, Map as MapIcon, Search, Sparkles } from 'lucide-react'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { INPUT } from '../shared/dialogParts'
import { Tooltip } from '../shared/Tooltip'
import { tripsApi } from '../../api/client'
import { collectionsApi } from '../../api/collections'
import { getCategoryIcon } from '../shared/categoryIcons'
@@ -36,10 +38,37 @@ function importOrder(p: CollectionImportablePlace): number {
function tripRange(trip: Trip, locale: string): string | null {
const from = formatDate(trip.start_date, locale)
const to = formatDate(trip.end_date, locale)
if (from && to && to !== from) return `${from} – ${to}`
if (from && to && to !== from) return `${from} → ${to}`
return from || to || null
}
/**
* The toolbar of the places step, pinned to the top of the scrolling body so the
* search and the filters stay in reach down a long trip. The negative margins
* let its background cover the body's padding while it is stuck.
*/
const STICKY_BAR = 'sticky top-0 z-10 -mx-6 -mb-3 -mt-5 flex flex-wrap items-center gap-2 bg-surface-card px-6 pb-3 pt-5'
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5'
/** A small fact on a trip tile; each is its own pill, so a narrow tile wraps between them instead of clipping a word. */
const META_PILL = 'inline-flex min-w-0 max-w-full items-center gap-1 truncate rounded-full bg-surface-tertiary px-1.5 py-px font-semibold tabular-nums text-content-muted'
const CHIP = 'inline-flex flex-none items-center gap-1 rounded-full px-2.5 py-1 font-semibold disabled:cursor-default disabled:opacity-50'
const CHIP_OFF = 'bg-surface-card text-content-muted ring-1 ring-edge-faint hover:text-content'
const CHIP_ON = 'bg-accent text-accent-text'
const TAG = 'flex-none whitespace-nowrap rounded-full px-2 py-[2px] font-semibold tabular-nums'
/** The dashed box a step shows when there is nothing to pick. */
function EmptyState({ icon, good = false, children }: { icon: React.ReactNode; good?: boolean; children: React.ReactNode }) {
return (
<div
className={`flex flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed px-4 py-8 text-center ${good ? 'border-success bg-success-soft text-success' : 'border-edge text-content-faint'}`}
style={fs(12.5, 'body')}
>
{icon}
<span>{children}</span>
</div>
)
}
/**
* Bulk import of a trip's places into the current list.
*
@@ -51,6 +80,7 @@ function tripRange(trip: Trip, locale: string): string | null {
export default function ImportFromTripModal({ isOpen, collectionId, collectionName, categories, onClose, onImported, t }: ImportFromTripModalProps): React.ReactElement {
const toast = useToast()
const { language } = useTranslation()
const labelId = useId()
const [step, setStep] = useState<Step>('trip')
const [trips, setTrips] = useState<Trip[]>([])
const [tripsLoading, setTripsLoading] = useState(false)
@@ -142,199 +172,218 @@ export default function ImportFromTripModal({ isOpen, collectionId, collectionNa
const categoryById = useMemo(() => new Map(categories.map(c => [c.id, c])), [categories])
const title = step === 'places' && trip
? trip.title
const tripShown = step === 'places' ? trip : null
const title = tripShown
? tripShown.title
: step === 'done'
? t('collections.importDoneTitle')
: t('collections.importTitle')
const footer = step === 'done'
? (
<div className="flex justify-end">
<button type="button" onClick={onClose} className="px-4 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold">{t('common.close')}</button>
</div>
// The first step says what it is for under the title; the second names the
// trip, and the dialog's own title moves up to the eyebrow.
let sub: React.ReactNode
if (step === 'trip') sub = t('collections.importPickTripHint', { name: collectionName })
else if (tripShown) sub = tripRange(tripShown, language) ?? undefined
const header = (
<DialogHeader
tile={<DialogTile><ListPlus size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
eyebrow={tripShown ? t('collections.importTitle') : undefined}
title={title}
sub={sub}
subWraps={step === 'trip'}
/>
)
let footer: React.ReactNode
if (step === 'done') {
footer = (
<DialogFooter>
<FooterSpacer />
<DialogButton variant="primary" onClick={onClose}>{t('common.close')}</DialogButton>
</DialogFooter>
)
: step === 'places'
? (
<div className="flex items-center justify-between gap-3">
<span className="text-[12.5px] text-content-faint">
{t('collections.importSelectedCount', { count: selected.size })}
</span>
<div className="flex gap-2">
<button type="button" onClick={onClose} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">{t('common.cancel')}</button>
<button
type="button"
onClick={runImport}
disabled={importing || selected.size === 0}
className="px-4 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-1.5"
>
{importing ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
{t('collections.importAction', { count: selected.size })}
</button>
</div>
</div>
)
: (
<div className="flex justify-end">
<button type="button" onClick={onClose} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">{t('common.cancel')}</button>
</div>
)
} else if (step === 'places') {
footer = (
<DialogFooter>
<DialogButton onClick={() => setStep('trip')} icon={<ArrowLeft size={14} strokeWidth={2.2} />}>{t('common.back')}</DialogButton>
<span className="ml-1 text-content-faint" style={fs(12.5, 'body')}>
{t('collections.importSelectedCount', { count: selected.size })}
</span>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton
variant="primary"
onClick={() => void runImport()}
disabled={importing || selected.size === 0}
icon={importing ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} strokeWidth={2.2} />}
>
{t('collections.importAction', { count: selected.size })}
</DialogButton>
</DialogFooter>
)
} else {
footer = (
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
</DialogFooter>
)
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="xl" footer={footer}>
<div className="col-imp">
{/* ── Step 1: which trip ─────────────────────────────────────────── */}
{step === 'trip' && (
<>
<p className="col-imp-lead">{t('collections.importPickTripHint', { name: collectionName })}</p>
{tripsLoading ? (
<div className="col-imp-grid">
{[0, 1, 2, 3].map(i => <div key={i} className="col-imp-trip trek-skeleton" style={{ height: 68 }} />)}
</div>
) : trips.length === 0 ? (
<div className="col-imp-empty">
<MapIcon size={22} />
<span>{t('collections.importNoTrips')}</span>
</div>
) : (
<div className="col-imp-grid trek-stagger">
{trips.map(tr => {
const range = tripRange(tr, language)
const places = tr.place_count ?? 0
return (
<button key={tr.id} type="button" className="col-imp-trip" onClick={() => pickTrip(tr)}>
<span className="col-imp-trip-cover">
{tr.cover_image
? <img src={tr.cover_image} alt="" loading="lazy" />
: <MapIcon size={16} />}
</span>
<span className="col-imp-trip-body">
<span className="col-imp-trip-name">{tr.title}</span>
{/* Badges rather than one run-on line: the tile is half the dialog
wide, and a date range plus a place count ran out of room and
got clipped mid-word. Each pill can now wrap as a unit. */}
<span className="col-imp-trip-meta">
{range && (
<span className="col-imp-pill" title={range}>
<Calendar size={10} />{range}
</span>
)}
<span className="col-imp-pill num" title={t('collections.importPlacesCount', { count: places })}>
<MapPin size={10} />{places}
</span>
</span>
</span>
<ChevronRight size={16} className="col-imp-trip-go" />
</button>
)
})}
</div>
)}
</>
)}
{/* ── Step 2: which places ───────────────────────────────────────── */}
{step === 'places' && (
<>
<div className="col-imp-bar">
<button type="button" className="col-imp-back" onClick={() => setStep('trip')} aria-label={t('common.back')}>
<ArrowLeft size={15} />
</button>
<div className="col-imp-search">
<Search size={14} />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('collections.importSearchPlaces')}
aria-label={t('collections.importSearchPlaces')}
/>
</div>
{savedCount > 0 && (
// Pinned at the top: the three steps are of very different heights.
<DialogShell open={!!isOpen} onClose={onClose} labelledBy={labelId} width="editor" align="top" header={header} footer={footer}>
{/* Step 1: which trip */}
{step === 'trip' && (
tripsLoading ? (
<div className="grid grid-cols-2 gap-2 max-sm:grid-cols-1">
{[0, 1, 2, 3].map(i => <div key={i} className="trek-skeleton h-[64px] rounded-[14px]" />)}
</div>
) : trips.length === 0 ? (
<EmptyState icon={<MapIcon size={22} />}>{t('collections.importNoTrips')}</EmptyState>
) : (
<div className="trek-stagger grid grid-cols-2 gap-2 max-sm:grid-cols-1">
{trips.map(tr => {
const range = tripRange(tr, language)
const places = tr.place_count ?? 0
return (
<button
key={tr.id}
type="button"
className={`col-imp-chip${onlyNew ? ' on' : ''}`}
aria-pressed={onlyNew}
onClick={() => setOnlyNew(v => !v)}
onClick={() => pickTrip(tr)}
className="group flex w-full items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-secondary p-2.5 text-left transition-colors hover:bg-surface-card hover:shadow-sm"
>
<Sparkles size={12} />{t('collections.importOnlyNew')}
<span className="grid h-[42px] w-[42px] flex-none place-items-center overflow-hidden rounded-[11px] bg-surface-tertiary text-content-faint">
{tr.cover_image
? <img src={tr.cover_image} alt="" loading="lazy" className="h-full w-full object-cover" />
: <MapIcon size={16} />}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate font-semibold text-content" style={fs(13.5, 'body')}>{tr.title}</span>
{/* Badges rather than one run-on line: a date range plus a place
count ran out of room on a half-width tile and got clipped
mid-word. Each pill can now wrap as a unit. */}
<span className="flex min-w-0 flex-wrap items-center gap-1" style={fs(10.5)}>
{range && (
<Tooltip label={range}>
<span className={META_PILL}><Calendar size={10} className="flex-none" />{range}</span>
</Tooltip>
)}
<Tooltip label={t('collections.importPlacesCount', { count: places })}>
<span className={`${META_PILL} flex-none`}><MapPin size={10} className="flex-none" />{places}</span>
</Tooltip>
</span>
</span>
<ChevronRight size={16} className="flex-none text-content-faint transition-transform group-hover:translate-x-0.5 group-hover:text-content-muted" />
</button>
)}
)
})}
</div>
)
)}
{/* Step 2: which places */}
{step === 'places' && (
<>
<div className={STICKY_BAR}>
<div className="relative min-w-[150px] flex-1">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('collections.importSearchPlaces')}
aria-label={t('collections.importSearchPlaces')}
className={`${INPUT} pl-8`}
/>
</div>
{savedCount > 0 && (
<button
type="button"
className="col-imp-chip"
onClick={toggleAllVisible}
disabled={selectableVisible.length === 0}
className={`${CHIP} ${onlyNew ? CHIP_ON : CHIP_OFF}`}
style={fs(11.5, 'body')}
aria-pressed={onlyNew}
onClick={() => setOnlyNew(v => !v)}
>
{allVisibleSelected ? t('collections.importClearSelection') : t('collections.importSelectAll')}
<Sparkles size={12} />{t('collections.importOnlyNew')}
</button>
</div>
{itemsLoading ? (
<div className="col-imp-list">
{[0, 1, 2, 3, 4].map(i => <div key={i} className="col-imp-row trek-skeleton" style={{ height: 56 }} />)}
</div>
) : items.length === 0 ? (
<div className="col-imp-empty">
<MapPin size={22} />
<span>{t('collections.importEmptyTrip')}</span>
</div>
) : newCount === 0 ? (
<div className="col-imp-empty col-imp-empty-good">
<Check size={22} />
<span>{t('collections.importNothingNew')}</span>
</div>
) : (
<div className="col-imp-list trek-stagger">
{visible.map(p => {
const dup = p.already_in_list
const on = selected.has(p.place_id)
const cat = p.category_id != null ? categoryById.get(p.category_id) : undefined
const Icon = getCategoryIcon(cat?.icon ?? undefined)
return (
<button
key={p.place_id}
type="button"
className={`col-imp-row${on ? ' on' : ''}${dup ? ' dup' : ''}`}
onClick={() => !dup && toggle(p.place_id)}
disabled={dup}
aria-pressed={dup ? undefined : on}
>
<span className="col-imp-box">{on && <Check size={12} strokeWidth={3} />}</span>
<span className="col-imp-ico" style={cat?.color ? { color: cat.color } : undefined}>
<Icon size={15} />
</span>
<span className="col-imp-txt">
<span className="nm">{p.name}</span>
{p.address && <span className="ad">{p.address}</span>}
</span>
<span className={`col-imp-tag${dup ? ' saved' : p.scheduled ? '' : ' fresh'}`}>
{dup
? t('collections.importAlreadySaved')
: p.scheduled
? t('collections.importOnDay', { count: p.day_number ?? 0 })
: t('collections.importUnscheduled')}
</span>
</button>
)
})}
</div>
)}
</>
)}
{/* ── Step 3: what happened ──────────────────────────────────────── */}
{step === 'done' && result && (
<div className="col-imp-done">
<span className="col-imp-done-ring"><Check size={26} strokeWidth={2.5} /></span>
<span className="col-imp-done-t">{t('collections.importDone', { count: result.copied })}</span>
<span className="col-imp-done-s">
{result.skipped > 0
? t('collections.importDoneSkipped', { count: result.skipped })
: t('collections.importDoneClean', { name: collectionName })}
</span>
<button
type="button"
className={`${CHIP} ${CHIP_OFF}`}
style={fs(11.5, 'body')}
onClick={toggleAllVisible}
disabled={selectableVisible.length === 0}
>
{allVisibleSelected ? t('collections.importClearSelection') : t('collections.importSelectAll')}
</button>
</div>
)}
</div>
</Modal>
{itemsLoading ? (
<div className={ROWS}>
{[0, 1, 2, 3, 4].map(i => <div key={i} className="trek-skeleton h-12 rounded-[10px]" />)}
</div>
) : items.length === 0 ? (
<EmptyState icon={<MapPin size={22} />}>{t('collections.importEmptyTrip')}</EmptyState>
) : newCount === 0 ? (
<EmptyState icon={<Check size={22} />} good>{t('collections.importNothingNew')}</EmptyState>
) : (
<div className={`${ROWS} trek-stagger`}>
{visible.map(p => {
const dup = p.already_in_list
const on = selected.has(p.place_id)
const cat = p.category_id != null ? categoryById.get(p.category_id) : undefined
const Icon = getCategoryIcon(cat?.icon ?? undefined)
let tag: React.ReactNode
if (dup) tag = <span className={`${TAG} text-content-faint`} style={fs(10.5)}>{t('collections.importAlreadySaved')}</span>
else if (p.scheduled) tag = <span className={`${TAG} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>{t('collections.importOnDay', { count: p.day_number ?? 0 })}</span>
else tag = <span className={`${TAG} bg-accent-subtle text-accent-on`} style={fs(10.5)}>{t('collections.importUnscheduled')}</span>
return (
<button
key={p.place_id}
type="button"
onClick={() => !dup && toggle(p.place_id)}
disabled={dup}
aria-pressed={dup ? undefined : on}
className={`flex min-h-[48px] w-full items-center gap-3 rounded-[10px] px-2.5 py-1.5 text-left disabled:cursor-default disabled:opacity-50 ${on ? 'bg-surface-card shadow-sm' : 'enabled:hover:bg-surface-card'}`}
>
<span className={`grid h-4 w-4 flex-none place-items-center rounded-[5px] ${dup ? 'invisible' : on ? 'bg-accent text-accent-text' : 'border-[1.5px] border-edge'}`}>
{on && <Check size={11} strokeWidth={3} />}
</span>
<span className="grid h-7 w-7 flex-none place-items-center rounded-[9px] bg-surface-tertiary text-content-muted" style={cat?.color ? { color: cat.color } : undefined}>
<Icon size={15} />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-semibold text-content" style={fs(13, 'body')}>{p.name}</span>
{p.address && <span className="truncate text-content-faint" style={fs(11)}>{p.address}</span>}
</span>
{tag}
</button>
)
})}
</div>
)}
</>
)}
{/* Step 3: what happened */}
{step === 'done' && result && (
<div className="flex flex-col items-center gap-2.5 px-4 pb-4 pt-6 text-center">
<span className="trek-popover-enter grid h-[60px] w-[60px] place-items-center rounded-full bg-success-soft text-success">
<Check size={26} strokeWidth={2.5} />
</span>
<span className="font-bold text-content" style={fs(15, 'subtitle')}>{t('collections.importDone', { count: result.copied })}</span>
<span className="max-w-[320px] leading-normal text-content-faint" style={fs(12.5, 'body')}>
{result.skipped > 0
? t('collections.importDoneSkipped', { count: result.skipped })
: t('collections.importDoneClean', { name: collectionName })}
</span>
</div>
)}
</DialogShell>
)
}
@@ -1,6 +1,8 @@
import React, { useState } from 'react'
import { Plus, Trash2, Loader2 } from 'lucide-react'
import Modal from '../shared/Modal'
import React, { useId, useState } from 'react'
import { Plus, Settings2, Trash2, Loader2 } from 'lucide-react'
import { DialogButton, DialogHeader, DialogSection, DialogShell, DialogTile, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { INPUT, PANEL } from '../shared/dialogParts'
import { Tooltip } from '../shared/Tooltip'
import type { CollectionLabel, CollectionLabelUpdateRequest } from '@trek/shared'
import type { TranslationFn } from '../../types'
@@ -19,17 +21,26 @@ interface LabelManagerProps {
/** Swatch row shared by the create form and each row's recolor control. */
function Swatches({ value, onPick }: { value: string; onPick: (c: string) => void }): React.ReactElement {
return (
<div className="flex items-center gap-1.5 flex-wrap">
{SWATCHES.map(c => (
<button
key={c}
type="button"
onClick={() => onPick(c)}
className={`w-5 h-5 rounded-full border transition-transform ${value.toLowerCase() === c ? 'border-content scale-110' : 'border-transparent'}`}
style={{ background: c }}
aria-label={c}
/>
))}
<div className="flex flex-none flex-wrap items-center gap-1.5">
{SWATCHES.map(c => {
const on = value.toLowerCase() === c
return (
<button
key={c}
type="button"
onClick={() => onPick(c)}
aria-label={c}
aria-pressed={on}
className="h-5 w-5 rounded-full p-0 transition-transform"
style={{
background: c,
outline: on ? '2px solid var(--text-primary)' : '2px solid transparent',
outlineOffset: 1.5,
transform: on ? 'scale(1.08)' : 'scale(1)',
}}
/>
)
})}
</div>
)
}
@@ -58,22 +69,28 @@ function LabelRow({ label, onUpdate, onDelete, t }: {
}
return (
<div className="flex items-center gap-2 px-2.5 py-2 rounded-xl border border-edge bg-surface-card">
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: color }} />
<div className="flex items-center gap-2.5 rounded-[12px] bg-surface-secondary py-1.5 pl-3 pr-1.5">
<span className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: color }} />
{/* Transparent on the row until pointed at or typed in; the dark: variants
outrank the global dark rule that paints every input. */}
<input
value={name}
onChange={e => setName(e.target.value)}
onBlur={commitName}
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
maxLength={60}
className="flex-1 min-w-0 bg-transparent text-[13px] text-content outline-none"
className="-ml-1.5 min-w-0 flex-1 rounded-[8px] border-0 bg-transparent px-1.5 py-1 font-semibold text-content outline-none hover:bg-surface-card focus:bg-surface-card focus:shadow-sm dark:bg-transparent dark:hover:bg-surface-card dark:focus:bg-surface-card"
style={fs(13, 'body')}
aria-label={t('collections.labels.name')}
/>
<Swatches value={color} onPick={pickColor} />
{busy && <Loader2 size={14} className="animate-spin text-content-faint shrink-0" />}
<button type="button" onClick={() => onDelete(label.id)} className="p-1 text-content-faint hover:text-danger shrink-0" aria-label={t('common.delete')}>
<Trash2 size={14} />
</button>
{busy && <Loader2 size={14} className="flex-none animate-spin text-content-faint" />}
<Tooltip label={t('common.delete')}>
<button type="button" onClick={() => onDelete(label.id)} aria-label={t('common.delete')}
className="grid h-7 w-7 flex-none place-items-center rounded-full text-content-faint hover:bg-surface-card hover:text-danger">
<Trash2 size={13} />
</button>
</Tooltip>
</div>
)
}
@@ -86,6 +103,7 @@ export default function LabelManager({ isOpen, labels, onCreate, onUpdate, onDel
const [newName, setNewName] = useState('')
const [newColor, setNewColor] = useState(SWATCHES[0])
const [adding, setAdding] = useState(false)
const labelId = useId()
const add = async () => {
const trimmed = newName.trim()
@@ -100,40 +118,55 @@ export default function LabelManager({ isOpen, labels, onCreate, onUpdate, onDel
}
}
// Every change is saved as it is made, so there is nothing to confirm in a footer.
return (
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.manage')} size="sm">
<div className="flex flex-col gap-3">
<DialogShell
open={isOpen}
onClose={onClose}
labelledBy={labelId}
width="narrow"
header={(
<DialogHeader
tile={<DialogTile><Settings2 size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={t('collections.labels.manage')}
/>
)}
>
<DialogSection label={t('collections.labels.title')}>
{labels.length === 0 ? (
<p className="text-center text-[13px] text-content-faint py-4">{t('collections.labels.empty')}</p>
<p className="m-0 rounded-[12px] bg-surface-secondary px-4 py-5 text-center text-content-faint" style={fs(12.5, 'body')}>{t('collections.labels.empty')}</p>
) : (
<div className="flex flex-col gap-1.5 max-h-[46vh] overflow-y-auto -mx-1 px-1">
<div className="flex flex-col gap-1.5">
{labels.map(l => <LabelRow key={l.id} label={l} onUpdate={onUpdate} onDelete={onDelete} t={t} />)}
</div>
)}
<div className="flex flex-col gap-2 pt-3 border-t border-edge">
<div className={`${PANEL} mt-3`}>
<div className="flex items-center gap-2">
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ background: newColor }} />
<span className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: newColor }} />
<input
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') add() }}
maxLength={60}
placeholder={t('collections.labels.namePlaceholder')}
className="flex-1 min-w-0 px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
className={`${INPUT} flex-1`}
/>
<button
type="button"
<DialogButton
variant="primary"
onClick={add}
disabled={!newName.trim() || adding}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-white text-[13px] font-semibold hover:opacity-90 disabled:opacity-50 shrink-0"
icon={adding ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} strokeWidth={2.2} />}
>
{adding ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />} {t('collections.labels.add')}
</button>
{t('collections.labels.add')}
</DialogButton>
</div>
<Swatches value={newColor} onPick={setNewColor} />
</div>
</div>
</Modal>
</DialogSection>
</DialogShell>
)
}
@@ -1,6 +1,8 @@
import React, { useEffect, useRef, useState } from 'react'
import { ImagePlus, Link2, Plus, Trash2, Search, Loader2 } from 'lucide-react'
import Modal from '../shared/Modal'
import React, { useEffect, useId, useRef, useState } from 'react'
import { Bookmark, ImagePlus, Trash2, Search, Loader2 } from 'lucide-react'
import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, fs } from '../shared/DialogShell'
import { AddRowButton, EditorField, INPUT, TEXTAREA } from '../shared/dialogParts'
import { Tooltip } from '../shared/Tooltip'
import { useCollectionStore } from '../../store/collectionStore'
import { useToast } from '../shared/Toast'
import { tripsApi } from '../../api/client'
@@ -19,6 +21,10 @@ interface CoverSearchPhoto {
const SWATCHES = ['#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#ef4444', '#3b82f6', '#22c55e']
/** A photographer's name laid over the bottom of a result, on a wash of the card colour. */
const PHOTO_CREDIT = 'absolute inset-x-0 bottom-0 truncate bg-[color:color-mix(in_srgb,var(--bg-card)_85%,transparent)] px-1.5 py-1 text-content-muted'
const ROW_ACTION = 'grid h-8 w-8 flex-none place-items-center rounded-[9px] text-content-faint hover:bg-surface-secondary hover:text-danger'
interface ListEditorModalProps {
/** null = closed, 'new' = create, a Collection = edit that list. */
target: Collection | 'new' | null
@@ -40,6 +46,7 @@ export default function ListEditorModal({ target, onClose, onCreated, onRequestD
const uploadCover = useCollectionStore(s => s.uploadCover)
const toast = useToast()
const fileRef = useRef<HTMLInputElement>(null)
const labelId = useId()
const editing = target && target !== 'new' ? target : null
const [name, setName] = useState('')
@@ -152,161 +159,146 @@ export default function ListEditorModal({ target, onClose, onCreated, onRequestD
}
}
return (
<Modal
isOpen
// The list's own colour runs through the dialog: the head band, its tile and the cover.
const header = (
<DialogHeader
tile={<DialogTile><Bookmark size={20} strokeWidth={1.9} style={{ color }} /></DialogTile>}
tint={`color-mix(in srgb, ${color} 12%, transparent)`}
labelId={labelId}
onClose={onClose}
title={editing ? t('collections.editListTitle') : t('collections.newList')}
size="md"
footer={
<div className="flex items-center gap-2">
{editing && editing.is_owner !== false && (
<button
type="button"
onClick={() => { onClose(); onRequestDelete(editing.id) }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-danger text-[13px] font-medium hover:bg-danger-soft"
>
<Trash2 size={14} /> {t('collections.deleteList')}
</button>
)}
<div className="flex justify-end gap-2 ml-auto">
<button type="button" onClick={onClose} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">
{t('common.cancel')}
</button>
<button type="button" onClick={save} disabled={!name.trim() || saving} className="px-3 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50">
{editing ? t('common.save') : t('collections.create')}
</button>
</div>
</div>
}
>
<div className="flex flex-col gap-4">
{/* Cover */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.coverImage')}</label>
<button
type="button"
onClick={() => fileRef.current?.click()}
className="relative w-full h-32 rounded-xl overflow-hidden border border-edge bg-surface-secondary flex items-center justify-center text-content-faint hover:border-accent transition-colors"
>
{coverPreview && <img src={coverPreview} alt="" className="absolute inset-0 w-full h-full object-cover" />}
<div className="absolute inset-0" style={{ background: `linear-gradient(135deg, ${color}55, transparent 70%)` }} />
<span className="relative flex items-center gap-2 text-[13px] font-medium" style={{ color: coverPreview ? '#fff' : undefined, textShadow: coverPreview ? '0 1px 4px rgba(0,0,0,.5)' : undefined }}>
<ImagePlus size={16} /> {coverPreview ? t('collections.changeCover') : t('collections.addCover')}
</span>
</button>
<input ref={fileRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" className="hidden" onChange={e => pickCover(e.target.files?.[0])} />
eyebrow={editing ? t('collections.editListTitle') : t('collections.newList')}
titleInput={{
value: name,
onChange: setName,
label: t('collections.listName'),
placeholder: t('collections.listNamePlaceholder'),
autoFocus: true,
onKeyDown: e => { if (e.key === 'Enter' && name.trim()) void save() },
}}
/>
)
{/* Unsplash cover search — same source as trip creation */}
<div className="mt-2 flex gap-2">
<input
value={coverQuery}
onChange={e => setCoverQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); searchCover() } }}
placeholder={t('dashboard.unsplashSearchPlaceholder')}
className="flex-1 min-w-0 px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
<button
type="button"
onClick={searchCover}
disabled={searchingCover || (!coverQuery.trim() && !name.trim())}
className="px-3 py-2 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover disabled:opacity-40 inline-flex items-center gap-1.5 whitespace-nowrap"
>
{searchingCover ? <Loader2 size={14} className="animate-spin" /> : <Search size={14} />}
{t('dashboard.searchUnsplash')}
</button>
</div>
{coverResults.length > 0 && (
<div className="grid grid-cols-3 gap-2 mt-2">
{coverResults.map(photo => (
const footer = (
<DialogFooter>
{editing && editing.is_owner !== false && (
<DeleteButton label={t('collections.deleteList')} onClick={() => { onClose(); onRequestDelete(editing.id) }} />
)}
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton
variant="primary"
onClick={() => void save()}
disabled={!name.trim() || saving}
icon={saving ? <Loader2 size={14} className="animate-spin" /> : undefined}
>
{editing ? t('common.save') : t('collections.create')}
</DialogButton>
</DialogFooter>
)
return (
// Pinned at the top: the photo results grow the body under the search.
<DialogShell onClose={onClose} labelledBy={labelId} width="detail" align="top" header={header} footer={footer}>
<DialogSection label={t('collections.coverImage')}>
<button
type="button"
onClick={() => fileRef.current?.click()}
className="relative flex h-32 w-full items-center justify-center overflow-hidden rounded-[14px] border border-edge-faint bg-surface-secondary transition-colors hover:border-content-faint"
>
{coverPreview && <img src={coverPreview} alt="" className="absolute inset-0 h-full w-full object-cover" />}
<span className="absolute inset-0" style={{ background: `linear-gradient(135deg, color-mix(in srgb, ${color} 33%, transparent), transparent 70%)` }} />
{/* A chip rather than bare text, so it reads the same on any photo. */}
<span className="relative inline-flex items-center gap-1.5 rounded-full bg-surface-card px-3 py-1.5 font-semibold text-content shadow-sm" style={fs(12.5, 'body')}>
<ImagePlus size={15} /> {coverPreview ? t('collections.changeCover') : t('collections.addCover')}
</span>
</button>
<input ref={fileRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" className="hidden" onChange={e => pickCover(e.target.files?.[0])} />
{/* Unsplash cover search, the same source as trip creation */}
<div className="mt-2 flex gap-2">
<input
value={coverQuery}
onChange={e => setCoverQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); searchCover() } }}
aria-label={t('dashboard.unsplashSearchPlaceholder')}
placeholder={t('dashboard.unsplashSearchPlaceholder')}
className={`${INPUT} flex-1`}
/>
<DialogButton
onClick={() => void searchCover()}
disabled={searchingCover || (!coverQuery.trim() && !name.trim())}
icon={searchingCover ? <Loader2 size={14} className="animate-spin" /> : <Search size={14} strokeWidth={2.2} />}
>
{t('dashboard.searchUnsplash')}
</DialogButton>
</div>
{coverResults.length > 0 && (
<div className="mt-2 grid grid-cols-3 gap-2">
{coverResults.map(photo => {
const on = coverPreview === photo.url
return (
<button
type="button"
key={photo.id}
onClick={() => pickUnsplash(photo)}
aria-label={photo.photographer || 'Unsplash'}
className={`relative h-20 overflow-hidden rounded-lg border transition-colors ${coverPreview === photo.url ? 'border-accent ring-2 ring-accent/25' : 'border-edge hover:border-accent'}`}
aria-pressed={on}
className={`relative h-20 overflow-hidden rounded-[10px] transition-shadow ${on ? 'ring-2 ring-accent' : 'ring-1 ring-edge-faint hover:ring-content-faint'}`}
>
<img src={photo.thumb} alt={photo.description || ''} loading="lazy" className="w-full h-full object-cover" />
{photo.photographer && (
<span className="absolute inset-x-0 bottom-0 truncate bg-black/55 px-1.5 py-1 text-[10px] text-white">{photo.photographer}</span>
)}
<img src={photo.thumb} alt={photo.description || ''} loading="lazy" className="h-full w-full object-cover" />
{photo.photographer && <span className={PHOTO_CREDIT} style={fs(10)}>{photo.photographer}</span>}
</button>
))}
</div>
)}
</div>
{/* Name */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.listName')}</label>
<input
autoFocus
value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && name.trim()) save() }}
placeholder={t('collections.listNamePlaceholder')}
className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent"
/>
</div>
{/* Colour */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-2">{t('collections.listColor')}</label>
<div className="flex gap-2 flex-wrap">
{SWATCHES.map(col => (
<button
key={col}
type="button"
onClick={() => setColor(col)}
className="w-7 h-7 rounded-full transition-transform hover:scale-110"
style={{ background: col, outline: color === col ? '2px solid var(--accent)' : 'none', outlineOffset: 2 }}
aria-label={col}
/>
))}
)
})}
</div>
</div>
)}
</DialogSection>
{/* Description */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.description')}</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={3}
placeholder={t('collections.descriptionPlaceholder')}
className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent resize-y"
/>
<EditorField label={t('collections.listColor')}>
<div role="group" aria-label={t('collections.listColor')} className="flex flex-wrap gap-2">
{SWATCHES.map(col => (
<button
key={col}
type="button"
onClick={() => setColor(col)}
aria-label={col}
aria-pressed={color === col}
className={`h-7 w-7 rounded-full transition-transform hover:scale-110 ${color === col ? 'ring-2 ring-accent ring-offset-2 ring-offset-surface-card' : ''}`}
style={{ background: col }}
/>
))}
</div>
</EditorField>
{/* Links */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.links')}</label>
<div className="flex flex-col gap-2">
{links.map((l, i) => (
<div key={i} className="flex items-center gap-2">
<input
value={l.label ?? ''}
onChange={e => setLink(i, { label: e.target.value })}
placeholder={t('collections.linkLabel')}
className="w-32 shrink-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent"
/>
<input
value={l.url}
onChange={e => setLink(i, { url: e.target.value })}
placeholder="https://…"
className="flex-1 min-w-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent"
/>
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} className="p-1.5 rounded-md text-content-faint hover:text-danger hover:bg-danger-soft" aria-label={t('common.delete')}>
<EditorField label={t('collections.description')} htmlFor={`${labelId}-description`}>
<textarea
id={`${labelId}-description`}
value={description}
onChange={e => setDescription(e.target.value)}
rows={3}
placeholder={t('collections.descriptionPlaceholder')}
className={`${TEXTAREA} resize-y`}
/>
</EditorField>
<DialogSection label={t('collections.links')}>
<div className="flex flex-col gap-2">
{links.map((l, i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-32 flex-none">
<input value={l.label ?? ''} onChange={e => setLink(i, { label: e.target.value })} placeholder={t('collections.linkLabel')} className={INPUT} />
</div>
<input value={l.url} onChange={e => setLink(i, { url: e.target.value })} placeholder="https://…" className={`${INPUT} flex-1`} />
<Tooltip label={t('common.delete')}>
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} aria-label={t('common.delete')} className={ROW_ACTION}>
<Trash2 size={14} />
</button>
</div>
))}
<button type="button" onClick={() => setLinks([...links, { url: '' }])} className="inline-flex items-center gap-1.5 self-start px-2.5 py-1.5 rounded-lg border border-dashed border-edge text-content-secondary text-[12.5px] font-medium hover:bg-surface-hover">
<Plus size={14} /> <Link2 size={13} /> {t('collections.addLink')}
</button>
</div>
</Tooltip>
</div>
))}
<AddRowButton onClick={() => setLinks([...links, { url: '' }])}>{t('collections.addLink')}</AddRowButton>
</div>
</div>
</Modal>
</DialogSection>
</DialogShell>
)
}
@@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react'
import { Search, ArrowRight, Copy, Loader2, Bookmark } from 'lucide-react'
import Modal from '../shared/Modal'
import React, { useId, useMemo, useState } from 'react'
import { Search, ArrowRight, Copy, CopyPlus, FolderInput, Loader2 } from 'lucide-react'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { INPUT } from '../shared/dialogParts'
import type { Collection } from '@trek/shared'
import type { TranslationFn } from '../../types'
@@ -17,12 +18,13 @@ interface MoveToListModalProps {
/**
* Target-list picker for moving or duplicating the selected places into another
* of the user's lists. `mode` only changes the wording + the trailing icon; the
* action itself is the parent's onPick.
* of the user's lists. `mode` only changes the wording + the icons; the action
* itself is the parent's onPick.
*/
export default function MoveToListModal({ mode, lists, count, onPick, onClose, t }: MoveToListModalProps): React.ReactElement {
const [search, setSearch] = useState('')
const [busy, setBusy] = useState<number | null>(null)
const labelId = useId()
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
@@ -35,56 +37,70 @@ export default function MoveToListModal({ mode, lists, count, onPick, onClose, t
try { await onPick(id) } finally { setBusy(null) }
}
// The head tile repeats the icon of the selection bar button that opened the dialog.
const Tile = mode === 'move' ? FolderInput : CopyPlus
const Trailing = mode === 'move' ? ArrowRight : Copy
return (
<Modal
isOpen
<DialogShell
onClose={onClose}
title={mode === 'move' ? t('collections.moveToListTitle', { count }) : t('collections.duplicateToListTitle', { count })}
size="sm"
labelledBy={labelId}
width="narrow"
align="top"
header={(
<DialogHeader
tile={<DialogTile><Tile size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={mode === 'move' ? t('collections.moveToListTitle', { count }) : t('collections.duplicateToListTitle', { count })}
/>
)}
footer={(
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
</DialogFooter>
)}
>
<div className="flex flex-col gap-3">
{lists.length > 3 && (
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.copyToTripSearch')}
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"
/>
</div>
)}
{filtered.length === 0 ? (
<p className="text-center text-[13px] text-content-faint py-8">{t('collections.noOtherLists')}</p>
) : (
<div className="flex flex-col gap-1 max-h-[50vh] overflow-y-auto -mx-1 px-1">
{filtered.map(list => {
const isBusy = busy === list.id
return (
<button
key={list.id}
type="button"
onClick={() => pick(list.id)}
disabled={busy != null}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-card text-left hover:bg-surface-hover transition-colors disabled:opacity-60"
>
<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' }}>
<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>
{isBusy ? <Loader2 size={15} className="animate-spin text-content-faint shrink-0" /> : <Trailing size={15} className="text-content-faint shrink-0" />}
</button>
)
})}
</div>
)}
</div>
</Modal>
{lists.length > 3 && (
<div className="relative">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.copyToTripSearch')}
aria-label={t('collections.copyToTripSearch')}
className={`${INPUT} pl-8`}
/>
</div>
)}
{filtered.length === 0 ? (
<p className="m-0 rounded-[12px] bg-surface-secondary px-4 py-6 text-center text-content-faint" style={fs(12.5, 'body')}>{t('collections.noOtherLists')}</p>
) : (
<div className="flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5">
{filtered.map(list => {
const isBusy = busy === list.id
return (
<button
key={list.id}
type="button"
onClick={() => pick(list.id)}
disabled={busy != null}
className="flex min-h-[48px] items-center gap-3 rounded-[10px] px-3 py-2 text-left hover:bg-surface-card disabled:opacity-60"
>
<span className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: list.color || '#6366f1' }} /* theme-lint-disable: the list's own colour, and the default a list without one is drawn in (ListsRail) */ />
<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>
{isBusy ? <Loader2 size={15} className="flex-none animate-spin text-content-faint" /> : <Trailing size={15} className="flex-none text-content-faint" />}
</button>
)
})}
</div>
)}
</DialogShell>
)
}
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Search, Bookmark, ArrowRight, Loader2, Plus } from 'lucide-react'
import Modal from '../shared/Modal'
import React, { useEffect, useId, useMemo, useState } from 'react'
import { Search, Bookmark, ArrowRight, Loader2 } from 'lucide-react'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { INPUT } from '../shared/dialogParts'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { collectionsApi } from '../../api/collections'
@@ -24,6 +25,7 @@ interface SaveTripPlacesToListModalProps {
export default function SaveTripPlacesToListModal({ isOpen, tripId, placeIds, onClose, onDone }: SaveTripPlacesToListModalProps): React.ReactElement | null {
const { t } = useTranslation()
const toast = useToast()
const labelId = useId()
const [lists, setLists] = useState<Collection[]>([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
@@ -68,51 +70,68 @@ export default function SaveTripPlacesToListModal({ isOpen, tripId, placeIds, on
}
return (
<Modal isOpen onClose={onClose} title={t('collections.saveNToList', { count: placeIds.length })} size="sm">
<div className="flex flex-col gap-3">
{lists.length > 5 && (
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.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"
/>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-10 text-content-faint"><Loader2 size={20} className="animate-spin" /></div>
) : filtered.length === 0 ? (
<p className="text-center text-[13px] text-content-faint py-8">{t('collections.noOwnLists')}</p>
) : (
<div className="flex flex-col gap-1 max-h-[50vh] overflow-y-auto -mx-1 px-1">
{filtered.map(list => {
const busy = busyId === list.id
return (
<button
key={list.id}
type="button"
onClick={() => pick(list)}
disabled={busyId != null}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-card text-left hover:bg-surface-hover transition-colors disabled:opacity-60"
>
<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' }}>
<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>
{busy ? <Loader2 size={15} className="animate-spin text-content-faint shrink-0" /> : <ArrowRight size={15} className="text-content-faint shrink-0" />}
</button>
)
})}
</div>
)}
<p className="text-[11.5px] text-content-faint inline-flex items-center gap-1.5"><Plus size={12} /> {t('collections.saveToListHint')}</p>
</div>
</Modal>
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="narrow"
align="top"
header={(
<DialogHeader
tile={<DialogTile><Bookmark size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={t('collections.saveNToList', { count: placeIds.length })}
sub={t('collections.saveToListHint')}
/>
)}
footer={(
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
</DialogFooter>
)}
>
{lists.length > 5 && (
<div className="relative">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.searchLists')}
aria-label={t('collections.searchLists')}
className={`${INPUT} pl-8`}
/>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-8 text-content-faint"><Loader2 size={20} className="animate-spin" /></div>
) : filtered.length === 0 ? (
<p className="m-0 rounded-[12px] bg-surface-secondary px-4 py-6 text-center text-content-faint" style={fs(12.5, 'body')}>{t('collections.noOwnLists')}</p>
) : (
<div className="flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5">
{filtered.map(list => {
const busy = busyId === list.id
return (
<button
key={list.id}
type="button"
onClick={() => pick(list)}
disabled={busyId != null}
className="flex min-h-[48px] items-center gap-3 rounded-[10px] px-3 py-2 text-left hover:bg-surface-card disabled:opacity-60"
>
<span className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: list.color || '#6366f1' }} /* theme-lint-disable: the list's own colour, and the default a list without one is drawn in (ListsRail) */ />
<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>
{busy ? <Loader2 size={15} className="flex-none animate-spin text-content-faint" /> : <ArrowRight size={15} className="flex-none text-content-faint" />}
</button>
)
})}
</div>
)}
</DialogShell>
)
}
@@ -57,6 +57,13 @@ function memberRow(username: string): HTMLElement {
return nameCell.parentElement as HTMLElement
}
/** The confirm button of the leave question. The question is drawn over the
* dialog, whose own Leave list button stays in the page, so it is the last one. */
function confirmLeaveButton(): HTMLElement {
const buttons = screen.getAllByRole('button', { name: 'Leave list' })
return buttons[buttons.length - 1]
}
describe('ShareCollectionModal', () => {
beforeEach(() => {
resetAllStores()
@@ -90,7 +97,8 @@ describe('ShareCollectionModal', () => {
it('FE-COMP-COLSHARE-002: the title names the list and the roster is counted', () => {
setup()
expect(screen.getByRole('heading', { name: 'Share “Tokyo 2026”' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Share list' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Tokyo 2026' })).toBeInTheDocument()
expect(screen.getByText('Members')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
})
@@ -257,7 +265,7 @@ describe('ShareCollectionModal', () => {
it('FE-COMP-COLSHARE-022: a confirmed leave calls the store and hands back to the page', async () => {
const { props } = setup({ isOwner: false })
fireEvent.click(screen.getByRole('button', { name: 'Leave list' }))
fireEvent.click(screen.getAllByRole('button', { name: 'Leave list' })[0])
fireEvent.click(confirmLeaveButton())
await waitFor(() => expect(actions.leave).toHaveBeenCalledWith(7))
expect(addToast).toHaveBeenCalledWith('You left the list', 'success', undefined)
expect(props.onAfterLeave).toHaveBeenCalledTimes(1)
@@ -267,7 +275,7 @@ describe('ShareCollectionModal', () => {
actions.leave.mockRejectedValue({ response: { data: { error: 'Owners cannot leave' } } })
const { props } = setup({ isOwner: false })
fireEvent.click(screen.getByRole('button', { name: 'Leave list' }))
fireEvent.click(screen.getAllByRole('button', { name: 'Leave list' })[0])
fireEvent.click(confirmLeaveButton())
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Owners cannot leave', 'error', undefined))
expect(props.onAfterLeave).not.toHaveBeenCalled()
})
@@ -286,9 +294,7 @@ describe('ShareCollectionModal', () => {
it('FE-COMP-COLSHARE-025: the modal close button calls onClose', () => {
const { props } = setup()
// The Modal chrome's only unnamed control is its close button.
const header = screen.getByRole('heading', { name: 'Share “Tokyo 2026”' }).parentElement as HTMLElement
fireEvent.click(header.querySelector('button') as HTMLButtonElement)
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(props.onClose).toHaveBeenCalledTimes(1)
})
})
@@ -1,10 +1,13 @@
import React, { useEffect, useMemo, useState } from 'react'
import React, { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { UserPlus, UserMinus, Loader2, Clock, Crown, LogOut } from 'lucide-react'
import { UserPlus, UserMinus, UserX, Loader2, Clock, Crown, LogOut, Share2 } from 'lucide-react'
import type { CollectionMember, CollectionRole } from '@trek/shared'
import Modal from '../shared/Modal'
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import ConfirmDialog from '../shared/ConfirmDialog'
import CustomSelect from '../shared/CustomSelect'
import { Tooltip } from '../shared/Tooltip'
import { useToast } from '../shared/Toast'
import { TripMemberAvatar } from '../Trips/TripMemberAvatar'
import { useCollectionStore } from '../../store/collectionStore'
import { useAuthStore } from '../../store/authStore'
import { collectionsApi } from '../../api/collections'
@@ -25,16 +28,22 @@ interface ShareCollectionModalProps {
const ROLE_ORDER: CollectionRole[] = ['viewer', 'editor', 'admin']
function MemberAvatar({ member }: { member: CollectionMember }): React.ReactElement {
const initial = (member.username || '?').charAt(0).toUpperCase()
// The trip share dialog's roster, so a shared list and a shared trip read alike.
const COUNT = 'rounded-full bg-surface-tertiary px-2 py-[2px] font-geist font-bold text-content-muted'
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5'
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5 hover:bg-surface-card'
const BADGE = 'inline-flex flex-none items-center gap-1 rounded-full px-2 py-[2px] font-semibold'
const DANGER_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:cursor-default disabled:opacity-50'
/** A round icon button of a row, named by its tooltip; faint until the pointer is on it. */
function RowAction({ label, onClick, disabled, children }: { label: string; onClick: () => void; disabled?: boolean; children: ReactNode }) {
return (
<span className="w-8 h-8 rounded-full shrink-0 overflow-hidden flex items-center justify-center bg-surface-secondary text-content-secondary text-[12px] font-semibold">
{member.avatar ? (
<img src={avatarSrc(member.avatar)!} alt="" className="w-full h-full object-cover" />
) : (
initial
)}
</span>
<Tooltip label={label}>
<button type="button" onClick={onClick} disabled={disabled} aria-label={label}
className="grid h-8 w-8 flex-none place-items-center rounded-[9px] text-content-faint hover:bg-surface-secondary hover:text-danger disabled:opacity-40">
{children}
</button>
</Tooltip>
)
}
@@ -56,6 +65,7 @@ export default function ShareCollectionModal({
t,
}: ShareCollectionModalProps): React.ReactElement | null {
const toast = useToast()
const labelId = useId()
const currentUserId = useAuthStore(s => s.user?.id)
const invite = useCollectionStore(s => s.invite)
const cancelInvite = useCollectionStore(s => s.cancelInvite)
@@ -162,168 +172,140 @@ export default function ShareCollectionModal({
}
}
return (
<Modal
isOpen
onClose={onClose}
title={t('collections.share.titleNamed', { name: collectionName })}
size="xl"
>
<div className="flex flex-col gap-5">
{/* Member roster */}
<div>
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-content-faint mb-2.5 flex items-center gap-2">
{t('collections.share.members')}
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-surface-secondary text-content-secondary text-[10px] font-bold tabular-nums">{sortedMembers.length}</span>
</h3>
<div className="flex flex-col gap-1.5">
{sortedMembers.map(member => {
const isSelf = member.user_id === currentUserId
const pending = member.status === 'pending'
return (
<div
key={member.user_id}
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-secondary/50 ${pending ? 'opacity-90' : ''}`}
>
<MemberAvatar member={member} />
<div className="flex-1 min-w-0">
<p className="text-[13.5px] font-semibold text-content truncate flex items-center gap-1.5">
{member.username}
{isSelf && <span className="text-content-faint font-normal text-[12px]">({t('collections.share.you')})</span>}
</p>
{member.email && !pending && (
<p className="text-[11.5px] text-content-faint truncate">{member.email}</p>
)}
</div>
{member.is_owner ? (
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full bg-amber-500/12 text-amber-600 dark:text-amber-400 shrink-0">
<Crown size={11} /> {t('collections.share.owner')}
</span>
) : pending ? (
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full bg-amber-500/12 text-amber-600 dark:text-amber-400 shrink-0">
<Clock size={11} /> {t('collections.share.pending')}
</span>
) : isOwner ? (
<div className="w-[118px] shrink-0">
<CustomSelect
size="sm"
value={member.role ?? 'editor'}
onChange={v => handleSetRole(member.user_id, v as CollectionRole)}
options={ROLE_ORDER.map(r => ({ value: r, label: t(`collections.role.${r}`) }))}
disabled={settingRoleId === member.user_id}
/>
</div>
) : (
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full bg-surface-secondary text-content-secondary shrink-0">
{t(`collections.role.${member.role ?? 'editor'}`)}
</span>
)}
{isOwner && pending && (
<button
type="button"
onClick={() => handleCancel(member.user_id)}
disabled={cancellingId === member.user_id}
className="shrink-0 text-[11px] font-medium px-2 py-1 rounded-md text-content-faint hover:text-danger hover:bg-danger-soft transition-colors disabled:opacity-50"
>
{cancellingId === member.user_id ? <Loader2 size={12} className="animate-spin" /> : t('collections.share.cancel')}
</button>
)}
{isOwner && !member.is_owner && member.status === 'accepted' && (
<button
type="button"
onClick={() => handleRemove(member.user_id)}
disabled={removingId === member.user_id}
title={t('collections.share.remove')}
aria-label={t('collections.share.remove')}
className="shrink-0 p-1 rounded-md text-content-faint hover:text-danger hover:bg-danger-soft transition-colors disabled:opacity-50"
>
{removingId === member.user_id ? <Loader2 size={12} className="animate-spin" /> : <UserMinus size={13} />}
</button>
)}
</div>
)
})}
</div>
</div>
const roleOptions = ROLE_ORDER.map(r => ({ value: r, label: t(`collections.role.${r}`) }))
{isOwner ? (
/* Owner: invite UI */
<div className="pt-1 border-t border-edge-secondary">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-content-faint mt-4 mb-2">
{t('collections.share.invite')}
</h3>
<p className="text-[12px] text-content-muted mb-3">{t('collections.share.inviteHint')}</p>
return (
<>
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="detail"
// While the leave question is up, Escape and the backdrop answer it, not this dialog.
blocked={confirmLeave}
header={(
<DialogHeader
tile={<DialogTile><Share2 size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
eyebrow={t('collections.share.title')}
title={collectionName}
/>
)}
// A member's way out sits where an editor keeps its delete: bottom left.
footer={isOwner ? undefined : (
<DialogFooter>
<button type="button" onClick={() => setConfirmLeave(true)} disabled={leaving} className={DANGER_BUTTON} style={fs(13, 'body')}>
{leaving ? <Loader2 size={14} className="animate-spin" /> : <LogOut size={14} strokeWidth={2.2} />}
{t('collections.share.leave')}
</button>
<FooterSpacer />
</DialogFooter>
)}
>
{isOwner && (
<DialogSection label={t('collections.share.invite')}>
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('collections.share.inviteHint')}</p>
{availableUsers.length === 0 ? (
<p className="text-[12px] text-content-faint text-center py-3">{t('collections.share.noUsers')}</p>
<p className="m-0 rounded-[12px] border border-dashed border-edge px-3 py-3 text-center text-content-faint" style={fs(12, 'body')}>
{t('collections.share.noUsers')}
</p>
) : (
<div className="flex items-stretch gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<CustomSelect
value={selectedUserId}
onChange={v => setSelectedUserId(v === '' ? '' : Number(v))}
options={availableUsers.map(u => ({ value: u.id, label: u.username }))}
placeholder={t('collections.share.inviteUser')}
searchable
/>
</div>
<div className="w-[128px] shrink-0">
<CustomSelect
size="sm"
value={inviteRole}
onChange={v => setInviteRole(v as CollectionRole)}
options={ROLE_ORDER.map(r => ({ value: r, label: t(`collections.role.${r}`) }))}
/>
</div>
<button
type="button"
onClick={handleInvite}
disabled={selectedUserId === '' || inviting}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-accent-text text-[13px] font-semibold hover:bg-accent-hover transition-colors disabled:opacity-50"
>
{inviting ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} />}
<span className="hidden sm:inline">{t('collections.share.sendInvite')}</span>
</button>
</div>
)}
</div>
) : (
/* Member: read-only roster + leave */
<div className="pt-1 border-t border-edge-secondary">
<p className="text-[12px] text-content-muted mt-4 mb-3">{t('collections.share.memberHint')}</p>
{confirmLeave ? (
<div className="flex flex-col gap-2.5">
<p className="text-[13px] text-content-secondary">{t('collections.share.leaveConfirm')}</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmLeave(false)}
className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover transition-colors"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleLeave}
disabled={leaving}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-danger text-white text-[13px] font-semibold hover:opacity-90 transition-opacity disabled:opacity-50"
>
{leaving ? <Loader2 size={14} className="animate-spin" /> : <LogOut size={14} />}
{t('collections.share.leave')}
</button>
<div className="w-[128px] flex-none">
<CustomSelect size="sm" value={inviteRole} onChange={v => setInviteRole(v as CollectionRole)} options={roleOptions} />
</div>
<DialogButton
variant="primary"
onClick={() => void handleInvite()}
disabled={selectedUserId === '' || inviting}
icon={inviting ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} strokeWidth={2.2} />}
>
{t('collections.share.sendInvite')}
</DialogButton>
</div>
) : (
<button
type="button"
onClick={() => setConfirmLeave(true)}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg border border-edge text-danger text-[13px] font-medium hover:bg-danger-soft transition-colors"
>
<LogOut size={14} /> {t('collections.share.leave')}
</button>
)}
</div>
</DialogSection>
)}
</div>
</Modal>
<DialogSection label={(
<span className="inline-flex items-center gap-2">
{t('collections.share.members')}
<span className={COUNT} style={fs(10)}>{sortedMembers.length}</span>
</span>
)}>
<div className={ROWS}>
{sortedMembers.map(member => {
const isSelf = member.user_id === currentUserId
const pending = member.status === 'pending'
let standing: ReactNode
if (member.is_owner) {
standing = <span className={`${BADGE} bg-warning-soft text-warning`} style={fs(10.5)}><Crown size={10} strokeWidth={2.4} />{t('collections.share.owner')}</span>
} else if (pending) {
standing = <span className={`${BADGE} bg-warning-soft text-warning`} style={fs(10.5)}><Clock size={10} strokeWidth={2.4} />{t('collections.share.pending')}</span>
} else if (isOwner) {
standing = (
<div className="w-[118px] flex-none">
<CustomSelect
size="sm"
value={member.role ?? 'editor'}
onChange={v => handleSetRole(member.user_id, v as CollectionRole)}
options={roleOptions}
disabled={settingRoleId === member.user_id}
/>
</div>
)
} else {
standing = <span className={`${BADGE} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>{t(`collections.role.${member.role ?? 'editor'}`)}</span>
}
return (
<div key={member.user_id} className={ROW}>
<TripMemberAvatar username={member.username} avatarUrl={avatarSrc(member.avatar)} />
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>
{member.username}
{isSelf && <span className="ml-1 font-normal text-content-faint">({t('collections.share.you')})</span>}
</div>
{member.email && !pending && (
<div className="truncate text-content-faint" style={fs(11.5)}>{member.email}</div>
)}
</div>
{standing}
{isOwner && pending && (
<RowAction label={t('collections.share.cancel')} onClick={() => handleCancel(member.user_id)} disabled={cancellingId === member.user_id}>
{cancellingId === member.user_id ? <Loader2 size={14} className="animate-spin" /> : <UserX size={15} />}
</RowAction>
)}
{isOwner && !member.is_owner && member.status === 'accepted' && (
<RowAction label={t('collections.share.remove')} onClick={() => handleRemove(member.user_id)} disabled={removingId === member.user_id}>
{removingId === member.user_id ? <Loader2 size={14} className="animate-spin" /> : <UserMinus size={15} />}
</RowAction>
)}
</div>
)
})}
</div>
{!isOwner && <p className="m-0 mt-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('collections.share.memberHint')}</p>}
</DialogSection>
</DialogShell>
<ConfirmDialog
isOpen={confirmLeave}
onClose={() => setConfirmLeave(false)}
onConfirm={() => void handleLeave()}
title={t('collections.share.leave')}
message={t('collections.share.leaveConfirm')}
confirmLabel={t('collections.share.leave')}
/>
</>
)
}
@@ -1,9 +1,9 @@
import React, { useMemo, useState } from 'react'
import React, { useId, useMemo, useState } from 'react'
import { Gauge, Hourglass, CircleDashed, Star } from 'lucide-react'
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
import { useTranslation } from '../../i18n'
import Modal from '../shared/Modal'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, FooterSpacer, NEUTRAL_TINT } from '../shared/DialogShell'
import MSheet from '../../mobile/components/MSheet'
import { FormSheetFooter, FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
import { useIsPhone } from '../../mobile/useIsPhone'
@@ -65,6 +65,7 @@ export default function DawarichAcceptDialog({
}: DawarichAcceptDialogProps): React.ReactElement | null {
const { t, locale } = useTranslation()
const phone = useIsPhone()
const labelId = useId()
const [name, setName] = useState('')
const [notes, setNotes] = useState('')
@@ -350,33 +351,34 @@ export default function DawarichAcceptDialog({
}
return (
<Modal
isOpen
<DialogShell
onClose={onCancel}
size="lg"
title={title}
footer={
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 rounded-lg text-body border border-edge text-content-secondary"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={confirm}
disabled={busy || missingTarget}
className="px-4 py-2 rounded-lg text-body font-medium bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50"
>
labelledBy={labelId}
width="detail"
header={(
<DialogHeader
// The mark fills its tile, rounded like the phone's: a small logo in a
// grey square reads as a placeholder.
tile={<span className="flex h-[46px] w-[46px] flex-none overflow-hidden rounded-[14px] shadow-sm"><DawarichIcon size={46} /></span>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onCancel}
eyebrow={t('dawarich.title')}
title={title}
/>
)}
footer={(
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onCancel}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" onClick={confirm} disabled={busy || missingTarget}>
{t(`dawarich.accept.confirm.${target}`)}
</button>
</div>
}
</DialogButton>
</DialogFooter>
)}
>
{fields}
</Modal>
</DialogShell>
)
}
@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useId, useState } from 'react'
import { Building2, Check, CalendarDays, Globe2, Hourglass, Info, Loader2, MapPin, RefreshCw, Star } from 'lucide-react'
import type { DawarichAtlasSuggestions, DawarichBucketScan } from '@trek/shared'
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
@@ -6,7 +6,7 @@ import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { Skeleton } from '../shared/Skeleton'
import { SlidingTabs } from '../shared/SlidingTabs'
import Modal from '../shared/Modal'
import { DialogFooter, DialogHeader, DialogShell, NEUTRAL_TINT } from '../shared/DialogShell'
import MSheet from '../../mobile/components/MSheet'
import { FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
import { useIsPhone } from '../../mobile/useIsPhone'
@@ -49,6 +49,7 @@ export default function DawarichAtlasDialog({
const { t, locale } = useTranslation()
const toast = useToast()
const phone = useIsPhone()
const labelId = useId()
const [tab, setTab] = useState<Tab>(initialTab)
const [scan, setScan] = useState<DawarichBucketScan | null>(null)
@@ -320,7 +321,7 @@ export default function DawarichAtlasDialog({
className={
phone
? 'inline-flex h-[38px] items-center gap-2 rounded-full bg-m-act px-4 text-[0.8125rem] font-bold text-m-actfg disabled:opacity-40'
: 'inline-flex items-center gap-2 rounded-lg bg-accent px-3.5 py-2 text-caption font-semibold text-accent-text hover:bg-accent-hover disabled:opacity-40'
: 'inline-flex items-center gap-1.5 rounded-[10px] bg-accent px-4 py-2 text-body font-medium text-accent-text hover:opacity-90 disabled:opacity-40'
}
>
<Check className="w-3.5 h-3.5" />
@@ -338,7 +339,7 @@ export default function DawarichAtlasDialog({
className={
phone
? 'inline-flex h-[38px] items-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 text-[0.8125rem] font-bold text-m-muted disabled:opacity-50'
: 'inline-flex items-center gap-2 rounded-lg border border-edge px-3 py-2 text-caption font-medium text-content-secondary hover:bg-surface-hover disabled:opacity-50'
: 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 text-body font-medium text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:opacity-50'
}
>
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
@@ -377,40 +378,38 @@ export default function DawarichAtlasDialog({
}
return (
<Modal
isOpen={isOpen}
<DialogShell
open={isOpen}
onClose={onClose}
size="xl"
title={
<span className="flex items-center gap-2.5">
{/* Rounded, like every other avatar-sized mark in TREK: the logo is a
square badge and a hard corner beside a rounded dialog reads as a
pasted-in image. */}
<span className="flex-shrink-0 overflow-hidden rounded-lg">
<DawarichIcon size={26} />
</span>
<span className="min-w-0">
<span className="block truncate text-subtitle font-semibold text-content">{t('dawarich.title')}</span>
<span className="block truncate text-caption font-normal text-content-muted">
{t('dawarich.atlas.dialogSubtitle')}
</span>
</span>
</span>
}
footer={
<div className="flex w-full items-center justify-between gap-3">
<span className="text-caption text-content-muted">
labelledBy={labelId}
width="editor"
// The answer replaces the pitch below the tabs, so the upper edge stays put.
align="top"
header={(
<DialogHeader
// The mark fills its tile, rounded like the phone's: a small logo in a
// grey square reads as a placeholder.
tile={<span className="flex h-[46px] w-[46px] flex-none overflow-hidden rounded-[14px] shadow-sm"><DawarichIcon size={46} /></span>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={t('dawarich.title')}
sub={t('dawarich.atlas.dialogSubtitle')}
/>
)}
// Before the first answer the pitch carries its own button, so there is no bar to show.
footer={againButton || confirmButton ? (
<DialogFooter>
<span className="min-w-0 flex-1 truncate text-caption text-content-muted">
{selected > 0 ? t('dawarich.selected', { count: selected }) : ''}
</span>
<div className="flex items-center gap-2">
{againButton}
{confirmButton}
</div>
</div>
}
{againButton}
{confirmButton}
</DialogFooter>
) : undefined}
>
{body}
</Modal>
</DialogShell>
)
}
@@ -1,4 +1,4 @@
// FE-JRN-INVITE-001 to FE-JRN-INVITE-011
// FE-JRN-INVITE-001 to FE-JRN-INVITE-014
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
@@ -110,12 +110,12 @@ describe('ContributorInviteDialog', () => {
const viewerBtn = screen.getByRole('button', { name: 'Viewer' })
const editorBtn = screen.getByRole('button', { name: 'Editor' })
expect(viewerBtn.className).toContain('bg-zinc-900')
expect(editorBtn.className).not.toContain('bg-zinc-900')
expect(viewerBtn).toHaveAttribute('aria-pressed', 'true')
expect(editorBtn).toHaveAttribute('aria-pressed', 'false')
await user.click(editorBtn)
expect(editorBtn.className).toContain('bg-zinc-900')
expect(viewerBtn.className).not.toContain('bg-zinc-900')
expect(editorBtn).toHaveAttribute('aria-pressed', 'true')
expect(viewerBtn).toHaveAttribute('aria-pressed', 'false')
})
it('FE-JRN-INVITE-008: posts the selected user and role, then reports success', async () => {
@@ -159,10 +159,7 @@ describe('ContributorInviteDialog', () => {
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledTimes(1)
// The header close button carries only an icon, so it is addressed by position.
const headerClose = screen.getByRole('heading', { name: 'Invite Contributor' })
.parentElement!.querySelector('button')!
act(() => { headerClose.click() })
act(() => { screen.getByRole('button', { name: 'Close' }).click() })
expect(onClose).toHaveBeenCalledTimes(2)
})
@@ -172,4 +169,82 @@ describe('ContributorInviteDialog', () => {
expect(await screen.findByText('No users found')).toBeInTheDocument()
})
it('FE-JRN-INVITE-012: Escape closes the dialog and goes no further, so the settings under it stay open', async () => {
const underneath = vi.fn()
document.addEventListener('keydown', underneath)
try {
const user = userEvent.setup()
const { onClose } = mountDialog()
await screen.findByText('maurice')
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalledTimes(1)
expect(underneath).not.toHaveBeenCalled()
} finally {
document.removeEventListener('keydown', underneath)
}
})
})
describe('ContributorInviteDialog on a phone', () => {
let width: number
beforeEach(() => {
width = window.innerWidth
Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true, writable: true })
})
afterEach(() => {
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true, writable: true })
})
it('FE-JRN-INVITE-013: keeps the panel it had, with the filled role button and the header close', async () => {
const user = userEvent.setup()
const { onClose, onInvited } = mountDialog()
await screen.findByText('maurice')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
const viewerBtn = screen.getByRole('button', { name: 'Viewer' })
const editorBtn = screen.getByRole('button', { name: 'Editor' })
expect(viewerBtn.className).toContain('bg-zinc-900')
expect(editorBtn.className).not.toContain('bg-zinc-900')
await user.click(editorBtn)
expect(editorBtn.className).toContain('bg-zinc-900')
expect(viewerBtn.className).not.toContain('bg-zinc-900')
// The header close button carries only an icon, so it is addressed by position.
const headerClose = screen.getByRole('heading', { name: 'Invite Contributor' })
.parentElement!.querySelector('button')!
act(() => { headerClose.click() })
expect(onClose).toHaveBeenCalledTimes(1)
// Escape is the desktop's: the phone panel never had it.
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalledTimes(1)
expect(onInvited).not.toHaveBeenCalled()
})
it('FE-JRN-INVITE-014: filters, selects and invites through the same state as the desktop', async () => {
const bodies: Record<string, unknown>[] = []
server.use(http.post('/api/journeys/4/contributors', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ ok: true })
}))
const user = userEvent.setup()
const { onInvited } = mountDialog({ existingUserIds: [1] })
await screen.findByText('julien')
await user.type(screen.getByPlaceholderText('Username or email...'), 'zzz')
expect(screen.getByText('No users found')).toBeInTheDocument()
await user.clear(screen.getByPlaceholderText('Username or email...'))
const inviteBtn = screen.getByRole('button', { name: 'Invite' })
expect(inviteBtn).toBeDisabled()
await user.click(screen.getByText('anna'))
await user.click(inviteBtn)
await waitFor(() => expect(onInvited).toHaveBeenCalledTimes(1))
expect(bodies[0]).toEqual({ user_id: 3, role: 'viewer' })
})
})
@@ -1,9 +1,21 @@
import { useEffect, useState } from 'react'
import { X, Check, UserPlus } from 'lucide-react'
import { useEffect, useId, useState } from 'react'
import { X, Check, UserPlus, Search, Loader2 } from 'lucide-react'
import { journeyApi, authApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { useIsPhone } from '../../mobile/useIsPhone'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { INPUT, Segmented } from '../shared/dialogParts'
import { TripMemberAvatar } from '../Trips/TripMemberAvatar'
import { avatarSrc } from '../../utils/avatarSrc'
const ROLES = ['viewer', 'editor'] as const
/**
* Adds a person to a journey. The desktop draws it in the planner's dialog
* frame; the phone, which opens it from its journey screen, keeps the panel it
* has always had. Both read and write the same state below.
*/
export default function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvited }: {
journeyId: number
existingUserIds: number[]
@@ -11,6 +23,8 @@ export default function ContributorInviteDialog({ journeyId, existingUserIds, on
onInvited: () => void
}) {
const { t } = useTranslation()
const phone = useIsPhone()
const labelId = useId()
const [users, setUsers] = useState<{ id: number; username: string; email?: string; avatar?: string | null }[]>([])
const [search, setSearch] = useState('')
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
@@ -22,6 +36,20 @@ export default function ContributorInviteDialog({ journeyId, existingUserIds, on
authApi.listUsers().then(d => setUsers(d.users || [])).catch(() => {})
}, [])
// Captured and stopped on the desktop: the dialog is usually opened from the
// journey settings, which close on Escape as well, and one key press should
// only take back this dialog, not the one under it.
useEffect(() => {
if (phone) return
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.stopPropagation()
onClose()
}
document.addEventListener('keydown', onKey, true)
return () => document.removeEventListener('keydown', onKey, true)
}, [phone, onClose])
const filtered = users.filter(u => {
if (existingUserIds.includes(u.id)) return false
if (!search) return true
@@ -44,94 +72,177 @@ export default function ContributorInviteDialog({ journeyId, existingUserIds, on
}
}
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5 bg-[rgba(9,9,11,0.75)]">
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden">
if (phone) {
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5 bg-[rgba(9,9,11,0.75)]">
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.contributors.invite')}</h2>
<button type="button" onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>
<div className="px-6 py-5 flex flex-col gap-4">
{/* Search */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.contributors.searchUser')}</label>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('journey.contributors.searchPlaceholder')}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
/>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.contributors.invite')}</h2>
<button type="button" onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>
{/* User list */}
<div className="max-h-[200px] overflow-y-auto flex flex-col gap-1">
{filtered.length === 0 && (
<p className="text-[12px] text-zinc-400 text-center py-4">{t('journey.contributors.noUsers')}</p>
)}
{filtered.map(u => (
<button type="button"
key={u.id}
onClick={() => setSelectedUserId(u.id)}
className={`w-full text-left flex items-center gap-2.5 p-2.5 rounded-lg cursor-pointer transition-all ${
selectedUserId === u.id
? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-white'
: 'hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent'
}`}
>
<div className="w-8 h-8 rounded-full bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 flex items-center justify-center text-[12px] font-semibold">
{u.username[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-zinc-900 dark:text-white">{u.username}</div>
<div className="text-[11px] text-zinc-500 truncate">{u.email}</div>
</div>
{selectedUserId === u.id && (
<div className="w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
<Check size={12} />
</div>
)}
</button>
))}
</div>
<div className="px-6 py-5 flex flex-col gap-4">
{/* Search */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.contributors.searchUser')}</label>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('journey.contributors.searchPlaceholder')}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
/>
</div>
{/* Role selector */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.invite.role')}</label>
<div className="flex gap-2">
{(['viewer', 'editor'] as const).map(r => (
{/* User list */}
<div className="max-h-[200px] overflow-y-auto flex flex-col gap-1">
{filtered.length === 0 && (
<p className="text-[12px] text-zinc-400 text-center py-4">{t('journey.contributors.noUsers')}</p>
)}
{filtered.map(u => (
<button type="button"
key={r}
onClick={() => setRole(r)}
className={`flex-1 py-2 rounded-lg text-[12px] font-medium border transition-all ${
role === r
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
: 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
key={u.id}
onClick={() => setSelectedUserId(u.id)}
className={`w-full text-left flex items-center gap-2.5 p-2.5 rounded-lg cursor-pointer transition-all ${
selectedUserId === u.id
? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-white'
: 'hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent'
}`}
>
{t(`journey.invite.${r}`)}
<div className="w-8 h-8 rounded-full bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 flex items-center justify-center text-[12px] font-semibold">
{u.username[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-zinc-900 dark:text-white">{u.username}</div>
<div className="text-[11px] text-zinc-500 truncate">{u.email}</div>
</div>
{selectedUserId === u.id && (
<div className="w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
<Check size={12} />
</div>
)}
</button>
))}
</div>
{/* Role selector */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.invite.role')}</label>
<div className="flex gap-2">
{ROLES.map(r => (
<button type="button"
key={r}
onClick={() => setRole(r)}
className={`flex-1 py-2 rounded-lg text-[12px] font-medium border transition-all ${
role === r
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
: 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
}`}
>
{t(`journey.invite.${r}`)}
</button>
))}
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<button type="button" onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
{t('common.cancel')}
</button>
<button type="button"
onClick={handleInvite}
disabled={!selectedUserId || sending}
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
{sending ? t('journey.invite.inviting') : t('journey.invite.invite')}
</button>
</div>
</div>
</div>
)
}
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<button type="button" onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
{t('common.cancel')}
</button>
<button type="button"
return (
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="narrow"
// The list shrinks while the search narrows it; a pinned top edge keeps the field still.
align="top"
header={(
<DialogHeader
tile={<DialogTile><UserPlus size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={t('journey.contributors.invite')}
/>
)}
// The role sits beside the button it goes with, in reach however long the list gets.
footer={(
<DialogFooter>
<Segmented<(typeof ROLES)[number]>
value={role}
options={ROLES.map(r => ({ value: r, label: t(`journey.invite.${r}`) }))}
onChange={setRole}
label={t('journey.invite.role')}
/>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton
variant="primary"
onClick={handleInvite}
disabled={!selectedUserId || sending}
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
icon={sending ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} strokeWidth={2.2} />}
>
{sending ? t('journey.invite.inviting') : t('journey.invite.invite')}
</button>
</div>
</DialogButton>
</DialogFooter>
)}
>
<div className="relative">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('journey.contributors.searchPlaceholder')}
aria-label={t('journey.contributors.searchUser')}
className={`${INPUT} pl-8`}
/>
</div>
</div>
<div className="flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5">
{filtered.length === 0 && (
<p className="m-0 px-4 py-6 text-center text-content-faint" style={fs(12.5, 'body')}>{t('journey.contributors.noUsers')}</p>
)}
{filtered.map(u => {
const on = selectedUserId === u.id
return (
<button
type="button"
key={u.id}
onClick={() => setSelectedUserId(u.id)}
aria-pressed={on}
className={`flex min-h-[48px] w-full items-center gap-3 rounded-[10px] px-2.5 py-1.5 text-left ${on ? 'bg-surface-card shadow-sm' : 'hover:bg-surface-card'}`}
>
<TripMemberAvatar username={u.username} avatarUrl={avatarSrc(u.avatar)} />
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(13, 'body')}>{u.username}</span>
{u.email && <span className="block truncate text-content-faint" style={fs(11.5)}>{u.email}</span>}
</span>
{on && (
<span className="grid h-5 w-5 flex-none place-items-center rounded-full bg-accent text-accent-text">
<Check size={12} strokeWidth={2.6} />
</span>
)}
</button>
)
})}
</div>
</DialogShell>
)
}
@@ -48,7 +48,7 @@ describe('AddTripDialog', () => {
it('FE-JRN-ADDTRIP-002: renders destination and start date as the trip subtitle', async () => {
mountDialog()
expect(await screen.findByText('Rome · 2026-03-14')).toBeInTheDocument()
expect(await screen.findByText('Rome, 2026-03-14')).toBeInTheDocument()
// Trips without a start date only show the destination.
expect(screen.getByText('Tromso')).toBeInTheDocument()
})
@@ -140,9 +140,7 @@ describe('AddTripDialog', () => {
expect(await screen.findByText('No trips available')).toBeInTheDocument()
const headerClose = screen.getByRole('heading', { name: 'Link Trip' })
.parentElement!.querySelector('button')!
act(() => { headerClose.click() })
act(() => { screen.getByRole('button', { name: 'Close' }).click() })
expect(onClose).toHaveBeenCalledTimes(1)
})
})
@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react'
import { X } from 'lucide-react'
import { useEffect, useId, useState } from 'react'
import { Link2, Search } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { journeyApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { INPUT } from '../shared/dialogParts'
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
export function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
@@ -12,6 +14,7 @@ export function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }:
onAdded: () => void
}) {
const { t } = useTranslation()
const labelId = useId()
const [trips, setTrips] = useState<{ id: number; title: string; destination?: string; start_date?: string; end_date?: string }[]>([])
const [search, setSearch] = useState('')
const [adding, setAdding] = useState<number | null>(null)
@@ -42,57 +45,68 @@ export function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }:
}
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5 bg-[rgba(9,9,11,0.75)]">
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.trips.linkTrip')}</h2>
<button type="button" onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>
<div className="px-6 py-5 flex flex-col gap-4">
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.trips.searchTrip')}</label>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('journey.trips.searchPlaceholder')}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
/>
</div>
<div className="max-h-[280px] overflow-y-auto flex flex-col gap-1">
{filtered.length === 0 && (
<p className="text-[12px] text-zinc-400 text-center py-4">{t('journey.trips.noTripsAvailable')}</p>
)}
{filtered.map(trip => (
<div
key={trip.id}
className="flex items-center gap-2.5 p-2.5 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent"
>
<div className="w-9 h-9 rounded-md flex-shrink-0" style={{ background: pickGradient(trip.id) }} />
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-zinc-900 dark:text-white truncate">{trip.title}</div>
{(trip.destination || trip.start_date) && (
<div className="text-[11px] text-zinc-500 truncate">
{trip.destination}{trip.destination && trip.start_date ? ' · ' : ''}{trip.start_date}
</div>
)}
</div>
<button type="button"
onClick={() => handleAdd(trip.id)}
disabled={adding === trip.id}
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-200 disabled:opacity-50"
>
{adding === trip.id ? '...' : t('journey.trips.link')}
</button>
</div>
))}
</div>
</div>
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="narrow"
// The list shrinks while the search narrows it; a pinned top edge keeps the field still.
align="top"
header={(
<DialogHeader
tile={<DialogTile><Link2 size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={t('journey.trips.linkTrip')}
/>
)}
footer={(
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
</DialogFooter>
)}
>
<div className="relative">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden="true" />
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('journey.trips.searchPlaceholder')}
aria-label={t('journey.trips.searchTrip')}
className={`${INPUT} pl-8`}
/>
</div>
</div>
{filtered.length === 0 ? (
<p className="m-0 rounded-[12px] bg-surface-secondary px-4 py-6 text-center text-content-faint" style={fs(12.5, 'body')}>{t('journey.trips.noTripsAvailable')}</p>
) : (
<div className="flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5">
{filtered.map(trip => (
<div key={trip.id} className="flex min-h-[52px] items-center gap-3 rounded-[10px] px-2.5 py-2 hover:bg-surface-card">
<span className="h-9 w-9 flex-none rounded-[9px]" style={{ background: pickGradient(trip.id) }} />
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{trip.title}</div>
{(trip.destination || trip.start_date) && (
<div className="truncate text-content-faint" style={fs(11.5)}>
{[trip.destination, trip.start_date].filter(Boolean).join(', ')}
</div>
)}
</div>
<button
type="button"
onClick={() => handleAdd(trip.id)}
disabled={adding === trip.id}
className="inline-flex flex-none items-center rounded-[8px] bg-accent px-2.5 py-1.5 font-semibold text-accent-text hover:opacity-90 disabled:cursor-default disabled:opacity-50"
style={fs(11.5, 'body')}
>
{adding === trip.id ? '...' : t('journey.trips.link')}
</button>
</div>
))}
</div>
)}
</DialogShell>
)
}
@@ -76,10 +76,10 @@ describe('EntryCard', () => {
it('FE-JRN-CARD-004: opens the photo-card menu and triggers edit', async () => {
const user = userEvent.setup()
const { container, onEdit } = mountCard(buildEntry({ photos: [buildPhoto(100)] }))
const { onEdit } = mountCard(buildEntry({ photos: [buildPhoto(100)] }))
// The photo itself is a button now, so pick the menu trigger by its own styling.
await user.click(container.querySelector('button[class*="bg-black/40"]') as HTMLElement)
// The photo itself is a button too, so the menu trigger is picked by its name.
await user.click(screen.getByRole('button', { name: 'More options' }))
await user.click(screen.getByRole('button', { name: 'Edit' }))
expect(onEdit).toHaveBeenCalledTimes(1)
@@ -96,15 +96,14 @@ describe('EntryCard', () => {
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('FE-JRN-CARD-006: closes the menu again when the backdrop is clicked', async () => {
it('FE-JRN-CARD-006: closes the menu again on a click outside it', async () => {
const user = userEvent.setup()
const { container } = mountCard(buildEntry())
await user.click(container.querySelectorAll('button')[0])
const backdrop = document.querySelector('.fixed.inset-0.z-\\[99\\]') as HTMLElement
expect(backdrop).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
await user.click(backdrop)
await user.click(document.body)
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
})
@@ -1,6 +1,5 @@
import { useState, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus, RouteOff, X } from 'lucide-react'
import { useState, useEffect } from 'react'
import { MapPin, Clock, Pencil, Trash2, Plus, RouteOff, X } from 'lucide-react'
import { formatLocationName } from '../../utils/formatters'
import { useTranslation } from '../../i18n'
import { pluginsApi } from '../../api/client'
@@ -9,6 +8,7 @@ import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { Tooltip } from '../shared/Tooltip'
import { MoreButton, type MenuEntry } from '../Planner/planParts'
import { PhotoGrid } from './JourneyDetailPagePhotoGrid'
import { MoodChip, WeatherChip } from './JourneyDetailPageChips'
import { ExpandableStory } from './JourneyDetailPageExpandableStory'
@@ -22,8 +22,10 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
}) {
const { t } = useTranslation()
const [menuOpen, setMenuOpen] = useState(false)
const menuBtnRef = useRef<HTMLButtonElement>(null)
const menuItems: MenuEntry[] = [
{ label: t('common.edit'), icon: Pencil, onClick: onEdit },
{ label: t('common.delete'), icon: Trash2, onClick: onDelete, danger: true },
]
// Extra rows contributed by journalEntryProvider plugins — same pattern as the
// PlaceInspector provider details: fetched only when plugins are active at all,
// fail-safe (the server drops slow/failing providers), only ever additive.
@@ -79,22 +81,10 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
)}
</div>
{/* Menu top-right */}
{/* Menu top-right, raised on the card colour so it reads on any photo */}
{!readOnly && (
<div className="absolute top-2.5 right-3 z-[2]">
<button type="button" ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-8 h-8 rounded-[10px] bg-black/40 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/50">
<MoreHorizontal size={14} />
</button>
{menuOpen && createPortal(
<>
<div className="fixed inset-0 z-[99]" role="presentation" onClick={() => setMenuOpen(false)} />
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
<button type="button" onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
<button type="button" onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
</div>
</>,
document.body,
)}
<MoreButton label={t('files.menu')} items={menuItems} size={32} alwaysVisible className="bg-surface-card shadow-sm" />
</div>
)}
@@ -125,23 +115,7 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
</span>
)}
</div>
{!readOnly && (
<div className="relative">
<button type="button" ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-7 h-7 rounded-md flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<MoreHorizontal size={14} />
</button>
{menuOpen && createPortal(
<>
<div className="fixed inset-0 z-[99]" role="presentation" onClick={() => setMenuOpen(false)} />
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
<button type="button" onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
<button type="button" onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
</div>
</>,
document.body,
)}
</div>
)}
{!readOnly && <MoreButton label={t('files.menu')} items={menuItems} size={28} alwaysVisible />}
</div>
)}
@@ -4,7 +4,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
import { localIsoDate } from '../../utils/localDate'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'
import { seedStore } from '../../../tests/helpers/store'
import { buildSettings } from '../../../tests/helpers/factories'
import { useSettingsStore } from '../../store/settingsStore'
@@ -75,9 +75,13 @@ function mountEditor(
onDone={onDone}
/>,
)
return { ...utils, onClose, onDone, onSave, onUploadPhotos, onAddProviderPhotos }
// The editor is a dialog in a portal on the body, so DOM queries go to the
// body; the render container itself stays empty.
return { ...utils, container: utils.baseElement, onClose, onDone, onSave, onUploadPhotos, onAddProviderPhotos }
}
const DISCARD_QUESTION = 'You have unsaved changes. Discard them?'
function useConnectedImmich() {
server.use(
http.get('/api/addons', () => HttpResponse.json({
@@ -134,7 +138,7 @@ describe('EntryEditor', () => {
expect(screen.getByDisplayValue('Rome')).toBeInTheDocument()
expect(screen.getByDisplayValue('Great food')).toBeInTheDocument()
expect(screen.getByDisplayValue('Crowded')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Amazing' }).className).not.toContain('border-zinc-200')
expect(screen.getByRole('button', { name: 'Amazing' }).className).not.toContain('border-edge-faint')
})
it('FE-JRN-EDITOR-003: saves the edited fields and finishes', async () => {
@@ -306,32 +310,32 @@ describe('EntryEditor', () => {
})
it('FE-JRN-EDITOR-013: asks before discarding a dirty editor', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
const user = userEvent.setup()
const { onClose } = mountEditor(buildEntry({ id: 10, title: 'Rome' }))
await user.type(screen.getByDisplayValue('Rome'), '!')
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(confirmSpy).toHaveBeenCalledWith('You have unsaved changes. Discard them?')
// Answering no keeps the editor and what was typed in it.
const question = screen.getByText(DISCARD_QUESTION).closest('[role="presentation"]') as HTMLElement
await user.click(within(question).getByRole('button', { name: 'Cancel' }))
expect(screen.queryByText(DISCARD_QUESTION)).not.toBeInTheDocument()
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByDisplayValue('Rome!')).toBeInTheDocument()
confirmSpy.mockReturnValue(true)
await user.click(screen.getByRole('button', { name: 'Cancel' }))
await user.click(screen.getByRole('button', { name: 'Discard' }))
expect(onClose).toHaveBeenCalledTimes(1)
confirmSpy.mockRestore()
})
it('FE-JRN-EDITOR-014: closes an untouched editor without asking', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
const user = userEvent.setup()
const { onClose } = mountEditor(buildEntry({ id: 10 }))
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(confirmSpy).not.toHaveBeenCalled()
expect(screen.queryByText(DISCARD_QUESTION)).not.toBeInTheDocument()
expect(onClose).toHaveBeenCalledTimes(1)
confirmSpy.mockRestore()
})
it('FE-JRN-EDITOR-015: adds and removes pro and con rows', async () => {
@@ -363,10 +367,10 @@ describe('EntryEditor', () => {
await user.click(screen.getByRole('button', { name: 'Neutral' }))
await user.click(screen.getByRole('button', { name: 'Rainy' }))
expect(screen.getByRole('button', { name: 'Rainy' }).className).toContain('bg-zinc-900')
expect(screen.getByRole('button', { name: 'Rainy' }).className).toContain('bg-accent')
await user.click(screen.getByRole('button', { name: 'Rainy' }))
expect(screen.getByRole('button', { name: 'Rainy' }).className).not.toContain('bg-zinc-900')
expect(screen.getByRole('button', { name: 'Rainy' }).className).not.toContain('bg-accent')
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSave).toHaveBeenCalled())
@@ -466,13 +470,13 @@ describe('EntryEditor', () => {
await user.click(screen.getByRole('button', { name: 'External photos' }))
expect(await screen.findByTestId('journey-external-provider-immich')).toBeInTheDocument()
expect(screen.getByText('Nearby photos first · Rome')).toBeInTheDocument()
expect(screen.getByText('Nearby photos first: Rome')).toBeInTheDocument()
expect(screen.getByText('Photos for Mar 15, 2026')).toBeInTheDocument()
await user.click(await screen.findByAltText(''))
await user.click(screen.getByRole('button', { name: 'Add (1)' }))
const clearBtn = await screen.findByRole('button', { name: /1 queued · Clear/ })
const clearBtn = await screen.findByRole('button', { name: /1 queued Clear/ })
await user.click(clearBtn)
expect(screen.queryByRole('button', { name: /queued/ })).not.toBeInTheDocument()
})
@@ -486,7 +490,7 @@ describe('EntryEditor', () => {
expect(await screen.findByText('All photos from this day')).toBeInTheDocument()
await user.click(await screen.findByAltText(''))
await user.click(screen.getByRole('button', { name: 'Add (1)' }))
await screen.findByRole('button', { name: /1 queued · Clear/ })
await screen.findByRole('button', { name: /1 queued Clear/ })
onAddProviderPhotos.mockRejectedValueOnce(new Error('provider down'))
await user.click(screen.getByRole('button', { name: 'Save' }))
@@ -704,15 +708,15 @@ describe('EntryEditor', () => {
// Queue a photo from the first provider, then switch tabs.
await user.click(await screen.findByAltText(''))
await user.click(screen.getByRole('button', { name: 'Add (1)' }))
await screen.findByRole('button', { name: /1 queued · Clear/ })
await screen.findByRole('button', { name: /1 queued Clear/ })
await user.click(screen.getByTestId('journey-external-provider-photoprism'))
expect(screen.getByTestId('journey-external-provider-photoprism').className).toContain('bg-zinc-900')
expect(screen.getByTestId('journey-external-provider-photoprism').className).toContain('bg-accent')
// The picker's own cancel drops back to the first available provider.
await user.click(screen.getAllByRole('button', { name: 'Cancel' })[0])
expect(screen.getByTestId('journey-external-provider-photoprism').className).not.toContain('bg-zinc-900')
expect(screen.getByRole('button', { name: /1 queued · Clear/ })).toBeInTheDocument()
expect(screen.getByTestId('journey-external-provider-photoprism').className).not.toContain('bg-accent')
expect(screen.getByRole('button', { name: /1 queued Clear/ })).toBeInTheDocument()
})
it('FE-JRN-EDITOR-033: merges a second pick into the already queued group', async () => {
@@ -739,7 +743,7 @@ describe('EntryEditor', () => {
await user.click(tiles[0])
await user.click(screen.getByRole('button', { name: 'Add (1)' }))
await screen.findByRole('button', { name: /1 queued · Clear/ })
await screen.findByRole('button', { name: /1 queued Clear/ })
// The first asset stays selected but is now greyed out, so the second Add
// re-sends it and the merge has to skip the duplicate.
@@ -747,7 +751,7 @@ describe('EntryEditor', () => {
const secondId = assetIdOf(remaining)
await user.click(remaining)
await user.click(screen.getByRole('button', { name: 'Add (2)' }))
expect(await screen.findByRole('button', { name: /2 queued · Clear/ })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: /2 queued Clear/ })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1))
@@ -857,11 +861,11 @@ describe('EntryEditor', () => {
expect(amazing.getAttribute('style')).toBeNull()
await user.click(amazing)
expect(amazing.className).not.toContain('border-zinc-200')
expect(amazing.className).not.toContain('border-edge-faint')
expect(amazing.getAttribute('style')).toBeTruthy()
await user.click(amazing)
expect(amazing.className).toContain('border-zinc-200')
expect(amazing.className).toContain('border-edge-faint')
expect(amazing.getAttribute('style')).toBe('')
})
@@ -1017,16 +1021,14 @@ describe('EntryEditor', () => {
})
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(screen.getByText(DISCARD_QUESTION)).toBeInTheDocument()
expect(onClose).not.toHaveBeenCalled()
confirmSpy.mockRestore()
})
it('FE-JRN-EDITOR-056: a clip without a poster is a play badge, not a request for its thumbnail (#2341)', () => {
// The thumbnail route answers 404 for such a clip on purpose, and the old
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
// FE-JRN-PICKER-001 to FE-JRN-PICKER-036
// FE-JRN-PICKER-001 to FE-JRN-PICKER-039
import { describe, it, expect, afterAll, beforeAll, beforeEach, vi } from 'vitest'
import { describe, it, expect, afterAll, afterEach, beforeAll, beforeEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render'
@@ -37,6 +37,8 @@ function searchReturns(assets: Record<string, unknown>[], hasMore = false) {
server.use(http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets, hasMore })))
}
// On a desktop viewport the picker draws in a dialog on the document body, outside
// the render container, so DOM queries go through baseElement.
function mountPicker(props: Partial<React.ComponentProps<typeof ProviderPicker>> = {}) {
const onClose = vi.fn()
const onAdd = vi.fn(async () => {})
@@ -266,9 +268,9 @@ describe('ProviderPicker', () => {
await delay(30)
return new HttpResponse(null, { status: 502 })
}))
const { container } = mountPicker()
const { baseElement } = mountPicker()
expect(container.querySelector('.animate-spin')).toBeInTheDocument()
expect(baseElement.querySelector('.animate-spin')).toBeInTheDocument()
expect(await screen.findByText('No photos yet')).toBeInTheDocument()
})
@@ -364,7 +366,7 @@ describe('ProviderPicker', () => {
return HttpResponse.json({ assets: [asset('a1')], hasMore: false })
}))
const user = userEvent.setup()
const { container } = mountPicker()
const { baseElement } = mountPicker()
await screen.findByText('March 15, 2026')
ranges.length = 0
@@ -373,7 +375,7 @@ describe('ProviderPicker', () => {
await user.click(screen.getByRole('button', { name: 'Search' }))
expect(ranges).toHaveLength(0)
const [fromPicker, toPicker] = Array.from(container.querySelectorAll('.flex-1 > .relative')) as HTMLElement[]
const [fromPicker, toPicker] = Array.from(baseElement.querySelectorAll('.flex-1 > .relative')) as HTMLElement[]
await user.click(within(fromPicker).getByRole('button'))
await user.click(within(fromPicker).getByRole('button', { name: '3' }))
await user.click(within(toPicker).getByRole('button'))
@@ -404,10 +406,10 @@ describe('ProviderPicker', () => {
onClose: vi.fn(),
onAdd: vi.fn(async () => {}),
} as React.ComponentProps<typeof ProviderPicker>
const { container, rerender } = render(<ProviderPicker {...props} />)
const { baseElement, rerender } = render(<ProviderPicker {...props} />)
await screen.findByText('March 15, 2026')
const order = () => Array.from(container.querySelectorAll('img'))
const order = () => Array.from(baseElement.querySelectorAll('img'))
.map(img => (img.getAttribute('src') || '').split('/assets/0/')[1]?.split('/')[0])
.filter(Boolean)
@@ -503,10 +505,10 @@ describe('ProviderPicker', () => {
: HttpResponse.json({ assets: [asset('lib1')], hasMore: false })
}))
const user = userEvent.setup()
const { container, onAdd } = mountPicker()
const { baseElement, onAdd } = mountPicker()
await screen.findByRole('button', { name: /Select all \(2\)/ })
await user.click(tile(container, 't1'))
await user.click(tile(baseElement, 't1'))
await user.click(screen.getByRole('button', { name: /All Photos/ }))
await user.click(await screen.findByRole('button', { name: /Select all \(1\)/ }))
expect(screen.getByRole('button', { name: 'Add (2)' })).toBeEnabled()
@@ -594,11 +596,11 @@ describe('ProviderPicker', () => {
return HttpResponse.json({ assets: [asset(`p${body.page}`)], hasMore: body.page < 2 })
}))
const user = userEvent.setup()
const { container, onAdd } = mountPicker()
const { baseElement, onAdd } = mountPicker()
await screen.findByRole('button', { name: /Select all \(1\+\)/ })
// A pick from before the run makes Add live on its own.
await user.click(tile(container, 'p1'))
await user.click(tile(baseElement, 'p1'))
const add = screen.getByRole('button', { name: 'Add (1)' })
expect(add).toBeEnabled()
@@ -626,9 +628,9 @@ describe('ProviderPicker', () => {
return HttpResponse.json({ assets: [asset(`p${body.page}`)], hasMore: body.page < 2 })
}))
const user = userEvent.setup()
const { container, onAdd } = mountPicker()
const { baseElement, onAdd } = mountPicker()
await screen.findByRole('button', { name: /Select all \(1\+\)/ })
await user.click(tile(container, 'p1'))
await user.click(tile(baseElement, 'p1'))
await user.click(screen.getByRole('button', { name: /Select all \(1\+\)/ }))
await waitFor(() => expect(pages).toEqual([1, 2]))
@@ -675,9 +677,9 @@ describe('ProviderPicker', () => {
const pages: number[] = []
endlessTrip(pages)
const user = userEvent.setup()
const { container } = mountPicker()
const { baseElement } = mountPicker()
/** The scroll trigger at the foot of the grid, the spinner that loads the next page. */
const scrollTrigger = () => container.querySelector('.mt-2.py-4 .animate-spin')
const scrollTrigger = () => baseElement.querySelector('.mt-2.py-4 .animate-spin')
const selectAll = await screen.findByRole('button', { name: /Select all \(1\+\)/ })
expect(scrollTrigger()).toBeInTheDocument()
@@ -754,15 +756,74 @@ describe('ProviderPicker', () => {
it('FE-JRN-PICKER-020: closes through the header button and the backdrop', async () => {
const user = userEvent.setup()
const { container, onClose } = mountPicker()
const { onClose } = mountPicker()
await screen.findByText('March 15, 2026')
const headerClose = screen.getByRole('heading', { name: 'Immich' })
.parentElement!.querySelectorAll('button')[0]
await user.click(headerClose)
await user.click(screen.getByRole('button', { name: 'Close' }))
expect(onClose).toHaveBeenCalledTimes(1)
await user.click(container.firstElementChild as HTMLElement)
await user.click(screen.getByRole('dialog').parentElement!)
expect(onClose).toHaveBeenCalledTimes(2)
})
it('FE-JRN-PICKER-037: with the add-to menu open, Escape closes the menu and leaves the picker open', async () => {
const user = userEvent.setup()
const { onClose } = mountPicker()
await screen.findAllByAltText('')
await user.click(screen.getByRole('button', { name: /New Gallery/ }))
expect(screen.getByRole('button', { name: 'Arrived in Rome' })).toBeInTheDocument()
await user.keyboard('{Escape}')
expect(screen.queryByRole('button', { name: 'Arrived in Rome' })).not.toBeInTheDocument()
expect(onClose).not.toHaveBeenCalled()
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalledTimes(1)
})
describe('on a phone', () => {
let width: number
beforeEach(() => {
width = window.innerWidth
Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true, writable: true })
})
afterEach(() => {
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true, writable: true })
})
it('FE-JRN-PICKER-038: keeps its own sheet, closed through the header button, the backdrop and Cancel', async () => {
const user = userEvent.setup()
const { container, onClose } = mountPicker()
await screen.findByText('March 15, 2026')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
const headerClose = screen.getByRole('heading', { name: 'Immich' })
.parentElement!.querySelectorAll('button')[0]
await user.click(headerClose)
expect(onClose).toHaveBeenCalledTimes(1)
await user.click(container.firstElementChild as HTMLElement)
expect(onClose).toHaveBeenCalledTimes(2)
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledTimes(3)
})
it('FE-JRN-PICKER-039: the sheet adds the picks through the same grouping as the dialog', async () => {
searchReturns([asset('a1'), asset('a2', { mediaType: 'video' })])
const user = userEvent.setup()
const { onAdd } = mountPicker()
const tiles = await screen.findAllByAltText('')
await user.click(tiles[0])
await user.click(tiles[1])
await user.click(screen.getByRole('button', { name: 'Add (2)' }))
expect(onAdd).toHaveBeenCalledWith(
[{ assetIds: ['a1', 'a2'], mediaTypes: ['image', 'video'], passphrase: undefined }],
null,
)
})
})
})
@@ -1,7 +1,9 @@
import { Calendar, Camera, Check, ChevronRight, Loader2, Play, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Calendar, Camera, Check, ChevronRight, Images, Loader2, Play, X } from 'lucide-react';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { memoriesApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useIsPhone } from '../../mobile/useIsPhone';
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../shared/DialogShell';
import {
fetchRemainingProviderPages,
groupPhotosByDate,
@@ -61,6 +63,8 @@ export function ProviderPicker({
embedded?: boolean;
}) {
const { t } = useTranslation();
const phone = useIsPhone();
const labelId = useId();
const [filter, setFilter] = useState<'day' | 'trip' | 'custom' | 'all' | 'album'>(initialDate ? 'day' : 'trip');
const [photos, setPhotos] = useState<any[]>([]);
const [albums, setAlbums] = useState<
@@ -315,46 +319,37 @@ export function ProviderPicker({
t('journey.stats.entries')
: t('journey.picker.newGallery');
return (
<div
data-testid={embedded ? 'journey-provider-picker-embedded' : undefined}
className={
embedded
? 'flex h-full min-h-0 w-full flex-col overflow-hidden'
: 'fixed inset-0 z-[9999] flex items-end justify-center overscroll-none bg-[rgba(9,9,11,0.75)] md:items-center md:p-5'
}
role="presentation"
onClick={embedded ? undefined : onClose}
onTouchMove={(e) => {
if (!embedded && e.target === e.currentTarget) e.preventDefault();
}}
>
<div
className={
embedded
? 'flex h-full w-full flex-col overflow-hidden bg-white dark:bg-zinc-900'
: 'flex max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] w-full max-w-[720px] flex-col overflow-hidden rounded-t-2xl bg-white shadow-[0_20px_40px_rgba(0,0,0,0.2)] md:max-h-[85vh] md:max-w-[960px] md:rounded-2xl dark:bg-zinc-900'
}
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
role="presentation"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
{!embedded && (
<div className="flex flex-shrink-0 items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
{provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider}
</h2>
<button
type="button"
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
<X size={16} />
</button>
</div>
)}
const providerName = provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider;
const addSelection = async () => {
if (adding) return;
setAdding(true);
try {
const groupMap = new Map<string | undefined, Array<SelectedAsset & { assetId: string }>>();
for (const [assetId, pick] of selected.entries()) {
const g = groupMap.get(pick.passphrase) || [];
g.push({ ...pick, assetId });
groupMap.set(pick.passphrase, g);
}
// Oldest first, so an entry numbers its new photos in the order
// they were taken rather than the grid's newest-first (#1587).
const groups = [...groupMap.entries()].map(([passphrase, picks]) => {
const ordered = sortByCaptureTimeAsc(picks);
return {
assetIds: ordered.map((p) => p.assetId),
mediaTypes: ordered.map((p) => (p.mediaType === 'video' ? 'video' : 'image')),
passphrase,
};
});
await onAdd(groups, targetEntryId);
} finally {
setAdding(false);
}
};
// Everything between the head and the foot, the same in all three frames below.
const content = (
<>
{/* Filter bar. Embedded in the entry editor this sits in a panel a few hundred
pixels tall, so its padding is the difference between three rows of
thumbnails and two. */}
@@ -696,6 +691,96 @@ export function ProviderPicker({
</div>
)}
</div>
</>
);
// The desktop draws the standalone picker in the planner's dialog frame. The
// phone keeps its sheet below, and the embedded picker has no frame at all.
if (!embedded && !phone) {
return (
<DialogShell
// With the add-to menu open, Escape and a click beside the dialog close
// the menu, not the picker and the picks made in it.
onClose={() => {
if (addToOpen) setAddToOpen(false);
else onClose();
}}
labelledBy={labelId}
width="wide"
// The tabs change how much the grid holds, so the upper edge stays put.
align="top"
header={(
<DialogHeader
tile={<DialogTile><Images size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={providerName}
/>
)}
// The bars stay pinned above the grid, which scrolls on its own.
bodyClassName="flex min-h-0 flex-1 flex-col"
footer={(
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton
variant="primary"
onClick={() => void addSelection()}
disabled={addDisabled}
icon={adding ? <Loader2 size={14} className="animate-spin" /> : undefined}
>
{t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
</DialogButton>
</DialogFooter>
)}
>
{content}
</DialogShell>
);
}
return (
<div
data-testid={embedded ? 'journey-provider-picker-embedded' : undefined}
className={
embedded
? 'flex h-full min-h-0 w-full flex-col overflow-hidden'
: 'fixed inset-0 z-[9999] flex items-end justify-center overscroll-none bg-[rgba(9,9,11,0.75)] md:items-center md:p-5'
}
role="presentation"
onClick={embedded ? undefined : onClose}
onTouchMove={(e) => {
if (!embedded && e.target === e.currentTarget) e.preventDefault();
}}
>
<div
className={
embedded
? 'flex h-full w-full flex-col overflow-hidden bg-white dark:bg-zinc-900'
: 'flex max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] w-full max-w-[720px] flex-col overflow-hidden rounded-t-2xl bg-white shadow-[0_20px_40px_rgba(0,0,0,0.2)] md:max-h-[85vh] md:max-w-[960px] md:rounded-2xl dark:bg-zinc-900'
}
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
role="presentation"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
{!embedded && (
<div className="flex flex-shrink-0 items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
{providerName}
</h2>
<button
type="button"
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
<X size={16} />
</button>
</div>
)}
{content}
{/* Footer. The count used to sit here as its own pill as well as on the Add
button; one of the two was always saying it twice, and inside the entry
@@ -712,31 +797,7 @@ export function ProviderPicker({
</button>
<button
type="button"
onClick={async () => {
if (adding) return;
setAdding(true);
try {
const groupMap = new Map<string | undefined, Array<SelectedAsset & { assetId: string }>>();
for (const [assetId, pick] of selected.entries()) {
const g = groupMap.get(pick.passphrase) || [];
g.push({ ...pick, assetId });
groupMap.set(pick.passphrase, g);
}
// Oldest first, so an entry numbers its new photos in the order
// they were taken rather than the grid's newest-first (#1587).
const groups = [...groupMap.entries()].map(([passphrase, picks]) => {
const ordered = sortByCaptureTimeAsc(picks);
return {
assetIds: ordered.map((p) => p.assetId),
mediaTypes: ordered.map((p) => (p.mediaType === 'video' ? 'video' : 'image')),
passphrase,
};
});
await onAdd(groups, targetEntryId);
} finally {
setAdding(false);
}
}}
onClick={addSelection}
disabled={addDisabled}
className="rounded-lg bg-zinc-900 px-3.5 py-2 text-[13px] font-medium text-white hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100"
>
@@ -1,4 +1,4 @@
// FE-JRN-SETTINGS-001 to FE-JRN-SETTINGS-019
// FE-JRN-SETTINGS-001 to FE-JRN-SETTINGS-027
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
@@ -149,9 +149,9 @@ describe('JourneySettingsDialog', () => {
uploaded = true
return HttpResponse.json({ ok: true })
}))
const { container, onSaved } = mountDialog()
const { baseElement, onSaved } = mountDialog()
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const fileInput = baseElement.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(fileInput, { target: { files: [new File(['x'], 'cover.png', { type: 'image/png' })] } })
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
@@ -161,9 +161,9 @@ describe('JourneySettingsDialog', () => {
it('FE-JRN-SETTINGS-007: reports a failed cover upload', async () => {
server.use(http.post('/api/journeys/3/cover', () => new HttpResponse(null, { status: 413 })))
const { container, onSaved } = mountDialog()
const { baseElement, onSaved } = mountDialog()
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const fileInput = baseElement.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(fileInput, { target: { files: [new File(['x'], 'cover.png', { type: 'image/png' })] } })
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Upload failed', 'error', undefined))
@@ -171,9 +171,9 @@ describe('JourneySettingsDialog', () => {
})
it('FE-JRN-SETTINGS-008: ignores a cover change event without a file', async () => {
const { container, onSaved } = mountDialog()
const { baseElement, onSaved } = mountDialog()
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const fileInput = baseElement.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(fileInput, { target: { files: [] } })
expect(onSaved).not.toHaveBeenCalled()
@@ -252,7 +252,7 @@ describe('JourneySettingsDialog', () => {
const user = userEvent.setup()
const { onSaved } = mountDialog()
await user.click(screen.getByTitle('Unlink trip'))
await user.click(screen.getByRole('button', { name: 'Unlink Trip' }))
expect(screen.getByText(/Unlink "Italy Trip"\?/)).toBeInTheDocument()
await user.click(confirmIn('Unlink Trip').getByRole('button', { name: 'Unlink' }))
@@ -266,7 +266,7 @@ describe('JourneySettingsDialog', () => {
const user = userEvent.setup()
const { onSaved } = mountDialog()
await user.click(screen.getByTitle('Unlink trip'))
await user.click(screen.getByRole('button', { name: 'Unlink Trip' }))
await user.click(confirmIn('Unlink Trip').getByRole('button', { name: 'Unlink' }))
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to unlink trip', 'error', undefined))
@@ -328,8 +328,9 @@ describe('JourneySettingsDialog', () => {
const { onClose } = mountDialog(buildJourney({ trips: [] }))
await user.click(screen.getByRole('button', { name: 'Add Trip' }))
const linkHeading = await screen.findByRole('heading', { name: 'Link Trip' })
await user.click(linkHeading.parentElement!.querySelector('button')!)
await screen.findByRole('heading', { name: 'Link Trip' })
// Both dialogs have a close button; this one is the link-trip dialog's.
await user.click(within(screen.getByRole('dialog', { name: 'Link Trip' })).getByRole('button', { name: 'Close' }))
expect(screen.queryByRole('heading', { name: 'Link Trip' })).not.toBeInTheDocument()
expect(onClose).not.toHaveBeenCalled()
@@ -362,9 +363,7 @@ describe('JourneySettingsDialog', () => {
const user = userEvent.setup()
const { onClose } = mountDialog()
// The header close button carries only an icon, so it is addressed by position.
const headerClose = screen.getByRole('heading', { name: 'Journey Settings' })
.parentElement!.querySelectorAll('button')[0]
const headerClose = screen.getByRole('button', { name: 'Close' })
await user.type(screen.getByDisplayValue('Rome & Florence'), '!')
act(() => { headerClose.click() })
@@ -412,4 +411,31 @@ describe('JourneySettingsDialog', () => {
await waitFor(() => expect(toastSpy).toHaveBeenCalled())
expect(onClose).not.toHaveBeenCalled()
})
it('FE-JRN-SETTINGS-026: Enter in the name saves, but not while the name is blank', async () => {
const user = userEvent.setup()
const { onSaved } = mountDialog()
const name = screen.getByRole('textbox', { name: 'Name' })
await user.clear(name)
await user.type(name, '{Enter}')
expect(updateJourney).not.toHaveBeenCalled()
await user.type(name, 'Italy 2027{Enter}')
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
expect(updateJourney).toHaveBeenCalledWith(3, { title: 'Italy 2027', subtitle: 'Rome & Florence' })
})
it('FE-JRN-SETTINGS-027: offers the dismissed suggestions back only when there are some', async () => {
const onRestoreSuggestions = vi.fn()
const props = { onClose: vi.fn(), onSaved: vi.fn(), onOpenInvite: vi.fn(), onRefresh: vi.fn(), onRestoreSuggestions }
const { unmount } = render(<JourneySettingsDialog journey={buildJourney()} {...props} />)
expect(screen.queryByRole('button', { name: /Bring back dismissed suggestions/ })).not.toBeInTheDocument()
unmount()
render(<JourneySettingsDialog journey={buildJourney({ dismissed_count: 3 })} {...props} />)
expect(screen.getByText('Dismissed suggestions (3)')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: /Bring back dismissed suggestions/ }))
expect(onRestoreSuggestions).toHaveBeenCalledTimes(1)
})
})
@@ -1,17 +1,39 @@
import { useState, useRef } from 'react'
import { useId, useState, useRef, type ChangeEvent, type ReactNode } from 'react'
import { useNavigate } from 'react-router'
import { X, ImagePlus, Plus, Trash2, UserPlus, Archive, ArchiveRestore, Undo2 } from 'lucide-react'
import { X, ImagePlus, Trash2, Archive, ArchiveRestore, Undo2, Settings, Crown } from 'lucide-react'
import { useJourneyStore } from '../../store/journeyStore'
import { useTranslation } from '../../i18n'
import { journeyApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import ConfirmDialog from '../shared/ConfirmDialog'
import { Tooltip } from '../shared/Tooltip'
import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { AddRowButton, EditorField, INPUT } from '../shared/dialogParts'
import JourneyShareSection from './JourneyShareSection'
import type { JourneyDetail } from '../../store/journeyStore'
import type { JourneyContributor, JourneyDetail } from '../../store/journeyStore'
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
import { normalizeImageFile } from '../../utils/convertHeic'
import ToggleSwitch from '../Settings/ToggleSwitch'
import { TripMemberAvatar } from '../Trips/TripMemberAvatar'
import { avatarSrc } from '../../utils/avatarSrc'
// The share dialogs' roster, so a journey's people read like a trip's.
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5'
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5'
const BADGE = 'inline-flex flex-none items-center gap-1 rounded-full px-2 py-[2px] font-semibold'
/** A round icon button of a row, named by its tooltip; faint until the pointer is on it. */
function RowAction({ label, onClick, children }: { label: string; onClick: () => void; children: ReactNode }) {
return (
<Tooltip label={label}>
<button type="button" onClick={onClick} aria-label={label}
className="grid h-8 w-8 flex-none place-items-center rounded-[9px] text-content-faint hover:bg-surface-card hover:text-danger">
{children}
</button>
</Tooltip>
)
}
export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh, onRestoreSuggestions }: {
journey: JourneyDetail
@@ -23,6 +45,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
onRestoreSuggestions?: () => Promise<void> | void
}) {
const { t } = useTranslation()
const labelId = useId()
const [title, setTitle] = useState(journey.title)
const [subtitle, setSubtitle] = useState(journey.subtitle || '')
const [saving, setSaving] = useState(false)
@@ -49,7 +72,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
}
}
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const handleCoverUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const formData = new FormData()
@@ -131,238 +154,208 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
}
}
const handleRemoveContributor = async (c: JourneyContributor) => {
if (!window.confirm(t('journey.contributors.removeConfirm', { username: c.username }))) return
try {
await journeyApi.removeContributor(journey.id, c.user_id)
toast.success(t('journey.contributors.removed'))
onRefresh()
} catch {
toast.error(t('journey.contributors.removeFailed'))
}
}
const archived = journey.status === 'archived'
const archiveLabel = archived ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')
const roleLabel = (role: JourneyContributor['role']) => (role === 'owner' ? t('members.owner') : t(`journey.invite.${role}`))
const header = (
<DialogHeader
tile={<DialogTile><Settings size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={handleClose}
eyebrow={t('journey.settings.title')}
titleInput={{
value: title,
onChange: setTitle,
label: t('journey.settings.name'),
placeholder: t('journey.frontpage.namePlaceholder'),
onKeyDown: e => { if (e.key === 'Enter' && title.trim() && !saving) void handleSave() },
}}
/>
)
const footer = (
<DialogFooter>
<DeleteButton label={t('journey.settings.delete')} onClick={() => setShowDeleteConfirm(true)} />
<Tooltip label={t('journey.settings.endDescription')} placement="top">
<DialogButton
onClick={handleArchiveToggle}
disabled={archiving}
icon={archived ? <ArchiveRestore size={14} strokeWidth={2.2} /> : <Archive size={14} strokeWidth={2.2} />}
>
{archiveLabel}
</DialogButton>
</Tooltip>
<FooterSpacer />
<DialogButton onClick={handleClose}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" onClick={handleSave} disabled={saving || !title.trim()}>
{saving ? t('common.saving') : t('common.save')}
</DialogButton>
</DialogFooter>
)
return (
<div role="presentation" className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]" onClick={handleClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div role="presentation" className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-[24px] shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[980px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
<>
<DialogShell
onClose={handleClose}
labelledBy={labelId}
width="wide"
// The share section below grows once its link exists, so the upper edge stays put.
align="top"
// While a question or the link-trip dialog is up, Escape and the backdrop belong to it.
blocked={showAddTrip || !!unlinkTarget || showDeleteConfirm || showDiscardConfirm}
header={header}
footer={footer}
>
<div className="grid grid-cols-2 items-start gap-6 max-md:grid-cols-1">
<div className="flex flex-col gap-5">
<DialogSection label={t('journey.settings.coverImage')}>
<input ref={coverRef} type="file" accept="image/*" onChange={handleCoverUpload} className="hidden" />
<button
type="button"
onClick={() => coverRef.current?.click()}
className="relative flex h-28 w-full items-center justify-center overflow-hidden rounded-[14px] border border-edge-faint bg-surface-secondary transition-colors hover:border-content-faint"
>
{journey.cover_image && <img src={`/uploads/${journey.cover_image}`} className="absolute inset-0 h-full w-full object-cover" alt="" />}
{/* A chip rather than bare text, so it reads the same on any photo. */}
<span className="relative inline-flex items-center gap-1.5 rounded-full bg-surface-card px-3 py-1.5 font-semibold text-content shadow-sm" style={fs(12.5, 'body')}>
<ImagePlus size={15} /> {journey.cover_image ? t('journey.settings.changeCover') : t('journey.settings.addCover')}
</span>
</button>
</DialogSection>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
<button type="button" onClick={handleClose} className="w-8 h-8 rounded-xl flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>
<div className="flex-1 overflow-y-auto overscroll-contain px-6 py-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5 items-start">
{/* Left column */}
<div className="flex flex-col gap-5 rounded-2xl p-4" style={{ background: 'var(--vg-surf2)', border: '1px solid var(--vg-line)' }}>
{/*Cover Image */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.settings.coverImage')}</label>
<input ref={coverRef} type="file" accept="image/*" onChange={handleCoverUpload} className="hidden" />
<button type="button"
onClick={() => coverRef.current?.click()}
className="w-full h-28 rounded-xl border border-dashed border-zinc-200 dark:border-zinc-700 flex items-center justify-center gap-2 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 overflow-hidden relative"
>
{journey.cover_image ? (
<>
<img src={`/uploads/${journey.cover_image}`} className="absolute inset-0 w-full h-full object-cover opacity-50" alt="" />
<span className="relative z-10 flex items-center gap-1.5"><ImagePlus size={14} /> {t('journey.settings.changeCover')}</span>
</>
) : (
<span className="flex items-center gap-1.5"><ImagePlus size={14} /> {t('journey.settings.addCover')}</span>
)}
</button>
</div>
{/* Title */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.settings.name')}</label>
<input
value={title}
onChange={e => setTitle(e.target.value)}
className="w-full px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400"
/>
</div>
{/* Subtitle */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.settings.subtitle')}</label>
<input
value={subtitle}
onChange={e => setSubtitle(e.target.value)}
placeholder={t('journey.settings.subtitlePlaceholder')}
className="w-full px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400"
/>
</div>
{/* Trip GPX tracks on the journey map (#2194) */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.settings.tracks')}</label>
<div className={`w-full flex items-center gap-3 px-3.5 py-2.5 border border-edge rounded-xl bg-surface-card text-left${savingTracks ? ' opacity-60' : ''}`}>
<span className="flex-1 min-w-0">
<span className="block text-[14px] text-content-primary">{t('journey.settings.showTripTracks')}</span>
<span className="block text-[11px] text-content-faint">{t('journey.settings.showTripTracksHint')}</span>
</span>
<ToggleSwitch
on={!!journey.show_trip_tracks}
onToggle={() => { if (!savingTracks) handleTracksToggle() }}
label={t('journey.settings.showTripTracks')}
<EditorField label={t('journey.settings.subtitle')} htmlFor={`${labelId}-subtitle`}>
<input
id={`${labelId}-subtitle`}
value={subtitle}
onChange={e => setSubtitle(e.target.value)}
placeholder={t('journey.settings.subtitlePlaceholder')}
className={INPUT}
/>
</div>
</div>
</EditorField>
{/* The three fields a journey may put away (discussion #2299) */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.settings.entryFields')}</label>
<p className="text-[11px] leading-snug text-zinc-500 mb-2">{t('journey.settings.entryFieldsHint')}</p>
<div className="flex flex-col gap-1.5">
{([
['show_verdict', t('journey.settings.showVerdict')],
['show_mood', t('journey.settings.showMood')],
['show_weather', t('journey.settings.showWeather')],
] as const).map(([field, label]) => {
const on = journey[field] !== 0
return (
<div
key={field}
className={`w-full flex items-center gap-3 px-3.5 py-2.5 border border-edge rounded-xl bg-surface-card text-left${savingField !== null ? ' opacity-60' : ''}`}
>
<span className="flex-1 min-w-0 text-[14px] text-content-primary">{label}</span>
{/* Trip GPX tracks on the journey map (#2194) */}
<DialogSection label={t('journey.settings.tracks')}>
<div className={ROWS}>
<div className={`${ROW}${savingTracks ? ' opacity-60' : ''}`}>
<span className="min-w-0 flex-1">
<span className="block text-content" style={fs(13, 'body')}>{t('journey.settings.showTripTracks')}</span>
<span className="block text-content-faint" style={fs(11.5)}>{t('journey.settings.showTripTracksHint')}</span>
</span>
<ToggleSwitch
on={!!journey.show_trip_tracks}
onToggle={() => { if (!savingTracks) handleTracksToggle() }}
label={t('journey.settings.showTripTracks')}
/>
</div>
</div>
</DialogSection>
{/* The three fields a journey may put away (discussion #2299) */}
<DialogSection label={t('journey.settings.entryFields')}>
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('journey.settings.entryFieldsHint')}</p>
<div className={ROWS}>
{([
['show_verdict', t('journey.settings.showVerdict')],
['show_mood', t('journey.settings.showMood')],
['show_weather', t('journey.settings.showWeather')],
] as const).map(([field, label]) => (
<div key={field} className={`${ROW}${savingField !== null ? ' opacity-60' : ''}`}>
<span className="min-w-0 flex-1 text-content" style={fs(13, 'body')}>{label}</span>
<ToggleSwitch
on={on}
on={journey[field] !== 0}
onToggle={() => { if (savingField === null) handleFieldToggle(field) }}
label={label}
/>
</div>
)
})}
</div>
</div>
))}
</div>
</DialogSection>
{/* The way back from dismissing suggestions one at a time. Only ever shown
when there is something to bring back, so it is not a permanent row
about a feature most journeys never touch. */}
{onRestoreSuggestions && (journey.dismissed_count ?? 0) > 0 && (
<button
type="button"
onClick={() => { void onRestoreSuggestions() }}
className="w-full flex items-center gap-3 px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl bg-white dark:bg-zinc-800 text-left hover:bg-zinc-50 dark:hover:bg-zinc-700/50 transition-colors"
>
<Undo2 size={15} className="text-zinc-400 flex-shrink-0" />
<span className="flex-1 min-w-0">
<span className="block text-[14px] text-zinc-900 dark:text-white">{t('journey.suggestions.restore')}</span>
<span className="block text-[11px] text-zinc-500">
{t('journey.suggestions.restoreCount', { count: String(journey.dismissed_count) })}
{/* The way back from dismissing suggestions one at a time. Only ever shown
when there is something to bring back, so it is not a permanent row
about a feature most journeys never touch. */}
{onRestoreSuggestions && (journey.dismissed_count ?? 0) > 0 && (
<button
type="button"
onClick={() => { void onRestoreSuggestions() }}
className="flex w-full items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-secondary px-3.5 py-2.5 text-left transition-colors hover:bg-surface-card"
>
<Undo2 size={15} className="flex-none text-content-faint" />
<span className="min-w-0 flex-1">
<span className="block text-content" style={fs(13, 'body')}>{t('journey.suggestions.restore')}</span>
<span className="block text-content-faint" style={fs(11.5)}>
{t('journey.suggestions.restoreCount', { count: String(journey.dismissed_count) })}
</span>
</span>
</span>
</button>
)}
</div>
{/* Right column */}
<div className="flex flex-col gap-5 rounded-2xl p-4" style={{ background: 'var(--vg-surf2)', border: '1px solid var(--vg-line)' }}>
{/*Synced Trips */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.detail.syncedTrips')}</label>
<div className="flex flex-col gap-1.5">
{journey.trips.map((trip: any) => (
<div key={trip.trip_id} className="flex items-center gap-2.5 p-2 rounded-xl bg-zinc-50 dark:bg-zinc-800">
<div className="w-8 h-8 rounded-md flex-shrink-0" style={{ background: pickGradient(trip.trip_id) }} />
<div className="flex-1 min-w-0">
<div className="text-[12px] font-medium text-zinc-900 dark:text-white">{trip.title}</div>
<div className="text-[10px] text-zinc-500">{trip.place_count || 0} {t('journey.synced.places')}</div>
</div>
<button type="button"
onClick={() => setUnlinkTarget({ trip_id: trip.trip_id, title: trip.title })}
className="w-8 h-8 rounded-xl flex-shrink-0 flex items-center justify-center bg-red-500/10 text-red-500 hover:bg-red-500/20 dark:bg-red-500/15 dark:hover:bg-red-500/25 transition-colors"
title="Unlink trip"
>
<Trash2 size={14} />
</button>
</div>
))}
{journey.trips.length === 0 && <p className="text-[11px] text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</p>}
<button type="button"
onClick={() => setShowAddTrip(true)}
className="w-full mt-1 flex items-center justify-center gap-1.5 py-2.5 rounded-xl border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
>
<Plus size={14} /> {t('journey.trips.addTrip')}
</button>
</div>
)}
</div>
{/* Contributors */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.detail.contributors')}</label>
<div className="flex flex-col gap-2">
{journey.contributors.map((c: any) => (
<div key={c.user_id} className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[11px] font-semibold">
{(c.username || '?')[0].toUpperCase()}
</div>
<div className="flex-1 text-[12px] font-medium text-zinc-900 dark:text-white">{c.username}</div>
<span className="shrink-0 rounded-full font-semibold uppercase" style={{ fontSize: 8.5, letterSpacing: '0.05em', padding: '2px 7px', ...(c.role === 'owner' ? { background: 'var(--vg-ink)', color: 'var(--vg-bg)' } : { background: 'color-mix(in srgb, var(--vg-ink3) 14%, transparent)', color: 'var(--vg-ink2)' }) }}>{c.role}</span>
{c.role !== 'owner' && (
<button type="button"
onClick={async () => {
if (!window.confirm(t('journey.contributors.removeConfirm', { username: c.username }))) return
try {
await journeyApi.removeContributor(journey.id, c.user_id)
toast.success(t('journey.contributors.removed'))
onRefresh()
} catch {
toast.error(t('journey.contributors.removeFailed'))
}
}}
aria-label={t('journey.contributors.remove')}
title={t('journey.contributors.remove')}
className="w-7 h-7 rounded-xl flex items-center justify-center text-zinc-400 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-500 transition-colors"
>
<X size={13} />
</button>
)}
<div className="flex flex-col gap-5">
<DialogSection label={t('journey.detail.syncedTrips')}>
{journey.trips.length > 0 ? (
<div className={`${ROWS} mb-2.5`}>
{journey.trips.map(trip => (
<div key={trip.trip_id} className={ROW}>
<span className="h-8 w-8 flex-none rounded-[9px]" style={{ background: pickGradient(trip.trip_id) }} />
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{trip.title}</div>
<div className="text-content-faint" style={fs(11.5)}>{trip.place_count || 0} {t('journey.synced.places')}</div>
</div>
<RowAction label={t('journey.trips.unlinkTrip')} onClick={() => setUnlinkTarget({ trip_id: trip.trip_id, title: trip.title })}>
<Trash2 size={14} />
</RowAction>
</div>
))}
</div>
))}
<button type="button"
onClick={onOpenInvite}
className="w-full mt-1 flex items-center justify-center gap-1.5 py-2.5 rounded-xl border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
>
<UserPlus size={14} /> {t('journey.contributors.invite')}
</button>
</div>
) : (
<p className="m-0 mb-2.5 text-content-faint" style={fs(11.5)}>{t('journey.trips.noTripsLinkedSettings')}</p>
)}
<AddRowButton onClick={() => setShowAddTrip(true)}>{t('journey.trips.addTrip')}</AddRowButton>
</DialogSection>
<DialogSection label={t('journey.detail.contributors')}>
<div className={`${ROWS} mb-2.5`}>
{journey.contributors.map(c => (
<div key={c.user_id} className={ROW}>
<TripMemberAvatar username={c.username} avatarUrl={avatarSrc(c.avatar)} size={28} />
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>{c.username}</span>
{c.role === 'owner' ? (
<span className={`${BADGE} bg-warning-soft text-warning`} style={fs(10.5)}>
<Crown size={10} strokeWidth={2.4} />{roleLabel(c.role)}
</span>
) : (
<span className={`${BADGE} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>{roleLabel(c.role)}</span>
)}
{c.role !== 'owner' && (
<RowAction label={t('journey.contributors.remove')} onClick={() => { void handleRemoveContributor(c) }}>
<X size={14} />
</RowAction>
)}
</div>
))}
</div>
<AddRowButton onClick={onOpenInvite}>{t('journey.contributors.invite')}</AddRowButton>
</DialogSection>
</div>
</div>
</div>
<div className="h-3" />
{/* Public Share */}
<JourneyShareSection journeyId={journey.id} />
</div>
{/* Footer */}
<div className="flex items-center gap-1.5 px-4 md:px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<button type="button"
onClick={() => setShowDeleteConfirm(true)}
aria-label={t('journey.settings.delete')}
title={t('journey.settings.delete')}
className="flex items-center justify-center gap-1.5 h-9 min-w-9 px-3 md:px-3.5 text-[12px] font-semibold text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-full transition-colors"
>
<Trash2 size={14} />
<span className="hidden md:inline">{t('journey.settings.delete')}</span>
</button>
<button type="button"
onClick={handleArchiveToggle}
disabled={archiving}
aria-label={journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}
title={t('journey.settings.endDescription')}
className="flex items-center justify-center gap-1.5 h-9 min-w-9 px-3 md:px-3.5 text-[12px] font-semibold text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded-full mr-auto disabled:opacity-40 transition-colors"
>
{journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />}
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
</button>
<button type="button" onClick={handleClose} className="h-10 px-4 rounded-full border border-zinc-200 dark:border-zinc-600 text-[13px] font-semibold text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors">{t('common.cancel')}</button>
<button type="button" onClick={handleSave} disabled={saving || !title.trim()} className="h-10 px-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-semibold hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 transition-colors">
{saving ? t('common.saving') : t('common.save')}
</button>
</div>
</div>
<JourneyShareSection journeyId={journey.id} />
</DialogShell>
{/* Unlink Trip confirm */}
<ConfirmDialog
isOpen={!!unlinkTarget}
onClose={() => setUnlinkTarget(null)}
@@ -383,17 +376,13 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
danger
/>
{/* Add Trip — sits inside the backdrop, so its clicks have to be kept
from bubbling into the close/discard handler underneath */}
{showAddTrip && (
<div role="presentation" onClick={e => e.stopPropagation()}>
<AddTripDialog
journeyId={journey.id}
existingTripIds={journey.trips.map((t: any) => t.trip_id)}
onClose={() => setShowAddTrip(false)}
onAdded={() => { setShowAddTrip(false); onSaved() }}
/>
</div>
<AddTripDialog
journeyId={journey.id}
existingTripIds={journey.trips.map(trip => trip.trip_id)}
onClose={() => setShowAddTrip(false)}
onAdded={() => { setShowAddTrip(false); onSaved() }}
/>
)}
<ConfirmDialog
@@ -415,6 +404,6 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
confirmLabel={t('common.discard')}
danger
/>
</div>
</>
)
}
@@ -80,12 +80,15 @@ describe('VacayPersons modals', () => {
openInvite()
const modal = await screen.findByText('Invite another TREK user to share a combined vacation calendar.')
fireEvent.click(modal.closest('.fixed') as HTMLElement)
// The dialog frame only closes on a press that starts and ends on the backdrop.
const backdrop = modal.closest('.fixed') as HTMLElement
fireEvent.mouseDown(backdrop)
fireEvent.click(backdrop)
expect(screen.queryByRole('button', { name: 'Send Invite' })).not.toBeInTheDocument()
openInvite()
const header = (await screen.findByText('Invite User')).closest('.justify-between') as HTMLElement
fireEvent.click(within(header).getByRole('button'))
await screen.findByText('Invite User')
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.queryByRole('button', { name: 'Send Invite' })).not.toBeInTheDocument()
openInvite()
@@ -118,13 +121,15 @@ describe('VacayPersons modals', () => {
render(<VacayPersons />)
fireEvent.click(screen.getByTitle('Change color'))
const heading = await screen.findByText('Change color')
fireEvent.click(within(heading.closest('.justify-between') as HTMLElement).getByRole('button'))
await screen.findByText('Change color')
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.queryByText('Change color', { selector: 'h2' })).not.toBeInTheDocument()
fireEvent.click(screen.getByTitle('Change color'))
const dialog = await screen.findByText('Change color', { selector: 'h2' })
fireEvent.click(dialog.closest('.fixed') as HTMLElement)
const backdrop = dialog.closest('.fixed') as HTMLElement
fireEvent.mouseDown(backdrop)
fireEvent.click(backdrop)
expect(screen.queryByText('Change color', { selector: 'h2' })).not.toBeInTheDocument()
})
})
+80 -69
View File
@@ -1,12 +1,12 @@
import { createPortal } from 'react-dom'
import { useState, useEffect, type HTMLAttributes } from 'react'
import { UserPlus, Check, Loader2, Clock, X } from 'lucide-react'
import { useState, useEffect, useId, type HTMLAttributes } from 'react'
import { UserPlus, Check, Loader2, Clock, Palette } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../types'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import apiClient from '../../api/client'
import VacayBadge from './VacayBadge'
@@ -32,6 +32,8 @@ export default function VacayPersons() {
const [availableUsers, setAvailableUsers] = useState([])
const [selectedInviteUser, setSelectedInviteUser] = useState(null)
const [inviting, setInviting] = useState(false)
const inviteLabelId = useId()
const colorLabelId = useId()
const loadAvailable = async () => {
try {
@@ -61,7 +63,11 @@ export default function VacayPersons() {
setColorEditUserId(null)
}
const editingUserColor = users.find(u => u.id === colorEditUserId)?.color || '#6366f1'
const closeInvite = () => setShowInvite(false)
const closeColorPicker = () => { setShowColorPicker(false); setColorEditUserId(null) }
const editingUser = users.find(u => u.id === colorEditUserId)
const editingUserColor = editingUser?.color || '#6366f1' // theme-lint-disable: a person's own colour, the default one when unset
return (
<div className="vg-card rounded-[22px]" style={{ padding: '14px 18px' }}>
@@ -134,72 +140,77 @@ export default function VacayPersons() {
))}
</div>
{/* Invite Modal — Portal to body to avoid z-index issues */}
{showInvite && createPortal(
<div className="fixed inset-0 flex items-center justify-center px-4 trek-backdrop-enter bg-[rgba(15,23,42,0.5)]" style={{ zIndex: 99990, paddingTop: 70 }}
role="presentation"
onClick={e => { if (e.target === e.currentTarget) setShowInvite(false) }}>
<div className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm bg-surface-card">
<div className="flex items-center justify-between p-5 border-b border-edge-secondary">
<h2 className="text-base font-semibold text-content">{t('vacay.inviteUser')}</h2>
<button type="button" onClick={() => setShowInvite(false)} className="p-1.5 rounded-lg transition-colors text-content-faint">
<X size={16} />
</button>
</div>
<div className="p-5 space-y-4">
<p className="text-xs text-content-muted">{t('vacay.inviteHint')}</p>
{availableUsers.length === 0 ? (
<p className="text-xs text-center py-4 text-content-faint">{t('vacay.noUsersAvailable')}</p>
) : (
<CustomSelect
value={selectedInviteUser}
onChange={setSelectedInviteUser}
options={availableUsers.map(u => ({ value: u.id, label: `${u.username} (${u.email})` }))}
placeholder={t('vacay.selectUser')}
searchable
/>
)}
<div className="flex gap-3 justify-end pt-2">
<button type="button" onClick={() => setShowInvite(false)} className="px-4 py-2 text-sm rounded-lg text-content-muted border border-edge">
{t('common.cancel')}
</button>
<button type="button" onClick={handleInvite} disabled={!selectedInviteUser || inviting}
className="px-4 py-2 text-sm rounded-lg transition-colors flex items-center gap-1.5 disabled:opacity-40 bg-content text-surface-card">
{inviting && <Loader2 size={13} className="animate-spin" />}
{t('vacay.sendInvite')}
</button>
</div>
</div>
</div>
</div>,
document.body
)}
<DialogShell
open={showInvite}
onClose={closeInvite}
labelledBy={inviteLabelId}
width="narrow"
header={(
<DialogHeader
tile={<DialogTile><UserPlus size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={inviteLabelId}
onClose={closeInvite}
title={t('vacay.inviteUser')}
sub={t('vacay.inviteHint')}
subWraps
/>
)}
footer={(
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={closeInvite}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" onClick={handleInvite} disabled={!selectedInviteUser || inviting}
icon={inviting ? <Loader2 size={14} className="animate-spin" /> : undefined}>
{t('vacay.sendInvite')}
</DialogButton>
</DialogFooter>
)}
>
{availableUsers.length === 0 ? (
<p className="m-0 rounded-[12px] bg-surface-secondary px-4 py-5 text-center text-content-faint" style={fs(12.5, 'body')}>
{t('vacay.noUsersAvailable')}
</p>
) : (
<CustomSelect
value={selectedInviteUser}
onChange={setSelectedInviteUser}
options={availableUsers.map(u => ({ value: u.id, label: `${u.username} (${u.email})` }))}
placeholder={t('vacay.selectUser')}
searchable
/>
)}
</DialogShell>
{/* Color Picker Modal — Portal to body */}
{showColorPicker && createPortal(
<div className="fixed inset-0 flex items-center justify-center px-4 trek-backdrop-enter bg-[rgba(15,23,42,0.5)]" style={{ zIndex: 99990, paddingTop: 70 }}
role="presentation"
onClick={e => { if (e.target !== e.currentTarget) return; setShowColorPicker(false); setColorEditUserId(null) }}>
<div className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-xs bg-surface-card">
<div className="flex items-center justify-between p-5 border-b border-edge-secondary">
<h2 className="text-base font-semibold text-content">{t('vacay.changeColor')}</h2>
<button type="button" onClick={() => { setShowColorPicker(false); setColorEditUserId(null) }} className="p-1.5 rounded-lg transition-colors text-content-faint">
<X size={16} />
</button>
</div>
<div className="p-5">
<div className="flex flex-wrap gap-2 justify-center">
{PRESET_COLORS.map(c => (
<button type="button" key={c} onClick={() => handleColorChange(c)}
className={`w-8 h-8 rounded-full transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${editingUserColor === c ? 'ring-2 ring-offset-2 scale-110' : 'hover:scale-110'}`}
style={{ backgroundColor: c }} />
))}
</div>
</div>
</div>
</div>,
document.body
)}
{/* A pick saves at once and closes, so there is nothing for a footer to confirm. */}
<DialogShell
open={showColorPicker}
onClose={closeColorPicker}
labelledBy={colorLabelId}
width="narrow"
header={(
<DialogHeader
tile={<DialogTile><Palette size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={colorLabelId}
onClose={closeColorPicker}
title={t('vacay.changeColor')}
sub={editingUser?.username}
/>
)}
>
<div role="group" aria-label={t('vacay.color')}
className="grid grid-cols-5 justify-items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-secondary p-4">
{PRESET_COLORS.map(c => {
const on = editingUserColor === c
return (
<button type="button" key={c} onClick={() => handleColorChange(c)} aria-pressed={on}
className={`h-10 w-10 rounded-full transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${on ? 'scale-110' : 'hover:scale-110'}`}
style={{ backgroundColor: c, outline: on ? '2px solid var(--text-primary)' : '2px solid transparent', outlineOffset: 2 }} />
)
})}
</div>
</DialogShell>
</div>
)
}
@@ -2,7 +2,7 @@
import React from 'react'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import { server } from '../../../tests/helpers/msw/server'
import { resetAllStores } from '../../../tests/helpers/store'
import { useVacayStore } from '../../store/vacayStore'
@@ -98,12 +98,15 @@ describe('VacaySharedCalendars errors', () => {
fireEvent.click(screen.getByTitle('Share calendar'))
const hint = await screen.findByRole('button', { name: 'Share' })
fireEvent.click(hint.closest('.fixed') as HTMLElement)
// The dialog frame only closes on a press that starts and ends on the backdrop.
const backdrop = hint.closest('.fixed') as HTMLElement
fireEvent.mouseDown(backdrop)
fireEvent.click(backdrop)
expect(screen.queryByRole('button', { name: 'Share' })).not.toBeInTheDocument()
fireEvent.click(screen.getByTitle('Share calendar'))
const header = (await screen.findByText('Share calendar')).closest('.justify-between') as HTMLElement
fireEvent.click(within(header).getByRole('button'))
await screen.findByText('Share calendar')
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.queryByRole('button', { name: 'Share' })).not.toBeInTheDocument()
fireEvent.click(screen.getByTitle('Share calendar'))
@@ -1,11 +1,11 @@
import { createPortal } from 'react-dom'
import { useState } from 'react'
import { useId, useState } from 'react'
import { Share2, Eye, EyeOff, Loader2, X } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../types'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import apiClient from '../../api/client'
import VacayBadge from './VacayBadge'
@@ -23,6 +23,7 @@ export default function VacaySharedCalendars() {
const [availableUsers, setAvailableUsers] = useState<{ id: number; username: string }[]>([])
const [selectedUser, setSelectedUser] = useState<number | null>(null)
const [sharing, setSharing] = useState(false)
const shareLabelId = useId()
const loadAvailable = async () => {
try {
@@ -55,6 +56,8 @@ export default function VacaySharedCalendars() {
removeShare(id).catch((err: unknown) => toast.error(getApiErrorMessage(err, t('vacay.shareFailed'))))
}
const closeShare = () => setShowShare(false)
const empty = incomingShares.length === 0 && outgoingShares.length === 0
return (
@@ -132,48 +135,47 @@ export default function VacaySharedCalendars() {
</div>
)}
{/* Share Modal — Portal to body to avoid z-index issues */}
{showShare && createPortal(
<div className="fixed inset-0 flex items-center justify-center px-4 trek-backdrop-enter bg-[rgba(15,23,42,0.5)]" style={{ zIndex: 99990, paddingTop: 70 }}
role="presentation"
onClick={() => setShowShare(false)}>
<div className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm bg-surface-card"
role="presentation"
onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-5 border-b border-edge-secondary">
<h2 className="text-base font-semibold text-content">{t('vacay.shareCalendar')}</h2>
<button type="button" onClick={() => setShowShare(false)} className="p-1.5 rounded-lg transition-colors text-content-faint">
<X size={16} />
</button>
</div>
<div className="p-5 space-y-4">
<p className="text-xs text-content-muted">{t('vacay.shareCalendarHint')}</p>
{availableUsers.length === 0 ? (
<p className="text-xs text-center py-4 text-content-faint">{t('vacay.noUsersAvailable')}</p>
) : (
<CustomSelect
value={selectedUser}
onChange={v => setSelectedUser(Number(v))}
options={availableUsers.map(u => ({ value: u.id, label: u.username }))}
placeholder={t('vacay.selectUser')}
searchable
/>
)}
<div className="flex gap-3 justify-end pt-2">
<button type="button" onClick={() => setShowShare(false)} className="px-4 py-2 text-sm rounded-lg text-content-muted border border-edge">
{t('common.cancel')}
</button>
<button type="button" onClick={handleShare} disabled={!selectedUser || sharing}
className="px-4 py-2 text-sm rounded-lg transition-colors flex items-center gap-1.5 disabled:opacity-40 bg-content text-surface-card">
{sharing && <Loader2 size={13} className="animate-spin" />}
{t('vacay.share')}
</button>
</div>
</div>
</div>
</div>,
document.body
)}
<DialogShell
open={showShare}
onClose={closeShare}
labelledBy={shareLabelId}
width="narrow"
header={(
<DialogHeader
tile={<DialogTile><Share2 size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={shareLabelId}
onClose={closeShare}
title={t('vacay.shareCalendar')}
sub={t('vacay.shareCalendarHint')}
subWraps
/>
)}
footer={(
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={closeShare}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" onClick={handleShare} disabled={!selectedUser || sharing}
icon={sharing ? <Loader2 size={14} className="animate-spin" /> : undefined}>
{t('vacay.share')}
</DialogButton>
</DialogFooter>
)}
>
{availableUsers.length === 0 ? (
<p className="m-0 rounded-[12px] bg-surface-secondary px-4 py-5 text-center text-content-faint" style={fs(12.5, 'body')}>
{t('vacay.noUsersAvailable')}
</p>
) : (
<CustomSelect
value={selectedUser}
onChange={v => setSelectedUser(Number(v))}
options={availableUsers.map(u => ({ value: u.id, label: u.username }))}
placeholder={t('vacay.selectUser')}
searchable
/>
)}
</DialogShell>
</div>
)
}
+2 -2
View File
@@ -900,8 +900,8 @@ describe('AtlasPage', () => {
async () => {
const popup = screen.queryByText(/mark as visited/i);
if (popup) {
// Click the backdrop (fixed overlay div)
const backdrop = document.querySelector('[style*="position: fixed"][style*="inset: 0"]') as HTMLElement | null;
// Click the backdrop: the DialogShell overlay around the dialog panel
const backdrop = screen.queryByRole('dialog')?.parentElement ?? null;
if (backdrop) {
await user.click(backdrop);
await waitFor(() => {
+229 -255
View File
@@ -5,9 +5,11 @@ import Navbar from '../components/Layout/Navbar'
import apiClient from '../api/client'
import CustomSelect from '../components/shared/CustomSelect'
import EmptyState from '../components/shared/EmptyState'
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search, Check } from 'lucide-react'
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search, Check, ArrowLeft, ChevronRight, type LucideIcon } from 'lucide-react'
import type { TranslationFn } from '../types'
import { Tooltip } from '../components/shared/Tooltip'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../components/shared/DialogShell'
import { EditorField, GRID_2 } from '../components/shared/dialogParts'
import DawarichAtlasSidePanel from '../components/Dawarich/DawarichAtlasSidePanel'
import { A2_TO_A3, countryCodeToFlag, findBucketDuplicate, isBucketDuplicateError, withCountryMarkedVisited, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
import { continentForCountry } from '@trek/shared'
@@ -18,6 +20,13 @@ import { useToast } from '../components/shared/Toast'
import { getApiErrorMessage } from '../types'
import HelpAnchor from '../components/Help/HelpAnchor'
// Fixed ids rather than useId: the page body stays free of hooks, and there is only one atlas.
const COUNTRY_DIALOG_TITLE = 'atlas-country-dialog-title'
const BUCKET_MONTH_ID = 'atlas-bucket-month'
const BUCKET_YEAR_ID = 'atlas-bucket-year'
/** The footer's destructive answer, filled like ConfirmDialog's. */
const DANGER_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-danger px-4 py-2 font-medium text-white hover:opacity-90' // theme-lint-disable: white on the danger fill, as ConfirmDialog draws it
export default function AtlasPage(): React.ReactElement {
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
// chunk of its own instead of a dead limb in this one.
@@ -200,282 +209,247 @@ function AtlasPageDesktop(): React.ReactElement {
</div>
{/* Country action popup */}
{/* Country action popup: the flag and the name at the head, what can be
done with the country below. A region names its country under its own name. */}
{confirmAction && (
<div role="presentation" className="bg-[rgba(0,0,0,0.4)]" style={{ position: 'fixed', inset: 0, zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}
onClick={() => setConfirmAction(null)}>
<div role="presentation" className="bg-surface-card" style={{ borderRadius: 16, padding: 24, maxWidth: 340, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}
onClick={e => e.stopPropagation()}>
{confirmAction.code.length === 2 ? (
<img src={`https://flagcdn.com/w80/${confirmAction.code.toLowerCase()}.png`} alt={confirmAction.code} style={{ width: 48, height: 34, borderRadius: 6, objectFit: 'cover', marginBottom: 12, display: 'inline-block' }} />
) : (
<div style={{ fontSize: 'calc(36px * var(--fs-scale-title, 1))', marginBottom: 12 }}>{countryCodeToFlag(confirmAction.code)}</div>
)}
<h3 className="text-content" style={{ margin: '0 0 16px', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}>{confirmAction.name}</h3>
{confirmAction.type === 'choose' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button type="button" onClick={async () => {
try {
await apiClient.post(`/addons/atlas/country/${confirmAction.code}/mark`)
setData(prev => (prev ? withCountryMarkedVisited(prev, confirmAction.code) : prev))
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
setConfirmAction(null)
}}
className="border border-edge"
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<MapPin size={18} className="text-content" style={{ flexShrink: 0 }} />
<div>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('atlas.markVisited')}</div>
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 1 }}>{t('atlas.markVisitedHint')}</div>
</div>
<DialogShell
onClose={() => setConfirmAction(null)}
labelledBy={COUNTRY_DIALOG_TITLE}
width="narrow"
header={(
<DialogHeader
tile={(
<DialogTile>
{confirmAction.code.length === 2 ? (
<img src={`https://flagcdn.com/w80/${confirmAction.code.toLowerCase()}.png`} alt={confirmAction.code} className="h-6 w-[34px] rounded-[5px] object-cover ring-1 ring-edge-faint" />
) : (
<span className="leading-none" style={fs(26, 'subtitle')}>{countryCodeToFlag(confirmAction.code)}</span>
)}
</DialogTile>
)}
// The two removals take the danger wash ConfirmDialog asks its destructive questions in.
tint={confirmAction.type === 'unmark' || confirmAction.type === 'unmark-region' ? 'var(--danger-soft)' : NEUTRAL_TINT}
labelId={COUNTRY_DIALOG_TITLE}
onClose={() => setConfirmAction(null)}
title={confirmAction.name}
sub={confirmAction.countryName || undefined}
/>
)}
footer={(
<DialogFooter>
{confirmAction.type === 'bucket' && (
<DialogButton onClick={() => setConfirmAction({ ...confirmAction, type: confirmAction.regionCode ? 'choose-region' : 'choose' })} icon={<ArrowLeft size={14} strokeWidth={2.2} />}>
{t('common.back')}
</DialogButton>
)}
<FooterSpacer />
{confirmAction.type !== 'bucket' && (
<DialogButton onClick={() => setConfirmAction(null)}>{t('common.cancel')}</DialogButton>
)}
{confirmAction.type === 'mark' && (
<DialogButton variant="primary" onClick={executeConfirmAction}>{t('atlas.markVisited')}</DialogButton>
)}
{confirmAction.type === 'unmark' && (
<button type="button" onClick={executeConfirmAction} className={DANGER_BUTTON} style={fs(13, 'body')}>
{t('atlas.unmark')}
</button>
<button type="button" onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' as any })}
className="border border-edge"
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<Star size={18} className="text-[#fbbf24]" style={{ flexShrink: 0 }} />
<div>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('atlas.addToBucket')}</div>
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
</div>
</button>
{(() => {
const wishlistItems = bucketList.filter(b => b.country_code === confirmAction.code)
if (wishlistItems.length === 0) return null
return (
<button type="button" onClick={async () => {
await Promise.all(wishlistItems.map(item => handleDeleteBucketItem(item.id)))
setConfirmAction(null)
}}
className="border border-edge"
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<Trash2 size={18} className="text-[#ef4444]" style={{ flexShrink: 0 }} />
<div>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('atlas.removeFromBucket')}</div>
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 1 }}>{t('atlas.removeFromBucketHint')}</div>
</div>
</button>
)
})()}
</div>
)}
{confirmAction.type === 'choose-region' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{confirmAction.countryName && (
<p className="text-content-muted" style={{ margin: '-8px 0 8px', fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{confirmAction.countryName}</p>
)}
)}
{confirmAction.type === 'unmark-region' && (
<button type="button" onClick={async () => {
const { code: countryCode, name: rName, regionCode: rCode } = confirmAction
const { code: countryCode, regionCode: rCode } = confirmAction
if (!rCode) return
try {
await apiClient.post(`/addons/atlas/region/${rCode}/mark`, { name: rName, country_code: countryCode })
await apiClient.delete(`/addons/atlas/region/${rCode}/mark`)
setVisitedRegions(prev => {
const existing = prev[countryCode] || []
if (existing.find(r => r.code === rCode)) return prev
return { ...prev, [countryCode]: [...existing, { code: rCode, name: rName, placeCount: 0, manuallyMarked: true }] }
const remaining = (prev[countryCode] || []).filter(r => r.code !== rCode)
const next = { ...prev, [countryCode]: remaining }
if (remaining.length === 0) delete next[countryCode]
return next
})
// If no visible regions remain at all (not just manually-marked ones:
// the server now hides a region regardless of how it was derived, and
// cascades to the country the same way), remove the country too, but
// only when it has no real place/trip data of its own: a country with
// real places is never actually hidden server-side (#1490), so
// optimistically removing it here would just flash and reappear on
// the next reload.
setData(prev => {
if (!prev) return prev
const c = prev.countries.find(c => c.code === countryCode)
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode)
if (remainingRegions.length > 0) return prev
const cont = continentForCountry(countryCode)
return {
...prev,
countries: prev.countries.filter(c => c.code !== countryCode),
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) },
}
})
setData(prev => (prev ? withCountryMarkedVisited(prev, countryCode) : prev))
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
setConfirmAction(null)
}}
className="border border-edge"
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<MapPin size={18} className="text-content" style={{ flexShrink: 0 }} />
<div>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('atlas.markVisited')}</div>
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 1 }}>{t('atlas.markRegionVisitedHint')}</div>
</div>
}} className={DANGER_BUTTON} style={fs(13, 'body')}>
{t('atlas.unmark')}
</button>
<button type="button" onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' })}
className="border border-edge"
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<Star size={18} className="text-[#fbbf24]" style={{ flexShrink: 0 }} />
<div>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('atlas.addToBucket')}</div>
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
</div>
</button>
</div>
)}
{confirmAction.type === 'unmark' && (
<>
<p className="text-content-muted" style={{ margin: '0 0 20px', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('atlas.confirmUnmark')}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button type="button" onClick={() => setConfirmAction(null)}
className="border border-edge text-content-muted"
style={{ padding: '8px 20px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.cancel')}
</button>
<button type="button" onClick={executeConfirmAction}
className="bg-[#ef4444] text-white"
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('atlas.unmark')}
</button>
</div>
</>
)}
{confirmAction.type === 'unmark-region' && (
<>
{confirmAction.countryName && (
<p className="text-content-muted" style={{ margin: '-8px 0 8px', fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{confirmAction.countryName}</p>
)}
<p className="text-content-muted" style={{ margin: '0 0 20px', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('atlas.confirmUnmarkRegion')}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button type="button" onClick={() => setConfirmAction(null)}
className="border border-edge text-content-muted"
style={{ padding: '8px 20px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.cancel')}
</button>
<button type="button" onClick={async () => {
const { code: countryCode, regionCode: rCode } = confirmAction
if (!rCode) return
try {
await apiClient.delete(`/addons/atlas/region/${rCode}/mark`)
setVisitedRegions(prev => {
const remaining = (prev[countryCode] || []).filter(r => r.code !== rCode)
const next = { ...prev, [countryCode]: remaining }
if (remaining.length === 0) delete next[countryCode]
return next
})
// If no visible regions remain at all (not just manually-marked ones —
// the server now hides a region regardless of how it was derived, and
// cascades to the country the same way), remove the country too, but
// only when it has no real place/trip data of its own: a country with
// real places is never actually hidden server-side (#1490), so
// optimistically removing it here would just flash and reappear on
// the next reload.
setData(prev => {
if (!prev) return prev
const c = prev.countries.find(c => c.code === countryCode)
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode)
if (remainingRegions.length > 0) return prev
const cont = continentForCountry(countryCode)
return {
...prev,
countries: prev.countries.filter(c => c.code !== countryCode),
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) },
}
})
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
setConfirmAction(null)
}}
className="bg-[#ef4444] text-white"
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('atlas.unmark')}
</button>
</div>
</>
)}
{confirmAction.type === 'bucket' && (
<>
<p className="text-content-muted" style={{ margin: '0 0 14px', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('atlas.bucketWhen')}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<CustomSelect
value={String(bucketMonth)}
onChange={v => setBucketMonth(Number(v))}
placeholder={t('atlas.month')}
options={[
{ value: '0', label: '—' },
...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })),
]}
size="sm"
/>
</div>
<div style={{ flex: 1 }}>
<CustomSelect
value={String(bucketYear)}
onChange={v => setBucketYear(Number(v))}
placeholder={t('atlas.year')}
options={[
{ value: '0', label: '—' },
...Array.from({ length: 20 }, (_, i) => ({ value: String(new Date().getFullYear() + i), label: String(new Date().getFullYear() + i) })),
]}
size="sm"
/>
</div>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
<button type="button" onClick={() => setConfirmAction({ ...confirmAction, type: confirmAction.regionCode ? 'choose-region' : 'choose' })}
className="border border-edge text-content-muted"
style={{ padding: '8px 20px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.back')}
</button>
<button type="button" onClick={async () => {
const targetDate = bucketMonth > 0 && bucketYear > 0 ? `${bucketYear}-${String(bucketMonth).padStart(2, '0')}` : null
// #1898: one entry per target date. The dialog stays open on a
// duplicate so another month can be picked right away.
if (findBucketDuplicate(bucketList, { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate, lat: null, lng: null })) {
)}
{confirmAction.type === 'bucket' && (
<DialogButton variant="primary" icon={<Star size={14} strokeWidth={2.2} />} onClick={async () => {
const targetDate = bucketMonth > 0 && bucketYear > 0 ? `${bucketYear}-${String(bucketMonth).padStart(2, '0')}` : null
// #1898: one entry per target date. The dialog stays open on a
// duplicate so another month can be picked right away.
if (findBucketDuplicate(bucketList, { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate, lat: null, lng: null })) {
toast.error(t('atlas.bucketDuplicate'))
return
}
try {
const r = await apiClient.post('/addons/atlas/bucket-list', { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate })
setBucketList(prev => [r.data.item, ...prev])
} catch (err) {
if (isBucketDuplicateError(err)) {
toast.error(t('atlas.bucketDuplicate'))
return
}
try {
const r = await apiClient.post('/addons/atlas/bucket-list', { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate })
setBucketList(prev => [r.data.item, ...prev])
} catch (err) {
if (isBucketDuplicateError(err)) {
toast.error(t('atlas.bucketDuplicate'))
return
}
toast.error(getApiErrorMessage(err, t('common.error')))
}
setBucketMonth(0); setBucketYear(0)
toast.error(getApiErrorMessage(err, t('common.error')))
}
setBucketMonth(0); setBucketYear(0)
setConfirmAction(null)
}}>
{t('atlas.addToBucket')}
</DialogButton>
)}
</DialogFooter>
)}
>
{confirmAction.type === 'choose' && (
<div className="flex flex-col gap-2">
<CountryChoice icon={MapPin} title={t('atlas.markVisited')} hint={t('atlas.markVisitedHint')} onClick={async () => {
try {
await apiClient.post(`/addons/atlas/country/${confirmAction.code}/mark`)
setData(prev => (prev ? withCountryMarkedVisited(prev, confirmAction.code) : prev))
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
setConfirmAction(null)
}} />
<CountryChoice icon={Star} tone="text-warning" title={t('atlas.addToBucket')} hint={t('atlas.addToBucketHint')}
onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' })} />
{(() => {
const wishlistItems = bucketList.filter(b => b.country_code === confirmAction.code)
if (wishlistItems.length === 0) return null
return (
<CountryChoice icon={Trash2} tone="text-danger" title={t('atlas.removeFromBucket')} hint={t('atlas.removeFromBucketHint')} onClick={async () => {
await Promise.all(wishlistItems.map(item => handleDeleteBucketItem(item.id)))
setConfirmAction(null)
}}
className="bg-[#fbbf24] text-[#1a1a1a]"
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('atlas.addToBucket')}
</button>
</div>
</>
)}
}} />
)
})()}
</div>
)}
{confirmAction.type === 'mark' && (
<>
<p className="text-content-muted" style={{ margin: '0 0 20px', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('atlas.confirmMark')}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button type="button" onClick={() => setConfirmAction(null)}
className="border border-edge text-content-muted"
style={{ padding: '8px 20px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.cancel')}
</button>
<button type="button" onClick={executeConfirmAction}
className="bg-content text-white"
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('atlas.markVisited')}
</button>
</div>
</>
)}
</div>
</div>
{confirmAction.type === 'choose-region' && (
<div className="flex flex-col gap-2">
<CountryChoice icon={MapPin} title={t('atlas.markVisited')} hint={t('atlas.markRegionVisitedHint')} onClick={async () => {
const { code: countryCode, name: rName, regionCode: rCode } = confirmAction
if (!rCode) return
try {
await apiClient.post(`/addons/atlas/region/${rCode}/mark`, { name: rName, country_code: countryCode })
setVisitedRegions(prev => {
const existing = prev[countryCode] || []
if (existing.find(r => r.code === rCode)) return prev
return { ...prev, [countryCode]: [...existing, { code: rCode, name: rName, placeCount: 0, manuallyMarked: true }] }
})
setData(prev => (prev ? withCountryMarkedVisited(prev, countryCode) : prev))
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
setConfirmAction(null)
}} />
<CountryChoice icon={Star} tone="text-warning" title={t('atlas.addToBucket')} hint={t('atlas.addToBucketHint')}
onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' })} />
</div>
)}
{confirmAction.type === 'unmark' && (
<p className="m-0 text-content-secondary" style={fs(13.5, 'body')}>{t('atlas.confirmUnmark')}</p>
)}
{confirmAction.type === 'unmark-region' && (
<p className="m-0 text-content-secondary" style={fs(13.5, 'body')}>{t('atlas.confirmUnmarkRegion')}</p>
)}
{confirmAction.type === 'mark' && (
<p className="m-0 text-content-secondary" style={fs(13.5, 'body')}>{t('atlas.confirmMark')}</p>
)}
{confirmAction.type === 'bucket' && (
<>
<p className="m-0 text-content-secondary" style={fs(13.5, 'body')}>{t('atlas.bucketWhen')}</p>
<div className={GRID_2}>
<EditorField label={t('atlas.month')} htmlFor={BUCKET_MONTH_ID}>
<CustomSelect
id={BUCKET_MONTH_ID}
value={String(bucketMonth)}
onChange={v => setBucketMonth(Number(v))}
placeholder={t('atlas.month')}
options={[
{ value: '0', label: t('common.none') },
...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })),
]}
size="sm"
/>
</EditorField>
<EditorField label={t('atlas.year')} htmlFor={BUCKET_YEAR_ID}>
<CustomSelect
id={BUCKET_YEAR_ID}
value={String(bucketYear)}
onChange={v => setBucketYear(Number(v))}
placeholder={t('atlas.year')}
options={[
{ value: '0', label: t('common.none') },
...Array.from({ length: 20 }, (_, i) => ({ value: String(new Date().getFullYear() + i), label: String(new Date().getFullYear() + i) })),
]}
size="sm"
/>
</EditorField>
</div>
</>
)}
</DialogShell>
)}
</div>
)
}
/** One answer of the country dialog, built like an export row: icon tile, what it does, and what that means. */
function CountryChoice({ icon: Icon, tone = 'text-content-secondary', title, hint, onClick }: {
icon: LucideIcon
/** The icon's colour class. */
tone?: string
title: string
hint: string
onClick: () => void
}): React.ReactElement {
return (
<button
type="button"
onClick={onClick}
className="group flex w-full items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-card px-3 py-2.5 text-left transition-colors hover:bg-surface-hover"
>
<span className={`grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary transition-colors group-hover:bg-surface-card ${tone}`}>
<Icon size={16} strokeWidth={1.9} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(13.5, 'body')}>{title}</span>
<span className="block text-content-muted" style={fs(11.5)}>{hint}</span>
</span>
<ChevronRight size={15} strokeWidth={2} className="flex-none text-content-faint transition-transform group-hover:translate-x-0.5" />
</button>
)
}
interface SidebarContentProps {
data: AtlasData | null
stats: AtlasStats
+12 -11
View File
@@ -28,9 +28,12 @@ function setAtlas(over: Record<string, unknown> = {}): AtlasController {
return mocks.atlas;
}
/** The two CustomSelect triggers in a popup both render the "—" placeholder option. */
/**
* The month and year triggers, both on their empty option: "None" in the country
* dialog (the key, since t is mocked here), the older dash in the sidebar's form.
*/
function selectTriggers(): HTMLElement[] {
return screen.getAllByRole('button').filter((b) => b.textContent?.trim() === '—');
return screen.getAllByRole('button').filter((b) => ['common.none', '—'].includes(b.textContent?.trim() ?? ''));
}
beforeEach(() => {
@@ -69,10 +72,8 @@ describe('AtlasPage wiring', () => {
expect(flag.src).toContain('flagcdn.com/w80/de.png');
const markBtn = screen.getByText('atlas.markVisited').closest('button') as HTMLButtonElement;
fireEvent.mouseEnter(markBtn);
expect(markBtn.style.background).toBe('var(--bg-secondary)');
fireEvent.mouseLeave(markBtn);
expect(markBtn.style.background).toBe('none');
// The option rows light up through their hover class now, not inline mouse handlers.
expect(markBtn.className).toContain('hover:bg-surface-hover');
fireEvent.click(markBtn);
@@ -106,7 +107,10 @@ describe('AtlasPage wiring', () => {
fireEvent.click(bucketBtn);
expect(atlas.setConfirmAction).toHaveBeenCalledWith({ ...action, type: 'bucket' });
fireEvent.click(screen.getByText('Germany').closest('div[style*="z-index: 1000"]') as HTMLElement);
// DialogShell closes only on a press that starts and ends on the backdrop.
const backdrop = screen.getByRole('dialog').parentElement as HTMLElement;
fireEvent.mouseDown(backdrop);
fireEvent.click(backdrop);
expect(atlas.setConfirmAction).toHaveBeenLastCalledWith(null);
});
@@ -181,10 +185,7 @@ describe('AtlasPage wiring', () => {
render(<AtlasPage />);
const bucketBtn = screen.getByText('atlas.addToBucket').closest('button') as HTMLButtonElement;
fireEvent.mouseEnter(bucketBtn);
expect(bucketBtn.style.background).toBe('var(--bg-secondary)');
fireEvent.mouseLeave(bucketBtn);
expect(bucketBtn.style.background).toBe('none');
expect(bucketBtn.className).toContain('hover:bg-surface-hover');
fireEvent.click(bucketBtn);
expect(atlas.setConfirmAction).toHaveBeenCalledWith({ ...action, type: 'bucket' });
+7 -16
View File
@@ -1,7 +1,7 @@
import React from 'react'
import { List as ListIcon, Map as MapIcon, Search, Bookmark, CheckCheck, X, Trash2, Copy, CopyPlus, FolderInput, Plus, Tags, DownloadCloud } from 'lucide-react'
import Navbar from '../components/Layout/Navbar'
import Modal from '../components/shared/Modal'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import ListsRail from '../components/Collections/ListsRail'
import ListEditorModal from '../components/Collections/ListEditorModal'
import CollectionHero from '../components/Collections/CollectionHero'
@@ -446,24 +446,15 @@ function CollectionsPageDesktop(): React.ReactElement {
)}
{/* Delete-list confirm */}
<Modal
<ConfirmDialog
isOpen={c.confirmDeleteList != null}
onClose={() => c.setConfirmDeleteList(null)}
onConfirm={c.handleDeleteList}
title={t('collections.deleteList')}
size="sm"
footer={
<div className="flex justify-end gap-2">
<button type="button" onClick={() => c.setConfirmDeleteList(null)} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">
{t('common.cancel')}
</button>
<button type="button" onClick={c.handleDeleteList} className="px-3 py-1.5 rounded-lg bg-danger text-white text-[13px] font-semibold hover:opacity-90">
{t('common.delete')}
</button>
</div>
}
>
<p className="text-[13px] text-content-secondary">{t('collections.deleteListConfirm')}</p>
</Modal>
message={t('collections.deleteListConfirm')}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
/>
</>
)
}
@@ -158,14 +158,14 @@ vi.mock('../components/Collections/BulkAssignLabelModal', () => ({
) : null,
}))
vi.mock('../components/shared/Modal', () => ({
default: (p: { isOpen: boolean; title: string; onClose: () => void; children: React.ReactNode; footer: React.ReactNode }) =>
vi.mock('../components/shared/ConfirmDialog', () => ({
default: (p: { isOpen: boolean; title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; onConfirm: () => void; onClose: () => void }) =>
p.isOpen ? (
<div data-testid="confirm-modal">
<span>{p.title}</span>
<button type="button" onClick={p.onClose}>modal-dismiss</button>
{p.children}
{p.footer}
<p>{p.message}</p>
<button type="button" onClick={p.onClose}>{p.cancelLabel}</button>
<button type="button" onClick={p.onConfirm}>{p.confirmLabel}</button>
</div>
) : null,
}))
@@ -742,12 +742,11 @@ describe('CollectionsPage — detail sheet and modals', () => {
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
expect(hook.handleDeleteList).toHaveBeenCalled()
// ConfirmDialog sends Cancel, the backdrop and Escape through the one onClose,
// so this also covers dismissing the question: the pending id is cleared.
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(hook.setConfirmDeleteList).toHaveBeenCalledWith(null)
// Dismissing the modal itself (backdrop / escape) clears the pending id too.
fireEvent.click(screen.getByText('modal-dismiss'))
expect(hook.setConfirmDeleteList).toHaveBeenCalledTimes(2)
expect(hook.setConfirmDeleteList).toHaveBeenCalledTimes(1)
})
it('FE-PAGE-COLLPAGE-047: no confirmation is rendered without a pending delete', () => {
+15 -15
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { localIsoDate } from '../utils/localDate';
import { render, screen, waitFor, cleanup } from '../../tests/helpers/render';
import { render, screen, waitFor, cleanup, within } from '../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
@@ -959,10 +959,11 @@ describe('JourneyDetailPage', () => {
await renderAndWait();
await openEntryEditor(user);
// Click Cancel to close
const cancelButtons = screen.getAllByText('Cancel');
// The Cancel button in the editor footer (not the ConfirmDialog mock)
await user.click(cancelButtons[0]);
// Click Cancel to close. The editor is a dialog in a portal on the body,
// after the page's own ConfirmDialog mocks, so its footer Cancel is picked
// from inside it rather than by position.
const editor = screen.getByText('New Entry').closest('[role="dialog"]') as HTMLElement;
await user.click(within(editor).getByRole('button', { name: 'Cancel' }));
await waitFor(() => {
expect(screen.queryByText('New Entry')).not.toBeInTheDocument();
@@ -1000,11 +1001,10 @@ describe('JourneyDetailPage', () => {
await renderAndWait();
await openSettingsDialog(user);
// "Name" label from i18n (displayed uppercase via CSS class)
expect(screen.getByText('Name')).toBeInTheDocument();
// The name is typed into the dialog's head band, labelled "Name" for screen readers
const nameInput = screen.getByRole('textbox', { name: 'Name' });
// The input has the current journey title
const nameInput = screen.getByDisplayValue('Italy 2026');
expect(nameInput).toBeInTheDocument();
expect(nameInput).toHaveValue('Italy 2026');
});
});
@@ -1272,8 +1272,8 @@ describe('JourneyDetailPage', () => {
const rainyBtn = screen.getByText('Rainy');
await user.click(rainyBtn);
// Active weather button gets bg-zinc-900 class
expect(rainyBtn.closest('button')!.className).toContain('bg-zinc-900');
// Active weather button takes the accent fill
expect(rainyBtn.closest('button')!.className).toContain('bg-accent');
});
});
@@ -2502,7 +2502,7 @@ describe('JourneyDetailPage', () => {
expect(screen.getByText('Paris Weekend')).toBeInTheDocument();
});
// Destination and start date appear combined in a subtitle: "Paris · 2026-05-01"
// Destination and start date appear combined in a subtitle: "Paris, 2026-05-01"
expect(screen.getByText(/Paris.*2026-05-01/)).toBeInTheDocument();
});
});
@@ -2573,8 +2573,8 @@ describe('JourneyDetailPage', () => {
const editorBtn = screen.getByText('Editor');
await user.click(editorBtn);
// Editor button should now be active (bg-zinc-900 class)
expect(editorBtn.closest('button')!.className).toContain('bg-zinc-900');
// Editor button should now be the pressed one
expect(editorBtn.closest('button')!).toHaveAttribute('aria-pressed', 'true');
});
});
@@ -3004,7 +3004,7 @@ describe('JourneyDetailPage', () => {
// The settings dialog shows trips with unlink buttons (Trash2 icon buttons)
const settingsDialog = screen.getByText('Journey Settings').closest('[class*="fixed"]')!;
// Find the unlink button (it's a red trash button next to Italy Trip)
const trashBtns = settingsDialog.querySelectorAll('button[title="Unlink trip"]');
const trashBtns = settingsDialog.querySelectorAll('button[aria-label="Unlink Trip"]');
expect(trashBtns.length).toBeGreaterThanOrEqual(1);
await user.click(trashBtns[0] as HTMLElement);
+4 -9
View File
@@ -1,7 +1,7 @@
// FE-PAGE-JOURNEY-001 to FE-PAGE-JOURNEY-010
import { describe, it, expect, beforeEach, vi } from 'vitest';
import React from 'react';
import { render, screen, waitFor } from '../../tests/helpers/render';
import { render, screen, waitFor, within } from '../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
@@ -340,14 +340,9 @@ describe('JourneyPage', () => {
});
await user.click(screen.getByText('Thailand 2026'));
// The modal footer has a Create/Create Journey button — find it by its disabled-capable parent
// The footer buttons live inside the border-t div at the bottom of the modal
const footerDiv = document.querySelector('.border-t.border-zinc-200');
const footerButtons = footerDiv?.querySelectorAll('button');
// The last button in the footer is the submit button
const submitBtn = footerButtons ? footerButtons[footerButtons.length - 1] : null;
expect(submitBtn).toBeTruthy();
await user.click(submitBtn!);
// The dialog's own Create Journey button, not the page's buttons of the same name.
const submitBtn = within(screen.getByRole('dialog')).getByRole('button', { name: 'Create Journey' });
await user.click(submitBtn);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/journey/99');
+162 -121
View File
@@ -1,14 +1,17 @@
import { useId, type Dispatch, type SetStateAction } from 'react'
import PageShell from '../components/Layout/PageShell'
import { localIsoDate } from '../utils/localDate'
import { useTranslation, TransHtml } from '../i18n'
import {
Plus, Search, Sparkles, Calendar, MapPin,
Check, X, ChevronRight,
Check, X, ChevronRight, BookOpen,
} from 'lucide-react'
import type { Journey } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
import { useJourney } from './journey/useJourney'
import HelpAnchor from '../components/Help/HelpAnchor'
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../components/shared/DialogShell'
import { EditorField, INPUT } from '../components/shared/dialogParts'
const GRADIENTS = [
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
@@ -246,132 +249,170 @@ function JourneyPageDesktop() {
</div>
</div>
{/* Create Modal */}
{showCreate && (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
{/* Header */}
<div className="px-7 pt-6 pb-5 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[18px] font-bold tracking-[-0.01em] text-zinc-900 dark:text-white">{t("journey.frontpage.createJourney")}</h2>
<p className="text-[13px] text-zinc-500 mt-1">{t('journey.frontpage.createNewSub')}</p>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-7 py-5">
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2.5">{t('journey.frontpage.journeyName')}</label>
<input
value={newTitle}
onChange={e => setNewTitle(e.target.value)}
placeholder={t('journey.frontpage.namePlaceholder')}
className="w-full px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white focus:border-zinc-900 dark:focus:border-zinc-400 focus:outline-none mb-5"
/>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2.5">{t('journey.settings.subtitle')}</label>
<input
value={newSubtitle}
onChange={e => setNewSubtitle(e.target.value)}
placeholder={t('journey.settings.subtitlePlaceholder')}
className="w-full px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white focus:border-zinc-900 dark:focus:border-zinc-400 focus:outline-none mb-5"
/>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2.5">{t('journey.frontpage.selectTrips')}</label>
<div className="flex flex-col gap-2 max-h-[320px] overflow-y-auto">
{availableTrips.map(trip => {
const selected = selectedTripIds.has(trip.id)
const status = trip.end_date && trip.end_date < localIsoDate()
? 'completed'
: trip.start_date && trip.start_date <= localIsoDate()
? 'active'
: 'upcoming'
const statusColors: Record<string, string> = {
completed: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400',
active: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
upcoming: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
}
const toggleTrip = () => {
setSelectedTripIds(prev => {
const next = new Set(prev)
if (next.has(trip.id)) next.delete(trip.id)
else next.add(trip.id)
return next
})
}
return (
<div
key={trip.id}
role="checkbox"
aria-checked={selected}
tabIndex={0}
onClick={toggleTrip}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleTrip() }
}}
className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-[border-color,background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${
selected
? 'border-zinc-900 dark:border-zinc-400 bg-zinc-50 dark:bg-zinc-800'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500'
}`}
>
<div className={`w-5 h-5 rounded-md border-2 flex items-center justify-center flex-shrink-0 ${
selected
? 'bg-zinc-900 dark:bg-white border-zinc-900 dark:border-white'
: 'border-zinc-300 dark:border-zinc-600'
}`}>
{selected && <Check size={12} className="text-white dark:text-zinc-900" />}
</div>
<div className="w-12 h-12 rounded-lg flex-shrink-0 overflow-hidden" style={{ background: pickGradient(trip.id) }}>
{trip.cover_image && (
<img src={trip.cover_image} className="w-full h-full object-cover" alt="" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{trip.title}</div>
<div className="text-[12px] text-zinc-500 flex items-center gap-2.5 mt-0.5">
<span className="flex items-center gap-1"><Calendar size={11} /> {trip.start_date ? Math.ceil((new Date(trip.end_date || trip.start_date).getTime() - new Date(trip.start_date).getTime()) / 86400000) + 1 : '?'}<span className="hidden md:inline"> {t('journey.stats.days').toLowerCase()}</span></span>
<span className="flex items-center gap-1"><MapPin size={11} /> {trip.place_count || 0}<span className="hidden md:inline"> {t("journey.frontpage.places")}</span></span>
</div>
</div>
<span className={`text-[10px] font-medium uppercase tracking-[0.05em] px-2 py-0.5 rounded-full ${statusColors[status]}`}>
{t(`journey.status.${status}`)}
</span>
</div>
)
})}
</div>
</div>
{/* Footer */}
<div className="px-7 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex items-center justify-between">
<div className="text-[12px] text-zinc-500">
<strong className="text-zinc-900 dark:text-white">{selectedTripIds.size}</strong> <span className="hidden md:inline">{t('journey.frontpage.tripsSelected')}</span><span className="md:hidden">{t('journey.frontpage.trips')}</span>
{selectedTripIds.size > 0 && <> · <strong className="text-zinc-900 dark:text-white">{totalPlaces}</strong> <span className="hidden md:inline">{t('journey.frontpage.placesImported')}</span><span className="md:hidden">{t('journey.frontpage.places')}</span></>}
</div>
<div className="flex items-center gap-2">
<button type="button"
onClick={() => setShowCreate(false)}
className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700"
>
{t('common.cancel')}
</button>
<button type="button"
onClick={handleCreate}
disabled={!newTitle.trim()}
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
<span className="md:hidden">{t('journey.create')}</span><span className="hidden md:inline">{t('journey.frontpage.createJourney')}</span>
</button>
</div>
</div>
</div>
</div>
<CreateJourneyDialog
title={newTitle}
onTitleChange={setNewTitle}
subtitle={newSubtitle}
onSubtitleChange={setNewSubtitle}
trips={availableTrips}
selectedTripIds={selectedTripIds}
onSelectedTripIdsChange={setSelectedTripIds}
totalPlaces={totalPlaces}
onClose={() => setShowCreate(false)}
onCreate={handleCreate}
/>
)}
</PageShell>
)
}
interface AvailableTrip {
id: number
title: string
start_date?: string | null
end_date?: string | null
place_count?: number | null
cover_image?: string | null
}
type TripStatus = 'completed' | 'active' | 'upcoming'
const TRIP_STATUS_LOOK: Record<TripStatus, string> = {
completed: 'bg-success-soft text-success',
active: 'bg-info-soft text-info',
upcoming: 'bg-warning-soft text-warning',
}
/**
* A new journey: its name typed into the head band, an optional subtitle and
* the trips it starts from. Everything it edits lives in useJourney; the
* dialog is its own component so the id it labels itself with stays out of
* the page body.
*/
function CreateJourneyDialog({ title, onTitleChange, subtitle, onSubtitleChange, trips, selectedTripIds, onSelectedTripIdsChange, totalPlaces, onClose, onCreate }: {
title: string
onTitleChange: (value: string) => void
subtitle: string
onSubtitleChange: (value: string) => void
trips: AvailableTrip[]
selectedTripIds: Set<number>
onSelectedTripIdsChange: Dispatch<SetStateAction<Set<number>>>
totalPlaces: number
onClose: () => void
onCreate: () => void | Promise<void>
}) {
const { t } = useTranslation()
const labelId = useId()
const today = localIsoDate()
const toggleTrip = (id: number) => {
onSelectedTripIdsChange(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const header = (
<DialogHeader
tile={<DialogTile><BookOpen size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
eyebrow={t('journey.frontpage.createJourney')}
titleInput={{
value: title,
onChange: onTitleChange,
label: t('journey.frontpage.journeyName'),
placeholder: t('journey.frontpage.namePlaceholder'),
autoFocus: true,
onKeyDown: e => { if (e.key === 'Enter' && title.trim()) void onCreate() },
}}
sub={t('journey.frontpage.createNewSub')}
subWraps
/>
)
const footer = (
<DialogFooter>
<span className="flex min-w-0 items-center gap-3 text-content-muted" style={fs(12.5, 'body')}>
<span><strong className="font-semibold text-content">{selectedTripIds.size}</strong> {t('journey.frontpage.tripsSelected')}</span>
{selectedTripIds.size > 0 && (
<span><strong className="font-semibold text-content">{totalPlaces}</strong> <span>{t('journey.frontpage.placesImported')}</span></span>
)}
</span>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" onClick={() => void onCreate()} disabled={!title.trim()}>
{t('journey.frontpage.createJourney')}
</DialogButton>
</DialogFooter>
)
return (
// Pinned at the top: the trips arrive after the dialog opens and grow the body.
<DialogShell onClose={onClose} labelledBy={labelId} width="detail" align="top" header={header} footer={footer}>
<EditorField label={t('journey.settings.subtitle')} htmlFor={`${labelId}-subtitle`}>
<input
id={`${labelId}-subtitle`}
value={subtitle}
onChange={e => onSubtitleChange(e.target.value)}
placeholder={t('journey.settings.subtitlePlaceholder')}
className={INPUT}
/>
</EditorField>
{trips.length > 0 && (
<DialogSection label={t('journey.frontpage.selectTrips')}>
<div className="flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5">
{trips.map(trip => {
const selected = selectedTripIds.has(trip.id)
let status: TripStatus = 'upcoming'
if (trip.end_date && trip.end_date < today) status = 'completed'
else if (trip.start_date && trip.start_date <= today) status = 'active'
const days = trip.start_date
? Math.ceil((new Date(trip.end_date || trip.start_date).getTime() - new Date(trip.start_date).getTime()) / 86400000) + 1
: '?'
return (
<div
key={trip.id}
role="checkbox"
aria-checked={selected}
tabIndex={0}
onClick={() => toggleTrip(trip.id)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleTrip(trip.id) }
}}
className={`flex min-h-[56px] cursor-pointer items-center gap-3 rounded-[10px] px-2.5 py-2 outline-none focus-visible:ring-2 focus-visible:ring-accent ${selected ? 'bg-surface-card shadow-sm' : 'hover:bg-surface-card'}`}
>
<span className={`grid h-4 w-4 flex-none place-items-center rounded-[5px] ${selected ? 'bg-accent text-accent-text' : 'border-[1.5px] border-edge'}`}>
{selected && <Check size={11} strokeWidth={3} />}
</span>
<span className="h-10 w-10 flex-none overflow-hidden rounded-[10px]" style={{ background: pickGradient(trip.id) }}>
{trip.cover_image && <img src={trip.cover_image} className="h-full w-full object-cover" alt="" />}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13.5, 'body')}>{trip.title}</div>
<div className="mt-0.5 flex items-center gap-2.5 text-content-faint" style={fs(11.5)}>
<span className="flex items-center gap-1"><Calendar size={11} /> {days} {t('journey.stats.days').toLowerCase()}</span>
<span className="flex items-center gap-1"><MapPin size={11} /> {trip.place_count || 0} {t('journey.frontpage.places')}</span>
</div>
</div>
<span className={`flex-none rounded-full px-2 py-[2px] font-semibold ${TRIP_STATUS_LOOK[status]}`} style={fs(10.5)}>
{t(`journey.status.${status}`)}
</span>
</div>
)
})}
</div>
</DialogSection>
)}
</DialogShell>
)
}
function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?: number; photo_count?: number; place_count?: number; trip_date_min?: string | null; trip_date_max?: string | null }; onClick: () => void }) {
const { t } = useTranslation()
const j = journey
+21 -1
View File
@@ -1,4 +1,4 @@
// FE-JRN-LISTWIRE-001 to FE-JRN-LISTWIRE-014
// FE-JRN-LISTWIRE-001 to FE-JRN-LISTWIRE-015
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { localIsoDate } from '../utils/localDate';
import { render, screen, fireEvent } from '../../tests/helpers/render';
@@ -206,4 +206,24 @@ describe('JourneyPage wiring', () => {
fireEvent.click(createButtons[createButtons.length - 1]);
expect(hook.handleCreate).toHaveBeenCalledTimes(1);
});
it('FE-JRN-LISTWIRE-015: Enter in the name creates only once there is a name, and the keyboard toggles a trip', () => {
const availableTrips = [{ id: 7, title: 'Keyboard trip', start_date: null, end_date: null, place_count: 2 }];
const { hook, rerender } = setup({ showCreate: true, newTitle: '', availableTrips });
fireEvent.keyDown(screen.getByPlaceholderText('e.g. Southeast Asia 2026'), { key: 'Enter' });
expect(hook.handleCreate).not.toHaveBeenCalled();
const row = screen.getByRole('checkbox', { name: /Keyboard trip/ });
fireEvent.keyDown(row, { key: ' ' });
fireEvent.keyDown(row, { key: 'Enter' });
fireEvent.keyDown(row, { key: 'a' });
expect(hook.setSelectedTripIds).toHaveBeenCalledTimes(2);
const named = buildHook({ showCreate: true, newTitle: 'Japan', availableTrips });
mocks.journey = named;
rerender(<JourneyPage />);
fireEvent.keyDown(screen.getByPlaceholderText('e.g. Southeast Asia 2026'), { key: 'Enter' });
expect(named.handleCreate).toHaveBeenCalledTimes(1);
});
});
+113 -76
View File
@@ -1,4 +1,4 @@
import React from 'react'
import React, { useId } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from '../i18n'
import PageShell from '../components/Layout/PageShell'
@@ -7,8 +7,9 @@ import VacayPersons from '../components/Vacay/VacayPersons'
import VacaySharedCalendars from '../components/Vacay/VacaySharedCalendars'
import VacayStats from '../components/Vacay/VacayStats'
import VacaySettings from '../components/Vacay/VacaySettings'
import { Plus, Minus, ChevronLeft, ChevronRight, Settings, CalendarDays, AlertTriangle, Eye, Pencil, Trash2, Unlink, ShieldCheck, SlidersHorizontal } from 'lucide-react'
import Modal from '../components/shared/Modal'
import { Plus, Minus, ChevronLeft, ChevronRight, Settings, CalendarDays, Eye, Pencil, Trash2, Unlink, ShieldCheck, SlidersHorizontal, Handshake } from 'lucide-react'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../components/shared/DialogShell'
import HelpAnchor from '../components/Help/HelpAnchor'
import { useVacay } from './vacay/useVacay'
@@ -173,9 +174,10 @@ function VacayPageDesktop(): React.ReactElement {
</div>
</div>
{/* Mobile Sidebar Drawer */}
{/* Mobile Sidebar Drawer. It sits just under the dialog layer, because the
invite, share and colour dialogs of the cards inside it open over it. */}
{showMobileSidebar && createPortal(
<div className="fixed inset-0 lg:hidden" style={{ zIndex: 99980 }}>
<div className="fixed inset-0 lg:hidden" style={{ zIndex: 'calc(var(--z-modal) - 1)' }}>
<div className="absolute inset-0 bg-[rgba(0,0,0,0.4)]" role="presentation" onClick={() => setShowMobileSidebar(false)} />
<div className="absolute left-0 top-0 bottom-0 w-[280px] overflow-y-auto p-3 flex flex-col gap-3 bg-surface"
style={{ boxShadow: '4px 0 24px rgba(0,0,0,0.15)', animation: 'slideInLeft 0.2s ease-out' }}>
@@ -185,74 +187,26 @@ function VacayPageDesktop(): React.ReactElement {
document.body
)}
{/* Settings Modal */}
<Modal isOpen={showSettings} onClose={() => setShowSettings(false)} title={t('vacay.settings')} size="3xl">
<VacaySettings onClose={() => setShowSettings(false)} />
</Modal>
<SettingsDialog open={showSettings} onClose={() => setShowSettings(false)} />
{/* Delete Year Modal */}
<Modal isOpen={deleteYear !== null} onClose={() => setDeleteYear(null)} title={t('vacay.removeYear')} size="sm">
<div className="space-y-4">
<div className="flex gap-3 p-3 rounded-lg bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.15)]">
<AlertTriangle size={18} className="text-red-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-content">
{t('vacay.removeYearConfirm', { year: deleteYear })}
</p>
<p className="text-xs mt-1 text-content-muted">
{t('vacay.removeYearHint')}
</p>
</div>
</div>
<div className="flex gap-3 justify-end">
<button type="button" onClick={() => setDeleteYear(null)} className="px-4 py-2 text-sm rounded-lg transition-colors border text-content-muted border-edge">
{t('common.cancel')}
</button>
<button type="button" onClick={async () => { await removeYear(deleteYear); setDeleteYear(null) }} className="px-4 py-2 text-sm bg-red-500 hover:bg-red-600 text-white rounded-lg transition-colors">
{t('vacay.remove')}
</button>
</div>
</div>
</Modal>
<ConfirmDialog
isOpen={deleteYear !== null}
onClose={() => setDeleteYear(null)}
onConfirm={() => removeYear(deleteYear)}
title={t('vacay.removeYear')}
message={t('vacay.removeYearConfirm', { year: deleteYear })}
confirmLabel={t('vacay.remove')}
>
<p className="m-0 text-content-muted" style={fs(12.5, 'body')}>{t('vacay.removeYearHint')}</p>
</ConfirmDialog>
{/* Incoming invite — forced fullscreen modal */}
{incomingInvites.length > 0 && createPortal(
<div className="fixed inset-0 flex items-center justify-center px-4 bg-[rgba(0,0,0,0.7)]"
style={{ zIndex: 99995, backdropFilter: 'blur(8px)' }}>
{incomingInvites.map(inv => (
<div key={inv.plan_id} className="trek-modal-enter w-full max-w-md rounded-2xl shadow-2xl overflow-hidden bg-surface-card">
<div className="px-6 pt-6 pb-4 text-center">
<div className="w-14 h-14 rounded-full mx-auto mb-4 flex items-center justify-center text-lg font-bold bg-surface-secondary text-content">
{inv.owner_username?.[0]?.toUpperCase()}
</div>
<h2 className="text-lg font-bold mb-1 text-content">
{t('vacay.inviteTitle')}
</h2>
<p className="text-sm text-content-muted">
<span className="font-semibold text-content">{inv.owner_username}</span> {t('vacay.inviteWantsToFuse')}
</p>
</div>
<div className="px-6 pb-4 space-y-2">
<InfoItem icon={Eye} text={t('vacay.fuseInfo1')} />
<InfoItem icon={Pencil} text={t('vacay.fuseInfo2')} />
<InfoItem icon={Trash2} text={t('vacay.fuseInfo3')} />
<InfoItem icon={ShieldCheck} text={t('vacay.fuseInfo4')} />
<InfoItem icon={Unlink} text={t('vacay.fuseInfo5')} />
</div>
<div className="px-6 pb-6 flex gap-3">
<button type="button" onClick={() => declineInvite(inv.plan_id)}
className="flex-1 px-4 py-2.5 text-sm font-medium rounded-xl transition-colors border text-content-muted border-edge">
{t('vacay.decline')}
</button>
<button type="button" onClick={() => acceptInvite(inv.plan_id)}
className="flex-1 px-4 py-2.5 text-sm font-medium rounded-xl transition-colors bg-content text-surface-card">
{t('vacay.acceptFusion')}
</button>
</div>
</div>
))}
</div>,
document.body
{incomingInvites[0] && (
<FusionRequestDialog
key={incomingInvites[0].plan_id}
invite={incomingInvites[0]}
onAccept={acceptInvite}
onDecline={declineInvite}
/>
)}
<style>{`
@@ -265,12 +219,95 @@ function VacayPageDesktop(): React.ReactElement {
)
}
function InfoItem({ icon: Icon, text }: { icon: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>; text: string }): React.ReactElement {
/** The plan's settings. They save as they change, so the frame has no footer. */
function SettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }): React.ReactElement {
const { t } = useTranslation()
const labelId = useId()
return (
<div className="flex items-start gap-3 px-3 py-2 rounded-lg bg-surface-secondary">
<Icon size={15} className="shrink-0 mt-0.5 text-content-muted" />
<span className="text-xs text-content">{text}</span>
</div>
<DialogShell
open={open}
onClose={onClose}
labelledBy={labelId}
width="wide"
// Calendars and their add form open inside, so the upper edge stays put.
align="top"
header={(
<DialogHeader
tile={<DialogTile><Settings size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={t('vacay.settings')}
/>
)}
>
<VacaySettings onClose={onClose} />
</DialogShell>
)
}
/**
* Someone asks to fuse their calendar with yours. The request has to be
* answered, so there is no close button and Escape and the backdrop leave it
* alone. Several requests are shown one after the other, the next once the
* first is answered.
*/
function FusionRequestDialog({ invite, onAccept, onDecline }: {
invite: { plan_id: number; owner_username: string }
onAccept: (planId: number) => void
onDecline: (planId: number) => void
}): React.ReactElement {
const { t } = useTranslation()
const labelId = useId()
return (
<DialogShell
onClose={() => { /* never called: blocked keeps Escape and the backdrop from closing it */ }}
blocked
labelledBy={labelId}
width="narrow"
header={(
// DialogHeader always draws a close button, so the band is drawn here without one.
<header className="flex-none px-6 pb-4 pt-5" style={{ background: NEUTRAL_TINT }}>
<div className="flex items-start gap-3.5">
<DialogTile><Handshake size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>
<div className="min-w-0 flex-1 pt-0.5">
<h2 id={labelId} className="m-0 truncate font-bold tracking-[-0.01em] text-content" style={fs(20, 'subtitle')}>
{t('vacay.inviteTitle')}
</h2>
<p className="m-0 mt-0.5 break-words font-geist text-content-muted" style={fs(12.5)}>
<span className="font-semibold text-content">{invite.owner_username}</span> {t('vacay.inviteWantsToFuse')}
</p>
</div>
</div>
</header>
)}
footer={(
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={() => onDecline(invite.plan_id)}>{t('vacay.decline')}</DialogButton>
<DialogButton variant="primary" onClick={() => onAccept(invite.plan_id)}>{t('vacay.acceptFusion')}</DialogButton>
</DialogFooter>
)}
>
<ul className="m-0 flex list-none flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5">
<InfoItem icon={Eye} text={t('vacay.fuseInfo1')} />
<InfoItem icon={Pencil} text={t('vacay.fuseInfo2')} />
<InfoItem icon={Trash2} text={t('vacay.fuseInfo3')} />
<InfoItem icon={ShieldCheck} text={t('vacay.fuseInfo4')} />
<InfoItem icon={Unlink} text={t('vacay.fuseInfo5')} />
</ul>
</DialogShell>
)
}
function InfoItem({ icon: Icon, text }: { icon: React.ComponentType<{ size?: number; strokeWidth?: number; className?: string }>; text: string }): React.ReactElement {
return (
<li className="flex items-start gap-3 rounded-[10px] px-2.5 py-2">
<span className="grid h-7 w-7 flex-none place-items-center rounded-[9px] bg-surface-card text-content-muted shadow-sm">
<Icon size={14} strokeWidth={2} />
</span>
<span className="min-w-0 pt-1 leading-normal text-content" style={fs(12.5, 'body')}>{text}</span>
</li>
)
}
+16 -1
View File
@@ -1,6 +1,9 @@
import { useEffect, useState, useCallback } from 'react'
import { useVacayStore } from '../../store/vacayStore'
import { addListener, removeListener } from '../../api/websocket'
import { useToast } from '../../components/shared/Toast'
import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../utils/apiError'
/**
* Vacay page logic — pulls the vacay store, owns the page-local UI state
@@ -14,6 +17,8 @@ export function useVacay() {
const [showSettings, setShowSettings] = useState<boolean>(false)
const [deleteYear, setDeleteYear] = useState<number | null>(null)
const [showMobileSidebar, setShowMobileSidebar] = useState<boolean>(false)
const toast = useToast()
const { t } = useTranslation()
useEffect(() => { loadAll() }, [])
@@ -42,6 +47,16 @@ export function useVacay() {
if (selectedYear) { loadEntries(selectedYear); loadStats(selectedYear); loadHolidays(selectedYear); loadSharedCalendars(selectedYear) }
}, [selectedYear])
// The confirm closes as it is answered, so a removal that fails says so here.
const removeYearReported = async (year: number | null) => {
if (year == null) return
try {
await removeYear(year)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
}
const handleAddNextYear = () => {
const nextYear = years.length > 0 ? Math.max(...years) + 1 : new Date().getFullYear()
addYear(nextYear)
@@ -53,7 +68,7 @@ export function useVacay() {
}
return {
years, selectedYear, setSelectedYear, removeYear, loading,
years, selectedYear, setSelectedYear, removeYear: removeYearReported, loading,
incomingInvites, acceptInvite, declineInvite, plan, sharedCalendars,
showSettings, setShowSettings, deleteYear, setDeleteYear,
showMobileSidebar, setShowMobileSidebar,
-131
View File
@@ -576,134 +576,3 @@
.trek-dash .col-drawer-head button { width: 40px; height: 40px; display: grid; place-items: center; border-radius: var(--r-sm); color: var(--ink-2); }
.trek-dash .col-drawer-head button:hover { background: var(--surface-2); color: var(--ink); }
@media (max-width: 720px) { .trek-dash .col-drawer { padding-top: calc(16px + env(safe-area-inset-top, 0px)); } }
/* ----------------- import-from-trip dialog -----------------
Lives in the modal portal, i.e. OUTSIDE .trek-dash, so it reads the global
theme tokens directly instead of the dashboard's --ink/--surface aliases. */
.col-imp { display: flex; flex-direction: column; gap: 14px; }
.col-imp-lead { font-size: 13px; line-height: 1.5; color: var(--text-secondary); margin: -2px 0 2px; }
/* step 1 — trips */
.col-imp-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
@media (max-width: 640px) { .col-imp-grid { grid-template-columns: 1fr; } }
.col-imp-trip {
display: flex; align-items: center; gap: 11px; width: 100%; padding: 10px 12px 10px 10px;
border: 1px solid var(--border-primary); border-radius: 14px; background: var(--bg-card);
text-align: left; cursor: pointer;
transition: border-color .14s ease, background .14s ease, transform .14s ease, box-shadow .14s ease;
}
.col-imp-trip:hover {
border-color: var(--accent); background: var(--bg-hover);
transform: translateY(-1px); box-shadow: var(--shadow-md);
}
.col-imp-trip-cover {
width: 42px; height: 42px; flex: none; border-radius: 11px; overflow: hidden;
background: var(--bg-tertiary); color: var(--text-faint);
display: grid; place-items: center;
}
.col-imp-trip-cover img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .3s cubic-bezier(0.23, 1, 0.32, 1); }
.col-imp-trip:hover .col-imp-trip-cover img { transform: scale(1.06); }
.col-imp-trip-body { display: flex; flex-direction: column; min-width: 0; flex: 1; gap: 5px; }
.col-imp-trip-name { font-size: 13.5px; font-weight: 650; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.col-imp-trip-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; min-width: 0; }
/* Each fact is its own pill so a narrow tile wraps between them instead of
clipping a word. The date pill yields first; the count never shrinks. */
.col-imp-pill {
display: inline-flex; align-items: center; gap: 4px; min-width: 0; max-width: 100%;
padding: 2px 7px; border-radius: 999px; background: var(--bg-tertiary);
font-size: 10.5px; font-weight: 600; color: var(--text-muted);
font-variant-numeric: tabular-nums; line-height: 1.45;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.col-imp-pill svg { flex: none; opacity: .75; }
.col-imp-pill.num { flex: none; }
.col-imp-trip-go { color: var(--text-faint); flex: none; transition: transform .14s ease, color .14s ease; }
.col-imp-trip:hover .col-imp-trip-go { transform: translateX(2px); color: var(--text-secondary); }
/* step 2 — toolbar */
.col-imp-bar { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.col-imp-back {
width: 30px; height: 30px; flex: none; display: grid; place-items: center; border-radius: 9px;
border: 1px solid var(--border-primary); color: var(--text-secondary); background: var(--bg-card);
transition: background .14s ease, color .14s ease;
}
.col-imp-back:hover { background: var(--bg-hover); color: var(--text-primary); }
.col-imp-search {
position: relative; display: flex; align-items: center; gap: 7px; flex: 1; min-width: 150px;
padding: 0 10px; height: 30px; border-radius: 9px;
border: 1px solid var(--border-primary); background: var(--bg-input); color: var(--text-faint);
transition: border-color .14s ease;
}
.col-imp-search:focus-within { border-color: var(--accent); }
.col-imp-search input {
flex: 1; min-width: 0; background: none; border: 0; outline: none;
font-size: 12.5px; color: var(--text-primary);
}
.col-imp-chip {
display: inline-flex; align-items: center; gap: 5px; height: 30px; padding: 0 11px; flex: none;
border-radius: 999px; border: 1px solid var(--border-primary); background: var(--bg-card);
font-size: 11.5px; font-weight: 600; color: var(--text-secondary); cursor: pointer;
transition: background .14s ease, color .14s ease, border-color .14s ease;
}
.col-imp-chip:hover:not(:disabled) { background: var(--bg-hover); color: var(--text-primary); }
.col-imp-chip.on { background: var(--accent-subtle); border-color: var(--accent); color: var(--accent-on); }
.col-imp-chip:disabled { opacity: .45; cursor: default; }
/* step 2 — rows */
.col-imp-list { display: flex; flex-direction: column; gap: 5px; max-height: 46vh; overflow-y: auto; padding: 1px; margin: -1px; }
.col-imp-row {
display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 11px;
border: 1px solid var(--border-primary); border-radius: 12px; background: var(--bg-card);
text-align: left; cursor: pointer;
transition: border-color .14s ease, background .14s ease;
}
.col-imp-row:hover:not(.dup) { background: var(--bg-hover); }
.col-imp-row.on { border-color: var(--accent); background: var(--accent-subtle); }
.col-imp-row.dup { opacity: .5; cursor: default; }
.col-imp-box {
width: 18px; height: 18px; flex: none; border-radius: 6px; display: grid; place-items: center;
border: 1.5px solid var(--border-secondary); color: var(--accent-text); background: var(--bg-input);
transition: background .14s ease, border-color .14s ease;
}
.col-imp-row.on .col-imp-box { background: var(--accent); border-color: var(--accent); }
.col-imp-row.dup .col-imp-box { visibility: hidden; }
.col-imp-ico {
width: 28px; height: 28px; flex: none; border-radius: 9px; display: grid; place-items: center;
background: var(--bg-tertiary); color: var(--text-muted);
}
.col-imp-txt { display: flex; flex-direction: column; min-width: 0; flex: 1; gap: 1px; }
.col-imp-txt .nm { font-size: 13px; font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.col-imp-txt .ad { font-size: 11px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.col-imp-tag {
flex: none; padding: 3px 9px; border-radius: 999px; font-size: 10.5px; font-weight: 650;
background: var(--bg-tertiary); color: var(--text-muted); white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.col-imp-tag.fresh { background: var(--accent-subtle); color: var(--accent-on); }
.col-imp-tag.saved { background: transparent; color: var(--text-faint); }
/* empty + done */
.col-imp-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px;
padding: 34px 16px; border: 1px dashed var(--border-primary); border-radius: 14px;
color: var(--text-faint); font-size: 12.5px; text-align: center;
}
.col-imp-empty-good { color: var(--success); border-color: var(--success); background: var(--success-soft); }
.col-imp-done { display: flex; flex-direction: column; align-items: center; gap: 9px; padding: 26px 16px 20px; text-align: center; }
@keyframes col-imp-pop {
0% { opacity: 0; transform: scale(0.6); }
60% { opacity: 1; transform: scale(1.06); }
100% { opacity: 1; transform: scale(1); }
}
.col-imp-done-ring {
width: 60px; height: 60px; border-radius: 50%; display: grid; place-items: center;
background: var(--success-soft); color: var(--success);
animation: col-imp-pop 420ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
.col-imp-done-t { font-size: 15px; font-weight: 700; color: var(--text-primary); }
.col-imp-done-s { font-size: 12.5px; color: var(--text-faint); max-width: 320px; line-height: 1.5; }
@media (prefers-reduced-motion: reduce) {
.col-imp-done-ring { animation: none; }
.col-imp-trip:hover { transform: none; }
.col-imp-trip:hover .col-imp-trip-cover img { transform: none; }
}