From 3678761a2d3368a5b94744c0bdd3d0c376853add Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:33:31 +0200 Subject: [PATCH] feat(places): custom place image (#1136) (#1641) * feat(places): upload a custom place image on the server (#1136) * i18n(places): custom place image labels * feat(places): click a place thumbnail to set a custom image * feat(places): custom place image on mobile place and collection sheets * test(places): cover custom place and collection image upload * docs(places): document custom place images * fix(places): carry osm_id on the embedded place so day-plan thumbnails auto-fetch (#1136) * feat(places): custom tooltip on the place image controls * fix(places): show a custom place image on map markers, skip the redundant photo fetch (#1136) --- client/src/api/client.ts | 6 + client/src/api/collections.ts | 2 + .../CollectionPlaceDetail.test.tsx | 22 ++++ .../Collections/CollectionPlaceDetail.tsx | 65 +++++++++- client/src/components/Map/MapView.tsx | 14 ++- client/src/components/Map/MapViewGL.tsx | 10 +- client/src/components/Map/placePhoto.ts | 10 ++ .../Planner/PlaceInspector.test.tsx | 14 +++ .../src/components/Planner/PlaceInspector.tsx | 14 ++- .../shared/PlaceAvatarUpload.test.tsx | 91 ++++++++++++++ .../components/shared/PlaceAvatarUpload.tsx | 111 ++++++++++++++++++ .../screens/collections/MCollPlaceSheet.tsx | 59 +++++++++- .../screens/collections/MCollections.tsx | 1 + .../screens/trip/sheets/MPlaceSheet.tsx | 81 +++++++++++-- client/src/pages/CollectionsPage.tsx | 1 + client/src/pages/TripPlannerPage.tsx | 2 + .../src/pages/collections/useCollections.ts | 4 +- client/src/store/collectionStore.ts | 8 ++ client/src/store/slices/placesSlice.ts | 52 +++++--- client/tests/helpers/msw/handlers/places.ts | 5 + client/tests/unit/tripStore.test.ts | 34 ++++++ .../collections/collections.controller.ts | 18 +++ .../nest/collections/collections.service.ts | 1 + server/src/nest/common/place-image-upload.ts | 38 ++++++ server/src/nest/places/places.controller.ts | 33 ++++++ server/src/nest/platform/platform.routes.ts | 1 + server/src/services/assignmentService.ts | 5 +- server/src/services/collectionsService.ts | 28 +++++ server/src/services/dayService.ts | 5 +- server/src/services/placeImage.ts | 36 ++++++ server/src/services/placeService.ts | 13 +- server/src/services/queryHelpers.ts | 1 + server/src/types.ts | 1 + server/tests/integration/assignments.test.ts | 24 ++++ server/tests/integration/places.test.ts | 41 +++++++ .../unit/nest/collections.controller.test.ts | 19 +++ .../tests/unit/nest/places.controller.test.ts | 40 +++++++ .../unit/services/collectionsService.test.ts | 46 ++++++++ .../tests/unit/services/placeService.test.ts | 68 +++++++++++ shared/src/collection/collection.schema.ts | 2 + shared/src/i18n/ar/places.ts | 4 + shared/src/i18n/br/places.ts | 4 + shared/src/i18n/ca/places.ts | 4 + shared/src/i18n/cs/places.ts | 4 + shared/src/i18n/de/places.ts | 4 + shared/src/i18n/en/places.ts | 4 + shared/src/i18n/es/places.ts | 4 + shared/src/i18n/fr/places.ts | 4 + shared/src/i18n/gr/places.ts | 4 + shared/src/i18n/hu/places.ts | 4 + shared/src/i18n/id/places.ts | 4 + shared/src/i18n/it/places.ts | 4 + shared/src/i18n/ja/places.ts | 4 + shared/src/i18n/ko/places.ts | 4 + shared/src/i18n/nl/places.ts | 4 + shared/src/i18n/pl/places.ts | 4 + shared/src/i18n/ru/places.ts | 4 + shared/src/i18n/sv/places.ts | 4 + shared/src/i18n/tr/places.ts | 4 + shared/src/i18n/uk/places.ts | 4 + shared/src/i18n/vi/places.ts | 4 + shared/src/i18n/zh-TW/places.ts | 4 + shared/src/i18n/zh/places.ts | 4 + shared/src/place/place.schema.ts | 3 + wiki/Collections.md | 2 + wiki/Places-and-Search.md | 6 + 66 files changed, 1080 insertions(+), 49 deletions(-) create mode 100644 client/src/components/Map/placePhoto.ts create mode 100644 client/src/components/shared/PlaceAvatarUpload.test.tsx create mode 100644 client/src/components/shared/PlaceAvatarUpload.tsx create mode 100644 server/src/nest/common/place-image-upload.ts create mode 100644 server/src/services/placeImage.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index d572b98e5..4f9f70951 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1,5 +1,6 @@ import axios, { AxiosInstance } from 'axios' import type { z } from 'zod' +import type { Place } from '../types' import { weatherResultSchema, type WeatherResult, inAppListResultSchema, type InAppListResult, @@ -398,6 +399,11 @@ export const placesApi = { update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data), searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data), + uploadImage: (tripId: number | string, id: number | string, file: File) => { + const fd = new FormData() + fd.append('image', file) + return postMultipart<{ place: Place }>(`/trips/${tripId}/places/${id}/image`, fd) + }, importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => { const fd = new FormData() fd.append('file', file) diff --git a/client/src/api/collections.ts b/client/src/api/collections.ts index 7c682bb32..5758d3a65 100644 --- a/client/src/api/collections.ts +++ b/client/src/api/collections.ts @@ -70,6 +70,8 @@ export const collectionsApi = { ax.post(`${base}/places/from-trip-many`, { collection_id: collectionId, source_trip_id: tripId, source_place_ids: placeIds, force }).then((r: AxiosResponse) => r.data), updatePlace: (pid: number, body: CollectionPlaceUpdateRequest): Promise => ax.patch(`${base}/places/${pid}`, body satisfies CollectionPlaceUpdateRequest).then((r: AxiosResponse) => r.data), + uploadPlaceImage: (pid: number, formData: FormData): Promise => + postMultipart(`${base}/places/${pid}/image`, formData), setStatus: (pid: number, status: CollectionStatus): Promise => ax.post(`${base}/places/${pid}/status`, { status }).then((r: AxiosResponse) => r.data), deletePlace: (pid: number): Promise => diff --git a/client/src/components/Collections/CollectionPlaceDetail.test.tsx b/client/src/components/Collections/CollectionPlaceDetail.test.tsx index bd0e30ac0..a4720ea8c 100644 --- a/client/src/components/Collections/CollectionPlaceDetail.test.tsx +++ b/client/src/components/Collections/CollectionPlaceDetail.test.tsx @@ -139,4 +139,26 @@ describe('CollectionPlaceDetail', () => { await user.click(await screen.findByRole('button', { name: 'Copy to trip' })); expect(props.onCopyToTrip).toHaveBeenCalledTimes(1); }); + + // ── Custom cover image (#1136) ────────────────────────────────────────────── + it('FE-COMP-COLDETAIL-011: shows the cover upload control when canEdit && onUploadImage', async () => { + renderDetail({ canEdit: true, onUploadImage: vi.fn() }); + expect(await screen.findByRole('button', { name: 'Upload image' })).toBeInTheDocument(); + }); + + it('FE-COMP-COLDETAIL-012: hides the cover upload control when onUploadImage is not provided', async () => { + renderDetail({ canEdit: true }); + // Wait for the mount photo effect to settle before asserting absence. + expect(await screen.findByRole('button', { name: 'Copy to trip' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Upload image' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Change image' })).not.toBeInTheDocument(); + }); + + it('FE-COMP-COLDETAIL-013: removing a custom cover calls onSave with { image_url: null }', async () => { + const user = userEvent.setup(); + const withImage: CollectionPlace = { ...place, image_url: '/uploads/places/mock.jpg' }; + const props = renderDetail({ canEdit: true, onUploadImage: vi.fn(), place: withImage }); + await user.click(await screen.findByRole('button', { name: 'Remove image' })); + expect(props.onSave).toHaveBeenCalledWith({ image_url: null }); + }); }); diff --git a/client/src/components/Collections/CollectionPlaceDetail.tsx b/client/src/components/Collections/CollectionPlaceDetail.tsx index 23354dca5..e99ee8747 100644 --- a/client/src/components/Collections/CollectionPlaceDetail.tsx +++ b/client/src/components/Collections/CollectionPlaceDetail.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' -import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags } from 'lucide-react' +import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags, Camera, Loader2 } from 'lucide-react' import type { CollectionPlace, CollectionStatus, CollectionLink, CollectionLabel } from '@trek/shared' import type { Category, TranslationFn } from '../../types' import MarkdownToolbar from '../Journey/MarkdownToolbar' @@ -11,6 +11,8 @@ import { entityGradient } from '../../utils/gradients' import { getCategoryIcon } from '../shared/categoryIcons' import { STATUS_META, STATUS_ORDER, normalizeLinkUrl } from '../../pages/collections/collectionsModel' import { useToast } from '../shared/Toast' +import { Tooltip } from '../shared/Tooltip' +import { normalizeImageFile } from '../../utils/convertHeic' import { getApiErrorMessage } from '../../types' function linkHost(url: string): string { @@ -28,7 +30,9 @@ interface CollectionPlaceDetailProps { anchorRect?: { left: number; width: number } | null onClose: () => void onSetStatus: (status: CollectionStatus) => void - onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[] }) => Promise + onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[]; image_url?: string | null }) => Promise + /** Upload a custom cover image (#1136); enables the cover change/remove controls. */ + onUploadImage?: (file: File) => Promise onCopyToTrip: () => void onRemove: () => void t: TranslationFn @@ -58,10 +62,12 @@ function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet: * is an always-live segmented control (auto-saves). */ export default function CollectionPlaceDetail({ - place, canEdit, canDelete, categories, labels, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t, + place, canEdit, canDelete, categories, labels, anchorRect, onClose, onSetStatus, onSave, onUploadImage, onCopyToTrip, onRemove, t, }: CollectionPlaceDetailProps): React.ReactElement { const toast = useToast() const [editing, setEditing] = useState(false) + const coverInputRef = useRef(null) + const [imgBusy, setImgBusy] = useState(false) const [name, setName] = useState(place.name) const [categoryId, setCategoryId] = useState(place.category_id ?? null) const [description, setDescription] = useState(place.description ?? '') @@ -99,6 +105,32 @@ export default function CollectionPlaceDetail({ }, [place.id]) const banner = place.image_url || fetchedPhoto + + const handleCoverPick = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + e.target.value = '' + if (!file || !onUploadImage) return + setImgBusy(true) + try { + await onUploadImage(await normalizeImageFile(file)) + } catch (err) { + toast.error(getApiErrorMessage(err, t('places.imageUploadError'))) + } finally { + setImgBusy(false) + } + } + + const handleImageRemove = async () => { + setImgBusy(true) + try { + await onSave({ image_url: null }) + } catch (err) { + toast.error(getApiErrorMessage(err, t('places.imageUploadError'))) + } finally { + setImgBusy(false) + } + } + const setLink = (i: number, patch: Partial) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l))) const toggleLabel = (id: number) => setLabelIds(labelIds.includes(id) ? labelIds.filter(x => x !== id) : [...labelIds, id]) const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []) } @@ -131,6 +163,33 @@ export default function CollectionPlaceDetail({ )} + {canEdit && onUploadImage && ( +
+ + + + {place.image_url && !imgBusy && ( + + + + )} + +
+ )}
{editing ? setName(e.target.value)} className="col-detail-name-input" autoFocus aria-label={t('collections.listName')} /> diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 0d45f9ca5..66c3ebc8f 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -102,9 +102,9 @@ function createPlaceIcon(place, orderNumbers, isSelected) { ">${label}` } - // Prefer base64 data URLs (no zoom lag); also accept same-origin proxy URLs as a fallback - // while the thumb is still being generated in the background - if (place.image_url && (place.image_url.startsWith('data:') || place.image_url.startsWith('/api/maps/place-photo/'))) { + // Prefer base64 data URLs (no zoom lag); also accept same-origin proxy + uploaded + // custom images (#1136) as a fallback while the thumb is still being generated + if (place.image_url && (place.image_url.startsWith('data:') || place.image_url.startsWith('/api/maps/place-photo/') || place.image_url.startsWith('/uploads/'))) { const imgIcon = L.divIcon({ className: '', html: `
{ const orderNumbers = dayOrderMap[place.id] ?? null const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` - const photoUrl = (pck && photoUrls[pck]) || place.image_url || null + // A custom image wins over the auto-fetched thumb; otherwise fall back to it. + const photoUrl = isCustomPlaceImage(place.image_url) ? place.image_url! : ((pck && photoUrls[pck]) || place.image_url || null) const selected = place.id === selectedPlaceId const el = createMarkerElement(place as Place & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected) el.addEventListener('click', (ev) => { diff --git a/client/src/components/Map/placePhoto.ts b/client/src/components/Map/placePhoto.ts new file mode 100644 index 000000000..a57294624 --- /dev/null +++ b/client/src/components/Map/placePhoto.ts @@ -0,0 +1,10 @@ +/** + * A place's image_url that is directly displayable on a map marker and should + * win over the auto-fetched provider thumbnail — a custom uploaded image (#1136) + * or an inline data URL. Provider proxy URLs (/api/maps/place-photo/…) are + * deliberately excluded: those still go through the downscale/thumb path so many + * markers don't lag on zoom. + */ +export function isCustomPlaceImage(url: string | null | undefined): boolean { + return !!url && (url.startsWith('/uploads/') || url.startsWith('data:')) +} diff --git a/client/src/components/Planner/PlaceInspector.test.tsx b/client/src/components/Planner/PlaceInspector.test.tsx index 8cf77b48a..26b32575e 100644 --- a/client/src/components/Planner/PlaceInspector.test.tsx +++ b/client/src/components/Planner/PlaceInspector.test.tsx @@ -718,4 +718,18 @@ describe('PlaceInspector', () => { } }); + // ── Custom thumbnail upload (#1136) ────────────────────────────────────────── + + it('FE-PLANNER-INSPECTOR-049: onUploadImage in trip mode renders the upload-capable avatar', () => { + render(); + // The place carries no image yet, so the avatar offers "Upload image". + expect(screen.getByRole('button', { name: 'Upload image' })).toBeTruthy(); + }); + + it('FE-PLANNER-INSPECTOR-050: without onUploadImage the avatar has no upload control', () => { + render(); + expect(screen.queryByRole('button', { name: 'Upload image' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Change image' })).toBeNull(); + }); + }); diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index b503688c6..d4e30a857 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -6,6 +6,7 @@ import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Map as MapIcon, Users, Mountain, TrendingUp, Bookmark, BookmarkCheck, Copy } from 'lucide-react' import PlaceAvatar from '../shared/PlaceAvatar' +import PlaceAvatarUpload from '../shared/PlaceAvatarUpload' import GuestBadge from '../shared/GuestBadge' import StatusBadge from '../Collections/StatusBadge' import { mapsApi, pluginsApi } from '../../api/client' @@ -128,6 +129,8 @@ interface PlaceInspectorProps { tripMembers?: TripMember[] onSetParticipants?: (assignmentId: number, dayId: number, participantIds: number[]) => void onUpdatePlace?: (placeId: number, data: Partial) => void + /** Upload a custom thumbnail (#1136); enables the click-to-change avatar in trip mode. */ + onUploadImage?: (placeId: number, file: File) => Promise leftWidth?: number rightWidth?: number // ── Collection-mode props ── @@ -141,7 +144,7 @@ export default function PlaceInspector({ place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null, assignments = {}, reservations = [], onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment, - files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, + files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, onUploadImage, leftWidth = 0, rightWidth = 0, collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, }: PlaceInspectorProps) { @@ -311,6 +314,7 @@ export default function PlaceInspector({ {/* Content — scrollable */} @@ -648,7 +652,7 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameInputRef, nameValue, setNameValue, - commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, locale, timeFormat, onClose }: any) { + commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, onUploadImage, locale, timeFormat, onClose }: any) { return (
{/* Avatar with open/closed ring + tag */} @@ -657,7 +661,11 @@ function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameIn borderRadius: '50%', padding: 2.5, background: openNow === true ? '#22c55e' : openNow === false ? '#ef4444' : 'transparent', }}> - + {onUploadImage + ? onUploadImage(place.id, file)} + onRemove={() => onUpdatePlace(place.id, { image_url: null })} /> + : }
{openNow !== null && ( ({ + getCached: vi.fn(() => null), + isLoading: vi.fn(() => false), + fetchPhoto: vi.fn(), + onThumbReady: vi.fn(() => () => {}), +})); + +// IntersectionObserver stub (PlaceAvatar observes visibility to lazy-load photos) +class MockIntersectionObserver { + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); +} + +beforeAll(() => { + (globalThis as any).IntersectionObserver = MockIntersectionObserver; +}); + +beforeEach(() => { + vi.mocked(getCached).mockReturnValue(null); + vi.mocked(isLoading).mockReturnValue(false); + vi.mocked(fetchPhoto).mockReset(); + vi.mocked(onThumbReady).mockReturnValue(() => {}); +}); + +import PlaceAvatarUpload from './PlaceAvatarUpload'; + +const placeNoImage = { + id: 1, + name: 'Eiffel Tower', + image_url: null, + google_place_id: null, + osm_id: null, + lat: 48.8584, + lng: 2.2945, +}; + +const placeWithImage = { ...placeNoImage, image_url: 'https://example.com/eiffel.jpg' }; + +describe('PlaceAvatarUpload', () => { + it('FE-COMP-PLACEAVUPLOAD-001: renders the underlying avatar image when image_url is set', () => { + render(); + const img = screen.getByRole('img') as HTMLImageElement; + expect(img.src).toContain('eiffel.jpg'); + }); + + it('FE-COMP-PLACEAVUPLOAD-002: clicking the avatar opens the hidden file picker', async () => { + const user = userEvent.setup(); + const { container } = render(); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + const clickSpy = vi.spyOn(input, 'click'); + await user.click(screen.getByRole('button', { name: 'Upload image' })); + expect(clickSpy).toHaveBeenCalled(); + }); + + it('FE-COMP-PLACEAVUPLOAD-003: choosing a file calls onUpload with the picked file', async () => { + const onUpload = vi.fn().mockResolvedValue(undefined); + const { container } = render(); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' }); + fireEvent.change(input, { target: { files: [file] } }); + await waitFor(() => expect(onUpload).toHaveBeenCalledWith(file)); + }); + + it('FE-COMP-PLACEAVUPLOAD-004: the remove button only appears when a custom image is set', () => { + const { rerender } = render(); + expect(screen.queryByRole('button', { name: 'Remove image' })).toBeNull(); + rerender(); + expect(screen.getByRole('button', { name: 'Remove image' })).toBeTruthy(); + }); + + it('FE-COMP-PLACEAVUPLOAD-005: clicking remove calls onRemove', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn().mockResolvedValue(undefined); + render(); + await user.click(screen.getByRole('button', { name: 'Remove image' })); + expect(onRemove).toHaveBeenCalled(); + }); + + it('FE-COMP-PLACEAVUPLOAD-006: the avatar aria-label reflects upload vs change from the places.* keys', () => { + const { rerender } = render(); + expect(screen.getByRole('button', { name: 'Upload image' })).toBeTruthy(); + rerender(); + expect(screen.getByRole('button', { name: 'Change image' })).toBeTruthy(); + }); +}); diff --git a/client/src/components/shared/PlaceAvatarUpload.tsx b/client/src/components/shared/PlaceAvatarUpload.tsx new file mode 100644 index 000000000..9297809dd --- /dev/null +++ b/client/src/components/shared/PlaceAvatarUpload.tsx @@ -0,0 +1,111 @@ +import { useRef, useState } from 'react' +import { Camera, Loader2, X } from 'lucide-react' +import PlaceAvatar from './PlaceAvatar' +import { Tooltip } from './Tooltip' +import { normalizeImageFile } from '../../utils/convertHeic' +import { useToast } from './Toast' +import { useTranslation, translateApiError } from '../../i18n' +import type { Place } from '../../types' + +interface Category { + color?: string + icon?: string +} + +interface PlaceAvatarUploadProps { + place: Pick + category?: Category | null + size?: number + onUpload: (file: File) => Promise + /** Clears the custom image; the auto-fetched default thumbnail then returns (#1136). */ + onRemove: () => Promise | void +} + +/** + * A PlaceAvatar the user can click to set a custom thumbnail (#1136): hover reveals + * a camera overlay with a tooltip, clicking opens the file picker, and when a custom + * upload is present a small corner button removes it (falling back to the default). + */ +export default function PlaceAvatarUpload({ place, category, size = 52, onUpload, onRemove }: PlaceAvatarUploadProps) { + const { t } = useTranslation() + const toast = useToast() + const fileRef = useRef(null) + const [busy, setBusy] = useState(false) + const hasCustom = Boolean(place.image_url) + + const handleFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + e.target.value = '' + if (!file) return + setBusy(true) + try { + await onUpload(await normalizeImageFile(file)) + } catch (err: unknown) { + toast.error(translateApiError(t, err, 'places.imageUploadError')) + } finally { + setBusy(false) + } + } + + const handleRemove = async (e: React.MouseEvent) => { + e.stopPropagation() + setBusy(true) + try { + await onRemove() + } catch (err: unknown) { + toast.error(translateApiError(t, err, 'places.imageUploadError')) + } finally { + setBusy(false) + } + } + + return ( +
+ +
{ if (!busy) fileRef.current?.click() }} + role="button" + tabIndex={0} + onKeyDown={e => { if ((e.key === 'Enter' || e.key === ' ') && !busy) { e.preventDefault(); fileRef.current?.click() } }} + aria-label={hasCustom ? t('places.changeImage') : t('places.uploadImage')} + > + + + {/* Hover overlay — a camera cue that this thumbnail is editable. */} +
+ {busy ? : } +
+
+
+ + {/* Remove button — only when a custom upload is set. */} + {hasCustom && !busy && ( + + + + )} + + +
+ ) +} diff --git a/client/src/mobile/screens/collections/MCollPlaceSheet.tsx b/client/src/mobile/screens/collections/MCollPlaceSheet.tsx index f08add532..9624d51ea 100644 --- a/client/src/mobile/screens/collections/MCollPlaceSheet.tsx +++ b/client/src/mobile/screens/collections/MCollPlaceSheet.tsx @@ -2,11 +2,12 @@ import { useEffect, useRef, useState } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' -import { Check, Copy, ExternalLink, MapPin, Pencil, Trash2, X } from 'lucide-react' +import { Camera, Check, Copy, ExternalLink, Loader2, MapPin, Pencil, Trash2, X } from 'lucide-react' import type { CollectionLabel, CollectionLink, CollectionPlace, CollectionStatus } from '@trek/shared' import type { Category, TranslationFn } from '../../../types' import { mapsApi } from '../../../api/client' import { useToast } from '../../../components/shared/Toast' +import { normalizeImageFile } from '../../../utils/convertHeic' import { getApiErrorMessage } from '../../../types' import { normalizeLinkUrl, STATUS_ORDER } from '../../../pages/collections/collectionsModel' import MSheet from '../../components/MSheet' @@ -33,7 +34,8 @@ interface MCollPlaceSheetProps { labels: CollectionLabel[] onClose: () => void onSetStatus: (status: CollectionStatus) => void - onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[] }) => Promise + onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[]; image_url?: string | null }) => Promise + onUploadImage?: (file: File) => Promise onCopyToTrip: () => void onRemove: () => void t: TranslationFn @@ -46,9 +48,11 @@ interface MCollPlaceSheetProps { * description / links. */ export default function MCollPlaceSheet({ - place, canEdit, canDelete, categories, labels, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t, + place, canEdit, canDelete, categories, labels, onClose, onSetStatus, onSave, onUploadImage, onCopyToTrip, onRemove, t, }: MCollPlaceSheetProps) { const toast = useToast() + const imageInputRef = useRef(null) + const [imgBusy, setImgBusy] = useState(false) // Hold the last place through the exit animation. const [held, setHeld] = useState(place) if (place && place !== held) setHeld(place) @@ -110,6 +114,32 @@ export default function MCollPlaceSheet({ } const cover = held?.image_url || fetchedPhoto + + const handleImagePick = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + e.target.value = '' + if (!file || !held || !onUploadImage) return + setImgBusy(true) + try { + await onUploadImage(await normalizeImageFile(file)) + } catch (err) { + toast.error(getApiErrorMessage(err, t('places.imageUploadError'))) + } finally { + setImgBusy(false) + } + } + + const handleImageRemove = async () => { + setImgBusy(true) + try { + await onSave({ image_url: null }) + } catch (err) { + toast.error(getApiErrorMessage(err, t('places.imageUploadError'))) + } finally { + setImgBusy(false) + } + } + const assignedLabels = labels.filter(l => (held?.label_ids ?? []).includes(l.id)) const toggleLabel = (id: number) => setLabelIds(labelIds.includes(id) ? labelIds.filter(x => x !== id) : [...labelIds, id]) @@ -144,6 +174,29 @@ export default function MCollPlaceSheet({ > + {canEdit && onUploadImage && ( +
+ + {held.image_url && !imgBusy && ( + + )} + +
+ )}
{held.name}
diff --git a/client/src/mobile/screens/collections/MCollections.tsx b/client/src/mobile/screens/collections/MCollections.tsx index 37c5b4b4d..b282866fd 100644 --- a/client/src/mobile/screens/collections/MCollections.tsx +++ b/client/src/mobile/screens/collections/MCollections.tsx @@ -453,6 +453,7 @@ export default function MCollections() { onClose={c.handleCloseDetail} onSetStatus={c.handleDetailStatus} onSave={patch => c.updatePlace(c.selectedPlace!.id, patch)} + onUploadImage={file => c.uploadPlaceImage(c.selectedPlace!.id, file)} onCopyToTrip={c.openCopyForSelectedPlace} onRemove={c.handleDetailRemove} t={t} diff --git a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx index 854d64801..d10db7b4a 100644 --- a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx @@ -1,11 +1,12 @@ import { useMemo, useRef, useState } from 'react' import { - Bookmark, ExternalLink, Map as MapIcon, Navigation, Paperclip, + Bookmark, Camera, ExternalLink, Loader2, Map as MapIcon, Navigation, Paperclip, Pencil, Phone, Plus, Trash2, Upload, X, } from 'lucide-react' import MSheet from '../../../components/MSheet' import type { MTripSheetsProps } from '../MTripShell' import { useTranslation, translateApiError } from '../../../../i18n' +import { normalizeImageFile } from '../../../../utils/convertHeic' import { assignmentsApi } from '../../../../api/client' import { useTripStore } from '../../../../store/tripStore' import { useAddonStore } from '../../../../store/addonStore' @@ -39,7 +40,9 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) { const [dayPickerOpen, setDayPickerOpen] = useState(false) const [participantPickerOpen, setParticipantPickerOpen] = useState(false) const [uploading, setUploading] = useState(false) + const [imgBusy, setImgBusy] = useState(false) const fileInputRef = useRef(null) + const imageInputRef = useRef(null) const close = () => { planner.setSelectedPlaceId(null) @@ -114,6 +117,32 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) { !f.deleted_at && (String(f.place_id) === String(place?.id) || (f.linked_place_ids || []).includes(place?.id ?? -1)), ) + const handleImagePick = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + e.target.value = '' + if (!file || !place) return + setImgBusy(true) + try { + await planner.tripActions.uploadPlaceImage(planner.tripId, place.id, await normalizeImageFile(file)) + } catch (err: unknown) { + planner.toast.error(translateApiError(t, err, 'places.imageUploadError')) + } finally { + setImgBusy(false) + } + } + + const handleImageRemove = async () => { + if (!place) return + setImgBusy(true) + try { + await planner.tripActions.updatePlace(planner.tripId, place.id, { image_url: null }) + } catch (err: unknown) { + planner.toast.error(translateApiError(t, err, 'places.imageUploadError')) + } finally { + setImgBusy(false) + } + } + const handleUpload = async (e: React.ChangeEvent) => { const selected = Array.from(e.target.files || []) if (!selected.length || !place) return @@ -158,16 +187,46 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
- {place.image_url ? ( -
- ) : ( -
- -
- )} +
+ {place.image_url ? ( +
+ ) : ( +
+ +
+ )} + {canEditPlaces && ( + <> + {/* Tap the thumbnail to set a custom image (#1136). */} + + {place.image_url && !imgBusy && ( + + )} + + + )} +
{category && ( diff --git a/client/src/pages/CollectionsPage.tsx b/client/src/pages/CollectionsPage.tsx index adbad7bed..50e497acb 100644 --- a/client/src/pages/CollectionsPage.tsx +++ b/client/src/pages/CollectionsPage.tsx @@ -295,6 +295,7 @@ function CollectionsPageDesktop(): React.ReactElement { onClose={c.handleCloseDetail} onSetStatus={c.handleDetailStatus} onSave={patch => c.updatePlace(c.selectedPlace!.id, patch)} + onUploadImage={file => c.uploadPlaceImage(c.selectedPlace!.id, file)} onCopyToTrip={c.openCopyForSelectedPlace} onRemove={c.handleDetailRemove} t={t} diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 31b9e58bd..c8063ea09 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -572,6 +572,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }} onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }} + onUploadImage={async (placeId, file) => { await tripActions.uploadPlaceImage(tripId, placeId, file) }} leftWidth={(isMobile || window.innerWidth < 900) ? 0 : (leftCollapsed ? 0 : leftWidth)} rightWidth={(isMobile || window.innerWidth < 900) ? 0 : (rightCollapsed ? 0 : rightWidth)} /> @@ -610,6 +611,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }} onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }} + onUploadImage={async (placeId, file) => { await tripActions.uploadPlaceImage(tripId, placeId, file) }} leftWidth={0} rightWidth={0} /> diff --git a/client/src/pages/collections/useCollections.ts b/client/src/pages/collections/useCollections.ts index e323a67da..a848c1e90 100644 --- a/client/src/pages/collections/useCollections.ts +++ b/client/src/pages/collections/useCollections.ts @@ -54,7 +54,7 @@ export function useCollections() { loading, placesLoading, loadAll, setActive, refreshActive, loadCollection, deleteCollection, - setStatus, updatePlace, deletePlace, deleteMany, copyToTrip, clearSelection, + setStatus, updatePlace, uploadPlaceImage, deletePlace, deleteMany, copyToTrip, clearSelection, moveToList, duplicateToList, setSelectedIds, createLabel, updateLabel, deleteLabel, assignLabels, acceptInvite, declineInvite, @@ -383,7 +383,7 @@ export function useCollections() { loading, placesLoading, // store setters setView, setStatusFilter, setCategoryFilter, setLabelFilter, setSearch, setSelectedPlaceId, setSelectMode, toggleSelect, - updatePlace, + updatePlace, uploadPlaceImage, // labels showLabelManager, setShowLabelManager, labelPickerOpen, setLabelPickerOpen, handleCreateLabel, handleUpdateLabel, handleDeleteLabel, handleBulkAssignLabels, handleAssignPlaceLabels, diff --git a/client/src/store/collectionStore.ts b/client/src/store/collectionStore.ts index 0f4653d30..4471b702e 100644 --- a/client/src/store/collectionStore.ts +++ b/client/src/store/collectionStore.ts @@ -55,6 +55,7 @@ interface CollectionState { setStatus: (placeId: number, status: CollectionStatus) => Promise updatePlace: (placeId: number, body: CollectionPlaceUpdateRequest) => Promise + uploadPlaceImage: (placeId: number, file: File) => Promise deletePlace: (placeId: number) => Promise deleteMany: (ids: number[]) => Promise copyToTrip: (tripId: number, placeIds: number[], force?: boolean) => Promise<{ copied: number; skipped: { id: number; name: string }[] }> @@ -230,6 +231,13 @@ export const useCollectionStore = create((set, get) => ({ if (updated) set({ places: get().places.map(p => (p.id === placeId ? updated : p)) }) }, + uploadPlaceImage: async (placeId: number, file: File) => { + const fd = new FormData() + fd.append('image', file) + const updated = await collectionsApi.uploadPlaceImage(placeId, fd) + if (updated) set({ places: get().places.map(p => (p.id === placeId ? updated : p)) }) + }, + deletePlace: async (placeId: number) => { set({ places: get().places.filter(p => p.id !== placeId) }) await collectionsApi.deletePlace(placeId) diff --git a/client/src/store/slices/placesSlice.ts b/client/src/store/slices/placesSlice.ts index a6382e3a3..da1a27a79 100644 --- a/client/src/store/slices/placesSlice.ts +++ b/client/src/store/slices/placesSlice.ts @@ -1,4 +1,5 @@ import { placeRepo } from '../../repo/placeRepo' +import { placesApi } from '../../api/client' import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' import type { Place, Assignment } from '../../types' @@ -11,11 +12,33 @@ export interface PlacesSlice { refreshPlaces: (tripId: number | string) => Promise addPlace: (tripId: number | string, placeData: Partial & { name: string }) => Promise updatePlace: (tripId: number | string, placeId: number, placeData: Partial) => Promise + uploadPlaceImage: (tripId: number | string, placeId: number, file: File) => Promise deletePlace: (tripId: number | string, placeId: number) => Promise deletePlacesMany: (tripId: number | string, placeIds: number[]) => Promise updatePlacesMany: (tripId: number | string, placeIds: number[], patch: Partial) => Promise } +/** Replace a place in the pool and in every day-assignment that embeds it, + * preserving the assignment's own times (mirrors updatePlace's reconciliation). */ +function applyUpdatedPlace(set: SetState, placeId: number, place: Place): void { + set(state => { + const updatedAssignments = { ...state.assignments } + let changed = false + for (const [dayId, items] of Object.entries(state.assignments)) { + if (items.some((a: Assignment) => a.place?.id === placeId)) { + updatedAssignments[dayId] = items.map((a: Assignment) => + a.place?.id === placeId ? { ...a, place: { ...place, place_time: a.place.place_time, end_time: a.place.end_time } } : a + ) + changed = true + } + } + return { + places: state.places.map(p => p.id === placeId ? place : p), + ...(changed ? { assignments: updatedAssignments } : {}), + } + }) +} + export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => ({ refreshPlaces: async (tripId) => { try { @@ -39,28 +62,25 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => updatePlace: async (tripId, placeId, placeData) => { try { const data = await placeRepo.update(tripId, placeId, placeData as Record) - set(state => { - const updatedAssignments = { ...state.assignments } - let changed = false - for (const [dayId, items] of Object.entries(state.assignments)) { - if (items.some((a: Assignment) => a.place?.id === placeId)) { - updatedAssignments[dayId] = items.map((a: Assignment) => - a.place?.id === placeId ? { ...a, place: { ...data.place, place_time: a.place.place_time, end_time: a.place.end_time } } : a - ) - changed = true - } - } - return { - places: state.places.map(p => p.id === placeId ? data.place : p), - ...(changed ? { assignments: updatedAssignments } : {}), - } - }) + applyUpdatedPlace(set, placeId, data.place) return data.place } catch (err: unknown) { throw new Error(getApiErrorMessage(err, 'Error updating place')) } }, + uploadPlaceImage: async (tripId, placeId, file) => { + // Uploads are online-only (binary multipart), so they bypass the offline repo. + // The server broadcast is echo-suppressed for us, so apply the returned place. + try { + const data = await placesApi.uploadImage(tripId, placeId, file) + applyUpdatedPlace(set, placeId, data.place) + return data.place + } catch (err: unknown) { + throw new Error(getApiErrorMessage(err, 'Error uploading image')) + } + }, + deletePlace: async (tripId, placeId) => { try { await placeRepo.delete(tripId, placeId) diff --git a/client/tests/helpers/msw/handlers/places.ts b/client/tests/helpers/msw/handlers/places.ts index 45f65a124..675f91e17 100644 --- a/client/tests/helpers/msw/handlers/places.ts +++ b/client/tests/helpers/msw/handlers/places.ts @@ -19,6 +19,11 @@ export const placesHandlers = [ return HttpResponse.json({ place }); }), + http.post('/api/trips/:id/places/:placeId/image', ({ params }) => { + const place = buildPlace({ id: Number(params.placeId), trip_id: Number(params.id), image_url: '/uploads/places/mock.jpg' }); + return HttpResponse.json({ place }); + }), + http.delete('/api/trips/:id/places/:placeId', () => { return HttpResponse.json({ success: true }); }), diff --git a/client/tests/unit/tripStore.test.ts b/client/tests/unit/tripStore.test.ts index e89b1cd96..00bf35b9a 100644 --- a/client/tests/unit/tripStore.test.ts +++ b/client/tests/unit/tripStore.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { useTripStore } from '../../src/store/tripStore'; +import { placesApi } from '../../src/api/client'; import { resetAllStores } from '../helpers/store'; import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote, buildBudgetItem, buildReservation, buildTripFile } from '../helpers/factories'; import { server } from '../helpers/msw/server'; @@ -369,6 +370,39 @@ describe('tripStore', () => { }); }); + describe('uploadPlaceImage', () => { + it('FE-TRIP-012: uploads the file and applies the returned place to places + embedded assignment', async () => { + const original = buildPlace({ id: 5, trip_id: 1, image_url: null }); + const assignment = buildAssignment({ id: 30, day_id: 40, place: original }); + useTripStore.setState({ places: [original], assignments: { '40': [assignment] } }); + + const updated = { ...original, image_url: '/uploads/places/mock.jpg' }; + const spy = vi.spyOn(placesApi, 'uploadImage').mockResolvedValue({ place: updated }); + + const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' }); + const result = await useTripStore.getState().uploadPlaceImage(1, 5, file); + + expect(spy).toHaveBeenCalledWith(1, 5, file); + expect(result).toEqual(updated); + + const state = useTripStore.getState(); + expect(state.places.find(p => p.id === 5)?.image_url).toBe('/uploads/places/mock.jpg'); + expect(state.assignments['40'][0].place.image_url).toBe('/uploads/places/mock.jpg'); + + spy.mockRestore(); + }); + + it('FE-TRIP-013: propagates an error when the upload fails', async () => { + useTripStore.setState({ places: [buildPlace({ id: 6, trip_id: 1 })] }); + const spy = vi.spyOn(placesApi, 'uploadImage').mockRejectedValue(new Error('boom')); + + const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' }); + await expect(useTripStore.getState().uploadPlaceImage(1, 6, file)).rejects.toThrow(); + + spy.mockRestore(); + }); + }); + describe('addCategory', () => { it('FE-TRIP-011: addCategory creates category and appends to categories', async () => { const existingCategory = buildCategory(); diff --git a/server/src/nest/collections/collections.controller.ts b/server/src/nest/collections/collections.controller.ts index e7a28afb6..014134dab 100644 --- a/server/src/nest/collections/collections.controller.ts +++ b/server/src/nest/collections/collections.controller.ts @@ -26,6 +26,8 @@ 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 { PLACE_IMAGE_UPLOAD } from '../common/place-image-upload'; +import { placeImageUrl } from '../../services/placeImage'; import { isDemoEmail } from '../../services/demo'; import { collectionCreateRequestSchema, @@ -166,6 +168,22 @@ export class CollectionsController { return this.collections.setStatus(user.id, Number(pid), body.status, socketId); } + @Post('places/:pid/image') + @HttpCode(200) + @UseInterceptors(FileInterceptor('image', PLACE_IMAGE_UPLOAD)) + uploadPlaceImage( + @CurrentUser() user: User, + @Param('pid') pid: 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); + return this.collections.setPlaceImage(user.id, Number(pid), placeImageUrl(file.filename), socketId); + } + @Delete('places/:pid') deletePlace(@CurrentUser() user: User, @Param('pid') pid: string, @Headers('x-socket-id') socketId?: string) { this.collections.deletePlace(user.id, Number(pid), socketId); diff --git a/server/src/nest/collections/collections.service.ts b/server/src/nest/collections/collections.service.ts index 6b5ddea7a..62be190cc 100644 --- a/server/src/nest/collections/collections.service.ts +++ b/server/src/nest/collections/collections.service.ts @@ -33,6 +33,7 @@ export class CollectionsService { } 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); } + setPlaceImage(userId: number, placeId: number, imageUrl: string | null, socketId?: string) { return svc.setPlaceImage(userId, placeId, imageUrl, 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); } diff --git a/server/src/nest/common/place-image-upload.ts b/server/src/nest/common/place-image-upload.ts new file mode 100644 index 000000000..14c038208 --- /dev/null +++ b/server/src/nest/common/place-image-upload.ts @@ -0,0 +1,38 @@ +import fs from 'fs'; +import path from 'path'; +import { diskStorage } from 'multer'; +import { v4 as uuidv4 } from 'uuid'; +import type { Request } from 'express'; +import { PLACE_IMAGES_DIR } from '../../services/placeImage'; + +const MAX_PLACE_IMAGE_SIZE = 20 * 1024 * 1024; // 20 MB — same cap as covers. + +/** + * Multer config for the custom place-image upload (#1136). Mirrors the collection + * COVER_UPLOAD: server-chosen UUID filename, image-only filter, written to the + * dedicated uploads/places dir. Shared by the trip-place and collection-place + * upload endpoints. + */ +export const PLACE_IMAGE_UPLOAD = { + storage: diskStorage({ + destination: (_req, _file, cb) => { + if (!fs.existsSync(PLACE_IMAGES_DIR)) fs.mkdirSync(PLACE_IMAGES_DIR, { recursive: true }); + cb(null, PLACE_IMAGES_DIR); + }, + filename: (_req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`), + }), + limits: { fileSize: MAX_PLACE_IMAGE_SIZE }, + fileFilter: (_req: Request, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => { + const ext = path.extname(file.originalname).toLowerCase(); + const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; + if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowed.includes(ext)) { + cb(null, true); + } else { + // Carry statusCode so TrekExceptionFilter maps the rejection to a 400 rather + // than a 500 (same contract as the avatar upload's fileFilter). + const err: Error & { statusCode?: number } = new Error('Only jpg, png, gif, webp images allowed'); + err.statusCode = 400; + cb(err, false); + } + }, +}; diff --git a/server/src/nest/places/places.controller.ts b/server/src/nest/places/places.controller.ts index 95bd6ee49..8f2598daf 100644 --- a/server/src/nest/places/places.controller.ts +++ b/server/src/nest/places/places.controller.ts @@ -21,6 +21,9 @@ import { PlacesService } from './places.service'; import { isUpdateConflict } from '../../services/conflictResult'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; +import { PLACE_IMAGE_UPLOAD } from '../common/place-image-upload'; +import { placeImageUrl } from '../../services/placeImage'; +import { isDemoEmail } from '../../services/demo'; const STRING_LIMITS: Record = { name: 200, description: 2000, address: 500, notes: 2000 }; const UPLOAD = { storage: memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }; @@ -266,6 +269,36 @@ export class PlacesController { return { place }; } + @Post(':id/image') + @HttpCode(200) + @UseInterceptors(FileInterceptor('image', PLACE_IMAGE_UPLOAD)) + uploadImage( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Param('id') id: string, + @UploadedFile() file: Express.Multer.File | undefined, + @Headers('x-socket-id') socketId?: string, + ) { + const trip = this.requireTrip(tripId, user); + this.requireEdit(trip, user); + 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); + } + // Reuse the existing image_url slot (the top-precedence thumbnail source); the + // update path reclaims any previously uploaded file it replaces. + const result = this.places.update(tripId, id, { image_url: placeImageUrl(file.filename) } as never); + if (!result || isUpdateConflict(result)) { + throw new HttpException({ error: 'Place not found' }, 404); + } + const place = result; + this.places.broadcast(tripId, 'place:updated', { place }, socketId); + this.places.onUpdated(place.id); + return { place }; + } + @Get(':id/image') async image(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) { this.requireTrip(tripId, user); diff --git a/server/src/nest/platform/platform.routes.ts b/server/src/nest/platform/platform.routes.ts index 8b207f377..a2db413b6 100644 --- a/server/src/nest/platform/platform.routes.ts +++ b/server/src/nest/platform/platform.routes.ts @@ -56,6 +56,7 @@ export function applyPlatformUploads(app: express.Application): void { app.use('/uploads/avatars', express.static(path.join(UPLOADS_DIR, 'avatars'))); app.use('/uploads/covers', express.static(path.join(UPLOADS_DIR, 'covers'))); app.use('/uploads/journey', express.static(path.join(UPLOADS_DIR, 'journey'))); + app.use('/uploads/places', express.static(path.join(UPLOADS_DIR, 'places'))); // Photos require either a valid logged-in session (via JWT with the // password_version gate) OR a share token that covers the SPECIFIC diff --git a/server/src/services/assignmentService.ts b/server/src/services/assignmentService.ts index 2301be7fa..2f2a89331 100644 --- a/server/src/services/assignmentService.ts +++ b/server/src/services/assignmentService.ts @@ -9,7 +9,7 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) { COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone, + p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id @@ -60,6 +60,7 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) { transport_mode: a.transport_mode, google_place_id: a.google_place_id, google_ftid: a.google_ftid, + osm_id: a.osm_id, website: a.website, phone: a.phone, category: a.category_id ? { @@ -80,7 +81,7 @@ export function listDayAssignments(dayId: string | number) { COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone, + p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id diff --git a/server/src/services/collectionsService.ts b/server/src/services/collectionsService.ts index 744999ade..be274b85e 100644 --- a/server/src/services/collectionsService.ts +++ b/server/src/services/collectionsService.ts @@ -3,6 +3,7 @@ import path from 'path'; import { db, canAccessTrip } from '../db/database'; import { broadcastToUser } from '../websocket'; import { checkPermission } from './permissions'; +import { reclaimPlaceImage } from './placeImage'; import type { Collection, CollectionDetailResponse, @@ -528,6 +529,12 @@ export function updatePlace(userId: number, placeId: number, body: import('@trek const currentCollection = collectionIdOfPlace(placeId); assertCanEdit(userId, currentCollection); + // Capture the previous thumbnail so a replaced/cleared custom upload (#1136) + // can be reclaimed once nothing references it any more. + const prevImage = body.image_url !== undefined + ? (db.prepare('SELECT image_url FROM collection_places WHERE id = ?').get(placeId) as { image_url: string | null } | undefined)?.image_url ?? null + : null; + const updates: string[] = []; const params: (string | number | null)[] = []; if (body.name !== undefined) { updates.push('name = ?'); params.push(body.name); } @@ -535,6 +542,7 @@ export function updatePlace(userId: number, placeId: number, body: import('@trek if (body.notes !== undefined) { updates.push('notes = ?'); params.push(body.notes ?? null); } if (body.status !== undefined) { updates.push('status = ?'); params.push(body.status); } if (body.category_id !== undefined) { updates.push('category_id = ?'); params.push(body.category_id ?? null); } + if (body.image_url !== undefined) { updates.push('image_url = ?'); params.push(body.image_url ?? null); } if (body.links !== undefined) { updates.push('links = ?'); params.push(serializeLinks(body.links)); } let movedTo: number | null = null; @@ -561,6 +569,10 @@ export function updatePlace(userId: number, placeId: number, body: import('@trek if (movedTo) db.prepare('DELETE FROM collection_place_labels WHERE collection_place_id = ?').run(placeId); if (body.label_ids !== undefined) setPlaceLabels(placeId, movedTo ?? currentCollection, body.label_ids); + if (body.image_url !== undefined && prevImage !== (body.image_url ?? null)) { + reclaimPlaceImage(prevImage); + } + notifyCollectionUsers(currentCollection, socketId, 'collections:updated'); if (movedTo) notifyCollectionUsers(movedTo, socketId, 'collections:updated'); return getPlaceById(placeId); @@ -577,24 +589,40 @@ export function setStatus(userId: number, placeId: number, status: CollectionSta export function deletePlace(userId: number, placeId: number, socketId?: string): void { const collectionId = collectionIdOfPlace(placeId); assertCanDelete(userId, collectionId); + const image = (db.prepare('SELECT image_url FROM collection_places WHERE id = ?').get(placeId) as { image_url: string | null } | undefined)?.image_url ?? null; db.prepare('DELETE FROM collection_places WHERE id = ?').run(placeId); // CASCADE drops tags. NO photo-cache reclaim. + reclaimPlaceImage(image); notifyCollectionUsers(collectionId, socketId, 'collections:updated'); } export function deletePlacesMany(userId: number, ids: number[], socketId?: string): number[] { const deleted: number[] = []; const touched = new Set(); + const images: (string | null)[] = []; for (const id of ids) { const collectionId = collectionIdOfPlace(id); assertCanDelete(userId, collectionId); + images.push((db.prepare('SELECT image_url FROM collection_places WHERE id = ?').get(id) as { image_url: string | null } | undefined)?.image_url ?? null); db.prepare('DELETE FROM collection_places WHERE id = ?').run(id); deleted.push(id); touched.add(collectionId); } + for (const image of images) reclaimPlaceImage(image); touched.forEach(cid => notifyCollectionUsers(cid, socketId, 'collections:updated')); return deleted; } +/** Set (or clear) a saved place's custom thumbnail, reclaiming the previous upload. */ +export function setPlaceImage(userId: number, placeId: number, imageUrl: string | null, socketId?: string): CollectionPlace { + const collectionId = collectionIdOfPlace(placeId); + assertCanEdit(userId, collectionId); + const prev = (db.prepare('SELECT image_url FROM collection_places WHERE id = ?').get(placeId) as { image_url: string | null } | undefined)?.image_url ?? null; + db.prepare('UPDATE collection_places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(imageUrl, placeId); + if (prev !== imageUrl) reclaimPlaceImage(prev); + notifyCollectionUsers(collectionId, socketId, 'collections:updated'); + return getPlaceById(placeId); +} + // --------------------------------------------------------------------------- // Copy to trip // --------------------------------------------------------------------------- diff --git a/server/src/services/dayService.ts b/server/src/services/dayService.ts index d73e99c92..315a70ece 100644 --- a/server/src/services/dayService.ts +++ b/server/src/services/dayService.ts @@ -15,7 +15,7 @@ export function getAssignmentsForDay(dayId: number | string) { COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone, + p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id @@ -55,6 +55,7 @@ export function getAssignmentsForDay(dayId: number | string) { transport_mode: a.transport_mode, google_place_id: a.google_place_id, google_ftid: a.google_ftid, + osm_id: a.osm_id, website: a.website, phone: a.phone, category: a.category_id ? { @@ -89,7 +90,7 @@ export function listDays(tripId: string | number) { COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone, + p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id diff --git a/server/src/services/placeImage.ts b/server/src/services/placeImage.ts new file mode 100644 index 000000000..b76df1b86 --- /dev/null +++ b/server/src/services/placeImage.ts @@ -0,0 +1,36 @@ +import fs from 'fs'; +import path from 'path'; +import { db } from '../db/database'; + +// Custom place images (the user-uploaded thumbnails from #1136) live in their own +// uploads subdir, served statically like covers. Storing the full /uploads/places/… +// path in image_url lets every existing thumbnail renderer show it unchanged. +export const PLACE_IMAGES_DIR = path.resolve(__dirname, '../../uploads/places'); +const URL_PREFIX = '/uploads/places/'; + +export function placeImageUrl(filename: string): string { + return `${URL_PREFIX}${filename}`; +} + +export function isUploadedPlaceImage(url: string | null | undefined): url is string { + return typeof url === 'string' && url.startsWith(URL_PREFIX); +} + +/** + * Delete a custom place-image file once nothing references it any more. A trip + * place and a collection saved-place can share the same uploaded file — save-to- + * collection and copy-to-trip copy image_url by reference — so we ref-count across + * both tables before unlinking. The path is confined to PLACE_IMAGES_DIR via + * basename, mirroring tripService.deleteOldCover. Best-effort: never throws. + */ +export function reclaimPlaceImage(url: string | null | undefined): void { + if (!isUploadedPlaceImage(url)) return; + const referenced = + db.prepare('SELECT 1 FROM places WHERE image_url = ? LIMIT 1').get(url) || + db.prepare('SELECT 1 FROM collection_places WHERE image_url = ? LIMIT 1').get(url); + if (referenced) return; + try { + const resolved = path.resolve(path.join(PLACE_IMAGES_DIR, path.basename(url))); + if (resolved.startsWith(PLACE_IMAGES_DIR + path.sep) && fs.existsSync(resolved)) fs.unlinkSync(resolved); + } catch { /* best-effort */ } +} diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts index a0bb5c848..241174d1a 100644 --- a/server/src/services/placeService.ts +++ b/server/src/services/placeService.ts @@ -17,6 +17,7 @@ import { enrichImportedPlaces, type EnrichablePlace } from './placeEnrichment'; import * as placePhotoCache from './placePhotoCache'; import { searchUnsplashPhotos, getUnsplashKey } from './unsplashService'; import { type UpdateConflict, isUpdateConflict } from './conflictResult'; +import { reclaimPlaceImage } from './placeImage'; // Reclaim a deleted place's cached marker photo if nothing else references it. // The cache key is the Google place_id, or — for coordinate-only places — the @@ -255,6 +256,12 @@ export function updatePlace( } } + // A custom uploaded thumbnail (#1136) that was just replaced or cleared leaves + // an orphan file behind — reclaim it once nothing references it any more. + if (image_url !== undefined && image_url !== existingPlace.image_url) { + reclaimPlaceImage(existingPlace.image_url); + } + return getPlaceWithTags(placeId); } @@ -269,6 +276,7 @@ export function deletePlace(tripId: string, placeId: string): boolean { if (!place) return false; db.prepare('DELETE FROM places WHERE id = ?').run(placeId); reclaimPhotoCache(place.google_place_id, place.image_url); + reclaimPlaceImage(place.image_url); return true; } @@ -289,7 +297,10 @@ export function deletePlacesMany(tripId: string, ids: number[]): number[] { }); run(ids); // Reclaim after the transaction commits so isReferenced() sees the final place set. - for (const row of reclaimable) reclaimPhotoCache(row.google_place_id, row.image_url); + for (const row of reclaimable) { + reclaimPhotoCache(row.google_place_id, row.image_url); + reclaimPlaceImage(row.image_url); + } return deleted; } diff --git a/server/src/services/queryHelpers.ts b/server/src/services/queryHelpers.ts index 63253e376..f2c4ebb0f 100644 --- a/server/src/services/queryHelpers.ts +++ b/server/src/services/queryHelpers.ts @@ -81,6 +81,7 @@ function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial[], parti transport_mode: a.transport_mode, google_place_id: a.google_place_id, google_ftid: a.google_ftid, + osm_id: a.osm_id, website: a.website, phone: a.phone, category: a.category_id ? { diff --git a/server/src/types.ts b/server/src/types.ts index 9bf0801d7..2e6fc615b 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -329,6 +329,7 @@ export interface AssignmentRow extends DayAssignment { transport_mode: string; google_place_id: string | null; google_ftid: string | null; + osm_id: string | null; website: string | null; phone: string | null; category_name: string | null; diff --git a/server/tests/integration/assignments.test.ts b/server/tests/integration/assignments.test.ts index 0f3aa0856..53122b8eb 100644 --- a/server/tests/integration/assignments.test.ts +++ b/server/tests/integration/assignments.test.ts @@ -191,6 +191,30 @@ describe('List assignments', () => { expect(res.body.assignments).toHaveLength(0); }); + it('ASSIGN-003 — the embedded place carries osm_id so the day-plan thumbnail can auto-fetch (#1136)', async () => { + const { user } = createUser(testDb); + const { trip, day, place } = setupAssignmentFixtures(user.id); + testDb.prepare('UPDATE places SET osm_id = ? WHERE id = ?').run('node:42', place.id); + + await request(app) + .post(`/api/trips/${trip.id}/days/${day.id}/assignments`) + .set('Cookie', authCookie(user.id)) + .send({ place_id: place.id }); + + const res = await request(app) + .get(`/api/trips/${trip.id}/days/${day.id}/assignments`) + .set('Cookie', authCookie(user.id)); + expect(res.status).toBe(200); + expect(res.body.assignments[0].place.osm_id).toBe('node:42'); + + // Also surfaced through the full trip-days bundle (the actual day-plan source). + const daysRes = await request(app) + .get(`/api/trips/${trip.id}/days`) + .set('Cookie', authCookie(user.id)); + const embedded = daysRes.body.days.find((d: { id: number }) => d.id === day.id).assignments[0].place; + expect(embedded.osm_id).toBe('node:42'); + }); + it('ASSIGN-006 — non-member cannot list assignments', async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb); diff --git a/server/tests/integration/places.test.ts b/server/tests/integration/places.test.ts index 2e473abdb..835715b1b 100644 --- a/server/tests/integration/places.test.ts +++ b/server/tests/integration/places.test.ts @@ -1025,3 +1025,44 @@ describe('Delete place — not found', () => { expect(res.status).toBe(404); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Custom place image upload (#1136) +// ───────────────────────────────────────────────────────────────────────────── + +describe('Custom place image upload', () => { + const FIXTURE_JPEG = path.join(__dirname, '../fixtures/small-image.jpg'); + const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf'); + + it('PLACE-026 — POST /:id/image stores the upload, then PUT image_url:null clears it', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Snap' }); + + const res = await request(app) + .post(`/api/trips/${trip.id}/places/${place.id}/image`) + .set('Cookie', authCookie(user.id)) + .attach('image', FIXTURE_JPEG); + expect(res.status).toBe(200); + expect(res.body.place.image_url).toMatch(/^\/uploads\/places\//); + + const cleared = await request(app) + .put(`/api/trips/${trip.id}/places/${place.id}`) + .set('Cookie', authCookie(user.id)) + .send({ image_url: null }); + expect(cleared.status).toBe(200); + expect(cleared.body.place.image_url).toBeNull(); + }); + + it('PLACE-027 — uploading a non-image (PDF) is rejected', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Snap' }); + + const res = await request(app) + .post(`/api/trips/${trip.id}/places/${place.id}/image`) + .set('Cookie', authCookie(user.id)) + .attach('image', FIXTURE_PDF); + expect(res.status).toBe(400); + }); +}); diff --git a/server/tests/unit/nest/collections.controller.test.ts b/server/tests/unit/nest/collections.controller.test.ts index e1a0f9e06..745e6f4a1 100644 --- a/server/tests/unit/nest/collections.controller.test.ts +++ b/server/tests/unit/nest/collections.controller.test.ts @@ -196,6 +196,25 @@ describe('CollectionsController', () => { }); }); + describe('uploadPlaceImage', () => { + const file = { filename: 'x.jpg' } as Express.Multer.File; + it('403 in demo mode for a demo user', () => { + process.env.DEMO_MODE = 'true'; + const demo = { ...user, email: 'demo@trek.app' } as User; + expect(thrown(() => new CollectionsController(makeService()).uploadPlaceImage(demo, '9', file))) + .toEqual({ status: 403, body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' } }); + }); + it('400 when no file was uploaded', () => { + expect(thrown(() => new CollectionsController(makeService()).uploadPlaceImage(user, '9', undefined))) + .toEqual({ status: 400, body: { error: 'No image uploaded' } }); + }); + it('stores the place image via /uploads/places and forwards the socket id', () => { + const svc = makeService({ setPlaceImage: vi.fn().mockReturnValue({ id: 9 }) }); + expect(new CollectionsController(svc).uploadPlaceImage(user, '9', file, 'sid')).toEqual({ id: 9 }); + expect(svc.setPlaceImage).toHaveBeenCalledWith(1, 9, '/uploads/places/x.jpg', 'sid'); + }); + }); + describe('labels', () => { it('create / update / delete / assign / unassign forward user + socket id', () => { const svc = makeService(); diff --git a/server/tests/unit/nest/places.controller.test.ts b/server/tests/unit/nest/places.controller.test.ts index b8c56e05a..163057e10 100644 --- a/server/tests/unit/nest/places.controller.test.ts +++ b/server/tests/unit/nest/places.controller.test.ts @@ -269,4 +269,44 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou const http = svc({ searchImage: vi.fn().mockRejectedValue(new HttpException({ error: 'rate limited' }, 429)) } as Partial); expect(await thrownAsync(() => new PlacesController(http).image(user, '5', '9'))).toEqual({ status: 429, body: { error: 'rate limited' } }); }); + + describe('POST /:id/image (custom place image #1136)', () => { + const file = { filename: 'abc.jpg' } as Express.Multer.File; + + it('404 when the trip is not accessible, 403 without place_edit', () => { + expect(thrown(() => new PlacesController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).uploadImage(user, '5', '9', file))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(thrown(() => new PlacesController(svc({ canEdit: vi.fn().mockReturnValue(false) })).uploadImage(user, '5', '9', file))).toEqual({ status: 403, body: { error: 'No permission' } }); + }); + + it('400 when no file was uploaded', () => { + expect(thrown(() => new PlacesController(svc()).uploadImage(user, '5', '9', undefined))).toEqual({ status: 400, body: { error: 'No image uploaded' } }); + }); + + it('404 when the place is missing (service returns null)', () => { + expect(thrown(() => new PlacesController(svc({ update: vi.fn().mockReturnValue(null) } as Partial)).uploadImage(user, '5', '9', file))).toEqual({ status: 404, body: { error: 'Place not found' } }); + }); + + it('stores the uploaded file as image_url, broadcasts + fires the update hook', () => { + const update = vi.fn().mockReturnValue({ id: 9 }); const onUpdated = vi.fn(); const broadcast = vi.fn(); + const s = svc({ update, onUpdated, broadcast } as Partial); + expect(new PlacesController(s).uploadImage(user, '5', '9', file, 'sock')).toEqual({ place: { id: 9 } }); + expect(update).toHaveBeenCalledWith('5', '9', { image_url: '/uploads/places/abc.jpg' }); + expect(broadcast).toHaveBeenCalledWith('5', 'place:updated', { place: { id: 9 } }, 'sock'); + expect(onUpdated).toHaveBeenCalledWith(9); + }); + + it('403 in demo mode for a demo account', () => { + const prev = process.env.DEMO_MODE; + process.env.DEMO_MODE = 'true'; + const demo = { ...user, email: 'demo@trek.app' } as User; + try { + expect(thrown(() => new PlacesController(svc()).uploadImage(demo, '5', '9', file))).toEqual({ + status: 403, body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, + }); + } finally { + if (prev === undefined) delete process.env.DEMO_MODE; + else process.env.DEMO_MODE = prev; + } + }); + }); }); diff --git a/server/tests/unit/services/collectionsService.test.ts b/server/tests/unit/services/collectionsService.test.ts index 7f8454147..d897ccccc 100644 --- a/server/tests/unit/services/collectionsService.test.ts +++ b/server/tests/unit/services/collectionsService.test.ts @@ -39,11 +39,14 @@ vi.mock('../../../src/websocket', () => ({ broadcastToUser, broadcast: vi.fn() } const notifSend = vi.fn().mockResolvedValue(undefined); vi.mock('../../../src/services/notificationService', () => ({ send: notifSend })); +import fs from 'fs'; +import path from 'path'; import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { createUser, createTrip, createPlace, createCategory, createTag, addTripMember } from '../../helpers/factories'; import * as svc from '../../../src/services/collectionsService'; import { removeIfUnreferenced } from '../../../src/services/placePhotoCache'; +import { PLACE_IMAGES_DIR } from '../../../src/services/placeImage'; function clearCollections() { testDb.exec(` @@ -568,3 +571,46 @@ describe('collection labels', () => { expect(svc.getCollection(u.id, b.id).places.find(x => x.id === p.id)!.label_ids).toEqual([]); }); }); + +// ── Custom saved-place image (#1136) ───────────────────────────────────────── + +describe('custom saved-place image', () => { + function writePlaceImage(name: string): string { + fs.mkdirSync(PLACE_IMAGES_DIR, { recursive: true }); + const filePath = path.join(PLACE_IMAGES_DIR, name); + fs.writeFileSync(filePath, 'jpeg-bytes'); + return filePath; + } + + it('COLLECTIONS-SVC-060: updatePlace sets image_url', () => { + const u = createUser(testDb).user; + const col = svc.createCollection(u.id, { name: 'Photos' }); + const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Pic' }).place!; + const updated = svc.updatePlace(u.id, p.id, { image_url: '/uploads/places/col-set.jpg' }); + expect(updated.image_url).toBe('/uploads/places/col-set.jpg'); + }); + + it('COLLECTIONS-SVC-061: setPlaceImage stores the url and reclaims a replaced upload', () => { + const u = createUser(testDb).user; + const col = svc.createCollection(u.id, { name: 'Photos' }); + const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Pic' }).place!; + const fileA = writePlaceImage('col-replace-a.jpg'); + svc.setPlaceImage(u.id, p.id, '/uploads/places/col-replace-a.jpg'); + expect(fs.existsSync(fileA)).toBe(true); + + const res = svc.setPlaceImage(u.id, p.id, '/uploads/places/col-replace-b.jpg'); + expect(res.image_url).toBe('/uploads/places/col-replace-b.jpg'); + expect(fs.existsSync(fileA)).toBe(false); + }); + + it('COLLECTIONS-SVC-062: deletePlace reclaims the uploaded image when unreferenced', () => { + const u = createUser(testDb).user; + const col = svc.createCollection(u.id, { name: 'Photos' }); + const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Pic', image_url: '/uploads/places/col-delete.jpg' }).place!; + const fileA = writePlaceImage('col-delete.jpg'); + expect(fs.existsSync(fileA)).toBe(true); + + svc.deletePlace(u.id, p.id); + expect(fs.existsSync(fileA)).toBe(false); + }); +}); diff --git a/server/tests/unit/services/placeService.test.ts b/server/tests/unit/services/placeService.test.ts index 9ccfcb5fc..7a8a59cf0 100644 --- a/server/tests/unit/services/placeService.test.ts +++ b/server/tests/unit/services/placeService.test.ts @@ -56,6 +56,7 @@ import { createUser, createTrip, createPlace, createCategory, createTag } from ' import path from 'path'; import fs from 'fs'; import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, updatePlacesMany, deletePlace, importGpx, importKmlPlaces, importGoogleList, searchPlaceImage } from '../../../src/services/placeService'; +import { PLACE_IMAGES_DIR } from '../../../src/services/placeImage'; const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test.gpx'); const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml'); @@ -697,3 +698,70 @@ describe('importKmlPlaces deduplication', () => { expect(result.summary.skippedCount).toBe(1); }); }); + +// ── Custom place image reclaim (#1136) ────────────────────────────────────────── + +describe('custom place image reclaim', () => { + function writePlaceImage(name: string): string { + fs.mkdirSync(PLACE_IMAGES_DIR, { recursive: true }); + const filePath = path.join(PLACE_IMAGES_DIR, name); + fs.writeFileSync(filePath, 'jpeg-bytes'); + return filePath; + } + + it('PLACE-SVC-046 — replacing image_url unlinks the previous upload once unreferenced', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Photo' }) as any; + const fileA = writePlaceImage('svc-replace-a.jpg'); + testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/places/svc-replace-a.jpg', place.id); + expect(fs.existsSync(fileA)).toBe(true); + + updatePlace(String(trip.id), String(place.id), { image_url: '/uploads/places/svc-replace-b.jpg' }); + expect(fs.existsSync(fileA)).toBe(false); + }); + + it('PLACE-SVC-047 — clearing image_url to null unlinks the previous upload', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Photo' }) as any; + const fileA = writePlaceImage('svc-clear.jpg'); + testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/places/svc-clear.jpg', place.id); + expect(fs.existsSync(fileA)).toBe(true); + + updatePlace(String(trip.id), String(place.id), { image_url: null } as any); + expect(fs.existsSync(fileA)).toBe(false); + }); + + it('PLACE-SVC-048 — deletePlace unlinks the uploaded image', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Photo' }) as any; + const fileA = writePlaceImage('svc-delete.jpg'); + testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/places/svc-delete.jpg', place.id); + expect(fs.existsSync(fileA)).toBe(true); + + deletePlace(String(trip.id), String(place.id)); + expect(fs.existsSync(fileA)).toBe(false); + }); + + it('PLACE-SVC-049 — a collection_places reference keeps the file when the trip place is deleted', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Shared Photo' }) as any; + const fileA = writePlaceImage('svc-shared.jpg'); + testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/places/svc-shared.jpg', place.id); + // A saved-place in a collection holds the same uploaded file — the ref-count guard must protect it. + const col = testDb.prepare('INSERT INTO collections (owner_id, name) VALUES (?, ?)').run(user.id, 'Saved'); + testDb.prepare('INSERT INTO collection_places (collection_id, owner_id, name, image_url) VALUES (?, ?, ?, ?)') + .run(col.lastInsertRowid, user.id, 'Shared Photo', '/uploads/places/svc-shared.jpg'); + expect(fs.existsSync(fileA)).toBe(true); + + deletePlace(String(trip.id), String(place.id)); + expect(fs.existsSync(fileA)).toBe(true); + + // resetTestDb does not clear collections; drop what this test inserted and its file. + testDb.exec('DELETE FROM collection_places; DELETE FROM collections;'); + fs.unlinkSync(fileA); + }); +}); diff --git a/shared/src/collection/collection.schema.ts b/shared/src/collection/collection.schema.ts index acdec68c3..28e6ed8e4 100644 --- a/shared/src/collection/collection.schema.ts +++ b/shared/src/collection/collection.schema.ts @@ -183,6 +183,8 @@ export const collectionPlaceUpdateRequestSchema = z.object({ tag_ids: z.array(z.number()).optional(), // Replace the place's per-collection label assignments (omit to leave unchanged). label_ids: z.array(z.number()).optional(), + // Custom thumbnail (#1136): null clears it (falls back to the auto-fetched photo). + image_url: z.string().nullable().optional(), }); export type CollectionPlaceUpdateRequest = z.infer; diff --git a/shared/src/i18n/ar/places.ts b/shared/src/i18n/ar/places.ts index 8547ca902..36418c16a 100644 --- a/shared/src/i18n/ar/places.ts +++ b/shared/src/i18n/ar/places.ts @@ -88,5 +88,9 @@ const places: TranslationStrings = { 'places.enrichOnImport': 'إثراء الأماكن عبر Google', 'places.enrichOnImportHint': 'يبحث عن كل مكان مستورد لإضافة الصور والعنوان وبيانات الاتصال. يتطلب مفتاح خرائط Google.', + 'places.uploadImage': 'رفع صورة', + 'places.changeImage': 'تغيير الصورة', + 'places.removeImage': 'إزالة الصورة', + 'places.imageUploadError': 'تعذّر رفع الصورة', }; export default places; diff --git a/shared/src/i18n/br/places.ts b/shared/src/i18n/br/places.ts index ca597bf1e..7866e17c8 100644 --- a/shared/src/i18n/br/places.ts +++ b/shared/src/i18n/br/places.ts @@ -88,5 +88,9 @@ const places: TranslationStrings = { 'places.enrichOnImport': 'Enriquecer lugares via Google', 'places.enrichOnImportHint': 'Busca cada lugar importado para adicionar fotos, endereço e contato. Usa sua chave do Google Maps.', + 'places.uploadImage': 'Enviar imagem', + 'places.changeImage': 'Alterar imagem', + 'places.removeImage': 'Remover imagem', + 'places.imageUploadError': 'Não foi possível enviar a imagem', }; export default places; diff --git a/shared/src/i18n/ca/places.ts b/shared/src/i18n/ca/places.ts index cc7546088..9f305d6b9 100644 --- a/shared/src/i18n/ca/places.ts +++ b/shared/src/i18n/ca/places.ts @@ -89,5 +89,9 @@ const places: TranslationStrings = { 'places.enrichOnImport': 'Enriquir llocs mitjançant Google', 'places.enrichOnImportHint': "Obté automàticament adreces, puntuacions i horaris d'obertura des de Google en importar.", + 'places.uploadImage': 'Puja una imatge', + 'places.changeImage': 'Canvia la imatge', + 'places.removeImage': 'Elimina la imatge', + 'places.imageUploadError': "No s'ha pogut pujar la imatge", }; export default places; diff --git a/shared/src/i18n/cs/places.ts b/shared/src/i18n/cs/places.ts index 09dbd7d7f..a09c692eb 100644 --- a/shared/src/i18n/cs/places.ts +++ b/shared/src/i18n/cs/places.ts @@ -87,5 +87,9 @@ const places: TranslationStrings = { 'places.enrichOnImport': 'Obohatit místa přes Google', 'places.enrichOnImportHint': 'Vyhledá každé importované místo a doplní fotky, adresu a kontakty. Vyžaduje klíč Google Maps.', + 'places.uploadImage': 'Nahrát obrázek', + 'places.changeImage': 'Změnit obrázek', + 'places.removeImage': 'Odebrat obrázek', + 'places.imageUploadError': 'Obrázek se nepodařilo nahrát', }; export default places; diff --git a/shared/src/i18n/de/places.ts b/shared/src/i18n/de/places.ts index 2abf2b4cd..8daea2627 100644 --- a/shared/src/i18n/de/places.ts +++ b/shared/src/i18n/de/places.ts @@ -84,6 +84,10 @@ const places: TranslationStrings = { 'places.nameRequired': 'Bitte einen Namen eingeben', 'places.saveError': 'Fehler beim Speichern', 'places.duplicateExists': "'{name}' ist bereits in dieser Reise.", + 'places.uploadImage': 'Bild hochladen', + 'places.changeImage': 'Bild ändern', + 'places.removeImage': 'Bild entfernen', + 'places.imageUploadError': 'Bild konnte nicht hochgeladen werden', 'places.addAnyway': 'Trotzdem hinzufügen', 'places.enrichOnImport': 'Orte über Google anreichern', 'places.enrichOnImportHint': diff --git a/shared/src/i18n/en/places.ts b/shared/src/i18n/en/places.ts index a893c51df..a06544af3 100644 --- a/shared/src/i18n/en/places.ts +++ b/shared/src/i18n/en/places.ts @@ -88,5 +88,9 @@ const places: TranslationStrings = { 'places.enrichOnImport': 'Enrich places via Google', 'places.enrichOnImportHint': 'Look up each imported place to fill in photos, address and contact details. Uses your Google Maps key.', + 'places.uploadImage': 'Upload image', + 'places.changeImage': 'Change image', + 'places.removeImage': 'Remove image', + 'places.imageUploadError': 'Could not upload image', }; export default places; diff --git a/shared/src/i18n/es/places.ts b/shared/src/i18n/es/places.ts index 996e1d84c..29bbaec46 100644 --- a/shared/src/i18n/es/places.ts +++ b/shared/src/i18n/es/places.ts @@ -88,5 +88,9 @@ const places: TranslationStrings = { 'places.enrichOnImport': 'Enriquecer lugares con Google', 'places.enrichOnImportHint': 'Busca cada lugar importado para añadir fotos, dirección y datos de contacto. Usa tu clave de Google Maps.', + 'places.uploadImage': 'Subir imagen', + 'places.changeImage': 'Cambiar imagen', + 'places.removeImage': 'Eliminar imagen', + 'places.imageUploadError': 'No se pudo subir la imagen', }; export default places; diff --git a/shared/src/i18n/fr/places.ts b/shared/src/i18n/fr/places.ts index 11bbcce66..f32d99fa2 100644 --- a/shared/src/i18n/fr/places.ts +++ b/shared/src/i18n/fr/places.ts @@ -88,5 +88,9 @@ const places: TranslationStrings = { 'places.enrichOnImport': 'Enrichir les lieux via Google', 'places.enrichOnImportHint': 'Recherche chaque lieu importé pour ajouter photos, adresse et coordonnées. Utilise votre clé Google Maps.', + 'places.uploadImage': 'Importer une image', + 'places.changeImage': "Changer l'image", + 'places.removeImage': "Supprimer l'image", + 'places.imageUploadError': "Impossible d'importer l'image", }; export default places; diff --git a/shared/src/i18n/gr/places.ts b/shared/src/i18n/gr/places.ts index 171637ae6..752839e79 100644 --- a/shared/src/i18n/gr/places.ts +++ b/shared/src/i18n/gr/places.ts @@ -88,5 +88,9 @@ const places: TranslationStrings = { 'places.enrichOnImport': 'Εμπλουτισμός τόπων μέσω Google', 'places.enrichOnImportHint': 'Αναζητά κάθε εισαγόμενο μέρος για να προσθέσει φωτογραφίες, διεύθυνση και στοιχεία επικοινωνίας. Απαιτεί κλειδί Google Maps.', + 'places.uploadImage': 'Μεταφόρτωση εικόνας', + 'places.changeImage': 'Αλλαγή εικόνας', + 'places.removeImage': 'Αφαίρεση εικόνας', + 'places.imageUploadError': 'Δεν ήταν δυνατή η μεταφόρτωση της εικόνας', }; export default places; diff --git a/shared/src/i18n/hu/places.ts b/shared/src/i18n/hu/places.ts index 595232fe2..464bf70b6 100644 --- a/shared/src/i18n/hu/places.ts +++ b/shared/src/i18n/hu/places.ts @@ -88,5 +88,9 @@ const places: TranslationStrings = { 'places.enrichOnImport': 'Helyek gazdagítása a Google-lel', 'places.enrichOnImportHint': 'Minden importált helyet megkeres, hogy fotókat, címet és elérhetőséget adjon hozzá. Google Maps-kulcs szükséges.', + 'places.uploadImage': 'Kép feltöltése', + 'places.changeImage': 'Kép módosítása', + 'places.removeImage': 'Kép eltávolítása', + 'places.imageUploadError': 'Nem sikerült feltölteni a képet', }; export default places; diff --git a/shared/src/i18n/id/places.ts b/shared/src/i18n/id/places.ts index ed75c370f..e74f88030 100644 --- a/shared/src/i18n/id/places.ts +++ b/shared/src/i18n/id/places.ts @@ -87,5 +87,9 @@ const places: TranslationStrings = { 'places.enrichOnImport': 'Perkaya tempat via Google', 'places.enrichOnImportHint': 'Mencari setiap tempat yang diimpor untuk menambahkan foto, alamat, dan kontak. Memerlukan kunci Google Maps.', + 'places.uploadImage': 'Unggah gambar', + 'places.changeImage': 'Ganti gambar', + 'places.removeImage': 'Hapus gambar', + 'places.imageUploadError': 'Tidak dapat mengunggah gambar', }; export default places; diff --git a/shared/src/i18n/it/places.ts b/shared/src/i18n/it/places.ts index c8cf49702..1e0b07dae 100644 --- a/shared/src/i18n/it/places.ts +++ b/shared/src/i18n/it/places.ts @@ -88,5 +88,9 @@ const places: TranslationStrings = { 'places.enrichOnImport': 'Arricchisci i luoghi con Google', 'places.enrichOnImportHint': 'Cerca ogni luogo importato per aggiungere foto, indirizzo e contatti. Usa la tua chiave Google Maps.', + 'places.uploadImage': 'Carica immagine', + 'places.changeImage': 'Cambia immagine', + 'places.removeImage': 'Rimuovi immagine', + 'places.imageUploadError': "Impossibile caricare l'immagine", }; export default places; diff --git a/shared/src/i18n/ja/places.ts b/shared/src/i18n/ja/places.ts index 4b1af954b..892dd57c7 100644 --- a/shared/src/i18n/ja/places.ts +++ b/shared/src/i18n/ja/places.ts @@ -87,5 +87,9 @@ const places: TranslationStrings = { 'places.enrichOnImport': 'Googleで場所を補完', 'places.enrichOnImportHint': 'インポートした各場所を検索して、写真・住所・連絡先を追加します。Google Maps キーが必要です。', + 'places.uploadImage': '画像をアップロード', + 'places.changeImage': '画像を変更', + 'places.removeImage': '画像を削除', + 'places.imageUploadError': '画像をアップロードできませんでした', }; export default places; diff --git a/shared/src/i18n/ko/places.ts b/shared/src/i18n/ko/places.ts index 6b59a04b3..ef40c55de 100644 --- a/shared/src/i18n/ko/places.ts +++ b/shared/src/i18n/ko/places.ts @@ -86,5 +86,9 @@ const places: TranslationStrings = { 'places.addAnyway': '그래도 추가', 'places.enrichOnImport': 'Google로 장소 정보 보강', 'places.enrichOnImportHint': '가져온 각 장소를 검색해 사진, 주소, 연락처를 추가합니다. Google Maps 키가 필요합니다.', + 'places.uploadImage': '이미지 업로드', + 'places.changeImage': '이미지 변경', + 'places.removeImage': '이미지 제거', + 'places.imageUploadError': '이미지를 업로드할 수 없습니다', }; export default places; diff --git a/shared/src/i18n/nl/places.ts b/shared/src/i18n/nl/places.ts index 1609175fe..d7e3b04e1 100644 --- a/shared/src/i18n/nl/places.ts +++ b/shared/src/i18n/nl/places.ts @@ -88,5 +88,9 @@ const places: TranslationStrings = { 'places.enrichOnImport': 'Plaatsen verrijken via Google', 'places.enrichOnImportHint': 'Zoekt elke geïmporteerde plaats op om fotos, adres en contactgegevens toe te voegen. Gebruikt je Google Maps-sleutel.', + 'places.uploadImage': 'Afbeelding uploaden', + 'places.changeImage': 'Afbeelding wijzigen', + 'places.removeImage': 'Afbeelding verwijderen', + 'places.imageUploadError': 'Afbeelding uploaden mislukt', }; export default places; diff --git a/shared/src/i18n/pl/places.ts b/shared/src/i18n/pl/places.ts index 35f0336ba..30c8f45b5 100644 --- a/shared/src/i18n/pl/places.ts +++ b/shared/src/i18n/pl/places.ts @@ -88,5 +88,9 @@ const places: TranslationStrings = { 'places.enrichOnImport': 'Wzbogać miejsca przez Google', 'places.enrichOnImportHint': 'Wyszukuje każde zaimportowane miejsce, aby dodać zdjęcia, adres i dane kontaktowe. Wymaga klucza Google Maps.', + 'places.uploadImage': 'Prześlij zdjęcie', + 'places.changeImage': 'Zmień zdjęcie', + 'places.removeImage': 'Usuń zdjęcie', + 'places.imageUploadError': 'Nie udało się przesłać zdjęcia', }; export default places; diff --git a/shared/src/i18n/ru/places.ts b/shared/src/i18n/ru/places.ts index ad9a89227..22a7865dc 100644 --- a/shared/src/i18n/ru/places.ts +++ b/shared/src/i18n/ru/places.ts @@ -88,5 +88,9 @@ const places: TranslationStrings = { 'places.enrichOnImport': 'Обогатить места через Google', 'places.enrichOnImportHint': 'Находит каждое импортированное место и добавляет фото, адрес и контакты. Требуется ключ Google Maps.', + 'places.uploadImage': 'Загрузить изображение', + 'places.changeImage': 'Изменить изображение', + 'places.removeImage': 'Удалить изображение', + 'places.imageUploadError': 'Не удалось загрузить изображение', }; export default places; diff --git a/shared/src/i18n/sv/places.ts b/shared/src/i18n/sv/places.ts index 99d4403d7..05287a4fe 100644 --- a/shared/src/i18n/sv/places.ts +++ b/shared/src/i18n/sv/places.ts @@ -88,5 +88,9 @@ const places: TranslationStrings = { 'places.enrichOnImport': 'Berika platser via Google', 'places.enrichOnImportHint': 'Sök upp varje importerad plats för att fylla i bilder, adress och kontaktuppgifter. Använder din Google Maps-nyckel.', + 'places.uploadImage': 'Ladda upp bild', + 'places.changeImage': 'Byt bild', + 'places.removeImage': 'Ta bort bild', + 'places.imageUploadError': 'Det gick inte att ladda upp bilden', }; export default places; diff --git a/shared/src/i18n/tr/places.ts b/shared/src/i18n/tr/places.ts index 86fe071fc..929ce2d6e 100644 --- a/shared/src/i18n/tr/places.ts +++ b/shared/src/i18n/tr/places.ts @@ -90,5 +90,9 @@ const places: TranslationStrings = { 'places.enrichOnImport': 'Yerleri Google ile zenginleştir', 'places.enrichOnImportHint': 'İçe aktarılan her yeri arayarak fotoğraf, adres ve iletişim bilgilerini ekler. Google Maps anahtarı gerekir.', + 'places.uploadImage': 'Görsel yükle', + 'places.changeImage': 'Görseli değiştir', + 'places.removeImage': 'Görseli kaldır', + 'places.imageUploadError': 'Görsel yüklenemedi', }; export default places; diff --git a/shared/src/i18n/uk/places.ts b/shared/src/i18n/uk/places.ts index 70ed89a6c..acdddad83 100644 --- a/shared/src/i18n/uk/places.ts +++ b/shared/src/i18n/uk/places.ts @@ -88,5 +88,9 @@ const places: TranslationStrings = { 'places.enrichOnImport': 'Збагатити місця через Google', 'places.enrichOnImportHint': 'Знаходить кожне імпортоване місце й додає фото, адресу та контакти. Потрібен ключ Google Maps.', + 'places.uploadImage': 'Завантажити зображення', + 'places.changeImage': 'Змінити зображення', + 'places.removeImage': 'Видалити зображення', + 'places.imageUploadError': 'Не вдалося завантажити зображення', }; export default places; diff --git a/shared/src/i18n/vi/places.ts b/shared/src/i18n/vi/places.ts index 2eb8915e5..edc68ca62 100644 --- a/shared/src/i18n/vi/places.ts +++ b/shared/src/i18n/vi/places.ts @@ -88,5 +88,9 @@ const places: TranslationStrings = { 'places.enrichOnImport': 'Làm phong phú các địa điểm thông qua Google', 'places.enrichOnImportHint': 'Tra cứu từng địa điểm đã nhập để điền ảnh, địa chỉ và thông tin liên hệ. Sử dụng khóa Google Maps của bạn.', + 'places.uploadImage': 'Tải ảnh lên', + 'places.changeImage': 'Đổi ảnh', + 'places.removeImage': 'Xóa ảnh', + 'places.imageUploadError': 'Không thể tải ảnh lên', }; export default places; diff --git a/shared/src/i18n/zh-TW/places.ts b/shared/src/i18n/zh-TW/places.ts index 5c6d47b34..c83c97768 100644 --- a/shared/src/i18n/zh-TW/places.ts +++ b/shared/src/i18n/zh-TW/places.ts @@ -84,5 +84,9 @@ const places: TranslationStrings = { 'places.addAnyway': '仍要新增', 'places.enrichOnImport': '透過 Google 豐富地點資訊', 'places.enrichOnImportHint': '查詢每個匯入的地點以補上照片、地址與聯絡資訊。需要 Google Maps 金鑰。', + 'places.uploadImage': '上傳圖片', + 'places.changeImage': '變更圖片', + 'places.removeImage': '移除圖片', + 'places.imageUploadError': '無法上傳圖片', }; export default places; diff --git a/shared/src/i18n/zh/places.ts b/shared/src/i18n/zh/places.ts index c7034796c..0bab897ef 100644 --- a/shared/src/i18n/zh/places.ts +++ b/shared/src/i18n/zh/places.ts @@ -84,5 +84,9 @@ const places: TranslationStrings = { 'places.addAnyway': '仍然添加', 'places.enrichOnImport': '通过 Google 丰富地点信息', 'places.enrichOnImportHint': '查找每个导入的地点以补充照片、地址和联系方式。需要 Google Maps 密钥。', + 'places.uploadImage': '上传图片', + 'places.changeImage': '更换图片', + 'places.removeImage': '移除图片', + 'places.imageUploadError': '无法上传图片', }; export default places; diff --git a/shared/src/place/place.schema.ts b/shared/src/place/place.schema.ts index 979bb7078..73d5db4da 100644 --- a/shared/src/place/place.schema.ts +++ b/shared/src/place/place.schema.ts @@ -95,6 +95,9 @@ export const assignmentPlaceSchema = z.object({ transport_mode: z.string().nullable().optional(), google_place_id: z.string().nullable().optional(), google_ftid: z.string().nullable().optional(), + // Carried on the embedded place so the day-plan thumbnail can auto-fetch an + // OSM photo the same way the sidebar/inspector do (#1136 follow-up). + osm_id: z.string().nullable().optional(), website: z.string().nullable().optional(), phone: z.string().nullable().optional(), category: placeCategorySchema.optional(), diff --git a/wiki/Collections.md b/wiki/Collections.md index 475c1ed34..401b27ddc 100644 --- a/wiki/Collections.md +++ b/wiki/Collections.md @@ -48,6 +48,8 @@ Places can be assigned a **category** from the same admin-defined set used acros Clicking a saved place opens a detail sheet showing a cover photo (fetched automatically when the place has none of its own), its category, its [labels](#custom-labels), a live status control, a markdown description, and links. Editing the place also lets you assign its labels. From there you can edit the place, **copy it into a trip**, or remove it from the list. +To set your own cover instead of the auto-fetched one, use the camera button on the detail sheet's cover to upload an image (JPG, PNG, GIF or WebP, up to 20 MB); the remove button next to it clears the custom image and restores the automatic default. + ## Filtering and bulk actions Above the places sit compact filters — by **status**, by **category**, and by [**label**](#custom-labels) — plus a **Select** toggle. In select mode you can: diff --git a/wiki/Places-and-Search.md b/wiki/Places-and-Search.md index 8a3362947..a09690172 100644 --- a/wiki/Places-and-Search.md +++ b/wiki/Places-and-Search.md @@ -53,6 +53,12 @@ Type or paste a `lat, lng` pair (e.g. `48.8566, 2.3522`) into the **Latitude** f Two inline warnings are shown when editing times: one if the end time is set to a value before or equal to the start time, and one if the times overlap with another place already assigned to the same day. +## Custom place image + +By default a place's thumbnail is fetched automatically (from Google/OpenStreetMap when the place was imported or matched, otherwise a category icon). To use your own photo instead, open the place's detail panel and click its round thumbnail — pick an image and it becomes that place's thumbnail everywhere (list, map marker, itinerary, PDF export and shared trips). A small remove button on the thumbnail clears the custom image and restores the automatic default. Accepted formats are JPG, PNG, GIF and WebP (HEIC is converted automatically), up to 20 MB. + +The same control is available on saved places in [Collections](Collections#place-detail). + ## Importing multiple places Drag a `.gpx`, `.kml`, or `.kmz` file onto the Places sidebar to import all waypoints or features at once. You can also import a saved-list share URL using the **Import list** button in the sidebar header — both Google Maps and Naver Maps list URLs are supported.