mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user