feat(collections): list details, place detail sheet, add-place, fusion kick (#1081)

Dashboard widget (B): the collections tool now shows the user's LISTS as
compact colour-washed badges (cover image tinted with the list colour, or a
gradient) that jump to the list — one list() call, no N+1.

List details (C): lists gain a description, a custom cover image (uploaded to
/uploads/covers, tinted with the list colour in the hero) and links. A shared
ListEditorModal handles both create and edit; the hero shows the description +
link chips. New `links` JSON column on collections + collection_places
(migration 151) with parse/serialize in the service; a POST :id/cover upload
endpoint mirroring trips; cover-file cleanup path-confined locally.

Place detail (D): clicking a place opens a bottom sheet (no backdrop, so the
map stays visible) — status cycle, copy-to-trip, remove, and an edit mode with
a markdown description + links editor (collectionsApi.updatePlace, now wired
via a store action). A "+" next to the search adds a place to the list via the
maps search.

Fusion + fixes (E): the owner can now remove an accepted member (kick) — new
removeMember service/route/store + a button in ShareCollectionModal, with a
collections:removed WS bounce. findMembership no longer matches on name alone
(coordinate proximity required, killing "Starbucks everywhere" false positives).
loadCollection swallows a 403/404 after a leave/remove so the URL sync can't
throw uncaught. Grid remnants gone; the map select toggle moved onto the map.

New strings across all 22 locales; i18n parity strict passes.
This commit is contained in:
Maurice
2026-07-01 11:09:10 +02:00
committed by Maurice
parent 25b8d28574
commit 68e101ed37
42 changed files with 1261 additions and 299 deletions
+4
View File
@@ -51,6 +51,8 @@ export const collectionsApi = {
ax.post(base, body satisfies CollectionCreateRequest).then((r: AxiosResponse) => r.data),
update: (id: number, body: CollectionUpdateRequest): Promise<{ collection: Collection }> =>
ax.patch(`${base}/${id}`, body satisfies CollectionUpdateRequest).then((r: AxiosResponse) => r.data),
uploadCover: (id: number, formData: FormData): Promise<Collection> =>
ax.post(`${base}/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then((r: AxiosResponse) => r.data),
remove: (id: number): Promise<unknown> =>
ax.delete(`${base}/${id}`).then((r: AxiosResponse) => r.data),
reorder: (orderedIds: number[]): Promise<unknown> =>
@@ -84,6 +86,8 @@ export const collectionsApi = {
ax.post(`${base}/invite/cancel`, { collection_id: collectionId, user_id: userId } satisfies CollectionInviteCancelRequest).then((r: AxiosResponse) => r.data),
leave: (collectionId: number): Promise<unknown> =>
ax.post(`${base}/leave`, { collection_id: collectionId }).then((r: AxiosResponse) => r.data),
removeMember: (collectionId: number, userId: number): Promise<unknown> =>
ax.post(`${base}/members/remove`, { collection_id: collectionId, user_id: userId }).then((r: AxiosResponse) => r.data),
availableUsers: (id: number): Promise<{ users: { id: number; username: string }[] }> =>
ax.get(`${base}/${id}/available-users`).then((r: AxiosResponse) => r.data),
}
@@ -0,0 +1,121 @@
import React, { useEffect, useState } from 'react'
import { Search, MapPin, Plus, Loader2 } from 'lucide-react'
import Modal from '../shared/Modal'
import { mapsApi } from '../../api/client'
import { collectionsApi } from '../../api/collections'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { getApiErrorMessage } from '../../types'
import type { TranslationFn } from '../../types'
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)
interface AddPlaceToCollectionModalProps {
isOpen: boolean
collectionId: number
collectionName: string
onClose: () => void
onAdded: () => void
t: TranslationFn
}
/**
* Search for a place (OSM / Google via the maps service) and save it straight
* into the current list. Stays open after each add so several places can be
* added in one go.
*/
export default function AddPlaceToCollectionModal({ isOpen, collectionId, collectionName, onClose, onAdded, t }: AddPlaceToCollectionModalProps): React.ReactElement {
const { language } = useTranslation()
const toast = useToast()
const [query, setQuery] = useState('')
const [results, setResults] = useState<MapsPlace[]>([])
const [searching, setSearching] = useState(false)
const [addingIdx, setAddingIdx] = useState<number | null>(null)
useEffect(() => {
if (!isOpen) { setQuery(''); setResults([]); setAddingIdx(null) }
}, [isOpen])
const search = async () => {
if (!query.trim()) return
setSearching(true)
try {
const res = await mapsApi.search(query, language)
setResults((res.places as MapsPlace[]) || [])
} catch (err) {
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally {
setSearching(false)
}
}
const add = async (r: MapsPlace, idx: number) => {
const name = str(r.name)
if (!name) return
setAddingIdx(idx)
try {
const res = await collectionsApi.savePlace({
collection_id: collectionId,
name,
address: str(r.address) ?? null,
lat: num(r.lat) ?? null,
lng: num(r.lng) ?? null,
google_place_id: str(r.google_place_id) ?? null,
google_ftid: str(r.google_ftid) ?? null,
osm_id: str(r.osm_id) ?? null,
website: str(r.website) ?? null,
phone: str(r.phone) ?? null,
})
if (res.duplicate) toast.info(t('collections.duplicateWarning'))
else { toast.success(t('collections.addedToList', { name: collectionName })); onAdded() }
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setAddingIdx(null)
}
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.addPlace')} size="md">
<div className="flex flex-col gap-3">
<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
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') 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>
</div>
<div className="flex flex-col gap-1.5 max-h-[380px] overflow-y-auto">
{results.length === 0 && !searching && (
<p className="text-[13px] text-content-faint py-6 text-center">{t('collections.addPlaceHint')}</p>
)}
{results.map((r, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-edge bg-surface-card">
<div className="w-9 h-9 rounded-lg bg-surface-secondary flex items-center justify-center text-content-faint shrink-0"><MapPin size={16} /></div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[13.5px] font-semibold text-content truncate">{str(r.name)}</span>
{str(r.address) && <span className="text-[12px] text-content-faint truncate">{str(r.address)}</span>}
</div>
<button type="button" onClick={() => add(r, i)} disabled={addingIdx === i} className="px-2.5 py-1.5 rounded-lg bg-inverse text-inverse-text text-[12px] font-semibold inline-flex items-center gap-1.5 disabled:opacity-50 shrink-0">
{addingIdx === i ? <Loader2 size={13} className="animate-spin" /> : <Plus size={13} />} {t('common.add')}
</button>
</div>
))}
</div>
</div>
</Modal>
)
}
@@ -1,6 +1,6 @@
import React from 'react'
import { Share2, Users, Plus } from 'lucide-react'
import type { CollectionMember } from '@trek/shared'
import { Share2, Users, Plus, Link2 } from 'lucide-react'
import type { CollectionMember, CollectionLink } from '@trek/shared'
import type { TranslationFn } from '../../types'
import type { StatusFilter } from '../../store/collectionStore'
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
@@ -14,9 +14,11 @@ function initials(name: string): string {
interface CollectionHeroProps {
eyebrow: string
title: string
/** List colour — drives the gradient wash when there is no cover image. */
/** List colour — drives the gradient wash (or tints the cover image). */
color: string
coverImage?: string | null
description?: string | null
links?: CollectionLink[]
counts: Record<StatusFilter, number>
statusFilter: StatusFilter
onStatusFilter: (f: StatusFilter) => void
@@ -37,8 +39,12 @@ interface CollectionHeroProps {
* (All / Idea / Want / Visited with live counts). Share + New-list actions sit
* top-right. Modelled on the dashboard hero-trip.
*/
function linkHost(url: string): string {
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
}
export default function CollectionHero({
eyebrow, title, color, coverImage, counts, statusFilter, onStatusFilter,
eyebrow, title, color, coverImage, description, links, counts, statusFilter, onStatusFilter,
members, canShare, isOwner, shareMemberCount, onShare, onNewList, t,
}: CollectionHeroProps): React.ReactElement {
const accepted = members.filter(m => m.status === 'accepted' || m.is_owner)
@@ -53,9 +59,14 @@ export default function CollectionHero({
return (
<header className="col-hero" style={{ ['--hero-color' as string]: color }}>
{coverImage
? <img className="col-hero-img" src={coverImage} alt="" />
: <div className="col-hero-bg" />}
{coverImage ? (
<>
<img className="col-hero-img" src={coverImage} alt="" />
<div className="col-hero-tint" />
</>
) : (
<div className="col-hero-bg" />
)}
<div className="col-hero-scrim" />
<div className="col-hero-actions">
@@ -94,6 +105,8 @@ export default function CollectionHero({
<h1 className="col-hero-title">{title}</h1>
{description && <p className="col-hero-desc">{description}</p>}
<div className="col-hero-stats">
{chips.map(chip => (
<button
@@ -108,6 +121,16 @@ export default function CollectionHero({
</button>
))}
</div>
{links && links.length > 0 && (
<div className="col-hero-links">
{links.map((l, i) => (
<a key={i} href={l.url} target="_blank" rel="noopener noreferrer" className="col-hero-link" onClick={e => e.stopPropagation()}>
<Link2 size={12} /> {l.label || linkHost(l.url)}
</a>
))}
</div>
)}
</div>
</header>
)
@@ -1,5 +1,5 @@
import React from 'react'
import { PanelLeftClose, PanelLeftOpen, Search } from 'lucide-react'
import { PanelLeftClose, PanelLeftOpen, Search, CheckSquare, Plus } from 'lucide-react'
import type { CollectionPlace } from '@trek/shared'
import type { TranslationFn } from '../../types'
import CollectionMap from './CollectionMap'
@@ -15,6 +15,13 @@ interface CollectionMapPanelProps {
/** 'list' = split (map can be expanded); 'map' = full (list collapsed). */
view: 'list' | 'map'
onToggleView: () => void
/** Bulk-select toggle — hidden for the "All saved" view. */
showSelect: boolean
selectMode: boolean
onToggleSelect: () => void
/** Show a "+" to add a place to the current list (real lists only). */
canAddPlace: boolean
onAddPlace: () => void
search: string
onSearch: (v: string) => void
t: TranslationFn
@@ -22,11 +29,12 @@ interface CollectionMapPanelProps {
/**
* The map surface for the collections page — the map plus its floating controls:
* a top-left toggle that collapses/expands the list (animating the split) and a
* top-right search box. Used both in the desktop split and full-map view.
* a top-left cluster (collapse/expand the list, toggle bulk-select) and a
* top-right search box. Used both in the desktop split and the full-map view.
*/
export default function CollectionMapPanel({
places, selectedPlaceId, onSelect, onDeselect, dark, overlay, view, onToggleView, search, onSearch, t,
places, selectedPlaceId, onSelect, onDeselect, dark, overlay, view, onToggleView,
showSelect, selectMode, onToggleSelect, canAddPlace, onAddPlace, search, onSearch, t,
}: CollectionMapPanelProps): React.ReactElement {
return (
<div className="col-map-shell">
@@ -39,18 +47,38 @@ export default function CollectionMapPanel({
/>
{overlay && (
<>
<button
type="button"
onClick={onToggleView}
className="col-map-toggle"
aria-label={view === 'map' ? t('collections.showList') : t('collections.expandMap')}
title={view === 'map' ? t('collections.showList') : t('collections.expandMap')}
>
{view === 'map' ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
</button>
<div className="col-map-search">
<Search size={15} />
<input value={search} onChange={e => onSearch(e.target.value)} placeholder={t('collections.search')} />
<div className="col-map-controls">
<button
type="button"
onClick={onToggleView}
className="col-map-btn"
aria-label={view === 'map' ? t('collections.showList') : t('collections.expandMap')}
title={view === 'map' ? t('collections.showList') : t('collections.expandMap')}
>
{view === 'map' ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
</button>
{showSelect && (
<button
type="button"
onClick={onToggleSelect}
className={`col-map-btn${selectMode ? ' on' : ''}`}
aria-label={t('collections.selectMode')}
title={t('collections.selectMode')}
>
<CheckSquare size={16} />
</button>
)}
</div>
<div className="col-map-topright">
{canAddPlace && (
<button type="button" onClick={onAddPlace} className="col-map-btn" aria-label={t('collections.addPlace')} title={t('collections.addPlace')}>
<Plus size={17} />
</button>
)}
<div className="col-map-search">
<Search size={15} />
<input value={search} onChange={e => onSearch(e.target.value)} placeholder={t('collections.search')} />
</div>
</div>
</>
)}
@@ -0,0 +1,156 @@
import React, { useEffect, useRef, useState } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check } from 'lucide-react'
import type { CollectionPlace, CollectionStatus, CollectionLink } from '@trek/shared'
import type { TranslationFn } from '../../types'
import MarkdownToolbar from '../Journey/MarkdownToolbar'
import StatusBadge from './StatusBadge'
import { entityGradient } from '../../utils/gradients'
function linkHost(url: string): string {
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
}
interface CollectionPlaceDetailProps {
place: CollectionPlace
canEdit: boolean
onClose: () => void
onSetStatus: (status: CollectionStatus) => void
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[] }) => Promise<void>
onCopyToTrip: () => void
onRemove: () => void
t: TranslationFn
}
/**
* Bottom detail sheet for a saved place — a glassy card docked to the bottom
* (full-width on mobile). Read mode renders the description as markdown + link
* chips; edit mode swaps in a name field, a markdown editor (toolbar + textarea)
* and a links editor, saving via collectionsApi.updatePlace. Also cycles the
* status, copies the place to a trip and removes it from the list.
*/
export default function CollectionPlaceDetail({
place, canEdit, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
}: CollectionPlaceDetailProps): React.ReactElement {
const [editing, setEditing] = useState(false)
const [name, setName] = useState(place.name)
const [description, setDescription] = useState(place.description ?? '')
const [links, setLinks] = useState<CollectionLink[]>(place.links ?? [])
const [saving, setSaving] = useState(false)
const descRef = useRef<HTMLTextAreaElement>(null)
// Reset the form only when a DIFFERENT place is opened (keyed on id, not on
// every field change — otherwise a save would clobber in-flight edits).
useEffect(() => {
setEditing(false)
setName(place.name)
setDescription(place.description ?? '')
setLinks(place.links ?? [])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [place.id])
const sub = place.category?.name || place.address
const banner = place.image_url
const setLink = (i: number, patch: Partial<CollectionLink>) =>
setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
const save = async () => {
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: l.url.trim() })).filter(l => l.url)
setSaving(true)
try {
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks })
setEditing(false)
} finally {
setSaving(false)
}
}
return (
<div className="col-detail" onClick={e => e.stopPropagation()}>
<div className="col-detail-cover" style={banner ? undefined : { backgroundImage: entityGradient(place.id) }}>
{banner && <img src={banner} alt="" />}
<div className="col-detail-cover-scrim" />
<button type="button" className="col-detail-close" onClick={onClose} aria-label={t('common.close')}><X size={16} /></button>
<div className="col-detail-head">
{editing ? (
<input value={name} onChange={e => setName(e.target.value)} className="col-detail-name-input" autoFocus />
) : (
<h2 className="col-detail-name">{place.name}</h2>
)}
{sub && <div className="col-detail-sub">{!place.category?.name && <MapPin size={12} />}{sub}</div>}
</div>
</div>
<div className="col-detail-body">
<div className="col-detail-actions">
<StatusBadge status={place.status} onChange={onSetStatus} t={t} />
<div className="col-detail-actions-spacer" />
{canEdit && !editing && (
<button type="button" className="col-detail-btn" onClick={() => setEditing(true)}><Pencil size={14} /> {t('common.edit')}</button>
)}
<button type="button" className="col-detail-btn" onClick={onCopyToTrip}><Copy size={14} /> {t('collections.copyToTrip')}</button>
{canEdit && (
<button type="button" className="col-detail-btn danger" onClick={onRemove}><Trash2 size={14} /> {t('collections.removeFromList')}</button>
)}
</div>
{editing ? (
<div className="col-detail-edit">
<label className="col-detail-label">{t('collections.description')}</label>
<MarkdownToolbar textareaRef={descRef} onUpdate={setDescription} />
<textarea
ref={descRef}
value={description}
onChange={e => setDescription(e.target.value)}
rows={5}
placeholder={t('collections.descriptionPlaceholder')}
className="col-detail-textarea"
/>
<label className="col-detail-label">{t('collections.links')}</label>
<div className="col-detail-links-edit">
{links.map((l, i) => (
<div key={i} className="col-detail-link-row">
<input value={l.label ?? ''} onChange={e => setLink(i, { label: e.target.value })} placeholder={t('collections.linkLabel')} className="col-detail-input w-28" />
<input value={l.url} onChange={e => setLink(i, { url: e.target.value })} placeholder="https://…" className="col-detail-input flex-1" />
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} className="col-detail-icon-btn" aria-label={t('common.delete')}><Trash2 size={14} /></button>
</div>
))}
<button type="button" onClick={() => setLinks([...links, { url: '' }])} className="col-detail-add-link"><Plus size={13} /> <Link2 size={12} /> {t('collections.addLink')}</button>
</div>
<div className="col-detail-edit-actions">
<button type="button" onClick={() => { setEditing(false); setName(place.name); setDescription(place.description ?? ''); setLinks(place.links ?? []) }} className="col-detail-btn">{t('common.cancel')}</button>
<button type="button" onClick={save} disabled={saving} className="col-detail-btn primary"><Check size={14} /> {t('common.save')}</button>
</div>
</div>
) : (
<>
{place.description && (
<div className="col-detail-md collab-note-md">
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description}</Markdown>
</div>
)}
{place.links && place.links.length > 0 && (
<div className="col-detail-links">
{place.links.map((l, i) => (
<a key={i} href={l.url} target="_blank" rel="noopener noreferrer" className="col-detail-link">
<ExternalLink size={13} /> {l.label || linkHost(l.url)}
</a>
))}
</div>
)}
{!place.description && (!place.links || place.links.length === 0) && canEdit && (
<button type="button" className="col-detail-empty" onClick={() => setEditing(true)}>
<Plus size={14} /> {t('collections.addDetails')}
</button>
)}
</>
)}
</div>
</div>
)
}
@@ -0,0 +1,198 @@
import React, { useEffect, useRef, useState } from 'react'
import { ImagePlus, Link2, Plus, Trash2 } from 'lucide-react'
import Modal from '../shared/Modal'
import { useCollectionStore } from '../../store/collectionStore'
import { useToast } from '../shared/Toast'
import { getApiErrorMessage } from '../../types'
import type { TranslationFn } from '../../types'
import type { Collection, CollectionLink } from '@trek/shared'
const SWATCHES = ['#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#ef4444', '#3b82f6', '#22c55e']
interface ListEditorModalProps {
/** null = closed, 'new' = create, a Collection = edit that list. */
target: Collection | 'new' | null
onClose: () => void
onCreated: (id: number) => void
t: TranslationFn
}
/**
* Create / edit a list — name, colour, an optional cover image (tinted with the
* list colour in the hero), a description and a set of links. On create it makes
* the list then uploads the cover to the new id; on edit it patches + re-uploads.
*/
export default function ListEditorModal({ target, onClose, onCreated, t }: ListEditorModalProps): React.ReactElement | null {
const createCollection = useCollectionStore(s => s.createCollection)
const updateCollection = useCollectionStore(s => s.updateCollection)
const uploadCover = useCollectionStore(s => s.uploadCover)
const toast = useToast()
const fileRef = useRef<HTMLInputElement>(null)
const editing = target && target !== 'new' ? target : null
const [name, setName] = useState('')
const [color, setColor] = useState('#6366f1')
const [description, setDescription] = useState('')
const [links, setLinks] = useState<CollectionLink[]>([])
const [coverFile, setCoverFile] = useState<File | null>(null)
const [coverPreview, setCoverPreview] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
// (Re)seed the form whenever the target changes.
useEffect(() => {
if (!target) return
setName(editing?.name ?? '')
setColor(editing?.color ?? '#6366f1')
setDescription(editing?.description ?? '')
setLinks(editing?.links ?? [])
setCoverFile(null)
setCoverPreview(editing?.cover_image ?? null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target])
if (!target) return null
const pickCover = (file: File | undefined) => {
if (!file) return
setCoverFile(file)
setCoverPreview(URL.createObjectURL(file))
}
const setLink = (i: number, patch: Partial<CollectionLink>) =>
setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
const save = async () => {
const trimmed = name.trim()
if (!trimmed) return
// Keep only links with a url; drop blank rows.
const cleanLinks = links
.map(l => ({ label: l.label?.trim() || undefined, url: l.url.trim() }))
.filter(l => l.url)
setSaving(true)
try {
let id = editing?.id
if (editing) {
await updateCollection(editing.id, { name: trimmed, color, description: description.trim() || null, links: cleanLinks })
} else {
const created = await createCollection({ name: trimmed, color, description: description.trim() || null, links: cleanLinks })
id = created?.id
}
if (id != null && coverFile) await uploadCover(id, coverFile)
if (!editing && id != null) onCreated(id)
onClose()
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setSaving(false)
}
}
return (
<Modal
isOpen
onClose={onClose}
title={editing ? t('collections.editListTitle') : t('collections.newList')}
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={!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 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])} />
</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>
{/* 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"
/>
</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-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')}>
<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>
</div>
</div>
</Modal>
)
}
@@ -13,13 +13,9 @@ interface ListsRailProps {
sharedLists: Collection[]
activeId: ActiveCollectionId
incomingInvites: IncomingCollectionInvite[]
editingListId: number | null
editingName: string
setEditingName: (v: string) => void
onSelect: (id: ActiveCollectionId) => void
onNewList: () => void
onStartRename: (id: number, name: string) => void
onCommitRename: () => void
onEdit: (list: Collection) => void
onSetColor: (id: number, color: string) => void
onRequestDelete: (id: number) => void
onAcceptInvite: (id: number) => void
@@ -30,26 +26,16 @@ interface ListsRailProps {
interface ListRowProps {
list: Collection
active: boolean
editing: boolean
editingName: string
setEditingName: (v: string) => void
onSelect: (id: number) => void
onStartRename: (id: number, name: string) => void
onCommitRename: () => void
onEdit: (list: Collection) => void
onSetColor: (id: number, color: string) => void
onRequestDelete: (id: number) => void
t: TranslationFn
}
function ListRow({
list, active, editing, editingName, setEditingName,
onSelect, onStartRename, onCommitRename, onSetColor, onRequestDelete, t,
}: ListRowProps): React.ReactElement {
function ListRow({ list, active, onSelect, onEdit, onSetColor, onRequestDelete, t }: ListRowProps): React.ReactElement {
const [menuOpen, setMenuOpen] = useState(false)
const [colorOpen, setColorOpen] = useState(false)
// Anchor rect for the portalled popover (fixed to the viewport). The popover
// is portalled to document.body so the rail's `overflow-y:auto` +
// `backdrop-filter` can't clip it.
const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(null)
const rowRef = useRef<HTMLDivElement>(null)
const btnRef = useRef<HTMLButtonElement>(null)
@@ -79,7 +65,6 @@ function ListRow({
document.addEventListener('mousedown', onDown, true)
document.addEventListener('keydown', onKey)
window.addEventListener('resize', close)
// Any scroll (page or rail) invalidates the anchor rect → just close.
window.addEventListener('scroll', close, true)
return () => {
document.removeEventListener('mousedown', onDown, true)
@@ -89,21 +74,6 @@ function ListRow({
}
}, [open])
if (editing) {
return (
<div className="col-row">
<input
autoFocus
value={editingName}
onChange={e => setEditingName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === 'Escape') onCommitRename() }}
onBlur={onCommitRename}
className="col-row-edit"
/>
</div>
)
}
const popover = open && anchor && ReactDOM.createPortal(
<div
data-list-pop
@@ -112,7 +82,7 @@ function ListRow({
>
{menuOpen && !colorOpen && (
<>
<button type="button" onClick={() => { setMenuOpen(false); onStartRename(list.id, list.name) }} className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-hover">
<button type="button" onClick={() => { setMenuOpen(false); onEdit(list) }} className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-hover">
<Pencil size={14} /> {t('collections.editList')}
</button>
<button type="button" onClick={() => setColorOpen(true)} className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-hover">
@@ -170,16 +140,15 @@ function ListRow({
/**
* Left rail of the user's lists: a "New list" action, the "All saved" union
* pseudo-list, owned lists (colour dot + count + rename/colour/delete menu), a
* pseudo-list, owned lists (colour dot + count + edit/colour/delete menu), a
* shared section, and an incoming-invites block. Styled with the collections
* glass tokens (`.col-*`); rendered both in the desktop rail and the mobile
* drawer.
* drawer. Editing a list opens the shared ListEditorModal via onEdit.
*/
export default function ListsRail(props: ListsRailProps): React.ReactElement {
const {
ownedLists, sharedLists, activeId, incomingInvites,
editingListId, editingName, setEditingName,
onSelect, onNewList, onStartRename, onCommitRename, onSetColor, onRequestDelete,
onSelect, onNewList, onEdit, onSetColor, onRequestDelete,
onAcceptInvite, onDeclineInvite, t,
} = props
@@ -198,40 +167,14 @@ export default function ListsRail(props: ListsRailProps): React.ReactElement {
{ownedLists.length > 0 && <div className="col-rail-sep" />}
{ownedLists.map(list => (
<ListRow
key={list.id}
list={list}
active={activeId === list.id}
editing={editingListId === list.id}
editingName={editingName}
setEditingName={setEditingName}
onSelect={onSelect}
onStartRename={onStartRename}
onCommitRename={onCommitRename}
onSetColor={onSetColor}
onRequestDelete={onRequestDelete}
t={t}
/>
<ListRow key={list.id} list={list} active={activeId === list.id} onSelect={onSelect} onEdit={onEdit} onSetColor={onSetColor} onRequestDelete={onRequestDelete} t={t} />
))}
{sharedLists.length > 0 && (
<>
<div className="col-rail-label"><Users size={12} /> {t('collections.shared')}</div>
{sharedLists.map(list => (
<ListRow
key={list.id}
list={list}
active={activeId === list.id}
editing={editingListId === list.id}
editingName={editingName}
setEditingName={setEditingName}
onSelect={onSelect}
onStartRename={onStartRename}
onCommitRename={onCommitRename}
onSetColor={onSetColor}
onRequestDelete={onRequestDelete}
t={t}
/>
<ListRow key={list.id} list={list} active={activeId === list.id} onSelect={onSelect} onEdit={onEdit} onSetColor={onSetColor} onRequestDelete={onRequestDelete} t={t} />
))}
</>
)}
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react'
import { UserPlus, Loader2, Clock, Check, Crown, LogOut } from 'lucide-react'
import { UserPlus, UserMinus, Loader2, Clock, Check, Crown, LogOut } from 'lucide-react'
import type { CollectionMember } from '@trek/shared'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
@@ -56,12 +56,14 @@ export default function ShareCollectionModal({
const currentUserId = useAuthStore(s => s.user?.id)
const invite = useCollectionStore(s => s.invite)
const cancelInvite = useCollectionStore(s => s.cancelInvite)
const removeMember = useCollectionStore(s => s.removeMember)
const leave = useCollectionStore(s => s.leave)
const [availableUsers, setAvailableUsers] = useState<{ id: number; username: string }[]>([])
const [selectedUserId, setSelectedUserId] = useState<number | ''>('')
const [inviting, setInviting] = useState(false)
const [cancellingId, setCancellingId] = useState<number | null>(null)
const [removingId, setRemovingId] = useState<number | null>(null)
const [confirmLeave, setConfirmLeave] = useState(false)
const [leaving, setLeaving] = useState(false)
@@ -117,6 +119,18 @@ export default function ShareCollectionModal({
}
}
const handleRemove = async (userId: number) => {
if (removingId != null) return
setRemovingId(userId)
try {
await removeMember(collectionId, userId)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setRemovingId(null)
}
}
const handleLeave = async () => {
setLeaving(true)
try {
@@ -185,6 +199,18 @@ export default function ShareCollectionModal({
{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>
)
})}
@@ -1,26 +1,22 @@
import React, { useEffect, useState } from 'react'
import { Bookmark, ArrowRight } from 'lucide-react'
import { Bookmark, ArrowRight, MapPin } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from '../../i18n'
import PlaceAvatar from '../shared/PlaceAvatar'
import { collectionsApi } from '../../api/collections'
import type { CollectionPlace } from '@trek/shared'
import { entityGradient } from '../../utils/gradients'
import type { Collection } from '@trek/shared'
/**
* Dashboard sidebar widget — a glassy `.tool` card (cloned from the dashboard's
* UpcomingTool) that surfaces the user's saved-places library: a total count
* plus the most recently saved places. Every row and the header "→" jump to
* /collections.
*
* Self-contained on purpose: it fetches its own data on mount, and it is only
* ever mounted when BOTH gates are satisfied (admin addon enabled AND the
* per-user widget flag on — see DashboardPage `showCollections`). That keeps the
* data fetch strictly "only when shown" and means the dashboard never hits
* /api/addons/collections while the addon is disabled.
* Dashboard sidebar widget — a glassy `.tool` card that surfaces the user's
* saved-place LISTS as compact colour-washed badges (a mini version of the
* collections hero): each badge shows the list's cover image (tinted with its
* colour) or a colour gradient, its name and place count, and jumps to that
* list. Fetches only list() (per-list place_count), so no N+1.
*/
export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): React.ReactElement {
const { t } = useTranslation()
const [places, setPlaces] = useState<CollectionPlace[]>([])
const [count, setCount] = useState(0)
const navigate = useNavigate()
const [lists, setLists] = useState<Collection[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
@@ -28,27 +24,9 @@ export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): R
;(async () => {
try {
const data = await collectionsApi.list()
if (cancelled) return
// Union every list the user owns or co-owns, deduped, newest first —
// mirrors the store's "All saved" pseudo-list so the widget shows the
// same recent saves the page does.
const results = await Promise.all(data.collections.map(c => collectionsApi.get(c.id).catch(() => null)))
if (cancelled) return
const seen = new Set<number>()
const merged: CollectionPlace[] = []
for (const res of results) {
if (!res) continue
for (const p of res.places) {
if (seen.has(p.id)) continue
seen.add(p.id)
merged.push(p)
}
}
merged.sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? ''))
setCount(merged.length)
setPlaces(merged.slice(0, 4))
if (!cancelled) setLists(data.collections)
} catch {
if (!cancelled) { setPlaces([]); setCount(0) }
if (!cancelled) setLists([])
} finally {
if (!cancelled) setLoading(false)
}
@@ -64,27 +42,28 @@ export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): R
<ArrowRight size={14} />
</button>
</div>
{loading ? null : places.length === 0 ? (
{loading ? null : lists.length === 0 ? (
<div className="col-empty">{t('collections.widget.empty')}</div>
) : (
<>
<div className="col-count">{t('collections.widget.savedCount', { count })}</div>
<div className="col-list">
{places.map(p => (
<button key={p.id} className="col-item" onClick={onOpen}>
<PlaceAvatar
place={p}
size={36}
category={p.category ? { color: p.category.color ?? undefined, icon: p.category.icon ?? undefined } : null}
/>
<div className="col-info">
<div className="t">{p.name}</div>
<div className="s">{p.category?.name || p.address || ''}</div>
</div>
</button>
))}
</div>
</>
<div className="col-badges">
{lists.slice(0, 6).map(list => (
<button
key={list.id}
className="col-badge"
style={{ ['--badge-color' as string]: list.color || '#6366f1' }}
onClick={() => navigate(`/collections/${list.id}`)}
>
{list.cover_image
? <img className="col-badge-media" src={list.cover_image} alt="" />
: <div className="col-badge-media" style={{ backgroundImage: entityGradient(list.id) }} />}
<div className="col-badge-tint" />
<div className="col-badge-body">
<span className="col-badge-name">{list.name}</span>
<span className="col-badge-count"><MapPin size={11} /> {list.place_count ?? 0}</span>
</div>
</button>
))}
</div>
)}
</div>
)
+50 -80
View File
@@ -1,21 +1,21 @@
import React from 'react'
import { List as ListIcon, Map as MapIcon, Search, Bookmark, CheckSquare, X, Trash2, Copy } from 'lucide-react'
import { List as ListIcon, Map as MapIcon, Search, Bookmark, CheckSquare, X, Trash2, Copy, Plus } from 'lucide-react'
import Navbar from '../components/Layout/Navbar'
import Modal from '../components/shared/Modal'
import ListsRail from '../components/Collections/ListsRail'
import ListEditorModal from '../components/Collections/ListEditorModal'
import CollectionHero from '../components/Collections/CollectionHero'
import CollectionList from '../components/Collections/CollectionList'
import CollectionMapPanel from '../components/Collections/CollectionMapPanel'
import CopyToTripModal from '../components/Collections/CopyToTripModal'
import ShareCollectionModal from '../components/Collections/ShareCollectionModal'
import PlaceInspector from '../components/Planner/PlaceInspector'
import AddPlaceToCollectionModal from '../components/Collections/AddPlaceToCollectionModal'
import CollectionPlaceDetail from '../components/Collections/CollectionPlaceDetail'
import { mappablePlaces } from './collections/collectionsModel'
import { useCollections } from './collections/useCollections'
import '../styles/dashboard.css'
import '../styles/collections.css'
const SWATCHES = ['#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#ef4444', '#3b82f6', '#22c55e']
function EmptyState({ icon, title, text, action }: { icon: React.ReactNode; title: string; text: string; action?: React.ReactNode }): React.ReactElement {
return (
<div className="col-emptystate">
@@ -53,6 +53,7 @@ export default function CollectionsPage(): React.ReactElement {
const desktopSplit = c.isWide && mappable.length > 0
const mapShown = mappable.length > 0 && (c.view === 'map' || c.isWide)
const mapOverlay = c.isWide && mapShown // the map carries the toggle + search
const canAddPlace = typeof c.activeId === 'number' // a real list, not "All saved"
const listEl = (
<CollectionList
@@ -76,6 +77,11 @@ export default function CollectionsPage(): React.ReactElement {
overlay={overlay}
view={c.view}
onToggleView={toggleView}
showSelect={showSelect}
selectMode={c.selectMode}
onToggleSelect={() => c.setSelectMode(!c.selectMode)}
canAddPlace={canAddPlace}
onAddPlace={() => c.setShowAddPlace(true)}
search={c.search}
onSearch={c.setSearch}
t={t}
@@ -108,13 +114,9 @@ export default function CollectionsPage(): React.ReactElement {
sharedLists={c.sharedLists}
activeId={c.activeId}
incomingInvites={c.incomingInvites}
editingListId={c.editingListId}
editingName={c.editingName}
setEditingName={c.setEditingName}
onSelect={c.handleSelectList}
onNewList={() => { c.setMobileRailOpen(false); c.setShowNewList(true) }}
onStartRename={c.handleStartRename}
onCommitRename={c.handleCommitRename}
onNewList={() => { c.setMobileRailOpen(false); c.setEditorTarget('new') }}
onEdit={list => { c.setMobileRailOpen(false); c.setEditorTarget(list) }}
onSetColor={c.handleSetColor}
onRequestDelete={c.setConfirmDeleteList}
onAcceptInvite={c.handleAcceptInvite}
@@ -137,7 +139,7 @@ export default function CollectionsPage(): React.ReactElement {
title={t('collections.empty.firstTitle')}
text={t('collections.empty.firstText')}
action={
<button type="button" onClick={() => c.setShowNewList(true)} className="col-cta">
<button type="button" onClick={() => c.setEditorTarget('new')} className="col-cta">
<Bookmark size={16} /> {t('collections.newList')}
</button>
}
@@ -150,6 +152,8 @@ export default function CollectionsPage(): React.ReactElement {
title={title}
color={heroColor}
coverImage={heroCover}
description={c.isAllSaved ? null : (c.activeCollection?.description ?? null)}
links={c.isAllSaved ? undefined : c.activeCollection?.links}
counts={c.counts}
statusFilter={c.statusFilter}
onStatusFilter={c.setStatusFilter}
@@ -158,12 +162,12 @@ export default function CollectionsPage(): React.ReactElement {
isOwner={c.isOwner}
shareMemberCount={c.shareMemberCount}
onShare={() => c.setShowShare(true)}
onNewList={() => c.setShowNewList(true)}
onNewList={() => c.setEditorTarget('new')}
t={t}
/>
</div>
{(!c.isWide || showSelect || !mapOverlay) && (
{!mapOverlay && (
<div className="col-toolbar">
<button type="button" className="col-rail-toggle" onClick={() => c.setMobileRailOpen(true)}>
<Bookmark size={15} /> {t('collections.title')}
@@ -189,6 +193,11 @@ export default function CollectionsPage(): React.ReactElement {
<CheckSquare size={16} />
</button>
)}
{canAddPlace && (
<button type="button" onClick={() => c.setShowAddPlace(true)} className="col-iconbtn" aria-label={t('collections.addPlace')} title={t('collections.addPlace')}>
<Plus size={16} />
</button>
)}
<div className="col-toolbar-spacer" />
{!mapOverlay && (
<div className="col-search">
@@ -232,29 +241,34 @@ export default function CollectionsPage(): React.ReactElement {
<div className="col-drawer">{rail}</div>
</>
)}
{/* Place detail — a bottom sheet (no backdrop, so the map behind stays
visible + interactive). Clicking another place / the map background
re-points or clears the selection. */}
{c.selectedPlace && (
<CollectionPlaceDetail
place={c.selectedPlace}
canEdit
onClose={c.handleCloseDetail}
onSetStatus={c.handleDetailStatus}
onSave={patch => c.updatePlace(c.selectedPlace!.id, patch)}
onCopyToTrip={c.openCopyForSelectedPlace}
onRemove={c.handleDetailRemove}
t={t}
/>
)}
</div>
{/* Detail panel — re-pointed PlaceInspector in collection mode. Kept outside
.trek-dash (it's a shared component on the app's own tokens). The outer
layer is click-through so the grid behind stays interactive; only the
floating card re-enables pointer events. */}
{c.detailPlace && !mapShown && (
<div className="fixed inset-0 z-[150]" style={{ pointerEvents: 'none', paddingTop: 'var(--nav-h)' }}>
<div className="relative w-full h-full">
<div style={{ pointerEvents: 'auto' }}>
<PlaceInspector
mode="collection"
place={c.detailPlace}
categories={c.detailCategories}
collectionStatus={c.selectedPlace?.status}
onClose={c.handleCloseDetail}
onCopyToTrip={c.openCopyForSelectedPlace}
onSetStatus={c.handleDetailStatus}
onRemoveFromList={c.handleDetailRemove}
/>
</div>
</div>
</div>
{/* Add a place to the current list */}
{typeof c.activeId === 'number' && c.activeCollection && (
<AddPlaceToCollectionModal
isOpen={c.showAddPlace}
collectionId={c.activeId}
collectionName={c.activeCollection.name}
onClose={() => c.setShowAddPlace(false)}
onAdded={c.handlePlaceAdded}
t={t}
/>
)}
{/* Copy to trip */}
@@ -280,52 +294,8 @@ export default function CollectionsPage(): React.ReactElement {
/>
)}
{/* New list modal */}
<Modal
isOpen={c.showNewList}
onClose={() => c.setShowNewList(false)}
title={t('collections.newList')}
size="sm"
footer={
<div className="flex justify-end gap-2">
<button type="button" onClick={() => c.setShowNewList(false)} 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.handleCreateList} disabled={!c.newListName.trim() || c.creating} className="px-3 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50">
{t('collections.create')}
</button>
</div>
}
>
<div className="flex flex-col gap-4">
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.listName')}</label>
<input
autoFocus
value={c.newListName}
onChange={e => c.setNewListName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && c.newListName.trim()) c.handleCreateList() }}
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>
<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={() => c.setNewListColor(col)}
className="w-7 h-7 rounded-full flex items-center justify-center transition-transform hover:scale-110"
style={{ background: col, outline: c.newListColor === col ? '2px solid var(--accent)' : 'none', outlineOffset: 2 }}
aria-label={col}
/>
))}
</div>
</div>
</div>
</Modal>
{/* Create / edit a list — name, colour, cover, description, links */}
<ListEditorModal target={c.editorTarget} onClose={() => c.setEditorTarget(null)} onCreated={c.handleEditorCreated} t={t} />
{/* Delete-list confirm */}
<Modal
+20 -51
View File
@@ -8,7 +8,7 @@ import { getApiErrorMessage } from '../../types'
import { addListener, removeListener } from '../../api/websocket'
import { useCollectionStore, ALL_SAVED } from '../../store/collectionStore'
import type { ActiveCollectionId } from '../../store/collectionStore'
import type { CollectionStatus } from '@trek/shared'
import type { Collection, CollectionStatus } from '@trek/shared'
import type { Category, Place } from '../../types'
import { filterPlaces, sortPlaces, statusCounts } from './collectionsModel'
@@ -46,23 +46,20 @@ export function useCollections() {
collections, activeId, places, members, incomingInvites,
view, statusFilter, search, selectedPlaceId, selectMode, selectedIds,
loading, placesLoading,
loadAll, setActive,
createCollection, updateCollection, deleteCollection,
setStatus, deletePlace, deleteMany, copyToTrip, clearSelection,
loadAll, setActive, refreshActive,
updateCollection, deleteCollection,
setStatus, updatePlace, deletePlace, deleteMany, copyToTrip, clearSelection,
acceptInvite, declineInvite,
setView, setStatusFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
} = store
// ── Page-local UI state ─────────────────────────────────────────────
const [showNewList, setShowNewList] = useState(false)
const [newListName, setNewListName] = useState('')
const [newListColor, setNewListColor] = useState('#6366f1')
const [editingListId, setEditingListId] = useState<number | null>(null)
const [editingName, setEditingName] = useState('')
// The list editor modal: null = closed, 'new' = create, a Collection = edit it.
const [editorTarget, setEditorTarget] = useState<Collection | 'new' | null>(null)
const [confirmDeleteList, setConfirmDeleteList] = useState<number | null>(null)
const [mobileRailOpen, setMobileRailOpen] = useState(false)
const [creating, setCreating] = useState(false)
const [showShare, setShowShare] = useState(false)
const [showAddPlace, setShowAddPlace] = useState(false)
// The place ids the Copy-to-trip modal is open for (null = closed). Single
// place from the detail panel, or the select-mode set for a bulk copy.
const [copyIds, setCopyIds] = useState<number[] | null>(null)
@@ -98,11 +95,11 @@ export function useCollections() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [routeId, collections.length])
// Live sync via WebSocket. collections:deleted must bounce a member off a
// list that was removed under them.
// Live sync via WebSocket. collections:deleted / :removed must bounce a member
// off a list that was removed (or that they were kicked from) under them.
const handleWsMessage = useCallback((msg: { type: string; collectionId?: number }) => {
if (!msg.type?.startsWith('collections:')) return
if (msg.type === 'collections:deleted') {
if (msg.type === 'collections:deleted' || msg.type === 'collections:removed') {
if (msg.collectionId != null && activeId === msg.collectionId) {
navigate('/collections')
}
@@ -153,41 +150,13 @@ export function useCollections() {
navigate(id === ALL_SAVED || id === null ? '/collections' : `/collections/${id}`)
}, [navigate])
const handleCreateList = useCallback(async () => {
const name = newListName.trim()
if (!name) return
setCreating(true)
try {
const created = await createCollection(name, newListColor)
setShowNewList(false)
setNewListName('')
setNewListColor('#6366f1')
if (created) navigate(`/collections/${created.id}`)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setCreating(false)
}
}, [newListName, newListColor, createCollection, navigate, toast, t])
// The editor modal owns its own form + create/update/upload calls; the hook
// just opens it and navigates to a freshly created list.
const handleEditorCreated = useCallback((id: number) => {
navigate(`/collections/${id}`)
}, [navigate])
const handleStartRename = useCallback((id: number, currentName: string) => {
setEditingListId(id)
setEditingName(currentName)
}, [])
const handleCommitRename = useCallback(async () => {
if (editingListId == null) return
const name = editingName.trim()
if (name) {
try {
await updateCollection(editingListId, { name })
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
}
setEditingListId(null)
setEditingName('')
}, [editingListId, editingName, updateCollection, toast, t])
const handlePlaceAdded = useCallback(() => { refreshActive() }, [refreshActive])
const handleSetColor = useCallback(async (id: number, color: string) => {
try {
@@ -318,9 +287,10 @@ export function useCollections() {
loading, placesLoading,
// store setters
setView, setStatusFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect,
updatePlace,
// local UI state
showNewList, setShowNewList, newListName, setNewListName, newListColor, setNewListColor, creating,
editingListId, editingName, setEditingName,
editorTarget, setEditorTarget, handleEditorCreated,
showAddPlace, setShowAddPlace, handlePlaceAdded,
confirmDeleteList, setConfirmDeleteList,
mobileRailOpen, setMobileRailOpen,
showShare, setShowShare, handleAfterLeave,
@@ -329,8 +299,7 @@ export function useCollections() {
handleDetailStatus, handleDetailRemove,
copyIds, openCopyForSelectedPlace, openCopyForSelection, closeCopy, handleCopyToTrip,
// handlers
handleSelectList, handleCreateList,
handleStartRename, handleCommitRename, handleSetColor, handleDeleteList,
handleSelectList, handleSetColor, handleDeleteList,
handleStatusChange, handleDeletePlace, handleDeleteSelected,
handleAcceptInvite, handleDeclineInvite,
}
+34 -4
View File
@@ -6,6 +6,9 @@ import type {
CollectionMember,
CollectionStatus,
CollectionListResponse,
CollectionCreateRequest,
CollectionUpdateRequest,
CollectionPlaceUpdateRequest,
} from '@trek/shared'
/** A pending invitation the current user has received (derived server-side). */
@@ -38,12 +41,14 @@ interface CollectionState {
setActive: (id: ActiveCollectionId) => Promise<void>
refreshActive: () => Promise<void>
createCollection: (name: string, color?: string, icon?: string) => Promise<Collection | null>
updateCollection: (id: number, updates: { name?: string; color?: string; icon?: string; description?: string | null }) => Promise<void>
createCollection: (payload: CollectionCreateRequest) => Promise<Collection | null>
updateCollection: (id: number, updates: CollectionUpdateRequest) => Promise<void>
uploadCover: (id: number, file: File) => Promise<void>
deleteCollection: (id: number) => Promise<void>
reorderCollections: (orderedIds: number[]) => Promise<void>
setStatus: (placeId: number, status: CollectionStatus) => Promise<void>
updatePlace: (placeId: number, body: CollectionPlaceUpdateRequest) => Promise<void>
deletePlace: (placeId: number) => Promise<void>
deleteMany: (ids: number[]) => Promise<void>
copyToTrip: (tripId: number, placeIds: number[], force?: boolean) => Promise<{ copied: number; skipped: { id: number; name: string }[] }>
@@ -52,6 +57,7 @@ interface CollectionState {
acceptInvite: (collectionId: number) => Promise<void>
declineInvite: (collectionId: number) => Promise<void>
cancelInvite: (collectionId: number, userId: number) => Promise<void>
removeMember: (collectionId: number, userId: number) => Promise<void>
leave: (collectionId: number) => Promise<void>
setView: (view: CollectionView) => void
@@ -97,6 +103,12 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
places: data.places,
members: data.collection.members ?? [],
})
} catch {
// The list may have been left / removed / deleted out from under us (a WS
// event or the URL sync can re-request an id we just lost access to). Clear
// it instead of leaving an uncaught 403/404 rejection; the route sync then
// bounces to /collections.
if (get().activeId === id) set({ activeId: null, places: [], members: [] })
} finally {
set({ placesLoading: false })
}
@@ -139,8 +151,8 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
await get().setActive(activeId)
},
createCollection: async (name: string, color?: string, icon?: string) => {
const data = await collectionsApi.create({ name, color, icon })
createCollection: async (payload) => {
const data = await collectionsApi.create(payload)
await get().loadAll()
return data.collection ?? null
},
@@ -151,6 +163,14 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
if (get().activeId === id) await get().loadCollection(id)
},
uploadCover: async (id: number, file: File) => {
const fd = new FormData()
fd.append('cover', file)
await collectionsApi.uploadCover(id, fd)
await get().loadAll()
if (get().activeId === id) await get().loadCollection(id)
},
deleteCollection: async (id: number) => {
await collectionsApi.remove(id)
if (get().activeId === id) set({ activeId: null, places: [], members: [] })
@@ -179,6 +199,11 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
}
},
updatePlace: async (placeId, body) => {
const res = await collectionsApi.updatePlace(placeId, body)
set({ places: get().places.map(p => (p.id === placeId ? res.place : p)) })
},
deletePlace: async (placeId: number) => {
set({ places: get().places.filter(p => p.id !== placeId) })
await collectionsApi.deletePlace(placeId)
@@ -217,6 +242,11 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
if (get().activeId === collectionId) await get().loadCollection(collectionId)
},
removeMember: async (collectionId: number, userId: number) => {
await collectionsApi.removeMember(collectionId, userId)
if (get().activeId === collectionId) await get().loadCollection(collectionId)
},
leave: async (collectionId: number) => {
await collectionsApi.leave(collectionId)
if (get().activeId === collectionId) set({ activeId: null, places: [], members: [] })
+79 -5
View File
@@ -138,6 +138,27 @@
font-size: 46px; font-weight: 600; letter-spacing: -.03em; line-height: 1; margin: 0;
text-shadow: 0 1px 12px rgba(0,0,0,.3), 0 1px 3px rgba(0,0,0,.35); overflow-wrap: anywhere;
}
/* A light wash of the list colour laid over the cover photo. */
.trek-dash .col-hero-tint {
position: absolute; inset: 0; z-index: 0;
background:
linear-gradient(135deg, color-mix(in oklch, var(--hero-color, var(--accent)) 52%, transparent) 0%, transparent 62%),
color-mix(in oklch, var(--hero-color, var(--accent)) 14%, transparent);
}
.trek-dash .col-hero-desc {
font-size: 13.5px; line-height: 1.42; color: rgba(255,255,255,.92); max-width: 620px; margin: -2px 0 2px;
text-shadow: 0 1px 4px rgba(0,0,0,.4);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.trek-dash .col-hero-links { display: flex; flex-wrap: wrap; gap: 7px; }
.trek-dash .col-hero-link {
display: inline-flex; align-items: center; gap: 5px; padding: 5px 11px; border-radius: 999px;
font-size: 12px; font-weight: 500; color: #fff; max-width: 220px;
background: rgba(255,255,255,.16); border: 1px solid rgba(255,255,255,.24);
backdrop-filter: blur(10px) saturate(1.4); -webkit-backdrop-filter: blur(10px) saturate(1.4);
transition: background .14s, transform .12s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.trek-dash .col-hero-link:hover { background: rgba(255,255,255,.28); transform: translateY(-1px); }
.trek-dash .col-hero-stats { display: flex; flex-wrap: wrap; gap: 8px; }
.trek-dash .col-chip {
display: inline-flex; align-items: center; gap: 7px; padding: 8px 13px; border-radius: 999px;
@@ -197,16 +218,17 @@
/* ----------------- map panel + floating controls ----------------- */
.trek-dash .col-map-shell { position: relative; width: 100%; height: 100%; }
.trek-dash .col-map-toggle {
position: absolute; top: 12px; left: 12px; z-index: 20;
.trek-dash .col-map-controls { position: absolute; top: 12px; left: 12px; z-index: 20; display: flex; gap: 8px; }
.trek-dash .col-map-btn {
width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; color: var(--ink);
background: var(--glass-bg); border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow);
backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur);
transition: transform .12s, background .15s;
transition: transform .12s, background .15s, color .15s;
}
.trek-dash .col-map-toggle:hover { transform: translateY(-1px); background: var(--surface); }
.trek-dash .col-map-btn:hover { transform: translateY(-1px); background: var(--surface); }
.trek-dash .col-map-btn.on { background: var(--accent); border-color: var(--accent); color: #fff; }
.trek-dash .col-map-topright { position: absolute; top: 12px; right: 12px; z-index: 20; display: flex; gap: 8px; align-items: center; }
.trek-dash .col-map-search {
position: absolute; top: 12px; right: 12px; z-index: 20;
display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px 0 12px; border-radius: 11px; color: var(--ink-3);
background: var(--glass-bg); border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow);
backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur);
@@ -272,6 +294,58 @@
.trek-dash .col-cta:hover { transform: translateY(-1px); box-shadow: var(--sh-lg); }
.trek-dash .col-cta:active { transform: translateY(0); }
/* ----------------- place detail bottom sheet ----------------- */
.trek-dash .col-detail {
position: fixed; left: 50%; transform: translateX(-50%); bottom: 20px; z-index: 150;
width: min(760px, calc(100% - 32px)); max-height: 64vh; display: flex; flex-direction: column; overflow: hidden;
border-radius: var(--r-xl); background: var(--glass-bg); border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow-hover), var(--glass-highlight);
backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur);
}
.trek-dash .col-detail-cover { position: relative; height: 116px; flex-shrink: 0; background-size: cover; background-position: center; }
.trek-dash .col-detail-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.trek-dash .col-detail-cover-scrim { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0,0,0,.12) 0%, rgba(0,0,0,.55) 100%); }
.trek-dash .col-detail-close {
position: absolute; top: 12px; right: 12px; z-index: 2; width: 32px; height: 32px; border-radius: 50%;
display: grid; place-items: center; background: rgba(0,0,0,.4); color: #fff; border: 1px solid rgba(255,255,255,.25);
backdrop-filter: blur(8px); transition: background .12s;
}
.trek-dash .col-detail-close:hover { background: rgba(0,0,0,.6); }
.trek-dash .col-detail-head { position: absolute; left: 18px; right: 18px; bottom: 12px; z-index: 1; color: #fff; }
.trek-dash .col-detail-name { font-size: 21px; font-weight: 700; letter-spacing: -.02em; margin: 0; text-shadow: 0 1px 6px rgba(0,0,0,.5); }
.trek-dash .col-detail-name-input { width: 100%; font-size: 20px; font-weight: 700; background: rgba(255,255,255,.18); border: 1px solid rgba(255,255,255,.32); border-radius: 8px; padding: 3px 8px; color: #fff; outline: none; }
.trek-dash .col-detail-sub { display: flex; align-items: center; gap: 5px; font-size: 12.5px; margin-top: 3px; opacity: .92; text-shadow: 0 1px 4px rgba(0,0,0,.5); }
.trek-dash .col-detail-body { padding: 16px 18px 18px; overflow-y: auto; display: flex; flex-direction: column; gap: 14px; }
.trek-dash .col-detail-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.trek-dash .col-detail-actions-spacer { flex: 1 1 auto; }
.trek-dash .col-detail-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border-radius: 9px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); background: var(--surface-2); border: 1px solid var(--line-2); transition: background .12s, color .12s; }
.trek-dash .col-detail-btn:hover { background: var(--surface); color: var(--ink); }
.trek-dash .col-detail-btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.trek-dash .col-detail-btn.danger { color: oklch(0.6 0.2 25); }
.trek-dash .col-detail-btn.danger:hover { background: oklch(0.6 0.2 25 / .12); }
.trek-dash .col-detail-md { font-size: 13.5px; color: var(--ink-2); line-height: 1.5; }
.trek-dash .col-detail-links { display: flex; flex-wrap: wrap; gap: 7px; }
.trek-dash .col-detail-link { display: inline-flex; align-items: center; gap: 5px; padding: 5px 11px; border-radius: 999px; font-size: 12px; font-weight: 500; color: var(--accent-ink); background: var(--accent-soft); transition: filter .12s; max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trek-dash .col-detail-link:hover { filter: brightness(1.05); }
.trek-dash .col-detail-empty { display: inline-flex; align-items: center; gap: 7px; align-self: flex-start; padding: 8px 12px; border-radius: 9px; border: 1px dashed var(--line-2); color: var(--ink-3); font-size: 12.5px; font-weight: 500; }
.trek-dash .col-detail-empty:hover { color: var(--ink); border-color: var(--ink-3); }
.trek-dash .col-detail-edit { display: flex; flex-direction: column; gap: 8px; }
.trek-dash .col-detail-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .1em; color: var(--ink-3); margin-top: 4px; }
.trek-dash .col-detail-textarea { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line-2); background: var(--surface); color: var(--ink); font-size: 13.5px; font-family: inherit; outline: none; resize: vertical; }
.trek-dash .col-detail-textarea:focus { border-color: var(--accent); }
.trek-dash .col-detail-links-edit { display: flex; flex-direction: column; gap: 8px; }
.trek-dash .col-detail-link-row { display: flex; align-items: center; gap: 8px; }
.trek-dash .col-detail-input { padding: 7px 10px; border-radius: 9px; border: 1px solid var(--line-2); background: var(--surface); color: var(--ink); font-size: 12.5px; outline: none; min-width: 0; }
.trek-dash .col-detail-input:focus { border-color: var(--accent); }
.trek-dash .col-detail-icon-btn { padding: 7px; border-radius: 8px; color: var(--ink-3); }
.trek-dash .col-detail-icon-btn:hover { color: oklch(0.6 0.2 25); background: var(--surface-2); }
.trek-dash .col-detail-add-link { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 11px; border-radius: 9px; border: 1px dashed var(--line-2); color: var(--ink-2); font-size: 12.5px; font-weight: 500; }
.trek-dash .col-detail-add-link:hover { background: var(--surface-2); }
.trek-dash .col-detail-edit-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
@media (max-width: 720px) {
.trek-dash .col-detail { left: 0; right: 0; bottom: 0; transform: none; width: 100%; max-height: 84vh; border-radius: var(--r-xl) var(--r-xl) 0 0; }
}
/* ----------------- empty / loading ----------------- */
.trek-dash .col-emptystate { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 84px 24px; }
.trek-dash .col-emptystate .ic { width: 60px; height: 60px; border-radius: var(--r-md); display: grid; place-items: center; margin-bottom: 18px; background: var(--surface-2); color: var(--ink-3); }
+20 -8
View File
@@ -541,15 +541,27 @@
.trek-dash .upc-type.other { background: var(--bg-2); color: var(--ink-2); }
.trek-dash .upc-type svg { width: 16px; height: 16px; }
/* ----------------- collections widget ----------------- */
.trek-dash .col-count { font-size: 12px; color: var(--ink-3); font-weight: 500; margin-bottom: 12px; }
/* ----------------- collections widget (list badges) ----------------- */
.trek-dash .col-empty { font-size: 13px; color: var(--ink-3); }
.trek-dash .col-list { display: flex; flex-direction: column; gap: 4px; }
.trek-dash .col-item { display: grid; grid-template-columns: 36px 1fr; gap: 12px; padding: 8px; border-radius: 12px; align-items: center; transition: background .12s; cursor: pointer; width: 100%; text-align: left; }
.trek-dash .col-item:hover { background: var(--surface-2); }
.trek-dash .col-info { min-width: 0; }
.trek-dash .col-info .t { font-size: 14px; font-weight: 500; margin-bottom: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trek-dash .col-info .s { font-size: 12px; color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trek-dash .col-badges { display: flex; flex-direction: column; gap: 9px; }
.trek-dash .col-badge {
position: relative; display: block; width: 100%; height: 56px; border-radius: 14px; overflow: hidden;
cursor: pointer; text-align: left; box-shadow: var(--sh-sm); transition: transform .15s cubic-bezier(.2,.7,.2,1), box-shadow .2s;
}
.trek-dash .col-badge:hover { transform: translateY(-2px); box-shadow: var(--sh-md); }
.trek-dash .col-badge-media { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background-size: cover; background-position: center; }
.trek-dash .col-badge-tint {
position: absolute; inset: 0;
background:
linear-gradient(90deg, color-mix(in oklch, var(--badge-color, #6366f1) 62%, black 8%) 0%, color-mix(in oklch, var(--badge-color, #6366f1) 34%, transparent) 58%, rgba(0,0,0,.28) 100%),
linear-gradient(180deg, rgba(0,0,0,0) 45%, rgba(0,0,0,.35) 100%);
}
.trek-dash .col-badge-body { position: relative; z-index: 1; height: 100%; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 14px; color: #fff; }
.trek-dash .col-badge-name { font-size: 14.5px; font-weight: 600; letter-spacing: -.01em; text-shadow: 0 1px 4px rgba(0,0,0,.4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trek-dash .col-badge-count {
flex-shrink: 0; display: inline-flex; align-items: center; gap: 4px; font-size: 11.5px; font-weight: 600; font-variant-numeric: tabular-nums;
padding: 4px 9px; border-radius: 999px; background: rgba(255,255,255,.2); border: 1px solid rgba(255,255,255,.28); backdrop-filter: blur(6px);
}
/* ----------------- responsive ----------------- */
@media (max-width: 1280px) {
+6
View File
@@ -3255,6 +3255,12 @@ function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_collection_place_tags_tag ON collection_place_tags(tag_id);
`);
},
// Migration 151: user-added links on collections + saved places (JSON text)
() => {
try { db.exec('ALTER TABLE collections ADD COLUMN links TEXT'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
try { db.exec('ALTER TABLE collection_places ADD COLUMN links TEXT'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
},
];
if (currentVersion < migrations.length) {
+2
View File
@@ -380,6 +380,7 @@ function createTables(db: Database.Database): void {
color TEXT DEFAULT '#6366f1',
icon TEXT DEFAULT 'Bookmark',
cover_image TEXT,
links TEXT,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
@@ -417,6 +418,7 @@ function createTables(db: Database.Database): void {
status TEXT NOT NULL DEFAULT 'idea',
source_trip_id INTEGER,
source_place_id INTEGER,
links TEXT,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
@@ -10,8 +10,16 @@ import {
Patch,
Post,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Request } from 'express';
import { diskStorage } from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
import type { User } from '../../types';
import { CollectionsService } from './collections.service';
import { CollectionsAddonGuard } from './collections-addon.guard';
@@ -29,6 +37,7 @@ import {
collectionInviteRequestSchema,
collectionInviteActionRequestSchema,
collectionInviteCancelRequestSchema,
collectionRemoveMemberRequestSchema,
type CollectionCreateRequest,
type CollectionUpdateRequest,
type CollectionSavePlaceRequest,
@@ -39,8 +48,28 @@ import {
type CollectionInviteRequest,
type CollectionInviteActionRequest,
type CollectionInviteCancelRequest,
type CollectionRemoveMemberRequest,
} from '@trek/shared';
const MAX_COVER_SIZE = 20 * 1024 * 1024;
const coversDir = path.join(__dirname, '../../../uploads/covers');
const COVER_UPLOAD = {
storage: diskStorage({
destination: (_req, _file, cb) => {
if (!fs.existsSync(coversDir)) fs.mkdirSync(coversDir, { recursive: true });
cb(null, coversDir);
},
filename: (_req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`),
}),
limits: { fileSize: MAX_COVER_SIZE },
fileFilter: (_req: Request, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
const ext = path.extname(file.originalname).toLowerCase();
const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowed.includes(ext)) cb(null, true);
else cb(new Error('Only jpg, png, gif, webp images allowed'), false);
},
};
/**
* /api/addons/collections — saved-place library (lists, places, fusion sharing).
*
@@ -200,6 +229,17 @@ export class CollectionsController {
return { success: true };
}
@Post('members/remove')
@HttpCode(200)
removeMember(@CurrentUser() user: User, @Body(new ZodValidationPipe(collectionRemoveMemberRequestSchema)) body: CollectionRemoveMemberRequest) {
this.collections.assertAccess(user.id, body.collection_id); // 404 if not visible
if (!this.collections.isOwner(user.id, body.collection_id)) {
throw new HttpException({ error: 'Only the owner can remove members' }, 403);
}
this.collections.removeMember(user.id, body.collection_id, body.user_id);
return { success: true };
}
// ── /:id (declared last so static prefixes win) ─────────────────────────────
@Get(':id/available-users')
availableUsers(@CurrentUser() user: User, @Param('id') id: string) {
@@ -210,6 +250,14 @@ export class CollectionsController {
return { users: this.collections.availableUsers(user.id, Number(id)) };
}
@Post(':id/cover')
@UseInterceptors(FileInterceptor('cover', COVER_UPLOAD))
uploadCover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined) {
if (!file) throw new HttpException({ error: 'No image uploaded' }, 400);
const coverUrl = `/uploads/covers/${file.filename}`;
return this.collections.setCollectionCover(user.id, Number(id), coverUrl);
}
@Get(':id')
get(@CurrentUser() user: User, @Param('id') id: string) {
return this.collections.getCollection(user.id, Number(id));
@@ -20,6 +20,7 @@ export class CollectionsService {
getCollection(userId: number, id: number) { return svc.getCollection(userId, id); }
createCollection(userId: number, body: CollectionCreateRequest) { return svc.createCollection(userId, body); }
updateCollection(userId: number, id: number, body: CollectionUpdateRequest) { return svc.updateCollection(userId, id, body); }
setCollectionCover(userId: number, id: number, coverUrl: string | null) { return svc.setCollectionCover(userId, id, coverUrl); }
deleteCollection(userId: number, id: number) { return svc.deleteCollection(userId, id); }
reorderCollections(userId: number, orderedIds: number[]) { return svc.reorderCollections(userId, orderedIds); }
@@ -48,5 +49,6 @@ export class CollectionsService {
declineInvite(userId: number, collectionId: number, socketId?: string) { return svc.declineInvite(userId, collectionId, socketId); }
cancelInvite(collectionId: number, ownerId: number, targetUserId: number) { return svc.cancelInvite(collectionId, ownerId, targetUserId); }
leaveCollection(userId: number, collectionId: number, socketId?: string) { return svc.leaveCollection(userId, collectionId, socketId); }
removeMember(ownerId: number, collectionId: number, targetUserId: number) { return svc.removeMember(ownerId, collectionId, targetUserId); }
availableUsers(ownerId: number, collectionId: number) { return svc.availableUsers(ownerId, collectionId); }
}
+70 -7
View File
@@ -1,3 +1,5 @@
import fs from 'fs';
import path from 'path';
import { db, canAccessTrip } from '../db/database';
import { broadcastToUser } from '../websocket';
import { checkPermission } from './permissions';
@@ -8,6 +10,7 @@ import type {
CollectionMember,
CollectionMembership,
CollectionPlace,
CollectionLink,
CollectionCreateRequest,
CollectionUpdateRequest,
CollectionSavePlaceRequest,
@@ -16,6 +19,33 @@ import type {
CollectionStatus,
} from '@trek/shared';
/** Links are stored as a JSON TEXT column; parse on read, stringify on write. */
function parseLinks(raw: unknown): CollectionLink[] | undefined {
if (typeof raw !== 'string' || !raw) return undefined;
try {
const v = JSON.parse(raw);
return Array.isArray(v) ? (v as CollectionLink[]) : undefined;
} catch {
return undefined;
}
}
function serializeLinks(links: CollectionLink[] | undefined): string | null {
return links && links.length ? JSON.stringify(links) : null;
}
/**
* Reclaim a replaced cover file. Path-confined to uploads/covers (mirrors
* tripService.deleteOldCover — kept local so this service doesn't pull the
* trips import graph). Collection + trip covers share the same directory.
*/
function deleteOldCollectionCover(coverImage: string | null | undefined): void {
if (!coverImage) return;
const coversDir = path.resolve(__dirname, '../../uploads/covers');
const resolved = path.resolve(path.join(coversDir, path.basename(coverImage)));
if (resolved.startsWith(coversDir + path.sep) && fs.existsSync(resolved)) fs.unlinkSync(resolved);
}
// ---------------------------------------------------------------------------
// Errors — thrown as plain Errors carrying a status; TrekExceptionFilter maps
// `err.status` → that HTTP code with an `{ error: message }` body.
@@ -100,6 +130,7 @@ function hydratePlaces(rows: PlaceRow[]): CollectionPlace[] {
const { category_name, category_color, category_icon, ...rest } = r;
return {
...rest,
links: parseLinks((r as { links?: unknown }).links),
category: r.category_id
? { id: r.category_id, name: category_name ?? '', color: category_color ?? null, icon: category_icon ?? null }
: undefined,
@@ -144,10 +175,10 @@ function buildMembers(collectionId: number): CollectionMember[] {
}
function getCollectionRow(id: number): Collection {
const col = db.prepare('SELECT * FROM collections WHERE id = ?').get(id) as Collection | undefined;
const col = db.prepare('SELECT * FROM collections WHERE id = ?').get(id) as (Collection & { links?: unknown }) | undefined;
if (!col) httpError(404, 'Collection not found');
const placeCount = (db.prepare('SELECT COUNT(*) AS n FROM collection_places WHERE collection_id = ?').get(id) as { n: number }).n;
return { ...col, place_count: placeCount, members: buildMembers(id) };
return { ...col, links: parseLinks(col.links), place_count: placeCount, members: buildMembers(id) };
}
// ---------------------------------------------------------------------------
@@ -191,14 +222,16 @@ export function getCollection(userId: number, id: number): CollectionDetailRespo
export function createCollection(userId: number, body: CollectionCreateRequest): Collection {
const max = (db.prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM collections WHERE owner_id = ?').get(userId) as { m: number }).m;
const result = db.prepare(`
INSERT INTO collections (owner_id, name, description, color, icon, sort_order)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO collections (owner_id, name, description, color, icon, cover_image, links, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
body.name,
body.description ?? null,
body.color ?? '#6366f1',
body.icon ?? 'Bookmark',
body.cover_image ?? null,
serializeLinks(body.links),
max + 1,
);
const col = getCollectionRow(Number(result.lastInsertRowid));
@@ -213,6 +246,8 @@ export function updateCollection(userId: number, id: number, body: CollectionUpd
if (body.description !== undefined) { updates.push('description = ?'); params.push(body.description ?? null); }
if (body.color !== undefined) { updates.push('color = ?'); params.push(body.color ?? null); }
if (body.icon !== undefined) { updates.push('icon = ?'); params.push(body.icon ?? null); }
if (body.cover_image !== undefined) { updates.push('cover_image = ?'); params.push(body.cover_image ?? null); }
if (body.links !== undefined) { updates.push('links = ?'); params.push(serializeLinks(body.links)); }
if (body.sort_order !== undefined) { updates.push('sort_order = ?'); params.push(body.sort_order); }
if (updates.length > 0) {
updates.push("updated_at = CURRENT_TIMESTAMP");
@@ -224,6 +259,17 @@ export function updateCollection(userId: number, id: number, body: CollectionUpd
return { ...col, is_owner: col.owner_id === userId };
}
/** Set (or clear) a list's cover image, reclaiming the previous file. */
export function setCollectionCover(userId: number, id: number, coverUrl: string | null): Collection {
assertAccess(userId, id);
const prev = (db.prepare('SELECT cover_image FROM collections WHERE id = ?').get(id) as { cover_image: string | null } | undefined)?.cover_image ?? null;
db.prepare('UPDATE collections SET cover_image = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(coverUrl, id);
if (prev && prev !== coverUrl) deleteOldCollectionCover(prev);
notifyCollectionUsers(id, undefined, 'collections:updated');
const col = getCollectionRow(id);
return { ...col, is_owner: col.owner_id === userId };
}
export function deleteCollection(userId: number, id: number): void {
assertAccess(userId, id);
if (!isOwner(userId, id)) httpError(403, 'Only the owner can delete this list');
@@ -333,8 +379,8 @@ export function savePlace(userId: number, body: CollectionSavePlaceRequest): Col
INSERT INTO collection_places (
collection_id, owner_id, saved_by, name, description, lat, lng, address,
category_id, price, currency, notes, image_url, google_place_id, google_ftid,
osm_id, website, phone, status, source_trip_id, source_place_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
osm_id, website, phone, status, source_trip_id, source_place_id, links
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
body.collection_id, ownerId, userId,
body.name, body.description ?? null, body.lat ?? null, body.lng ?? null, body.address ?? null,
@@ -342,6 +388,7 @@ export function savePlace(userId: number, body: CollectionSavePlaceRequest): Col
body.image_url ?? null, body.google_place_id ?? null, body.google_ftid ?? null,
body.osm_id ?? null, body.website ?? null, body.phone ?? null,
body.status ?? 'idea', body.source_trip_id ?? null, body.source_place_id ?? null,
serializeLinks(body.links),
);
const placeId = Number(result.lastInsertRowid);
@@ -389,9 +436,11 @@ export function updatePlace(userId: number, placeId: number, body: import('@trek
const updates: string[] = [];
const params: (string | number | null)[] = [];
if (body.name !== undefined) { updates.push('name = ?'); params.push(body.name); }
if (body.description !== undefined) { updates.push('description = ?'); params.push(body.description ?? null); }
if (body.notes !== undefined) { updates.push('notes = ?'); params.push(body.notes ?? null); }
if (body.status !== undefined) { updates.push('status = ?'); params.push(body.status); }
if (body.category_id !== undefined) { updates.push('category_id = ?'); params.push(body.category_id ?? null); }
if (body.links !== undefined) { updates.push('links = ?'); params.push(serializeLinks(body.links)); }
let movedTo: number | null = null;
if (body.collection_id !== undefined && body.collection_id !== currentCollection) {
@@ -527,7 +576,11 @@ export function findMembership(
const params: (string | number)[] = [...ids];
if (query.google_place_id) { conditions.push('cp.google_place_id = ?'); params.push(query.google_place_id); }
if (query.google_ftid) { conditions.push('cp.google_ftid = ?'); params.push(query.google_ftid); }
if (query.name && query.name.trim()) { conditions.push('lower(trim(cp.name)) = ?'); params.push(query.name.trim().toLowerCase()); }
// Coordinate proximity is the location signal. A bare NAME match is deliberately
// NOT a condition on its own — "Starbucks" (or any repeated name) would otherwise
// false-positive the inspector's "already saved" bookmark. When coords are given
// the name still effectively matches via the same-location row below; without an
// id or coords there is nothing strong enough to claim it's the same place.
if (query.lat != null && query.lng != null) {
conditions.push('(cp.lat IS NOT NULL AND cp.lng IS NOT NULL AND abs(cp.lat - ?) <= ? AND abs(cp.lng - ?) <= ?)');
params.push(query.lat, COORD_DEDUP_TOLERANCE, query.lng, COORD_DEDUP_TOLERANCE);
@@ -612,6 +665,16 @@ export function leaveCollection(userId: number, collectionId: number, socketId:
notifyCollectionUsers(collectionId, socketId, 'collections:left');
}
/** Owner removes an already-accepted member (a "kick"). */
export function removeMember(ownerId: number, collectionId: number, targetUserId: number): void {
if (!isOwner(ownerId, collectionId)) httpError(403, 'Not allowed');
if (targetUserId === ownerId) httpError(400, 'Owner cannot be removed');
const res = db.prepare("DELETE FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'accepted'").run(collectionId, targetUserId);
if (res.changes === 0) httpError(404, 'Member not found');
notifyCollectionUsers(collectionId, undefined, 'collections:left'); // refresh remaining members
broadcastToUser(targetUserId, { type: 'collections:removed', collectionId }); // bounce the removed user
}
export function availableUsers(ownerId: number, collectionId: number): { id: number; username: string }[] {
return db.prepare(`
SELECT u.id, u.username FROM users u
@@ -6,6 +6,14 @@ export const COLLECTION_STATUSES = ['idea', 'want', 'visited'] as const;
export const collectionStatusSchema = z.enum(COLLECTION_STATUSES).catch('idea').default('idea');
export type CollectionStatus = (typeof COLLECTION_STATUSES)[number];
/** A user-added link on a list or a saved place (stored as a JSON array). */
export const collectionLinkSchema = z.object({
label: z.string().max(120).optional(),
url: z.string().trim().url().max(2000),
});
export type CollectionLink = z.infer<typeof collectionLinkSchema>;
export const collectionLinksSchema = z.array(collectionLinkSchema).max(30);
/** A saved place — assignmentPlace minus itinerary, plus status + provenance. */
export const collectionPlaceSchema = z.object({
id: z.number(),
@@ -33,6 +41,7 @@ export const collectionPlaceSchema = z.object({
sort_order: z.number().optional(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
links: collectionLinksSchema.optional(),
category: placeCategorySchema.optional(),
tags: z.array(tagSchema.partial()).optional(),
});
@@ -58,6 +67,7 @@ export const collectionSchema = z.object({
color: z.string().nullable().optional(),
icon: z.string().nullable().optional(),
cover_image: z.string().nullable().optional(),
links: collectionLinksSchema.optional(),
sort_order: z.number().optional(),
place_count: z.number().optional(),
is_owner: z.boolean().optional(),
@@ -73,6 +83,8 @@ export const collectionCreateRequestSchema = z.object({
description: z.string().max(2000).nullable().optional(),
color: z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/).optional(),
icon: z.string().max(40).optional(),
cover_image: z.string().max(500).nullable().optional(),
links: collectionLinksSchema.optional(),
});
export type CollectionCreateRequest = z.infer<typeof collectionCreateRequestSchema>;
@@ -102,6 +114,7 @@ export const collectionSavePlaceRequestSchema = z.object({
website: z.string().nullable().optional(),
phone: z.string().nullable().optional(),
status: collectionStatusSchema.optional(),
links: collectionLinksSchema.optional(),
tag_ids: z.array(z.number()).optional(),
force: z.boolean().optional(), // "add anyway" over a dedup match
});
@@ -118,10 +131,12 @@ export type CollectionSaveFromTripRequest = z.infer<typeof collectionSaveFromTri
export const collectionPlaceUpdateRequestSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
status: collectionStatusSchema.optional(),
category_id: z.number().nullable().optional(),
collection_id: z.number().optional(), // move to another list
links: collectionLinksSchema.optional(),
tag_ids: z.array(z.number()).optional(),
});
export type CollectionPlaceUpdateRequest = z.infer<typeof collectionPlaceUpdateRequestSchema>;
@@ -153,6 +168,13 @@ export const collectionInviteCancelRequestSchema = z.object({
});
export type CollectionInviteCancelRequest = z.infer<typeof collectionInviteCancelRequestSchema>;
/** Owner removes an ALREADY-ACCEPTED member (kick). */
export const collectionRemoveMemberRequestSchema = z.object({
collection_id: z.number(),
user_id: z.number(),
});
export type CollectionRemoveMemberRequest = z.infer<typeof collectionRemoveMemberRequestSchema>;
// ── Responses ─────────────────────────────────────────────────────────────
export const collectionListResponseSchema = z.object({
collections: z.array(collectionSchema),
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': 'تعديل القائمة',
'collections.coverImage': 'صورة الغلاف',
'collections.addCover': 'إضافة غلاف',
'collections.changeCover': 'تغيير الغلاف',
'collections.description': 'الوصف',
'collections.descriptionPlaceholder': 'أضف وصفًا…',
'collections.links': 'روابط',
'collections.linkLabel': 'التسمية',
'collections.addLink': 'إضافة رابط',
'collections.addDetails': 'إضافة تفاصيل',
'collections.addPlace': 'إضافة مكان',
'collections.addPlaceSearch': 'ابحث عن مكان…',
'collections.addPlaceHint': 'ابحث لإضافة مكان إلى هذه القائمة',
'collections.showList': 'إظهار القائمة',
'collections.expandMap': 'توسيع الخريطة',
'collections.hero.mine': 'مجموعتي',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': 'Editar lista',
'collections.coverImage': 'Imagem de capa',
'collections.addCover': 'Adicionar capa',
'collections.changeCover': 'Alterar capa',
'collections.description': 'Descrição',
'collections.descriptionPlaceholder': 'Adicione uma descrição…',
'collections.links': 'Links',
'collections.linkLabel': 'Rótulo',
'collections.addLink': 'Adicionar link',
'collections.addDetails': 'Adicionar detalhes',
'collections.addPlace': 'Adicionar um lugar',
'collections.addPlaceSearch': 'Pesquisar um lugar…',
'collections.addPlaceHint': 'Pesquise para adicionar um lugar a esta lista',
'collections.showList': 'Mostrar lista',
'collections.expandMap': 'Ampliar mapa',
'collections.hero.mine': 'Minha coleção',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': 'Upravit seznam',
'collections.coverImage': 'Obrázek na obálku',
'collections.addCover': 'Přidat obálku',
'collections.changeCover': 'Změnit obálku',
'collections.description': 'Popis',
'collections.descriptionPlaceholder': 'Přidejte popis…',
'collections.links': 'Odkazy',
'collections.linkLabel': 'Popisek',
'collections.addLink': 'Přidat odkaz',
'collections.addDetails': 'Přidat podrobnosti',
'collections.addPlace': 'Přidat místo',
'collections.addPlaceSearch': 'Hledat místo…',
'collections.addPlaceHint': 'Vyhledejte a přidejte místo do tohoto seznamu',
'collections.showList': 'Zobrazit seznam',
'collections.expandMap': 'Zvětšit mapu',
'collections.hero.mine': 'Moje sbírka',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': 'Liste bearbeiten',
'collections.coverImage': 'Titelbild',
'collections.addCover': 'Titelbild hinzufügen',
'collections.changeCover': 'Titelbild ändern',
'collections.description': 'Beschreibung',
'collections.descriptionPlaceholder': 'Beschreibung hinzufügen…',
'collections.links': 'Links',
'collections.linkLabel': 'Bezeichnung',
'collections.addLink': 'Link hinzufügen',
'collections.addDetails': 'Details hinzufügen',
'collections.addPlace': 'Ort hinzufügen',
'collections.addPlaceSearch': 'Nach einem Ort suchen…',
'collections.addPlaceHint': 'Suchen, um einen Ort zur Liste hinzuzufügen',
'collections.showList': 'Liste zeigen',
'collections.expandMap': 'Karte vergrößern',
'collections.hero.mine': 'Meine Sammlung',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': 'Edit list',
'collections.coverImage': 'Cover image',
'collections.addCover': 'Add cover',
'collections.changeCover': 'Change cover',
'collections.description': 'Description',
'collections.descriptionPlaceholder': 'Add a description…',
'collections.links': 'Links',
'collections.linkLabel': 'Label',
'collections.addLink': 'Add link',
'collections.addDetails': 'Add details',
'collections.addPlace': 'Add a place',
'collections.addPlaceSearch': 'Search for a place…',
'collections.addPlaceHint': 'Search to add a place to this list',
'collections.showList': 'Show list',
'collections.expandMap': 'Expand map',
'collections.hero.mine': 'My collection',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': 'Editar lista',
'collections.coverImage': 'Imagen de portada',
'collections.addCover': 'Añadir portada',
'collections.changeCover': 'Cambiar portada',
'collections.description': 'Descripción',
'collections.descriptionPlaceholder': 'Añade una descripción…',
'collections.links': 'Enlaces',
'collections.linkLabel': 'Etiqueta',
'collections.addLink': 'Añadir enlace',
'collections.addDetails': 'Añadir detalles',
'collections.addPlace': 'Añadir un lugar',
'collections.addPlaceSearch': 'Buscar un lugar…',
'collections.addPlaceHint': 'Busca para añadir un lugar a esta lista',
'collections.showList': 'Mostrar lista',
'collections.expandMap': 'Ampliar mapa',
'collections.hero.mine': 'Mi colección',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': 'Modifier la liste',
'collections.coverImage': 'Image de couverture',
'collections.addCover': 'Ajouter une couverture',
'collections.changeCover': 'Changer la couverture',
'collections.description': 'Description',
'collections.descriptionPlaceholder': 'Ajouter une description…',
'collections.links': 'Liens',
'collections.linkLabel': 'Libellé',
'collections.addLink': 'Ajouter un lien',
'collections.addDetails': 'Ajouter des détails',
'collections.addPlace': 'Ajouter un lieu',
'collections.addPlaceSearch': 'Rechercher un lieu…',
'collections.addPlaceHint': 'Recherchez pour ajouter un lieu à cette liste',
'collections.showList': 'Afficher la liste',
'collections.expandMap': 'Agrandir la carte',
'collections.hero.mine': 'Ma collection',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': 'Επεξεργασία λίστας',
'collections.coverImage': 'Εικόνα εξωφύλλου',
'collections.addCover': 'Προσθήκη εξωφύλλου',
'collections.changeCover': 'Αλλαγή εξωφύλλου',
'collections.description': 'Περιγραφή',
'collections.descriptionPlaceholder': 'Προσθέστε μια περιγραφή…',
'collections.links': 'Σύνδεσμοι',
'collections.linkLabel': 'Ετικέτα',
'collections.addLink': 'Προσθήκη συνδέσμου',
'collections.addDetails': 'Προσθήκη λεπτομερειών',
'collections.addPlace': 'Προσθήκη τόπου',
'collections.addPlaceSearch': 'Αναζήτηση τόπου…',
'collections.addPlaceHint': 'Αναζητήστε για να προσθέσετε έναν τόπο σε αυτήν τη λίστα',
'collections.showList': 'Εμφάνιση λίστας',
'collections.expandMap': 'Μεγέθυνση χάρτη',
'collections.hero.mine': 'Η συλλογή μου',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': 'Lista szerkesztése',
'collections.coverImage': 'Borítókép',
'collections.addCover': 'Borító hozzáadása',
'collections.changeCover': 'Borító módosítása',
'collections.description': 'Leírás',
'collections.descriptionPlaceholder': 'Adjon hozzá leírást…',
'collections.links': 'Hivatkozások',
'collections.linkLabel': 'Címke',
'collections.addLink': 'Hivatkozás hozzáadása',
'collections.addDetails': 'Részletek hozzáadása',
'collections.addPlace': 'Hely hozzáadása',
'collections.addPlaceSearch': 'Hely keresése…',
'collections.addPlaceHint': 'Keressen, hogy helyet adjon a listához',
'collections.showList': 'Lista megjelenítése',
'collections.expandMap': 'Térkép nagyítása',
'collections.hero.mine': 'Saját gyűjtemény',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': 'Edit daftar',
'collections.coverImage': 'Gambar sampul',
'collections.addCover': 'Tambah sampul',
'collections.changeCover': 'Ubah sampul',
'collections.description': 'Deskripsi',
'collections.descriptionPlaceholder': 'Tambahkan deskripsi…',
'collections.links': 'Tautan',
'collections.linkLabel': 'Label',
'collections.addLink': 'Tambah tautan',
'collections.addDetails': 'Tambah detail',
'collections.addPlace': 'Tambah tempat',
'collections.addPlaceSearch': 'Cari tempat…',
'collections.addPlaceHint': 'Cari untuk menambahkan tempat ke daftar ini',
'collections.showList': 'Tampilkan daftar',
'collections.expandMap': 'Perbesar peta',
'collections.hero.mine': 'Koleksi saya',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': 'Modifica elenco',
'collections.coverImage': 'Immagine di copertina',
'collections.addCover': 'Aggiungi copertina',
'collections.changeCover': 'Cambia copertina',
'collections.description': 'Descrizione',
'collections.descriptionPlaceholder': 'Aggiungi una descrizione…',
'collections.links': 'Link',
'collections.linkLabel': 'Etichetta',
'collections.addLink': 'Aggiungi link',
'collections.addDetails': 'Aggiungi dettagli',
'collections.addPlace': 'Aggiungi un luogo',
'collections.addPlaceSearch': 'Cerca un luogo…',
'collections.addPlaceHint': 'Cerca per aggiungere un luogo a questo elenco',
'collections.showList': 'Mostra elenco',
'collections.expandMap': 'Espandi mappa',
'collections.hero.mine': 'La mia raccolta',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': 'リストを編集',
'collections.coverImage': 'カバー画像',
'collections.addCover': 'カバーを追加',
'collections.changeCover': 'カバーを変更',
'collections.description': '説明',
'collections.descriptionPlaceholder': '説明を追加…',
'collections.links': 'リンク',
'collections.linkLabel': 'ラベル',
'collections.addLink': 'リンクを追加',
'collections.addDetails': '詳細を追加',
'collections.addPlace': '場所を追加',
'collections.addPlaceSearch': '場所を検索…',
'collections.addPlaceHint': '検索してこのリストに場所を追加',
'collections.showList': 'リストを表示',
'collections.expandMap': '地図を拡大',
'collections.hero.mine': 'マイコレクション',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': '목록 편집',
'collections.coverImage': '커버 이미지',
'collections.addCover': '커버 추가',
'collections.changeCover': '커버 변경',
'collections.description': '설명',
'collections.descriptionPlaceholder': '설명 추가…',
'collections.links': '링크',
'collections.linkLabel': '레이블',
'collections.addLink': '링크 추가',
'collections.addDetails': '세부정보 추가',
'collections.addPlace': '장소 추가',
'collections.addPlaceSearch': '장소 검색…',
'collections.addPlaceHint': '검색하여 이 목록에 장소 추가',
'collections.showList': '목록 표시',
'collections.expandMap': '지도 확대',
'collections.hero.mine': '내 컬렉션',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': 'Lijst bewerken',
'collections.coverImage': 'Omslagafbeelding',
'collections.addCover': 'Omslag toevoegen',
'collections.changeCover': 'Omslag wijzigen',
'collections.description': 'Beschrijving',
'collections.descriptionPlaceholder': 'Voeg een beschrijving toe…',
'collections.links': 'Links',
'collections.linkLabel': 'Label',
'collections.addLink': 'Link toevoegen',
'collections.addDetails': 'Details toevoegen',
'collections.addPlace': 'Plaats toevoegen',
'collections.addPlaceSearch': 'Zoek een plaats…',
'collections.addPlaceHint': 'Zoek om een plaats aan deze lijst toe te voegen',
'collections.showList': 'Lijst tonen',
'collections.expandMap': 'Kaart vergroten',
'collections.hero.mine': 'Mijn collectie',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': 'Edytuj listę',
'collections.coverImage': 'Obraz okładki',
'collections.addCover': 'Dodaj okładkę',
'collections.changeCover': 'Zmień okładkę',
'collections.description': 'Opis',
'collections.descriptionPlaceholder': 'Dodaj opis…',
'collections.links': 'Linki',
'collections.linkLabel': 'Etykieta',
'collections.addLink': 'Dodaj link',
'collections.addDetails': 'Dodaj szczegóły',
'collections.addPlace': 'Dodaj miejsce',
'collections.addPlaceSearch': 'Wyszukaj miejsce…',
'collections.addPlaceHint': 'Wyszukaj, aby dodać miejsce do tej listy',
'collections.showList': 'Pokaż listę',
'collections.expandMap': 'Powiększ mapę',
'collections.hero.mine': 'Moja kolekcja',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': 'Изменить список',
'collections.coverImage': 'Обложка',
'collections.addCover': 'Добавить обложку',
'collections.changeCover': 'Изменить обложку',
'collections.description': 'Описание',
'collections.descriptionPlaceholder': 'Добавьте описание…',
'collections.links': 'Ссылки',
'collections.linkLabel': 'Название',
'collections.addLink': 'Добавить ссылку',
'collections.addDetails': 'Добавить детали',
'collections.addPlace': 'Добавить место',
'collections.addPlaceSearch': 'Поиск места…',
'collections.addPlaceHint': 'Найдите, чтобы добавить место в этот список',
'collections.showList': 'Показать список',
'collections.expandMap': 'Развернуть карту',
'collections.hero.mine': 'Моя коллекция',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': 'Redigera lista',
'collections.coverImage': 'Omslagsbild',
'collections.addCover': 'Lägg till omslag',
'collections.changeCover': 'Byt omslag',
'collections.description': 'Beskrivning',
'collections.descriptionPlaceholder': 'Lägg till en beskrivning…',
'collections.links': 'Länkar',
'collections.linkLabel': 'Etikett',
'collections.addLink': 'Lägg till länk',
'collections.addDetails': 'Lägg till detaljer',
'collections.addPlace': 'Lägg till en plats',
'collections.addPlaceSearch': 'Sök efter en plats…',
'collections.addPlaceHint': 'Sök för att lägga till en plats i listan',
'collections.showList': 'Visa lista',
'collections.expandMap': 'Förstora karta',
'collections.hero.mine': 'Min samling',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': 'Listeyi düzenle',
'collections.coverImage': 'Kapak resmi',
'collections.addCover': 'Kapak ekle',
'collections.changeCover': 'Kapağı değiştir',
'collections.description': 'Açıklama',
'collections.descriptionPlaceholder': 'Bir açıklama ekleyin…',
'collections.links': 'Bağlantılar',
'collections.linkLabel': 'Etiket',
'collections.addLink': 'Bağlantı ekle',
'collections.addDetails': 'Ayrıntı ekle',
'collections.addPlace': 'Yer ekle',
'collections.addPlaceSearch': 'Yer ara…',
'collections.addPlaceHint': 'Bu listeye yer eklemek için arayın',
'collections.showList': 'Listeyi göster',
'collections.expandMap': 'Haritayı genişlet',
'collections.hero.mine': 'Koleksiyonum',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': 'Редагувати список',
'collections.coverImage': 'Обкладинка',
'collections.addCover': 'Додати обкладинку',
'collections.changeCover': 'Змінити обкладинку',
'collections.description': 'Опис',
'collections.descriptionPlaceholder': 'Додайте опис…',
'collections.links': 'Посилання',
'collections.linkLabel': 'Підпис',
'collections.addLink': 'Додати посилання',
'collections.addDetails': 'Додати деталі',
'collections.addPlace': 'Додати місце',
'collections.addPlaceSearch': 'Пошук місця…',
'collections.addPlaceHint': 'Знайдіть, щоб додати місце до цього списку',
'collections.showList': 'Показати список',
'collections.expandMap': 'Розгорнути карту',
'collections.hero.mine': 'Моя колекція',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': 'Chỉnh sửa danh sách',
'collections.coverImage': 'Ảnh bìa',
'collections.addCover': 'Thêm ảnh bìa',
'collections.changeCover': 'Đổi ảnh bìa',
'collections.description': 'Mô tả',
'collections.descriptionPlaceholder': 'Thêm mô tả…',
'collections.links': 'Liên kết',
'collections.linkLabel': 'Nhãn',
'collections.addLink': 'Thêm liên kết',
'collections.addDetails': 'Thêm chi tiết',
'collections.addPlace': 'Thêm địa điểm',
'collections.addPlaceSearch': 'Tìm địa điểm…',
'collections.addPlaceHint': 'Tìm để thêm địa điểm vào danh sách này',
'collections.showList': 'Hiện danh sách',
'collections.expandMap': 'Mở rộng bản đồ',
'collections.hero.mine': 'Bộ sưu tập của tôi',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': '編輯清單',
'collections.coverImage': '封面圖片',
'collections.addCover': '新增封面',
'collections.changeCover': '更換封面',
'collections.description': '描述',
'collections.descriptionPlaceholder': '新增描述…',
'collections.links': '連結',
'collections.linkLabel': '標籤',
'collections.addLink': '新增連結',
'collections.addDetails': '新增詳情',
'collections.addPlace': '新增地點',
'collections.addPlaceSearch': '搜尋地點…',
'collections.addPlaceHint': '搜尋以將地點加入此清單',
'collections.showList': '顯示清單',
'collections.expandMap': '展開地圖',
'collections.hero.mine': '我的收藏',
+13
View File
@@ -1,6 +1,19 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.editListTitle': '编辑清单',
'collections.coverImage': '封面图片',
'collections.addCover': '添加封面',
'collections.changeCover': '更换封面',
'collections.description': '描述',
'collections.descriptionPlaceholder': '添加描述…',
'collections.links': '链接',
'collections.linkLabel': '标签',
'collections.addLink': '添加链接',
'collections.addDetails': '添加详情',
'collections.addPlace': '添加地点',
'collections.addPlaceSearch': '搜索地点…',
'collections.addPlaceHint': '搜索以将地点添加到此清单',
'collections.showList': '显示列表',
'collections.expandMap': '展开地图',
'collections.hero.mine': '我的收藏',