+
-
- {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;
}