From b8ceb081fad99733d046ce59372cb13b714de885 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 1 Jul 2026 14:09:50 +0200 Subject: [PATCH] feat(collections): hero edit/share row, place photos, edit-echo fix, wider page (#1081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Editing a place (category, status, …) no longer reloads the view: the mutating client's own socket is now excluded from the WS broadcast (x-socket-id threaded through save/update/status/delete + list update/cover), so the optimistic update stands on its own instead of being chased by an echoed refetch. - Detail sheet pulls a higher-res cover photo from the maps provider when the place has no image of its own (the avatar thumbnail was too low-res). - Hero: Share moved onto the title row (no more empty top band) with an Edit button beside it; editing/deleting a list now happens there. The list rail drops its per-row kebab entirely (and with it the janky open animation). - The list editor can delete the collection from its footer (owner only). - Wider, screen-relative page (max-width min(2100px, 95vw)). - List rows: the place avatar no longer shrinks when the address is long. --- .../components/Collections/CollectionHero.tsx | 47 ++++---- .../Collections/CollectionPlaceDetail.tsx | 20 +++- .../Collections/ListEditorModal.tsx | 29 +++-- .../src/components/Collections/ListsRail.tsx | 100 ++---------------- client/src/pages/CollectionsPage.tsx | 6 +- client/src/styles/collections.css | 7 +- .../collections/collections.controller.ts | 26 ++--- .../nest/collections/collections.service.ts | 14 +-- server/src/services/collectionsService.ts | 30 +++--- 9 files changed, 121 insertions(+), 158 deletions(-) diff --git a/client/src/components/Collections/CollectionHero.tsx b/client/src/components/Collections/CollectionHero.tsx index df8cf7cd2..1bf5fc658 100644 --- a/client/src/components/Collections/CollectionHero.tsx +++ b/client/src/components/Collections/CollectionHero.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Share2, Users, Link2 } from 'lucide-react' +import { Share2, Users, Link2, Pencil } from 'lucide-react' import type { CollectionMember, CollectionLink } from '@trek/shared' import type { TranslationFn } from '../../types' @@ -21,6 +21,8 @@ interface CollectionHeroProps { members: CollectionMember[] canShare: boolean isOwner: boolean + canEdit: boolean + onEdit: () => void shareMemberCount: number onShare: () => void t: TranslationFn @@ -39,7 +41,7 @@ function linkHost(url: string): string { export default function CollectionHero({ eyebrow, title, color, coverImage, description, links, - members, canShare, isOwner, shareMemberCount, onShare, t, + members, canShare, isOwner, canEdit, onEdit, shareMemberCount, onShare, t, }: CollectionHeroProps): React.ReactElement { const accepted = members.filter(m => m.status === 'accepted' || m.is_owner) const showAvatars = accepted.length > 1 @@ -58,22 +60,6 @@ export default function CollectionHero({ )}
-
- {canShare && ( - - )} -
-
{eyebrow} @@ -98,7 +84,30 @@ export default function CollectionHero({ )}
-

{title}

+
+

{title}

+
+ {canEdit && ( + + )} + {canShare && ( + + )} +
+
{description &&

{description}

}
diff --git a/client/src/components/Collections/CollectionPlaceDetail.tsx b/client/src/components/Collections/CollectionPlaceDetail.tsx index 66f64847c..eb28826fb 100644 --- a/client/src/components/Collections/CollectionPlaceDetail.tsx +++ b/client/src/components/Collections/CollectionPlaceDetail.tsx @@ -6,6 +6,7 @@ import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag import type { CollectionPlace, CollectionStatus, CollectionLink } from '@trek/shared' import type { Category, TranslationFn } from '../../types' import MarkdownToolbar from '../Journey/MarkdownToolbar' +import { mapsApi } from '../../api/client' import { entityGradient } from '../../utils/gradients' import { getCategoryIcon } from '../shared/categoryIcons' import { STATUS_META, STATUS_ORDER, normalizeLinkUrl } from '../../pages/collections/collectionsModel' @@ -63,6 +64,9 @@ export default function CollectionPlaceDetail({ const [description, setDescription] = useState(place.description ?? '') const [links, setLinks] = useState(place.links ?? []) const [saving, setSaving] = useState(false) + // A higher-res photo pulled from the maps provider when the place has none of + // its own — the list avatar's little thumbnail is too low-res for the cover. + const [fetchedPhoto, setFetchedPhoto] = useState(null) const descRef = useRef(null) // Reset only when a DIFFERENT place is opened (keyed on id, not on every field). @@ -75,7 +79,21 @@ export default function CollectionPlaceDetail({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [place.id]) - const banner = place.image_url + // Fetch a cover photo when the place doesn't carry its own image. + useEffect(() => { + setFetchedPhoto(null) + if (place.image_url) return + const photoId = place.google_place_id || place.osm_id || (place.lat != null && place.lng != null ? `${place.lat},${place.lng}` : null) + if (!photoId) return + let cancelled = false + mapsApi.placePhoto(photoId, place.lat ?? undefined, place.lng ?? undefined, place.name) + .then(res => { if (!cancelled && res?.photoUrl) setFetchedPhoto(res.photoUrl) }) + .catch(() => {}) + return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [place.id]) + + const banner = place.image_url || fetchedPhoto const setLink = (i: number, patch: Partial) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l))) const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []) } diff --git a/client/src/components/Collections/ListEditorModal.tsx b/client/src/components/Collections/ListEditorModal.tsx index 48a9e5f1e..bbdab3f45 100644 --- a/client/src/components/Collections/ListEditorModal.tsx +++ b/client/src/components/Collections/ListEditorModal.tsx @@ -15,6 +15,8 @@ interface ListEditorModalProps { target: Collection | 'new' | null onClose: () => void onCreated: (id: number) => void + /** Owner-only: hand off to the delete-confirm flow when editing a list. */ + onRequestDelete: (id: number) => void t: TranslationFn } @@ -23,7 +25,7 @@ interface ListEditorModalProps { * 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 { +export default function ListEditorModal({ target, onClose, onCreated, onRequestDelete, t }: ListEditorModalProps): React.ReactElement | null { const createCollection = useCollectionStore(s => s.createCollection) const updateCollection = useCollectionStore(s => s.updateCollection) const uploadCover = useCollectionStore(s => s.uploadCover) @@ -113,13 +115,24 @@ export default function ListEditorModal({ target, onClose, onCreated, t }: ListE title={editing ? t('collections.editListTitle') : t('collections.newList')} size="md" footer={ -
- - +
+ {editing && editing.is_owner !== false && ( + + )} +
+ + +
} > diff --git a/client/src/components/Collections/ListsRail.tsx b/client/src/components/Collections/ListsRail.tsx index 772bb4d59..94fbab693 100644 --- a/client/src/components/Collections/ListsRail.tsx +++ b/client/src/components/Collections/ListsRail.tsx @@ -1,6 +1,5 @@ -import React, { useEffect, useRef, useState } from 'react' -import ReactDOM from 'react-dom' -import { Plus, Layers, MoreHorizontal, Pencil, Trash2, Users } from 'lucide-react' +import React from 'react' +import { Plus, Layers, Users } from 'lucide-react' import type { Collection } from '@trek/shared' import type { TranslationFn } from '../../types' import { ALL_SAVED } from '../../store/collectionStore' @@ -13,112 +12,33 @@ interface ListsRailProps { incomingInvites: IncomingCollectionInvite[] onSelect: (id: ActiveCollectionId) => void onNewList: () => void - onEdit: (list: Collection) => void - onRequestDelete: (id: number) => void onAcceptInvite: (id: number) => void onDeclineInvite: (id: number) => void t: TranslationFn } -interface ListRowProps { - list: Collection - active: boolean - onSelect: (id: number) => void - onEdit: (list: Collection) => void - onRequestDelete: (id: number) => void - t: TranslationFn -} - -function ListRow({ list, active, onSelect, onEdit, onRequestDelete, t }: ListRowProps): React.ReactElement { - const [open, setOpen] = useState(false) - const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(null) - const rowRef = useRef(null) - const btnRef = useRef(null) - // Owner or accepted member may edit any accessible list; only the owner may - // delete it. - const canDelete = list.is_owner !== false - - const toggle = (e: React.MouseEvent) => { - e.stopPropagation() - if (open) { setOpen(false); return } - const r = btnRef.current?.getBoundingClientRect() - if (r) setAnchor({ top: r.bottom + 6, right: Math.max(8, window.innerWidth - r.right) }) - setOpen(true) - } - - useEffect(() => { - if (!open) return - const close = () => setOpen(false) - const onDown = (e: MouseEvent) => { - const el = e.target as HTMLElement - if (rowRef.current?.contains(el) || el.closest?.('[data-list-pop]')) return - close() - } - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close() } - document.addEventListener('mousedown', onDown, true) - document.addEventListener('keydown', onKey) - window.addEventListener('resize', close) - window.addEventListener('scroll', close, true) - return () => { - document.removeEventListener('mousedown', onDown, true) - document.removeEventListener('keydown', onKey) - window.removeEventListener('resize', close) - window.removeEventListener('scroll', close, true) - } - }, [open]) - - const popover = open && anchor && ReactDOM.createPortal( -
- - {canDelete && ( - - )} -
, - document.body, - ) - +function ListRow({ list, active, onSelect }: { list: Collection; active: boolean; onSelect: (id: number) => void }): React.ReactElement { return ( -
+
- - {popover}
) } /** * Left rail of the user's lists: a "New list" action, the "All saved" union - * pseudo-list, owned lists (colour dot + count + an always-visible kebab → - * edit/delete), a shared section, and an incoming-invites block. Editing opens - * the shared ListEditorModal (name/colour/cover/description/links) via onEdit. + * pseudo-list, owned lists (colour dot + count), a shared section, and an + * incoming-invites block. Selecting a list makes it active; editing / deleting + * happens from the Edit button in the hero of the active list. */ export default function ListsRail(props: ListsRailProps): React.ReactElement { const { ownedLists, sharedLists, activeId, incomingInvites, - onSelect, onNewList, onEdit, onRequestDelete, - onAcceptInvite, onDeclineInvite, t, + onSelect, onNewList, onAcceptInvite, onDeclineInvite, t, } = props return ( @@ -136,14 +56,14 @@ export default function ListsRail(props: ListsRailProps): React.ReactElement { {ownedLists.length > 0 &&
} {ownedLists.map(list => ( - + ))} {sharedLists.length > 0 && ( <>
{t('collections.shared')}
{sharedLists.map(list => ( - + ))} )} diff --git a/client/src/pages/CollectionsPage.tsx b/client/src/pages/CollectionsPage.tsx index 295c9c4cf..1edc05a11 100644 --- a/client/src/pages/CollectionsPage.tsx +++ b/client/src/pages/CollectionsPage.tsx @@ -143,8 +143,6 @@ export default function CollectionsPage(): React.ReactElement { incomingInvites={c.incomingInvites} onSelect={c.handleSelectList} onNewList={() => { c.setMobileRailOpen(false); c.setEditorTarget('new') }} - onEdit={list => { c.setMobileRailOpen(false); c.setEditorTarget(list) }} - onRequestDelete={c.setConfirmDeleteList} onAcceptInvite={c.handleAcceptInvite} onDeclineInvite={c.handleDeclineInvite} t={t} @@ -183,6 +181,8 @@ export default function CollectionsPage(): React.ReactElement { members={c.members} canShare={c.canShare} isOwner={c.isOwner} + canEdit={!c.isAllSaved && c.isOwner && c.activeCollection != null} + onEdit={() => { if (c.activeCollection) c.setEditorTarget(c.activeCollection) }} shareMemberCount={c.shareMemberCount} onShare={() => c.setShowShare(true)} t={t} @@ -321,7 +321,7 @@ export default function CollectionsPage(): React.ReactElement { )} {/* Create / edit a list — name, colour, cover, description, links */} - c.setEditorTarget(null)} onCreated={c.handleEditorCreated} t={t} /> + c.setEditorTarget(null)} onCreated={c.handleEditorCreated} onRequestDelete={c.setConfirmDeleteList} t={t} /> {/* Delete-list confirm */} Number.isFinite(Number(v)))) { throw new HttpException({ error: 'ids must be an array of numbers' }, 400); } - return { deleted: this.collections.deletePlacesMany(user.id, ids.map(Number)) }; + return { deleted: this.collections.deletePlacesMany(user.id, ids.map(Number), socketId) }; } @Patch('places/:pid') @@ -134,8 +134,9 @@ export class CollectionsController { @CurrentUser() user: User, @Param('pid') pid: string, @Body(new ZodValidationPipe(collectionPlaceUpdateRequestSchema)) body: CollectionPlaceUpdateRequest, + @Headers('x-socket-id') socketId?: string, ) { - return this.collections.updatePlace(user.id, Number(pid), body); + return this.collections.updatePlace(user.id, Number(pid), body, socketId); } @Post('places/:pid/status') @@ -144,13 +145,14 @@ export class CollectionsController { @CurrentUser() user: User, @Param('pid') pid: string, @Body(new ZodValidationPipe(collectionSetStatusRequestSchema)) body: CollectionSetStatusRequest, + @Headers('x-socket-id') socketId?: string, ) { - return this.collections.setStatus(user.id, Number(pid), body.status); + return this.collections.setStatus(user.id, Number(pid), body.status, socketId); } @Delete('places/:pid') - deletePlace(@CurrentUser() user: User, @Param('pid') pid: string) { - this.collections.deletePlace(user.id, Number(pid)); + deletePlace(@CurrentUser() user: User, @Param('pid') pid: string, @Headers('x-socket-id') socketId?: string) { + this.collections.deletePlace(user.id, Number(pid), socketId); return { success: true }; } @@ -253,13 +255,13 @@ export class CollectionsController { @Post(':id/cover') @UseInterceptors(FileInterceptor('cover', COVER_UPLOAD)) - uploadCover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined) { + uploadCover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined, @Headers('x-socket-id') socketId?: string) { if (process.env.DEMO_MODE?.toLowerCase() === 'true' && isDemoEmail(user.email)) { throw new HttpException({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, 403); } 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); + return this.collections.setCollectionCover(user.id, Number(id), coverUrl, socketId); } @Get(':id') @@ -268,8 +270,8 @@ export class CollectionsController { } @Patch(':id') - update(@CurrentUser() user: User, @Param('id') id: string, @Body(new ZodValidationPipe(collectionUpdateRequestSchema)) body: CollectionUpdateRequest) { - return this.collections.updateCollection(user.id, Number(id), body); + update(@CurrentUser() user: User, @Param('id') id: string, @Body(new ZodValidationPipe(collectionUpdateRequestSchema)) body: CollectionUpdateRequest, @Headers('x-socket-id') socketId?: string) { + return this.collections.updateCollection(user.id, Number(id), body, socketId); } @Delete(':id') diff --git a/server/src/nest/collections/collections.service.ts b/server/src/nest/collections/collections.service.ts index bc5066321..40f2ab5e0 100644 --- a/server/src/nest/collections/collections.service.ts +++ b/server/src/nest/collections/collections.service.ts @@ -19,19 +19,19 @@ export class CollectionsService { listCollections(userId: number) { return svc.listCollections(userId); } 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); } + updateCollection(userId: number, id: number, body: CollectionUpdateRequest, socketId?: string) { return svc.updateCollection(userId, id, body, socketId); } + setCollectionCover(userId: number, id: number, coverUrl: string | null, socketId?: string) { return svc.setCollectionCover(userId, id, coverUrl, socketId); } deleteCollection(userId: number, id: number) { return svc.deleteCollection(userId, id); } reorderCollections(userId: number, orderedIds: number[]) { return svc.reorderCollections(userId, orderedIds); } - savePlace(userId: number, body: CollectionSavePlaceRequest) { return svc.savePlace(userId, body); } + savePlace(userId: number, body: CollectionSavePlaceRequest, socketId?: string) { return svc.savePlace(userId, body, socketId); } saveFromTripPlace(userId: number, collectionId: number, tripId: number, placeId: number, force?: boolean) { return svc.saveFromTripPlace(userId, collectionId, tripId, placeId, force); } - updatePlace(userId: number, placeId: number, body: CollectionPlaceUpdateRequest) { return svc.updatePlace(userId, placeId, body); } - setStatus(userId: number, placeId: number, status: CollectionStatus) { return svc.setStatus(userId, placeId, status); } - deletePlace(userId: number, placeId: number) { return svc.deletePlace(userId, placeId); } - deletePlacesMany(userId: number, ids: number[]) { return svc.deletePlacesMany(userId, ids); } + updatePlace(userId: number, placeId: number, body: CollectionPlaceUpdateRequest, socketId?: string) { return svc.updatePlace(userId, placeId, body, socketId); } + setStatus(userId: number, placeId: number, status: CollectionStatus, socketId?: string) { return svc.setStatus(userId, placeId, status, socketId); } + deletePlace(userId: number, placeId: number, socketId?: string) { return svc.deletePlace(userId, placeId, socketId); } + deletePlacesMany(userId: number, ids: number[], socketId?: string) { return svc.deletePlacesMany(userId, ids, socketId); } copyToTrip(userId: number, body: CollectionCopyToTripRequest) { return svc.copyToTrip(userId, body); } findMembership(userId: number, query: { google_place_id?: string; google_ftid?: string; name?: string; lat?: number; lng?: number }) { diff --git a/server/src/services/collectionsService.ts b/server/src/services/collectionsService.ts index f76ec1374..d85778ecc 100644 --- a/server/src/services/collectionsService.ts +++ b/server/src/services/collectionsService.ts @@ -238,7 +238,7 @@ export function createCollection(userId: number, body: CollectionCreateRequest): return { ...col, is_owner: true }; } -export function updateCollection(userId: number, id: number, body: CollectionUpdateRequest): Collection { +export function updateCollection(userId: number, id: number, body: CollectionUpdateRequest, socketId?: string): Collection { assertAccess(userId, id); const updates: string[] = []; const params: (string | number | null)[] = []; @@ -254,18 +254,18 @@ export function updateCollection(userId: number, id: number, body: CollectionUpd params.push(id); db.prepare(`UPDATE collections SET ${updates.join(', ')} WHERE id = ?`).run(...params); } - notifyCollectionUsers(id, undefined, 'collections:updated'); + notifyCollectionUsers(id, socketId, 'collections:updated'); const col = getCollectionRow(id); 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 { +export function setCollectionCover(userId: number, id: number, coverUrl: string | null, socketId?: string): 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'); + notifyCollectionUsers(id, socketId, 'collections:updated'); const col = getCollectionRow(id); return { ...col, is_owner: col.owner_id === userId }; } @@ -366,7 +366,7 @@ function attachTags(collectionPlaceId: number, tagIds: number[] | undefined): vo for (const tid of tagIds) stmt.run(collectionPlaceId, tid); } -export function savePlace(userId: number, body: CollectionSavePlaceRequest): CollectionSaveResult { +export function savePlace(userId: number, body: CollectionSavePlaceRequest, socketId?: string): CollectionSaveResult { assertAccess(userId, body.collection_id); if (!body.force) { @@ -393,7 +393,7 @@ export function savePlace(userId: number, body: CollectionSavePlaceRequest): Col const placeId = Number(result.lastInsertRowid); attachTags(placeId, body.tag_ids); - notifyCollectionUsers(body.collection_id, undefined, 'collections:updated'); + notifyCollectionUsers(body.collection_id, socketId, 'collections:updated'); return { place: getPlaceById(placeId) }; } @@ -429,7 +429,7 @@ export function saveFromTripPlace( }); } -export function updatePlace(userId: number, placeId: number, body: import('@trek/shared').CollectionPlaceUpdateRequest): CollectionPlace { +export function updatePlace(userId: number, placeId: number, body: import('@trek/shared').CollectionPlaceUpdateRequest, socketId?: string): CollectionPlace { const currentCollection = collectionIdOfPlace(placeId); assertAccess(userId, currentCollection); @@ -461,27 +461,27 @@ export function updatePlace(userId: number, placeId: number, body: import('@trek attachTags(placeId, body.tag_ids); } - notifyCollectionUsers(currentCollection, undefined, 'collections:updated'); - if (movedTo) notifyCollectionUsers(movedTo, undefined, 'collections:updated'); + notifyCollectionUsers(currentCollection, socketId, 'collections:updated'); + if (movedTo) notifyCollectionUsers(movedTo, socketId, 'collections:updated'); return getPlaceById(placeId); } -export function setStatus(userId: number, placeId: number, status: CollectionStatus): CollectionPlace { +export function setStatus(userId: number, placeId: number, status: CollectionStatus, socketId?: string): CollectionPlace { const collectionId = collectionIdOfPlace(placeId); assertAccess(userId, collectionId); db.prepare("UPDATE collection_places SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(status, placeId); - notifyCollectionUsers(collectionId, undefined, 'collections:updated'); + notifyCollectionUsers(collectionId, socketId, 'collections:updated'); return getPlaceById(placeId); } -export function deletePlace(userId: number, placeId: number): void { +export function deletePlace(userId: number, placeId: number, socketId?: string): void { const collectionId = collectionIdOfPlace(placeId); assertAccess(userId, collectionId); db.prepare('DELETE FROM collection_places WHERE id = ?').run(placeId); // CASCADE drops tags. NO photo-cache reclaim. - notifyCollectionUsers(collectionId, undefined, 'collections:updated'); + notifyCollectionUsers(collectionId, socketId, 'collections:updated'); } -export function deletePlacesMany(userId: number, ids: number[]): number[] { +export function deletePlacesMany(userId: number, ids: number[], socketId?: string): number[] { const deleted: number[] = []; const touched = new Set(); for (const id of ids) { @@ -491,7 +491,7 @@ export function deletePlacesMany(userId: number, ids: number[]): number[] { deleted.push(id); touched.add(collectionId); } - touched.forEach(cid => notifyCollectionUsers(cid, undefined, 'collections:updated')); + touched.forEach(cid => notifyCollectionUsers(cid, socketId, 'collections:updated')); return deleted; }