mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(collections): hero edit/share row, place photos, edit-echo fix, wider page (#1081)
- 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.
This commit is contained in:
@@ -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({
|
||||
)}
|
||||
<div className="col-hero-scrim" />
|
||||
|
||||
<div className="col-hero-actions">
|
||||
{canShare && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShare}
|
||||
aria-label={isOwner ? t('collections.share.button') : t('collections.shared')}
|
||||
title={isOwner ? t('collections.share.button') : t('collections.shared')}
|
||||
className={`col-glass-btn${isOwner && shareMemberCount > 0 ? ' has-count' : ''}`}
|
||||
>
|
||||
{isOwner ? <Share2 size={15} /> : <Users size={15} />}
|
||||
<span className="txt">{isOwner ? t('collections.share.button') : t('collections.shared')}</span>
|
||||
{isOwner && shareMemberCount > 0 && <span className="cnt">{shareMemberCount}</span>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-hero-content">
|
||||
<div className="col-hero-eyebrow">
|
||||
<span>{eyebrow}</span>
|
||||
@@ -98,7 +84,30 @@ export default function CollectionHero({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="col-hero-title">{title}</h1>
|
||||
<div className="col-hero-titlerow">
|
||||
<h1 className="col-hero-title">{title}</h1>
|
||||
<div className="col-hero-actions">
|
||||
{canEdit && (
|
||||
<button type="button" onClick={onEdit} aria-label={t('common.edit')} title={t('common.edit')} className="col-glass-btn">
|
||||
<Pencil size={15} />
|
||||
<span className="txt">{t('common.edit')}</span>
|
||||
</button>
|
||||
)}
|
||||
{canShare && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShare}
|
||||
aria-label={isOwner ? t('collections.share.button') : t('collections.shared')}
|
||||
title={isOwner ? t('collections.share.button') : t('collections.shared')}
|
||||
className={`col-glass-btn${isOwner && shareMemberCount > 0 ? ' has-count' : ''}`}
|
||||
>
|
||||
{isOwner ? <Share2 size={15} /> : <Users size={15} />}
|
||||
<span className="txt">{isOwner ? t('collections.share.button') : t('collections.shared')}</span>
|
||||
{isOwner && shareMemberCount > 0 && <span className="cnt">{shareMemberCount}</span>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{description && <p className="col-hero-desc">{description}</p>}
|
||||
</div>
|
||||
|
||||
@@ -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<CollectionLink[]>(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<string | null>(null)
|
||||
const descRef = useRef<HTMLTextAreaElement>(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<CollectionLink>) => 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 ?? []) }
|
||||
|
||||
|
||||
@@ -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={
|
||||
<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 className="flex items-center gap-2">
|
||||
{editing && editing.is_owner !== false && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onClose(); onRequestDelete(editing.id) }}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-danger text-[13px] font-medium hover:bg-danger-soft"
|
||||
>
|
||||
<Trash2 size={14} /> {t('collections.deleteList')}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 ml-auto">
|
||||
<button type="button" onClick={onClose} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={save} disabled={!name.trim() || saving} className="px-3 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50">
|
||||
{editing ? t('common.save') : t('collections.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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<HTMLDivElement>(null)
|
||||
const btnRef = useRef<HTMLButtonElement>(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(
|
||||
<div
|
||||
data-list-pop
|
||||
className="fixed z-[300] min-w-[172px] p-1.5 rounded-xl bg-surface-card border border-edge shadow-dropdown"
|
||||
style={{ top: anchor.top, right: anchor.right }}
|
||||
>
|
||||
<button type="button" onClick={() => { setOpen(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('common.edit')}
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button type="button" onClick={() => { setOpen(false); onRequestDelete(list.id) }} className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-danger hover:bg-danger-soft">
|
||||
<Trash2 size={14} /> {t('collections.deleteList')}
|
||||
</button>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
|
||||
function ListRow({ list, active, onSelect }: { list: Collection; active: boolean; onSelect: (id: number) => void }): React.ReactElement {
|
||||
return (
|
||||
<div ref={rowRef} className={`col-row${open ? ' menu-open' : ''}`}>
|
||||
<div className="col-row">
|
||||
<button type="button" onClick={() => onSelect(list.id)} className={`col-row-btn${active ? ' on' : ''}`}>
|
||||
<span className="dot" style={{ background: list.color || '#6366f1' }} />
|
||||
<span className="nm">{list.name}</span>
|
||||
<span className="ct">{list.place_count ?? 0}</span>
|
||||
</button>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="col-row-menu"
|
||||
aria-label={t('collections.listMenu')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
{popover}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 && <div className="col-rail-sep" />}
|
||||
{ownedLists.map(list => (
|
||||
<ListRow key={list.id} list={list} active={activeId === list.id} onSelect={onSelect} onEdit={onEdit} onRequestDelete={onRequestDelete} t={t} />
|
||||
<ListRow key={list.id} list={list} active={activeId === list.id} onSelect={onSelect} />
|
||||
))}
|
||||
|
||||
{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} onSelect={onSelect} onEdit={onEdit} onRequestDelete={onRequestDelete} t={t} />
|
||||
<ListRow key={list.id} list={list} active={activeId === list.id} onSelect={onSelect} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 */}
|
||||
<ListEditorModal target={c.editorTarget} onClose={() => c.setEditorTarget(null)} onCreated={c.handleEditorCreated} t={t} />
|
||||
<ListEditorModal target={c.editorTarget} onClose={() => c.setEditorTarget(null)} onCreated={c.handleEditorCreated} onRequestDelete={c.setConfirmDeleteList} t={t} />
|
||||
|
||||
{/* Delete-list confirm */}
|
||||
<Modal
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
/* Sticky left list rail + fluid body. */
|
||||
.trek-dash .col-page {
|
||||
max-width: 1720px; margin: 0 auto; padding: 30px 44px 110px;
|
||||
max-width: min(2100px, 95vw); margin: 0 auto; padding: 30px 44px 110px;
|
||||
display: grid; grid-template-columns: 272px minmax(0, 1fr); gap: 30px; align-items: start;
|
||||
}
|
||||
.trek-dash .col-body { min-width: 0; }
|
||||
@@ -113,7 +113,8 @@
|
||||
position: relative; z-index: 2; width: 100%; padding: 18px 28px 20px;
|
||||
display: flex; flex-direction: column; justify-content: flex-end; gap: 9px; color: #fff;
|
||||
}
|
||||
.trek-dash .col-hero-actions { position: absolute; top: 20px; right: 22px; z-index: 3; display: flex; gap: 8px; }
|
||||
.trek-dash .col-hero-titlerow { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
|
||||
.trek-dash .col-hero-actions { display: flex; gap: 8px; flex-shrink: 0; align-items: center; margin-bottom: 3px; }
|
||||
.trek-dash .col-glass-btn {
|
||||
display: inline-flex; align-items: center; gap: 7px; height: 38px; padding: 0 15px; border-radius: 999px;
|
||||
background: rgba(255,255,255,.16); border: 1px solid rgba(255,255,255,.24); color: #fff;
|
||||
@@ -248,7 +249,7 @@
|
||||
/* ----------------- list view ----------------- */
|
||||
.trek-dash .col-listview { display: flex; flex-direction: column; gap: 7px; }
|
||||
.trek-dash .col-lrow {
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 14px; align-items: center;
|
||||
display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; gap: 14px; align-items: center;
|
||||
padding: 10px 14px; border-radius: var(--r-md);
|
||||
background: var(--glass-bg); border: 1px solid var(--glass-border); box-shadow: var(--sh-xs);
|
||||
cursor: pointer; transition: transform .12s, box-shadow .15s, border-color .15s;
|
||||
|
||||
@@ -110,8 +110,8 @@ export class CollectionsController {
|
||||
// ── Places (static prefixes before /:id) ────────────────────────────────────
|
||||
@Post('places')
|
||||
@HttpCode(200)
|
||||
savePlace(@CurrentUser() user: User, @Body(new ZodValidationPipe(collectionSavePlaceRequestSchema)) body: CollectionSavePlaceRequest) {
|
||||
return this.collections.savePlace(user.id, body);
|
||||
savePlace(@CurrentUser() user: User, @Body(new ZodValidationPipe(collectionSavePlaceRequestSchema)) body: CollectionSavePlaceRequest, @Headers('x-socket-id') socketId?: string) {
|
||||
return this.collections.savePlace(user.id, body, socketId);
|
||||
}
|
||||
|
||||
@Post('places/from-trip')
|
||||
@@ -122,11 +122,11 @@ export class CollectionsController {
|
||||
|
||||
@Post('places/delete-many')
|
||||
@HttpCode(200)
|
||||
deleteMany(@CurrentUser() user: User, @Body('ids') ids: unknown) {
|
||||
deleteMany(@CurrentUser() user: User, @Body('ids') ids: unknown, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!Array.isArray(ids) || !ids.every((v) => 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')
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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<number>();
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user