diff --git a/client/src/components/Collections/CollectionMapPanel.tsx b/client/src/components/Collections/CollectionMapPanel.tsx
index 5d95218da..d87e169d2 100644
--- a/client/src/components/Collections/CollectionMapPanel.tsx
+++ b/client/src/components/Collections/CollectionMapPanel.tsx
@@ -46,8 +46,8 @@ export default function CollectionMapPanel({
dark={dark}
/>
{overlay && (
- <>
-
)
diff --git a/client/src/components/Collections/CollectionPlaceDetail.tsx b/client/src/components/Collections/CollectionPlaceDetail.tsx
index 450826457..20ec62488 100644
--- a/client/src/components/Collections/CollectionPlaceDetail.tsx
+++ b/client/src/components/Collections/CollectionPlaceDetail.tsx
@@ -8,6 +8,9 @@ import type { TranslationFn } from '../../types'
import MarkdownToolbar from '../Journey/MarkdownToolbar'
import StatusBadge from './StatusBadge'
import { entityGradient } from '../../utils/gradients'
+import { normalizeLinkUrl } from '../../pages/collections/collectionsModel'
+import { useToast } from '../shared/Toast'
+import { getApiErrorMessage } from '../../types'
function linkHost(url: string): string {
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
@@ -34,6 +37,7 @@ interface CollectionPlaceDetailProps {
export default function CollectionPlaceDetail({
place, canEdit, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
}: CollectionPlaceDetailProps): React.ReactElement {
+ const toast = useToast()
const [editing, setEditing] = useState(false)
const [name, setName] = useState(place.name)
const [description, setDescription] = useState(place.description ?? '')
@@ -58,11 +62,13 @@ export default function CollectionPlaceDetail({
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)
+ const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
setSaving(true)
try {
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks })
setEditing(false)
+ } catch (err) {
+ toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setSaving(false)
}
diff --git a/client/src/components/Collections/ListEditorModal.tsx b/client/src/components/Collections/ListEditorModal.tsx
index 1373d9926..48a9e5f1e 100644
--- a/client/src/components/Collections/ListEditorModal.tsx
+++ b/client/src/components/Collections/ListEditorModal.tsx
@@ -4,6 +4,7 @@ import Modal from '../shared/Modal'
import { useCollectionStore } from '../../store/collectionStore'
import { useToast } from '../shared/Toast'
import { getApiErrorMessage } from '../../types'
+import { normalizeLinkUrl } from '../../pages/collections/collectionsModel'
import type { TranslationFn } from '../../types'
import type { Collection, CollectionLink } from '@trek/shared'
@@ -37,6 +38,12 @@ export default function ListEditorModal({ target, onClose, onCreated, t }: ListE
const [coverFile, setCoverFile] = useState(null)
const [coverPreview, setCoverPreview] = useState(null)
const [saving, setSaving] = useState(false)
+ // Remembers a freshly created list id so a retry after a cover-upload failure
+ // updates it instead of creating a duplicate list.
+ const [createdId, setCreatedId] = useState(null)
+ const objectUrl = useRef(null)
+
+ const dropObjectUrl = () => { if (objectUrl.current) { URL.revokeObjectURL(objectUrl.current); objectUrl.current = null } }
// (Re)seed the form whenever the target changes.
useEffect(() => {
@@ -46,16 +53,24 @@ export default function ListEditorModal({ target, onClose, onCreated, t }: ListE
setDescription(editing?.description ?? '')
setLinks(editing?.links ?? [])
setCoverFile(null)
+ dropObjectUrl()
setCoverPreview(editing?.cover_image ?? null)
+ setCreatedId(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target])
+ // Revoke the last preview blob on unmount.
+ useEffect(() => () => dropObjectUrl(), [])
+
if (!target) return null
const pickCover = (file: File | undefined) => {
if (!file) return
+ dropObjectUrl()
+ const url = URL.createObjectURL(file)
+ objectUrl.current = url
setCoverFile(file)
- setCoverPreview(URL.createObjectURL(file))
+ setCoverPreview(url)
}
const setLink = (i: number, patch: Partial) =>
@@ -64,18 +79,22 @@ export default function ListEditorModal({ target, onClose, onCreated, t }: ListE
const save = async () => {
const trimmed = name.trim()
if (!trimmed) return
- // Keep only links with a url; drop blank rows.
+ // Normalise + keep only links with a url; drop blank rows.
const cleanLinks = links
- .map(l => ({ label: l.label?.trim() || undefined, url: l.url.trim() }))
+ .map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) }))
.filter(l => l.url)
+ const payload = { name: trimmed, color, description: description.trim() || null, links: cleanLinks }
setSaving(true)
try {
- let id = editing?.id
- if (editing) {
- await updateCollection(editing.id, { name: trimmed, color, description: description.trim() || null, links: cleanLinks })
+ // A prior create that failed at the cover step left `createdId` set — reuse
+ // it so a retry updates that list instead of creating a duplicate.
+ let id = editing?.id ?? createdId
+ if (id != null) {
+ await updateCollection(id, payload)
} else {
- const created = await createCollection({ name: trimmed, color, description: description.trim() || null, links: cleanLinks })
- id = created?.id
+ const created = await createCollection(payload)
+ id = created?.id ?? null
+ setCreatedId(id)
}
if (id != null && coverFile) await uploadCover(id, coverFile)
if (!editing && id != null) onCreated(id)
diff --git a/client/src/pages/collections/collectionsModel.ts b/client/src/pages/collections/collectionsModel.ts
index fda8becb2..feb185f65 100644
--- a/client/src/pages/collections/collectionsModel.ts
+++ b/client/src/pages/collections/collectionsModel.ts
@@ -73,3 +73,15 @@ export function statusCounts(places: CollectionPlace[]): Record typeof p.lat === 'number' && typeof p.lng === 'number')
}
+
+/**
+ * Normalise a user-typed link: prepend https:// when there's no scheme so the
+ * href is absolute (a bare "booking.com" would otherwise resolve relative to the
+ * SPA route and 404). Returns '' for blanks. The server further restricts to
+ * http/https.
+ */
+export function normalizeLinkUrl(url: string): string {
+ const u = url.trim()
+ if (!u) return ''
+ return /^https?:\/\//i.test(u) ? u : `https://${u.replace(/^\/+/, '')}`
+}
diff --git a/client/src/styles/collections.css b/client/src/styles/collections.css
index d1b34aeff..e8834ebf8 100644
--- a/client/src/styles/collections.css
+++ b/client/src/styles/collections.css
@@ -218,23 +218,29 @@
/* ----------------- map panel + floating controls ----------------- */
.trek-dash .col-map-shell { position: relative; width: 100%; height: 100%; }
-.trek-dash .col-map-controls { position: absolute; top: 12px; left: 12px; z-index: 20; display: flex; gap: 8px; }
+/* One top bar so the left cluster (collapse / select) and the right cluster
+ (add / search) share the width and never overlap on a narrow split map — the
+ search shrinks instead. */
+.trek-dash .col-map-topbar { position: absolute; top: 12px; left: 12px; right: 12px; z-index: 20; display: flex; align-items: center; gap: 10px; }
+.trek-dash .col-map-group { display: flex; align-items: center; gap: 8px; }
+.trek-dash .col-map-group.right { margin-left: auto; min-width: 0; }
.trek-dash .col-map-btn {
- width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; color: var(--ink);
+ width: 38px; height: 38px; flex-shrink: 0; 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, color .15s;
}
.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 {
- display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px 0 12px; border-radius: 11px; color: var(--ink-3);
+ display: flex; align-items: center; gap: 8px; height: 38px; padding: 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);
+ flex: 0 1 auto; min-width: 0;
}
+.trek-dash .col-map-search svg { flex-shrink: 0; }
.trek-dash .col-map-search input {
- width: 180px; background: none; border: 0; outline: none; color: var(--ink); font-size: 13.5px; font-family: inherit;
+ flex: 1 1 auto; width: 150px; min-width: 0; background: none; border: 0; outline: none; color: var(--ink); font-size: 13.5px; font-family: inherit;
}
.trek-dash .col-map-search input::placeholder { color: var(--ink-3); }
diff --git a/client/src/styles/dashboard.css b/client/src/styles/dashboard.css
index 443dbb49e..26fef4bfb 100644
--- a/client/src/styles/dashboard.css
+++ b/client/src/styles/dashboard.css
@@ -553,8 +553,8 @@
.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%);
+ linear-gradient(90deg, color-mix(in oklch, var(--badge-color, #6366f1) 88%, black) 0%, color-mix(in oklch, var(--badge-color, #6366f1) 40%, transparent) 62%, rgba(0,0,0,.3) 100%),
+ linear-gradient(180deg, rgba(0,0,0,0) 40%, rgba(0,0,0,.4) 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; }
diff --git a/server/src/nest/collections/collections.controller.ts b/server/src/nest/collections/collections.controller.ts
index 83e40b463..3a8801869 100644
--- a/server/src/nest/collections/collections.controller.ts
+++ b/server/src/nest/collections/collections.controller.ts
@@ -26,6 +26,7 @@ import { CollectionsAddonGuard } from './collections-addon.guard';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
+import { isDemoEmail } from '../../services/demo';
import {
collectionCreateRequestSchema,
collectionUpdateRequestSchema,
@@ -253,6 +254,9 @@ 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) {
+ 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);
diff --git a/shared/src/collection/collection.schema.ts b/shared/src/collection/collection.schema.ts
index a0514a86a..0d7f12330 100644
--- a/shared/src/collection/collection.schema.ts
+++ b/shared/src/collection/collection.schema.ts
@@ -9,7 +9,8 @@ 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),
+ // http/https only — blocks javascript:/data: hrefs and forces an absolute link.
+ url: z.string().trim().max(2000).regex(/^https?:\/\/.+/i),
});
export type CollectionLink = z.infer;
export const collectionLinksSchema = z.array(collectionLinkSchema).max(30);