fix(collections): review follow-ups on the B–E work (#1081)

- Block the list-cover upload in demo mode (mirror the trips cover endpoint).
- Restrict list/place links to http(s) (schema) and normalise scheme-less URLs
  to https:// on save, so a bare "booking.com" no longer resolves as a relative
  SPA route (and javascript:/data: hrefs are rejected).
- Place detail: surface save errors with a toast instead of silently swallowing
  a 400 and leaving the sheet stuck in edit mode.
- List editor: don't create a duplicate list when a retry follows a cover-upload
  failure (reuse the created id); revoke the cover preview object URL.
- Map controls: one top bar (left toggle/select, right add/search) so they can't
  overlap on a narrow split map — the search shrinks instead.
- Dashboard list badge: full-opacity colour wash so the name stays legible over
  bright covers.
This commit is contained in:
Maurice
2026-07-01 11:24:36 +02:00
committed by Maurice
parent 68e101ed37
commit de4077d461
8 changed files with 69 additions and 21 deletions
@@ -46,8 +46,8 @@ export default function CollectionMapPanel({
dark={dark}
/>
{overlay && (
<>
<div className="col-map-controls">
<div className="col-map-topbar">
<div className="col-map-group">
<button
type="button"
onClick={onToggleView}
@@ -69,7 +69,7 @@ export default function CollectionMapPanel({
</button>
)}
</div>
<div className="col-map-topright">
<div className="col-map-group right">
{canAddPlace && (
<button type="button" onClick={onAddPlace} className="col-map-btn" aria-label={t('collections.addPlace')} title={t('collections.addPlace')}>
<Plus size={17} />
@@ -80,7 +80,7 @@ export default function CollectionMapPanel({
<input value={search} onChange={e => onSearch(e.target.value)} placeholder={t('collections.search')} />
</div>
</div>
</>
</div>
)}
</div>
)
@@ -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)
}
@@ -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<File | null>(null)
const [coverPreview, setCoverPreview] = useState<string | null>(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<number | null>(null)
const objectUrl = useRef<string | null>(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<CollectionLink>) =>
@@ -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)
@@ -73,3 +73,15 @@ export function statusCounts(places: CollectionPlace[]): Record<StatusFilter, nu
export function mappablePlaces(places: CollectionPlace[]): CollectionPlace[] {
return places.filter(p => 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(/^\/+/, '')}`
}
+11 -5
View File
@@ -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); }
+2 -2
View File
@@ -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; }
@@ -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);
+2 -1
View File
@@ -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<typeof collectionLinkSchema>;
export const collectionLinksSchema = z.array(collectionLinkSchema).max(30);