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 && ( - <> -
+
+
-
+
{canAddPlace && (
- +
)}
) 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);