From 35566690e9165bf9ba3d37d612fb6f08992f6d78 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 23 Oct 2026 19:07:53 +0200 Subject: [PATCH] feat(places): the picture of a place is chosen in the editor, and the view opens its photos - The editor's 'Pick a picture' is the one place a place's picture is set, on the desktop dialog and the phone sheet alike: upload one from the device, keep the current one (now shown as chosen), take an image attached to the place, or one of the sources' photos, or remove it. Nothing is sent before the save: the upload and the attached file are put in place once the place is saved, so Cancel leaves the picture as it was and a new place can get one too. Without the details column the same picker sits in the form. - The view (inspector and phone sheet) shows the picture plainly; a click opens the gallery of its own picture and its sources' photos. - The gallery drops an answer that arrives after the place changed, and its credits link to their source pages. The lightbox is a labelled modal dialog that takes and gives back focus, names its arrows and follows the reading direction. - The picker leaves the details column (PlaceImagePicker), the column reads the shared enrichment cache, and PlaceAvatarUpload and usePlaceImagePick, which only the view used, are gone. --- client/scripts/format-baseline.json | 2 - client/scripts/i18n-unused-baseline.json | 2 +- client/scripts/size-baseline.json | 6 +- .../src/components/Journey/PhotoLightbox.tsx | 56 ++- .../components/Planner/PlaceDetailsColumn.tsx | 258 ++------------ .../src/components/Planner/PlaceFormMedia.tsx | 83 +++++ .../Planner/PlaceFormModal.helpers.ts | 8 +- .../src/components/Planner/PlaceFormModal.tsx | 55 +-- .../components/Planner/PlaceImagePicker.tsx | 337 ++++++++++++++++++ .../src/components/Planner/PlaceInspector.tsx | 31 +- .../components/Planner/PlacePhotoGallery.tsx | 69 ++-- .../src/components/Planner/placeEnrichment.ts | 48 +-- .../components/Planner/placeImageChoice.ts | 48 +++ .../components/Planner/placePhotoCredit.ts | 16 +- .../Planner/usePlaceActions.test.ts | 62 +--- .../src/components/Planner/usePlaceActions.ts | 27 -- client/src/components/Planner/usePlaceForm.ts | 49 ++- .../src/components/Planner/usePlaceGallery.ts | 138 +++---- .../components/Planner/usePlaceImageField.ts | 57 +++ .../shared/PlaceAvatarUpload.test.tsx | 91 ----- .../components/shared/PlaceAvatarUpload.tsx | 204 ----------- .../screens/trip/sheets/MPlaceEditSheet.tsx | 15 +- .../screens/trip/sheets/MPlaceSheet.tsx | 56 +-- client/src/pages/TripPlannerPage.tsx | 2 - .../src/pages/tripPlanner/placeImageSave.ts | 41 +++ client/src/pages/tripPlanner/usePlaceEdits.ts | 5 + shared/src/i18n/ar/journey.ts | 2 + shared/src/i18n/ar/places.ts | 9 +- shared/src/i18n/az/journey.ts | 2 + shared/src/i18n/az/places.ts | 9 +- shared/src/i18n/br/journey.ts | 2 + shared/src/i18n/br/places.ts | 9 +- shared/src/i18n/ca/journey.ts | 2 + shared/src/i18n/ca/places.ts | 9 +- shared/src/i18n/cs/journey.ts | 2 + shared/src/i18n/cs/places.ts | 9 +- shared/src/i18n/de/journey.ts | 2 + shared/src/i18n/de/places.ts | 9 +- shared/src/i18n/en/journey.ts | 2 + shared/src/i18n/en/places.ts | 9 +- shared/src/i18n/es/journey.ts | 2 + shared/src/i18n/es/places.ts | 9 +- shared/src/i18n/et/journey.ts | 2 + shared/src/i18n/et/places.ts | 9 +- shared/src/i18n/fr/journey.ts | 2 + shared/src/i18n/fr/places.ts | 9 +- shared/src/i18n/gr/journey.ts | 2 + shared/src/i18n/gr/places.ts | 9 +- shared/src/i18n/hu/journey.ts | 2 + shared/src/i18n/hu/places.ts | 9 +- shared/src/i18n/id/journey.ts | 2 + shared/src/i18n/id/places.ts | 9 +- shared/src/i18n/it/journey.ts | 2 + shared/src/i18n/it/places.ts | 9 +- shared/src/i18n/ja/journey.ts | 2 + shared/src/i18n/ja/places.ts | 9 +- shared/src/i18n/ko/journey.ts | 2 + shared/src/i18n/ko/places.ts | 9 +- shared/src/i18n/nl/journey.ts | 2 + shared/src/i18n/nl/places.ts | 9 +- shared/src/i18n/pl/journey.ts | 2 + shared/src/i18n/pl/places.ts | 9 +- shared/src/i18n/ru/journey.ts | 2 + shared/src/i18n/ru/places.ts | 9 +- shared/src/i18n/sk/journey.ts | 2 + shared/src/i18n/sk/places.ts | 9 +- shared/src/i18n/sv/journey.ts | 2 + shared/src/i18n/sv/places.ts | 9 +- shared/src/i18n/th/journey.ts | 2 + shared/src/i18n/th/places.ts | 9 +- shared/src/i18n/tr/journey.ts | 2 + shared/src/i18n/tr/places.ts | 9 +- shared/src/i18n/uk/journey.ts | 2 + shared/src/i18n/uk/places.ts | 9 +- shared/src/i18n/vi/journey.ts | 2 + shared/src/i18n/vi/places.ts | 9 +- shared/src/i18n/zh-TW/journey.ts | 2 + shared/src/i18n/zh-TW/places.ts | 9 +- shared/src/i18n/zh/journey.ts | 2 + shared/src/i18n/zh/places.ts | 9 +- 80 files changed, 1090 insertions(+), 973 deletions(-) create mode 100644 client/src/components/Planner/PlaceFormMedia.tsx create mode 100644 client/src/components/Planner/PlaceImagePicker.tsx create mode 100644 client/src/components/Planner/placeImageChoice.ts create mode 100644 client/src/components/Planner/usePlaceImageField.ts delete mode 100644 client/src/components/shared/PlaceAvatarUpload.test.tsx delete mode 100644 client/src/components/shared/PlaceAvatarUpload.tsx create mode 100644 client/src/pages/tripPlanner/placeImageSave.ts diff --git a/client/scripts/format-baseline.json b/client/scripts/format-baseline.json index 087167588..79098a567 100644 --- a/client/scripts/format-baseline.json +++ b/client/scripts/format-baseline.json @@ -1123,8 +1123,6 @@ "src/components/shared/PasswordChecklist.tsx", "src/components/shared/PlaceAvatar.test.tsx", "src/components/shared/PlaceAvatar.tsx", - "src/components/shared/PlaceAvatarUpload.test.tsx", - "src/components/shared/PlaceAvatarUpload.tsx", "src/components/shared/PluginIcon.test.tsx", "src/components/shared/PluginIcon.tsx", "src/components/shared/PublicLanguagePicker.test.tsx", diff --git a/client/scripts/i18n-unused-baseline.json b/client/scripts/i18n-unused-baseline.json index 58cf390d4..218e126e8 100644 --- a/client/scripts/i18n-unused-baseline.json +++ b/client/scripts/i18n-unused-baseline.json @@ -26,7 +26,7 @@ "packing.ts": 6, "pdf.ts": 1, "perm.ts": 4, - "places.ts": 10, + "places.ts": 9, "planner.ts": 53, "reservations.ts": 21, "roadtrip.ts": 36, diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index cf48c4788..c7603e912 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -14,15 +14,15 @@ "src/components/Planner/DayPlanSidebar.test.tsx": 5349, "src/components/Planner/DayPlanSidebar.tsx": 2953, "src/components/Planner/PlaceFormModal.test.tsx": 2836, - "src/components/Planner/PlaceFormModal.tsx": 1193, - "src/components/Planner/PlaceInspector.tsx": 1304, + "src/components/Planner/PlaceFormModal.tsx": 1158, + "src/components/Planner/PlaceInspector.tsx": 1290, "src/components/Studio/TravelElements.tsx": 1738, "src/components/Studio/countryShapes.ts": 2595, "src/index.css": 1636, "src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507, "src/pages/JourneyDetailPage.test.tsx": 4030, "src/pages/LoginPage.tsx": 1348, - "src/pages/TripPlannerPage.tsx": 1131, + "src/pages/TripPlannerPage.tsx": 1128, "src/pages/atlas/useAtlas.ts": 1211, "src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518, "src/pages/tripPlanner/useTripPlanner.test.tsx": 3270, diff --git a/client/src/components/Journey/PhotoLightbox.tsx b/client/src/components/Journey/PhotoLightbox.tsx index 00dcae50d..31c46f5d0 100644 --- a/client/src/components/Journey/PhotoLightbox.tsx +++ b/client/src/components/Journey/PhotoLightbox.tsx @@ -9,6 +9,8 @@ interface LightboxPhoto { id: string src: string caption?: string | null + /** Where the caption's credit points to (a photo's source page), opened in a new tab. */ + captionHref?: string | null provider?: string asset_id?: string | null owner_id?: number | null @@ -19,6 +21,8 @@ interface Props { photos: LightboxPhoto[] startIndex?: number onClose: () => void + /** What the dialog is called for assistive tech. */ + label?: string } /** The round glass buttons of the top bar. */ @@ -28,10 +32,20 @@ const ROUND_BTN: React.CSSProperties = { color: '#fff', cursor: 'pointer', } -export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props) { +export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }: Props) { const [idx, setIdx] = useState(startIndex) const touchStart = useRef<{ x: number; y: number } | null>(null) + const dialogRef = useRef(null) const { t } = useTranslation() + // Right to left, the next photo lies to the left: the arrow keys and swipes follow the reading direction. + const rtl = typeof document !== 'undefined' && document.documentElement.dir === 'rtl' + + // Focus moves into the dialog when it opens and back to what opened it when it closes. + useEffect(() => { + const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null + dialogRef.current?.focus() + return () => opener?.focus() + }, []) const photo = photos[idx] // Zoom starts over on every photo (#1484). @@ -46,12 +60,12 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() - if (e.key === 'ArrowLeft') prev() - if (e.key === 'ArrowRight') next() + if (e.key === 'ArrowLeft') (rtl ? next : prev)() + if (e.key === 'ArrowRight') (rtl ? prev : next)() } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [prev, next, onClose]) + }, [prev, next, onClose, rtl]) // A pinch, a double tap or a pan of an enlarged photo belongs to the zoom; only // the rest is a swipe that turns the page or closes the lightbox. @@ -79,7 +93,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props } // horizontal swipe if (Math.abs(dx) > 50 && Math.abs(dy) < 80) { - if (dx < 0) next() + if (dx < 0 !== rtl) next() else prev() } touchStart.current = null @@ -89,7 +103,13 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props return (
- + )} @@ -179,27 +199,33 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props {/* Next button */} {hasNext && ( - )} {/* Caption — bottom center overlay */} {photo.caption && ( -
+

{photo.caption}

+ padding: '6px 14px', borderRadius: 10, pointerEvents: 'auto', + }}> + {photo.captionHref ? ( + + {photo.caption} + + ) : photo.caption} +

)}
diff --git a/client/src/components/Planner/PlaceDetailsColumn.tsx b/client/src/components/Planner/PlaceDetailsColumn.tsx index c146626b8..f3b2ccc1a 100644 --- a/client/src/components/Planner/PlaceDetailsColumn.tsx +++ b/client/src/components/Planner/PlaceDetailsColumn.tsx @@ -33,22 +33,22 @@ import { fs } from '../shared/DialogShell' import { LABEL } from '../shared/dialogParts' import { COLUMN_CARD, ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from './placeDialogParts' import type { TranslationFn } from '../../types' +import PlaceImagePicker, { type PlaceImageOwn } from './PlaceImagePicker' +import { chosenImage, type ChosenImage } from './placeImageChoice' +import { cacheKeyFor, readCachedEnrichment, storeEnrichment, type PlaceDetailsSelection } from './placeEnrichment' -/** The place the column is describing. Null while nothing is selected. */ -export interface PlaceDetailsSelection { - placeId?: string - lat: number - lng: number - name: string - /** The picked search result, so the server can skip its own details lookup. */ - details?: Record -} +export { __clearEnrichmentCacheForTests, type PlaceDetailsSelection } from './placeEnrichment' interface PlaceDetailsColumnProps { selection: PlaceDetailsSelection | null /** Currently chosen hero image, so the picked tile can show as picked. */ - selectedImageUrl?: string + selectedImageUrl?: string | null onPickImage: (url: string | null) => void + /** + * The editor's own pictures (upload, the current one, attached images) and + * which tile is chosen; without it only the sources' pictures are offered. + */ + image?: { own: PlaceImageOwn; chosen: ChosenImage } onAdoptDescription: (text: string) => void /** The looked-up opening hours, once they arrive, so the form can take them over (#2472). */ onHours?: (hours: PlaceHours) => void @@ -75,47 +75,6 @@ interface PlaceDetailsColumnProps { t: TranslationFn } -/** - * Module-level cache plus sessionStorage, same shape as usePlaceDetails in - * PlaceInspector. Clicking back and forth between two search results must not - * pay for the provider fan-out twice. - */ -const enrichmentCache = new Map() - -/** Test seam: the module-level cache otherwise leaks between cases. */ -export function __clearEnrichmentCacheForTests(): void { - enrichmentCache.clear() -} - -function readSession(key: string): MapsPlaceEnrichmentResult | undefined { - try { - const raw = sessionStorage.getItem(key) - return raw ? (JSON.parse(raw) as MapsPlaceEnrichmentResult) : undefined - } catch { - return undefined - } -} - -function writeSession(key: string, value: MapsPlaceEnrichmentResult): void { - try { - sessionStorage.setItem(key, JSON.stringify(value)) - } catch { - /* private mode / quota — the in-memory cache still helps for this session */ - } -} - -/** - * Bumped with the server's CACHE_VERSION. sessionStorage outlives a deploy, so - * without it the tab that was open while the fix shipped keeps replaying the - * answer the fix was about — and reports it as still broken. - */ -const ENRICH_CACHE_V = 4 - -function cacheKeyFor(selection: PlaceDetailsSelection, language: string): string { - const id = selection.placeId || `coords:${selection.lat}:${selection.lng}` - return `enrich_v${ENRICH_CACHE_V}_${id}_${language}` -} - type LoadState = 'idle' | 'loading' | 'ready' | 'error' /** @@ -161,6 +120,7 @@ export default function PlaceDetailsColumn({ locale = 'en-US', fluid = false, variant = 'sheet', + image, t, }: PlaceDetailsColumnProps): React.ReactElement { const dialog = variant === 'dialog' @@ -183,9 +143,8 @@ export default function PlaceDetailsColumn({ return } - const cached = enrichmentCache.get(selectionKey) ?? readSession(selectionKey) + const cached = readCachedEnrichment(selection, language) if (cached) { - enrichmentCache.set(selectionKey, cached) setData(cached) setState('ready') return @@ -217,8 +176,7 @@ export default function PlaceDetailsColumn({ ) .then((result) => { if (controller.signal.aborted) return - enrichmentCache.set(selectionKey, result) - writeSession(selectionKey, result) + storeEnrichment(selection, language, result) setData(result) setState('ready') }) @@ -279,6 +237,16 @@ export default function PlaceDetailsColumn({ )}
+ {/* The place's picture first: its own ones are there whatever the sources answer. */} + + {/* Nothing picked yet, which is a state rather than a fault: the mascot waits with an idle look instead of a sad one. The dialog says the same in a quiet card, and the column keeps its width either way. */} @@ -331,7 +299,6 @@ export default function PlaceDetailsColumn({ {selection && state === 'ready' && !data?.disabled && !isEmpty && ( <> - void - dialog?: boolean + own?: PlaceImageOwn + dialog: boolean t: TranslationFn }): React.ReactElement | null { - const [hovered, setHovered] = useState(null) - if (photos.length === 0) return null - - const shown = photos.find((p) => p.url === (hovered ?? selectedImageUrl)) ?? photos[0] - const body = ( - <> -
- {photos.map((photo) => ( - - ))} -
-

- -

- - ) - + const picker = + if (photos.length === 0 && !own) return null return ( - {dialog ?
{body}
: body} + {dialog ?
{picker}
: picker}
) } -function PhotoTile({ - photo, - selected, - onPick, - onHover, - dialog = false, - t, -}: { - photo: PlacePhotoCandidate - selected: boolean - onPick: (url: string | null) => void - onHover: (url: string | null) => void - /** On a white card, and without the native tooltip: hovering a tile already moves the credit line under the grid to it. */ - dialog?: boolean - t: TranslationFn -}): React.ReactElement { - const credit = creditOf(photo) - - return ( - - ) -} - /** * Who the description came from, as the reader would name them. * @@ -533,75 +412,6 @@ function descriptionSourceLabel( } } -function sourceLabelFor(source: PlacePhotoCandidate['source']): string { - if (source === 'google') return 'Google' - if (source === 'wikipedia') return 'Wikipedia' - return 'Wikimedia Commons' -} - -/** - * Author and licence for the picture currently in play. - * - * Not decoration: Commons images are largely CC BY / CC BY-SA, and reusing one - * without naming its author does not satisfy those terms. When a source hands - * us no author (Google), we say where it came from rather than inventing one. - * Rendered inline — the container supplies the colour and the truncation. - */ -function PhotoCredit({ photo, dialog = false }: { photo: PlacePhotoCandidate; dialog?: boolean }): React.ReactElement { - const credit = creditOf(photo) - - if (dialog) { - // Two pieces side by side with room between them, rather than joined by a dot. - return ( - <> - - {photo.sourceUrl ? ( - - {credit} - - ) : ( - credit - )} - - {photo.license && ( - - {photo.licenseUrl ? ( - - {photo.license} - - ) : ( - photo.license - )} - - )} - - ) - } - - return ( - <> - {photo.sourceUrl ? ( - - {credit} - - ) : ( - credit - )} - {photo.license && ( - <> - {' · '} - {photo.licenseUrl ? ( - - {photo.license} - - ) : ( - photo.license - )} - - )} - - ) -} /** * Shown when the free sources found nothing and no Google key is configured. diff --git a/client/src/components/Planner/PlaceFormMedia.tsx b/client/src/components/Planner/PlaceFormMedia.tsx new file mode 100644 index 000000000..f7f6b6156 --- /dev/null +++ b/client/src/components/Planner/PlaceFormMedia.tsx @@ -0,0 +1,83 @@ +import { Paperclip, X } from 'lucide-react'; +import React from 'react'; +import type { TranslationFn } from '../../types'; +import { fs } from '../shared/DialogShell'; +import { Tooltip } from '../shared/Tooltip'; +import { LABEL } from '../shared/dialogParts'; +import PlaceImagePicker, { type PlaceImageOwn } from './PlaceImagePicker'; +import { COLUMN_CARD, WHITE_BUTTON } from './placeDialogParts'; +import type { ChosenImage } from './placeImageChoice'; + +/** Files chosen before the place exists: they are uploaded once the save has given it an id. */ +export function PendingFiles({ + files, + fileRef, + onAdd, + onRemove, + t, +}: { + files: File[]; + fileRef: React.RefObject; + onAdd: (e: React.ChangeEvent) => void; + onRemove: (index: number) => void; + t: TranslationFn; +}) { + return ( +
+
{t('files.title')}
+ + + {files.length > 0 && ( +
+ {files.map((file, idx) => ( +
+
+ ))} +
+ )} +
+ ); +} + +/** + * The place's picture in the form itself, where no details column stands + * beside it (place details switched off): the same picker, with the place's + * own pictures and an upload, under the field's label. + */ +export function PlaceImageSection({ + image, + onPickUrl, + t, +}: { + image: { own: PlaceImageOwn; chosen: ChosenImage }; + onPickUrl: (url: string | null) => void; + t: TranslationFn; +}) { + return ( +
+
{t('places.details.pickImage')}
+
+ +
+
+ ); +} diff --git a/client/src/components/Planner/PlaceFormModal.helpers.ts b/client/src/components/Planner/PlaceFormModal.helpers.ts index 994cd0e18..0101207ed 100644 --- a/client/src/components/Planner/PlaceFormModal.helpers.ts +++ b/client/src/components/Planner/PlaceFormModal.helpers.ts @@ -1,4 +1,5 @@ import type { Assignment, Place, RoadtripStopType } from '@trek/shared' +import { pendingImagePayload, type PendingImagePayload, type PendingPlaceImage } from './placeImageChoice' import type { PlaceDetailsSelection } from './PlaceDetailsColumn' export interface PlaceFormData { @@ -27,7 +28,8 @@ export interface PlaceFormData { // Hero image picked from the detail column. Optional and absent from // DEFAULT_FORM on purpose: the mobile sheet shares this type and never sets // it, and places.service already writes image_url through on create/update. - image_url?: string + // Null drops the place's picture on save; undefined keeps it. + image_url?: string | null // Day-specific note on the in-context assignment (#2163). Only hydrated when // the form opened with an assignment in context; both forms drop it from the // submit payload when unchanged, and useTripPlanner strips it off the place @@ -308,6 +310,8 @@ export interface PlaceFormPayload extends Omit( pendingFiles: File[], assignment: Pick | null | undefined, extra?: (lat: number | null, lng: number | null) => E, + pendingImage: PendingPlaceImage | null = null, ): PlaceFormPayload & E { const lat = form.lat ? Number.parseFloat(form.lat) : null const lng = form.lng ? Number.parseFloat(form.lng) : null @@ -332,6 +337,7 @@ export function placeFormPayload( category_id: form.category_id || null, ...(extra ? extra(lat, lng) : {}), _pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined, + _pendingImage: pendingImagePayload(pendingImage), } as PlaceFormPayload & E if (!assignment || (form.assignment_notes ?? '') === (assignment.notes ?? '')) { delete payload.assignment_notes diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 2a0f12f35..77408547e 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -8,9 +8,11 @@ import { useTripStore } from '../../store/tripStore' import { useSettingsStore } from '../../store/settingsStore' import CollectionPicker from '../Collections/CollectionPicker' import PlaceDetailsColumn from './PlaceDetailsColumn' +import { PendingFiles, PlaceImageSection } from './PlaceFormMedia' +import { usePlaceImageField } from './usePlaceImageField' import { useToast } from '../shared/Toast' import { Tooltip } from '../shared/Tooltip' -import { Search, Paperclip, X, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed, PencilLine } from 'lucide-react' +import { Search, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed, PencilLine } from 'lucide-react' import { useTranslation } from '../../i18n' import { PlaceContactFields } from './PlaceContactFields' import { weekFromPeriods } from './placeHours' @@ -121,8 +123,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) { const { form, setForm, autoFilledRef, pendingFiles, addFilesFromInput, removeFile, handlePaste, isSaving, setIsSaving, duplicateWarning, detailsSelection, setDetailsSelection, openForm, handleChange: changeField, pickImage, - adoptDescription, warnIfDuplicate, + adoptDescription, warnIfDuplicate, pendingImage, stageUpload, stageAttached, removeImage, } = usePlaceForm({ t, toast, canUploadFiles }) + const image = usePlaceImageField({ + place, imageUrl: form.image_url, pendingImage, canUploadFiles, stageUpload, stageAttached, removeImage, + }) // The index the picked place came from, as its row named it, shown as a pill // in the head band. Null for a place typed by hand or taken from the saved list. const [pickedSource, setPickedSource] = useState(null) @@ -358,7 +363,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { ...(serviceStop ? { _serviceStop: serviceStopChoice(serviceStop, lat, lng, serviceStopLeg).placement } : {}), - })) + }), pendingImage) const saved = await onSave(payload) // Open the Costs editor for the saved place when the user asked to // create/edit its linked expense — gated on an id, so a create that the @@ -442,6 +447,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { handleRemoveFile: removeFile, handlePaste, pickImage, + image, adoptDescription, hasTimeError, handleSubmit, @@ -606,6 +612,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { selection={detailsSelection} selectedImageUrl={form.image_url} onPickImage={S.pickImage} + image={S.image} onAdoptDescription={S.adoptDescription} onHours={(hours) => S.setDetailsHours(weekFromPeriods(hours.periods))} hasDescription={!!form.description.trim()} @@ -879,6 +886,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { maxLength={2000} /> + {!showDetails && } {/* The link and the files side by side, as on a booking. */}
@@ -1117,44 +1125,3 @@ function MarkdownField({ id, label, value, onChange, placeholder, maxLength }: { ) } - -/** Files chosen before the place exists: they are uploaded once the save has given it an id. */ -function PendingFiles({ files, fileRef, onAdd, onRemove, t }: { - files: File[] - fileRef: React.RefObject - onAdd: (e: React.ChangeEvent) => void - onRemove: (index: number) => void - t: Translate -}) { - return ( -
-
{t('files.title')}
- - - {files.length > 0 && ( -
- {files.map((file, idx) => ( -
-
- ))} -
- )} -
- ) -} diff --git a/client/src/components/Planner/PlaceImagePicker.tsx b/client/src/components/Planner/PlaceImagePicker.tsx new file mode 100644 index 000000000..65c90cb62 --- /dev/null +++ b/client/src/components/Planner/PlaceImagePicker.tsx @@ -0,0 +1,337 @@ +import type { PlacePhotoCandidate } from '@trek/shared'; +import { Check, ImageOff, ImagePlus, Loader2 } from 'lucide-react'; +import React, { useEffect, useRef, useState } from 'react'; +import { fetchImageAsBlob } from '../../api/authUrl'; +import type { TranslationFn, TripFile } from '../../types'; +import { normalizeImageFile } from '../../utils/convertHeic'; +import { Tooltip } from '../shared/Tooltip'; +import type { ChosenImage } from './placeImageChoice'; +import { creditOf } from './placePhotoCredit'; + +/** The place's own pictures beside the sources' ones: what it has, what it can get, what waits for the save. */ +export interface PlaceImageOwn { + /** The picture the place has now; null on a new place or one without. */ + currentUrl: string | null; + /** Images attached to the place that can stand as its picture. */ + attached: TripFile[]; + /** The picture from the device that waits for the save, as a preview. */ + pendingPreview: string | null; + /** False where the user may not upload files; the upload tile is then left out. */ + canUpload: boolean; + onUpload: (file: File) => void; + onPickAttached: (file: TripFile) => void; + onRemove: () => void; +} + +interface PlaceImagePickerProps { + /** What the sources know, as found for the place; empty while they load or when there are none. */ + photos: PlacePhotoCandidate[]; + chosen: ChosenImage; + onPickUrl: (url: string | null) => void; + /** Left out where only the sources' pictures are offered. */ + own?: PlaceImageOwn; + /** On a white card, without native tooltips: hovering moves the credit line instead. */ + dialog?: boolean; + t: TranslationFn; +} + +const TILE = 'group relative block aspect-square w-full overflow-hidden rounded-lg transition-shadow'; + +function ring(selected: boolean, dialog: boolean): string { + if (selected) + return `ring-2 ring-accent ring-offset-2 ${dialog ? 'ring-offset-surface-card' : 'ring-offset-surface-secondary'}`; + return 'ring-1 ring-edge hover:ring-content-muted'; +} + +function Picked(): React.ReactElement { + return ( + + + + ); +} + +/** What the credit line under the grid says: a source's photo with its author and licence, or the place's own. */ +type Credit = { kind: 'photo'; photo: PlacePhotoCandidate } | { kind: 'own'; label: string } | null; + +/** + * The one place a place's picture is chosen, in the editor: a tile to upload + * one from the device, the picture it has now, the images attached to it and + * the pictures its sources know. The chosen tile is ringed and ticked; a + * second tap on a source's photo lets go of it, and "Remove picture" leaves + * the place without one. Nothing is sent before the form is saved. + */ +export default function PlaceImagePicker({ + photos, + chosen, + onPickUrl, + own, + dialog = false, + t, +}: PlaceImagePickerProps): React.ReactElement | null { + const [hovered, setHovered] = useState(null); + const fileRef = useRef(null); + const [reading, setReading] = useState(false); + const current = own?.currentUrl && !photos.some((p) => p.url === own.currentUrl) ? own.currentUrl : null; + const attached = own?.attached ?? []; + const hasAnything = + photos.length > 0 || !!current || attached.length > 0 || !!own?.pendingPreview || !!own?.canUpload; + if (!hasAnything) return null; + + const pick = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file || !own) return; + setReading(true); + try { + own.onUpload(await normalizeImageFile(file)); + } finally { + setReading(false); + } + }; + + const credit = (): Credit => { + if (hovered) return hovered; + if (chosen.kind === 'url') { + const photo = photos.find((p) => p.url === chosen.url); + if (photo) return { kind: 'photo', photo }; + } + if (chosen.kind === 'current') return { kind: 'own', label: t('places.image.current') }; + if (chosen.kind === 'upload') return { kind: 'own', label: t('places.image.own') }; + if (chosen.kind === 'attached') { + const file = attached.find((f) => f.id === chosen.fileId); + if (file) return { kind: 'own', label: file.original_name }; + } + if (chosen.kind === 'none' && (own?.currentUrl || photos.length > 0)) + return { kind: 'own', label: t('places.image.none') }; + return photos[0] ? { kind: 'photo', photo: photos[0] } : null; + }; + const shown = credit(); + const removable = !!own && chosen.kind !== 'none'; + + return ( + <> +
+ {own?.canUpload && ( + + + + )} + {current && ( + + )} + {attached.map((file) => ( + own?.onPickAttached(file)} + onHover={(on) => setHovered(on ? { kind: 'own', label: file.original_name } : null)} + dialog={dialog} + /> + ))} + {photos.map((photo) => ( + setHovered(on ? { kind: 'photo', photo } : null)} + dialog={dialog} + t={t} + /> + ))} +
+ {own?.canUpload && ( + void pick(e)} + /> + )} + {(shown || removable) && ( +
+ + {shown?.kind === 'photo' && } + {shown?.kind === 'own' && {shown.label}} + + {removable && ( + + )} +
+ )} + + ); +} + +function PhotoTile({ + photo, + selected, + onPick, + onHover, + dialog, + t, +}: { + photo: PlacePhotoCandidate; + selected: boolean; + onPick: (url: string | null) => void; + onHover: (on: boolean) => void; + dialog: boolean; + t: TranslationFn; +}): React.ReactElement { + const credit = creditOf(photo); + return ( + + ); +} + +/** An image attached to the place, loaded through the authenticated file route. */ +function AttachedTile({ + file, + selected, + onPick, + onHover, + dialog, +}: { + file: TripFile; + selected: boolean; + onPick: () => void; + onHover: (on: boolean) => void; + dialog: boolean; +}): React.ReactElement { + const [src, setSrc] = useState(null); + useEffect(() => { + let current = true; + fetchImageAsBlob(file.url) + .then((url) => { + if (current) setSrc(url); + }) + .catch(() => { + if (current) setSrc(null); + }); + return () => { + current = false; + }; + }, [file.url]); + return ( + + ); +} + +/** + * Author and licence of the source's photo in play, each linked to its page. + * + * Not decoration: Commons images are largely CC BY / CC BY-SA, and reusing one + * without naming its author does not satisfy those terms. When a source hands + * us no author (Google), we say where it came from rather than inventing one. + */ +export function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElement { + const credit = creditOf(photo); + return ( + <> + + {photo.sourceUrl ? ( + + {credit} + + ) : ( + credit + )} + + {photo.license && ( + + {photo.licenseUrl ? ( + + {photo.license} + + ) : ( + photo.license + )} + + )} + + ); +} diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index c0f383c0e..46b7d6f51 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -9,7 +9,7 @@ import { markdownLinkComponents } from '../shared/markdownLink' import { X, Clock, MapPin, ExternalLink, Phone, Mail, Banknote, Pencil, Plus, Minus, ChevronDown, ChevronUp, ChevronRight, FileText, Upload, File, FileImage, Star, Navigation, Mountain, Bookmark, BookmarkCheck, Copy } from 'lucide-react' import { hoursLines, periodsFromWeek } from './placeHours' import PlaceAvatar from '../shared/PlaceAvatar' -import PlaceAvatarUpload, { pickableImages } from '../shared/PlaceAvatarUpload' +import PlacePhotoGallery from './PlacePhotoGallery' import { BlurredCode } from '../shared/BookingCode' import PlaceRating from '../shared/StarRating' import TrackColorPicker from '../shared/TrackColorPicker' @@ -186,10 +186,6 @@ 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 - /** Takes a picture already attached to the place as its image (#1242). */ - onImageFromFile?: (placeId: number, fileId: number) => Promise /** Cast/clear the current user's star vote (#1435); enables the rating row. */ onRate?: (placeId: number, rating: number | null) => Promise | void leftWidth?: number @@ -215,7 +211,7 @@ export default function PlaceInspector({ place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null, assignments = {}, reservations = [], onEditTransport, onEditReservation, onOpenBooking, onClose, onEdit: editPlace, onDelete: deletePlace, onAssignToDay, onRemoveAssignment, - files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onUploadImage, onImageFromFile, onRate, + files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onRate, leftWidth = 0, rightWidth = 0, clearMapPill = false, collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive, }: PlaceInspectorProps) { @@ -392,9 +388,6 @@ export default function PlaceInspector({ editingName={editingName} nameInputRef={nameInputRef} nameValue={nameValue} setNameValue={setNameValue} commitNameEdit={commitNameEdit} handleNameKeyDown={handleNameKeyDown} startNameEdit={startNameEdit} onUpdatePlace={onUpdatePlace} - onUploadImage={mode === 'trip' && onUpdatePlace ? onUploadImage : undefined} - attachedImages={pickableImages(placeFiles)} - onImageFromFile={mode === 'trip' && onUpdatePlace ? onImageFromFile : undefined} onClose={onClose} /> {/* The body scrolls inside the capped card; each block keeps its natural height (#1195). */} @@ -598,19 +591,17 @@ interface InspectorHeadProps { handleNameKeyDown: (e: React.KeyboardEvent) => void startNameEdit: () => void onUpdatePlace?: (placeId: number, data: Partial) => void - onUploadImage?: (placeId: number, file: File) => Promise - attachedImages: TripFile[] - onImageFromFile?: (placeId: number, fileId: number) => Promise onClose: () => void } /** * The head band: the avatar with its open/closed ring and photo credit, the - * name (a double-click renames it), the address, and the facts as pills. + * name (a double-click renames it), the address, and the facts as pills. The + * avatar opens the place's photos; its picture is changed in the editor. */ function InspectorHead({ place, category, openNow, phone, email, rating, ratingCount, price, time, editingName, nameInputRef, - nameValue, setNameValue, commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, onUploadImage, attachedImages, onImageFromFile, onClose }: InspectorHeadProps) { - const { t, locale } = useTranslation() + nameValue, setNameValue, commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, onClose }: InspectorHeadProps) { + const { t, locale, language } = useTranslation() const ring = openNow === true ? 'bg-success' : openNow === false ? 'bg-danger' : 'bg-surface-card shadow-sm' const hasCoords = !!(place.lat && place.lng) const hasPills = openNow !== null || !!category || !!time || rating != null || !!price || place.source === 'dawarich' || !!phone || !!email || hasCoords @@ -619,13 +610,9 @@ function InspectorHead({ place, category, openNow, phone, email, rating, ratingC
- {onUploadImage - ? onUploadImage(place.id, file)} - onRemove={() => onUpdatePlace(place.id, { image_url: null })} - attachedImages={attachedImages} - onPickAttached={onImageFromFile ? (fileId: number) => onImageFromFile(place.id, fileId) : undefined} /> - : } + + +
diff --git a/client/src/components/Planner/PlacePhotoGallery.tsx b/client/src/components/Planner/PlacePhotoGallery.tsx index 56db6730a..ff53da156 100644 --- a/client/src/components/Planner/PlacePhotoGallery.tsx +++ b/client/src/components/Planner/PlacePhotoGallery.tsx @@ -1,31 +1,60 @@ -import React from 'react' -import { createPortal } from 'react-dom' -import PhotoLightbox from '../Journey/PhotoLightbox' -import { usePlaceGallery } from './usePlaceGallery' -import type { Place } from '../../types' +import { Loader2 } from 'lucide-react'; +import React from 'react'; +import { createPortal } from 'react-dom'; +import type { Place } from '../../types'; +import PhotoLightbox from '../Journey/PhotoLightbox'; +import { Tooltip } from '../shared/Tooltip'; +import { usePlaceGallery } from './usePlaceGallery'; interface PlacePhotoGalleryProps { - place: Place - language: string - /** Receives the opener plus whether there is anything to open. */ - children: (open: () => void, canOpen: boolean, loading: boolean) => React.ReactNode + place: Place; + language: string; + /** What opens the gallery: the place's picture, or what stands in for it. */ + children: React.ReactNode; + /** The open gallery's label, and the trigger's tooltip. */ + label: string; + /** The trigger's shape, so its focus ring follows the picture (round on desktop, a rounded tile on the phone). */ + className?: string; } /** - * Wraps a place avatar and portals the lightbox to : the inspector and - * the phone sheet both sit inside transformed / stacking containers, where an - * in-place fixed overlay would be trapped. + * The place's picture as the way into its photos: a click opens a full screen + * gallery of its own picture and what its sources know, each credited. While + * there is nothing to show the picture stays a plain picture. The lightbox is + * portalled to : the inspector and the phone sheet sit inside + * transformed containers, where a fixed overlay would be trapped. */ -export default function PlacePhotoGallery({ place, language, children }: PlacePhotoGalleryProps): React.ReactElement { - const { photos, open, close, isOpen, loading, canOpen } = usePlaceGallery(place, language) +export default function PlacePhotoGallery({ + place, + language, + children, + label, + className = '', +}: PlacePhotoGalleryProps): React.ReactElement { + const { photos, open, close, isOpen, loading, canOpen } = usePlaceGallery(place, language); + if (!canOpen) return <>{children}; return ( <> - {children(open, canOpen, loading)} - {isOpen && photos.length > 0 && createPortal( - , - document.body, - )} + + + + {isOpen && + photos.length > 0 && + createPortal(, document.body)} - ) + ); } diff --git a/client/src/components/Planner/placeEnrichment.ts b/client/src/components/Planner/placeEnrichment.ts index aefafdc9b..6502041b9 100644 --- a/client/src/components/Planner/placeEnrichment.ts +++ b/client/src/components/Planner/placeEnrichment.ts @@ -1,16 +1,16 @@ -import type { MapsPlaceEnrichmentResult } from '@trek/shared' +import type { MapsPlaceEnrichmentResult } from '@trek/shared'; /** * The place an enrichment consumer is describing — the editor's details column * and the view-mode photo gallery both use this shape. */ export interface PlaceDetailsSelection { - placeId?: string - lat: number - lng: number - name: string + placeId?: string; + lat: number; + lng: number; + name: string; /** The picked search result, so the server can skip its own details lookup. */ - details?: Record + details?: Record; } /** @@ -19,25 +19,25 @@ export interface PlaceDetailsSelection { * pay for the provider fan-out twice, and the gallery must reuse the pictures * the editor already fetched for the same place. */ -const enrichmentCache = new Map() +const enrichmentCache = new Map(); /** Test seam: the module-level cache otherwise leaks between cases. */ export function __clearEnrichmentCacheForTests(): void { - enrichmentCache.clear() + enrichmentCache.clear(); } function readSession(key: string): MapsPlaceEnrichmentResult | undefined { try { - const raw = sessionStorage.getItem(key) - return raw ? (JSON.parse(raw) as MapsPlaceEnrichmentResult) : undefined + const raw = sessionStorage.getItem(key); + return raw ? (JSON.parse(raw) as MapsPlaceEnrichmentResult) : undefined; } catch { - return undefined + return undefined; } } function writeSession(key: string, value: MapsPlaceEnrichmentResult): void { try { - sessionStorage.setItem(key, JSON.stringify(value)) + sessionStorage.setItem(key, JSON.stringify(value)); } catch { /* private mode / quota — the in-memory cache still helps for this session */ } @@ -48,30 +48,30 @@ function writeSession(key: string, value: MapsPlaceEnrichmentResult): void { * without it the tab that was open while the fix shipped keeps replaying the * answer the fix was about — and reports it as still broken. */ -const ENRICH_CACHE_V = 4 +const ENRICH_CACHE_V = 4; export function cacheKeyFor(selection: PlaceDetailsSelection, language: string): string { - const id = selection.placeId || `coords:${selection.lat}:${selection.lng}` - return `enrich_v${ENRICH_CACHE_V}_${id}_${language}` + const id = selection.placeId || `coords:${selection.lat}:${selection.lng}`; + return `enrich_v${ENRICH_CACHE_V}_${id}_${language}`; } /** The answer if it was already fetched this session, else undefined. */ export function readCachedEnrichment( selection: PlaceDetailsSelection, - language: string, + language: string ): MapsPlaceEnrichmentResult | undefined { - const key = cacheKeyFor(selection, language) - const cached = enrichmentCache.get(key) ?? readSession(key) - if (cached) enrichmentCache.set(key, cached) - return cached + const key = cacheKeyFor(selection, language); + const cached = enrichmentCache.get(key) ?? readSession(key); + if (cached) enrichmentCache.set(key, cached); + return cached; } export function storeEnrichment( selection: PlaceDetailsSelection, language: string, - value: MapsPlaceEnrichmentResult, + value: MapsPlaceEnrichmentResult ): void { - const key = cacheKeyFor(selection, language) - enrichmentCache.set(key, value) - writeSession(key, value) + const key = cacheKeyFor(selection, language); + enrichmentCache.set(key, value); + writeSession(key, value); } diff --git a/client/src/components/Planner/placeImageChoice.ts b/client/src/components/Planner/placeImageChoice.ts new file mode 100644 index 000000000..f698f5271 --- /dev/null +++ b/client/src/components/Planner/placeImageChoice.ts @@ -0,0 +1,48 @@ +import type { TripFile } from '../../types'; + +const PICKABLE = /^image\/(jpeg|png|gif|webp)$/; + +/** The images among a place's files that can stand as its picture (#1242). */ +export function pickableImages(files: readonly TripFile[]): TripFile[] { + return files.filter((f) => PICKABLE.test(f.mime_type || '')); +} + +/** + * A picture the form will make the place's own once it is saved: a file from + * the device, or an image already attached to the place. Nothing is sent + * before the save, so cancelling the form leaves the picture as it was, and a + * new place can get one too. + */ +export type PendingPlaceImage = { kind: 'upload'; file: File; preview: string } | { kind: 'attached'; file: TripFile }; + +/** What the picker shows as chosen: the tile that stands for what the save will do. */ +export type ChosenImage = + | { kind: 'current' } + | { kind: 'none' } + | { kind: 'url'; url: string } + | { kind: 'upload' } + | { kind: 'attached'; fileId: number }; + +/** + * The choice the form holds, read as a tile. `imageUrl` is the form's field: + * undefined keeps the place's picture, null drops it, a URL takes a source's + * photo. A waiting picture wins over all of them. + */ +export function chosenImage( + imageUrl: string | null | undefined, + pending: PendingPlaceImage | null, + currentUrl: string | null | undefined +): ChosenImage { + if (pending) return pending.kind === 'upload' ? { kind: 'upload' } : { kind: 'attached', fileId: pending.file.id }; + if (imageUrl === null) return { kind: 'none' }; + if (imageUrl) return imageUrl === currentUrl ? { kind: 'current' } : { kind: 'url', url: imageUrl }; + return currentUrl ? { kind: 'current' } : { kind: 'none' }; +} + +/** What travels with the save to put a waiting picture in place: the file, or the attached file's id. */ +export type PendingImagePayload = File | { fileId: number }; + +export function pendingImagePayload(pending: PendingPlaceImage | null): PendingImagePayload | undefined { + if (!pending) return undefined; + return pending.kind === 'upload' ? pending.file : { fileId: pending.file.id }; +} diff --git a/client/src/components/Planner/placePhotoCredit.ts b/client/src/components/Planner/placePhotoCredit.ts index ed472867a..a2d2e1c04 100644 --- a/client/src/components/Planner/placePhotoCredit.ts +++ b/client/src/components/Planner/placePhotoCredit.ts @@ -1,8 +1,8 @@ -import type { PlacePhotoCandidate } from '@trek/shared' +import type { PlacePhotoCandidate } from '@trek/shared'; /** The name to put under a picture. Google gives no author, so it gets its own name. */ export function creditOf(photo: PlacePhotoCandidate): string { - return photo.attribution || sourceLabelFor(photo.source) + return photo.attribution || sourceLabelFor(photo.source); } /** @@ -10,13 +10,13 @@ export function creditOf(photo: PlacePhotoCandidate): string { * Proper names, so they are not translated. */ export function sourceLabelFor(source: PlacePhotoCandidate['source']): string { - if (source === 'google') return 'Google' - if (source === 'wikipedia') return 'Wikipedia' - return 'Wikimedia Commons' + if (source === 'google') return 'Google'; + if (source === 'wikipedia') return 'Wikipedia'; + return 'Wikimedia Commons'; } -/** Author and licence joined for a single-line caption. */ +/** Author and licence joined for a single-line caption, with a comma as the rest of the app writes it. */ export function photoCaption(photo: PlacePhotoCandidate): string { - const credit = creditOf(photo) - return photo.license ? `${credit} · ${photo.license}` : credit + const credit = creditOf(photo); + return photo.license ? `${credit}, ${photo.license}` : credit; } diff --git a/client/src/components/Planner/usePlaceActions.test.ts b/client/src/components/Planner/usePlaceActions.test.ts index 6c9aca4fc..4883d0aa8 100644 --- a/client/src/components/Planner/usePlaceActions.test.ts +++ b/client/src/components/Planner/usePlaceActions.test.ts @@ -1,6 +1,6 @@ // FE-PLANNER-PLACEACT-001 to -015: the place writes behind both the desktop // inspector and the phone place sheet. -import { act, renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; import type { ChangeEvent } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -8,14 +8,12 @@ import { buildAssignment, buildPlace } from '../../../tests/helpers/factories'; import { resetAllStores } from '../../../tests/helpers/store'; import { assignmentsApi } from '../../api/client'; import { useTripStore } from '../../store/tripStore'; -import { normalizeImageFile } from '../../utils/convertHeic'; import { participantsWith, participantsWithout, placeActions, splitParticipants, usePlaceFileUpload, - usePlaceImagePick, } from './usePlaceActions'; vi.mock('../../utils/convertHeic', () => ({ @@ -208,61 +206,3 @@ describe('usePlaceFileUpload', () => { expect(result.current.filesExpanded).toBe(false); }); }); - -describe('usePlaceImagePick', () => { - it('FE-PLANNER-PLACEACT-013: converts the picked picture, uploads it and clears the input', async () => { - const upload = vi.fn(async (_file: File) => undefined); - const toast = { error: vi.fn() }; - const { result } = renderHook(() => usePlaceImagePick(upload, toast)); - const { event, target } = pick([new File(['x'], 'photo.heic')]); - - await act(async () => { - await result.current.pickImage(event); - }); - - expect(normalizeImageFile).toHaveBeenCalledTimes(1); - expect(upload.mock.calls[0][0].name).toBe('normalized-photo.heic'); - expect(target.value).toBe(''); - expect(result.current.busy).toBe(false); - expect(toast.error).not.toHaveBeenCalled(); - }); - - it('FE-PLANNER-PLACEACT-014: a refused picture is reported and the busy flag drops again', async () => { - let fail: (err: Error) => void = () => undefined; - const upload = vi.fn(() => new Promise((_resolve, reject) => (fail = reject))); - const toast = { error: vi.fn() }; - const { result } = renderHook(() => usePlaceImagePick(upload, toast)); - - let running: Promise = Promise.resolve(); - act(() => { - running = result.current.pickImage(pick([new File(['x'], 'a.png')]).event); - }); - expect(result.current.busy).toBe(true); - await waitFor(() => expect(upload).toHaveBeenCalled()); - - await act(async () => { - fail(new Error('unsupported')); - await running; - }); - expect(toast.error).toHaveBeenCalledWith('places.imageUploadError'); - expect(result.current.busy).toBe(false); - }); - - it('FE-PLANNER-PLACEACT-015: without an upload or a file the pick only clears the input', async () => { - const upload = vi.fn(async () => undefined); - const noUpload = renderHook(() => usePlaceImagePick(undefined, { error: vi.fn() })); - const nothingPicked = renderHook(() => usePlaceImagePick(upload, { error: vi.fn() })); - const first = pick([new File(['x'], 'a.png')]); - const second = pick([]); - await act(async () => { - await noUpload.result.current.pickImage(first.event); - await nothingPicked.result.current.pickImage(second.event); - }); - expect(first.target.value).toBe(''); - expect(second.target.value).toBe(''); - expect(upload).not.toHaveBeenCalled(); - expect(normalizeImageFile).not.toHaveBeenCalled(); - act(() => noUpload.result.current.setBusy(true)); - expect(noUpload.result.current.busy).toBe(true); - }); -}); diff --git a/client/src/components/Planner/usePlaceActions.ts b/client/src/components/Planner/usePlaceActions.ts index ad115c4c7..66655cc79 100644 --- a/client/src/components/Planner/usePlaceActions.ts +++ b/client/src/components/Planner/usePlaceActions.ts @@ -4,7 +4,6 @@ import { translateApiError, useTranslation } from '../../i18n'; import type { TripStoreState } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore'; import type { Place } from '../../types'; -import { normalizeImageFile } from '../../utils/convertHeic'; type Translate = (key: string, params?: Record) => string; @@ -133,29 +132,3 @@ export function usePlaceFileUpload( return { uploading, filesExpanded, setFilesExpanded, fileInputRef, handleUpload }; } - -/** - * A picture picked as a place's image (#1136): converted from HEIC where needed, - * uploaded, and a failure said in a toast. `busy` also covers whatever else the - * caller does to the image while it runs. Without an upload the pick does nothing. - */ -export function usePlaceImagePick(upload: ((file: File) => Promise) | undefined, toast: PlaceActionToast) { - const { t } = useTranslation(); - const [busy, setBusy] = useState(false); - - const pickImage = async (e: ChangeEvent) => { - const file = e.target.files?.[0]; - e.target.value = ''; - if (!file || !upload) return; - setBusy(true); - try { - await upload(await normalizeImageFile(file)); - } catch (err: unknown) { - toast.error(translateApiError(t, err, 'places.imageUploadError')); - } finally { - setBusy(false); - } - }; - - return { busy, setBusy, pickImage }; -} diff --git a/client/src/components/Planner/usePlaceForm.ts b/client/src/components/Planner/usePlaceForm.ts index e97b96985..89ac161e7 100644 --- a/client/src/components/Planner/usePlaceForm.ts +++ b/client/src/components/Planner/usePlaceForm.ts @@ -1,8 +1,8 @@ import type React from 'react'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { useTranslation } from '../../i18n'; -import type { Category } from '../../types'; +import type { Category, TripFile } from '../../types'; import { offlineOr } from '../../utils/needsConnection'; import type { useToast } from '../shared/Toast'; import type { PlaceDetailsSelection } from './PlaceDetailsColumn'; @@ -15,6 +15,7 @@ import { type PlacePrefill, type ResultField, } from './PlaceFormModal.helpers'; +import type { PendingPlaceImage } from './placeImageChoice'; type Translate = ReturnType['t']; type Toast = ReturnType; @@ -40,6 +41,20 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) { // user's and survives. See mergeResult. const autoFilledRef = useRef>(new Set()); const [pendingFiles, setPendingFiles] = useState([]); + // The picture that becomes the place's own when the form is saved. + const [pendingImage, setPendingImageState] = useState(null); + const previewRef = useRef(null); + const setPendingImage = (next: PendingPlaceImage | null) => { + if (previewRef.current) URL.revokeObjectURL(previewRef.current); + previewRef.current = next?.kind === 'upload' ? next.preview : null; + setPendingImageState(next); + }; + useEffect( + () => () => { + if (previewRef.current) URL.revokeObjectURL(previewRef.current); + }, + [] + ); const [isSaving, setIsSaving] = useState(false); const [duplicateWarning, setDuplicateWarning] = useState(null); // What the details column is describing. Null until there is a place to describe. @@ -59,6 +74,7 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) { autoFilledRef.current = openingAutoFilled(place, prefill); setDetailsSelection(openingDetailsSelection(place, prefill)); setPendingFiles([]); + setPendingImage(null); setDuplicateWarning(null); }; @@ -91,8 +107,29 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) { } }; - /** A hero image picked in the details column, or none. */ - const pickImage = (url: string | null) => setForm((prev) => ({ ...prev, image_url: url ?? undefined })); + /** A source's photo picked in the details column, or the place's own picture again. */ + const pickImage = (url: string | null) => { + setPendingImage(null); + setForm((prev) => ({ ...prev, image_url: url ?? undefined })); + }; + + /** A picture from the device, shown at once and uploaded when the form is saved. */ + const stageUpload = (file: File) => { + setPendingImage({ kind: 'upload', file, preview: URL.createObjectURL(file) }); + setForm((prev) => ({ ...prev, image_url: undefined })); + }; + + /** An image already attached to the place, made its picture when the form is saved. */ + const stageAttached = (file: TripFile) => { + setPendingImage({ kind: 'attached', file }); + setForm((prev) => ({ ...prev, image_url: undefined })); + }; + + /** No picture at all: the save drops the place's own one. */ + const removeImage = () => { + setPendingImage(null); + setForm((prev) => ({ ...prev, image_url: null })); + }; /** The details column's description, taken over into the form. */ const adoptDescription = (text: string) => setForm((prev) => ({ ...prev, description: text })); @@ -127,6 +164,10 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) { openForm, handleChange, pickImage, + pendingImage, + stageUpload, + stageAttached, + removeImage, adoptDescription, warnIfDuplicate, }; diff --git a/client/src/components/Planner/usePlaceGallery.ts b/client/src/components/Planner/usePlaceGallery.ts index feb4b34a0..a688e469a 100644 --- a/client/src/components/Planner/usePlaceGallery.ts +++ b/client/src/components/Planner/usePlaceGallery.ts @@ -1,29 +1,31 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import type { PlacePhotoCandidate } from '@trek/shared' -import { mapsApi } from '../../api/client' -import { useAuthStore } from '../../store/authStore' -import { useTranslation, translateApiError } from '../../i18n' -import { useToast } from '../shared/Toast' -import { photoCaption } from './placePhotoCredit' -import { readCachedEnrichment, storeEnrichment, type PlaceDetailsSelection } from './placeEnrichment' -import type { Place } from '../../types' +import type { PlacePhotoCandidate } from '@trek/shared'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { mapsApi } from '../../api/client'; +import { translateApiError, useTranslation } from '../../i18n'; +import { useAuthStore } from '../../store/authStore'; +import type { Place } from '../../types'; +import { useToast } from '../shared/Toast'; +import { readCachedEnrichment, storeEnrichment, type PlaceDetailsSelection } from './placeEnrichment'; +import { photoCaption } from './placePhotoCredit'; /** What the lightbox renders; mirrors Journey/PhotoLightbox's private photo shape. */ export interface GalleryPhoto { - id: string - src: string - caption: string | null + id: string; + src: string; + caption: string | null; + /** The photo's source page, which the credit links to. */ + captionHref: string | null; } interface UsePlaceGallery { - photos: GalleryPhoto[] - open: () => void - close: () => void - isOpen: boolean + photos: GalleryPhoto[]; + open: () => void; + close: () => void; + isOpen: boolean; /** Loading enrichment; the trigger can show a spinner while it waits. */ - loading: boolean + loading: boolean; /** False when there is no picture and nobody who could supply one. */ - canOpen: boolean + canOpen: boolean; } /** @@ -35,86 +37,100 @@ interface UsePlaceGallery { * it, and the shared session cache makes a second open instant. */ export function usePlaceGallery(place: Place, language: string): UsePlaceGallery { - const placesEnrichEnabled = useAuthStore((s) => s.placesEnrichEnabled) - const { t } = useTranslation() - const toast = useToast() - const [candidates, setCandidates] = useState(null) - const [isOpen, setIsOpen] = useState(false) - const [loading, setLoading] = useState(false) + const placesEnrichEnabled = useAuthStore((s) => s.placesEnrichEnabled); + const { t } = useTranslation(); + const toast = useToast(); + const [candidates, setCandidates] = useState(null); + const [isOpen, setIsOpen] = useState(false); + const [loading, setLoading] = useState(false); + // The place a request was made for: an answer that comes back after the user + // moved on to another place is dropped instead of opening over it. + const requestRef = useRef(0); const selection = useMemo(() => { - if (place.lat == null || place.lng == null) return null - const lat = Number(place.lat) - const lng = Number(place.lng) - if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null + if (place.lat == null || place.lng == null) return null; + const lat = Number(place.lat); + const lng = Number(place.lng); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; return { placeId: place.google_place_id || place.amap_poi_id || place.osm_id || undefined, lat, lng, name: place.name || '', - } - }, [place.google_place_id, place.amap_poi_id, place.osm_id, place.lat, place.lng, place.name]) + }; + }, [place.google_place_id, place.amap_poi_id, place.osm_id, place.lat, place.lng, place.name]); // A different place owns different pictures: drop the previous answer so the // next open never flashes them. useEffect(() => { - setCandidates(null) - setIsOpen(false) - }, [place.id]) + requestRef.current += 1; + setCandidates(null); + setIsOpen(false); + setLoading(false); + }, [place.id]); const photos = useMemo(() => { - const list: GalleryPhoto[] = [] - if (place.image_url) list.push({ id: 'custom', src: place.image_url, caption: null }) + const list: GalleryPhoto[] = []; + if (place.image_url) list.push({ id: 'custom', src: place.image_url, caption: null, captionHref: null }); for (const candidate of candidates ?? []) { - if (list.some((p) => p.src === candidate.url)) continue - list.push({ id: candidate.key, src: candidate.url, caption: photoCaption(candidate) }) + if (list.some((p) => p.src === candidate.url)) continue; + list.push({ + id: candidate.key, + src: candidate.url, + caption: photoCaption(candidate), + captionHref: candidate.sourceUrl ?? null, + }); } - return list - }, [place.image_url, candidates]) + return list; + }, [place.image_url, candidates]); - const canOpen = !!place.image_url || (placesEnrichEnabled && !!selection) + const canOpen = !!place.image_url || (placesEnrichEnabled && !!selection); - const close = useCallback(() => setIsOpen(false), []) + const close = useCallback(() => setIsOpen(false), []); const open = useCallback(() => { // The custom image is already on screen — open now and let any provider // pictures join it as they arrive. - if (place.image_url) setIsOpen(true) + if (place.image_url) setIsOpen(true); if (!placesEnrichEnabled || !selection) { - if (!place.image_url) toast.info(t('places.details.noPhotos')) - return + if (!place.image_url) toast.info(t('places.details.noPhotos')); + return; } if (candidates !== null) { - if (place.image_url || candidates.length) setIsOpen(true) - else toast.info(t('places.details.noPhotos')) - return + if (place.image_url || candidates.length) setIsOpen(true); + else toast.info(t('places.details.noPhotos')); + return; } const apply = (list: PlacePhotoCandidate[]) => { - setCandidates(list) - if (place.image_url || list.length) setIsOpen(true) - else toast.info(t('places.details.noPhotos')) - } + setCandidates(list); + if (place.image_url || list.length) setIsOpen(true); + else toast.info(t('places.details.noPhotos')); + }; - const cached = readCachedEnrichment(selection, language) + const cached = readCachedEnrichment(selection, language); if (cached) { - apply(cached.photos) - return + apply(cached.photos); + return; } - setLoading(true) + const request = ++requestRef.current; + const current = () => request === requestRef.current; + setLoading(true); mapsApi .placeEnrichment({ ...selection, lang: language }) .then((result) => { - storeEnrichment(selection, language, result) - apply(result.photos) + storeEnrichment(selection, language, result); + if (current()) apply(result.photos); }) .catch((err: unknown) => { - if (!place.image_url) toast.error(translateApiError(t, err, 'places.details.error')) + if (current() && !place.image_url) toast.error(translateApiError(t, err, 'places.details.error')); }) - .finally(() => setLoading(false)) - }, [place.image_url, placesEnrichEnabled, selection, candidates, language, t, toast]) + .finally(() => { + if (current()) setLoading(false); + }); + }, [place.image_url, placesEnrichEnabled, selection, candidates, language, t, toast]); - return { photos, open, close, isOpen, loading, canOpen } + return { photos, open, close, isOpen, loading, canOpen }; } diff --git a/client/src/components/Planner/usePlaceImageField.ts b/client/src/components/Planner/usePlaceImageField.ts new file mode 100644 index 000000000..a51abfcc4 --- /dev/null +++ b/client/src/components/Planner/usePlaceImageField.ts @@ -0,0 +1,57 @@ +import { useMemo } from 'react'; +import { useShallow } from 'zustand/react/shallow'; + +import { useTripStore } from '../../store/tripStore'; +import type { Place, TripFile } from '../../types'; +import { filesForPlace } from '../../utils/placeFiles'; +import type { PlaceImageOwn } from './PlaceImagePicker'; +import { chosenImage, pickableImages, type ChosenImage, type PendingPlaceImage } from './placeImageChoice'; + +interface PlaceImageFieldOptions { + /** The place being edited; null or undefined while a new one is made. */ + place: Pick | null | undefined; + /** The form's image field: undefined keeps the picture, null drops it, a URL takes a source's photo. */ + imageUrl: string | null | undefined; + pendingImage: PendingPlaceImage | null; + canUploadFiles: boolean; + stageUpload: (file: File) => void; + stageAttached: (file: TripFile) => void; + removeImage: () => void; +} + +/** + * The editor's picture of a place, the same for the desktop dialog and the + * phone sheet: the picture it has now, the images attached to it, the one + * waiting for the save, and which tile of the picker stands for the choice. + * A new place has no attached files yet; it can still be given a picture from + * the device, uploaded once the save has made it. + */ +export function usePlaceImageField({ + place, + imageUrl, + pendingImage, + canUploadFiles, + stageUpload, + stageAttached, + removeImage, +}: PlaceImageFieldOptions): { own: PlaceImageOwn; chosen: ChosenImage } { + const { files, reservations } = useTripStore(useShallow((s) => ({ files: s.files, reservations: s.reservations }))); + const placeId = place?.id; + const attached = useMemo( + () => (placeId ? pickableImages(filesForPlace(files, placeId, reservations)) : []), + [files, placeId, reservations] + ); + const currentUrl = place?.image_url ?? null; + return { + own: { + currentUrl, + attached, + pendingPreview: pendingImage?.kind === 'upload' ? pendingImage.preview : null, + canUpload: canUploadFiles, + onUpload: stageUpload, + onPickAttached: stageAttached, + onRemove: removeImage, + }, + chosen: chosenImage(imageUrl, pendingImage, currentUrl), + }; +} diff --git a/client/src/components/shared/PlaceAvatarUpload.test.tsx b/client/src/components/shared/PlaceAvatarUpload.test.tsx deleted file mode 100644 index c24c5a2a4..000000000 --- a/client/src/components/shared/PlaceAvatarUpload.test.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'; -import userEvent from '@testing-library/user-event'; -import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService'; - -// Mock photoService — PlaceAvatarUpload wraps PlaceAvatar, which pulls thumbnails. -vi.mock('../../services/photoService', () => ({ - 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 deleted file mode 100644 index 559d41758..000000000 --- a/client/src/components/shared/PlaceAvatarUpload.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import { createPortal } from 'react-dom' -import { Camera, Loader2, Upload, X } from 'lucide-react' -import PlaceAvatar from './PlaceAvatar' -import { Tooltip } from './Tooltip' -import { useToast } from './Toast' -import { useTranslation, translateApiError } from '../../i18n' -import { fetchImageAsBlob } from '../../api/authUrl' -import { usePlaceImagePick } from '../Planner/usePlaceActions' -import type { Place, TripFile } 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 - /** Pictures already attached to the place, offered as its image beside an upload (#1242). */ - attachedImages?: TripFile[] - onPickAttached?: (fileId: number) => Promise -} - -const PICKABLE = /^image\/(jpeg|png|gif|webp)$/ - -/** The images among a place's files that can stand as its picture. */ -export function pickableImages(files: TripFile[]): TripFile[] { - return files.filter(f => PICKABLE.test(f.mime_type || '')) -} - -/** - * 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, attachedImages = [], onPickAttached }: PlaceAvatarUploadProps) { - const { t } = useTranslation() - const toast = useToast() - const fileRef = useRef(null) - const anchorRef = useRef(null) - const { busy, setBusy, pickImage: handleFile } = usePlaceImagePick(onUpload, toast) - const [chooserOpen, setChooserOpen] = useState(false) - const hasCustom = Boolean(place.image_url) - const offersAttached = !!onPickAttached && attachedImages.length > 0 - // With pictures on the place the click asks where from; without, it goes straight to the device. - const openPicker = () => { if (busy) return; if (offersAttached) setChooserOpen(o => !o); else fileRef.current?.click() } - - const pickAttached = async (fileId: number) => { - setChooserOpen(false) - setBusy(true) - try { - await onPickAttached!(fileId) - } 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 (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPicker() } }} - 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 && ( - - - - )} - - - {chooserOpen && ( - { setChooserOpen(false); fileRef.current?.click() }} - onPick={id => { void pickAttached(id) }} - onClose={() => setChooserOpen(false)} - /> - )} -
- ) -} - -/** Upload from the device, or one of the pictures already on the place (#1242). */ -function ImageChooser({ anchor, images, onUpload, onPick, onClose }: { - anchor: HTMLElement | null - images: TripFile[] - onUpload: () => void - onPick: (fileId: number) => void - onClose: () => void -}) { - const { t } = useTranslation() - const ref = useRef(null) - const [pos, setPos] = useState<{ top: number; left: number } | null>(null) - - useLayoutEffect(() => { - if (!anchor) return - const r = anchor.getBoundingClientRect() - const width = 264 - setPos({ top: r.bottom + 8, left: Math.min(Math.max(12, r.left), window.innerWidth - width - 12) }) - }, [anchor]) - - useEffect(() => { - const onDown = (e: MouseEvent) => { - const target = e.target as Node - if (!ref.current?.contains(target) && !anchor?.contains(target)) onClose() - } - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } - document.addEventListener('mousedown', onDown) - document.addEventListener('keydown', onKey) - return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey) } - }, [anchor, onClose]) - - if (!pos) return null - return createPortal( -
- -
{t('places.fromAttachedFiles')}
-
- {images.map(f => onPick(f.id)} />)} -
-
, - document.body, - ) -} - -function AttachedThumb({ file, onPick }: { file: TripFile; onPick: () => void }) { - const [src, setSrc] = useState(null) - useEffect(() => { - let current = true - fetchImageAsBlob(file.url).then(u => { if (current) setSrc(u) }).catch(() => {}) - return () => { current = false } - }, [file.url]) - return ( - - - - ) -} diff --git a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx index ee6710c4c..0ac3b5b77 100644 --- a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx @@ -21,6 +21,8 @@ import PlTimeFields from './PlTimeFields' import PlFileAttach from './PlFileAttach' import MLinkedCosts from './MLinkedCosts' import PlaceDetailsColumn from '../../../../components/Planner/PlaceDetailsColumn' +import { PlaceImageSection } from '../../../../components/Planner/PlaceFormMedia' +import { usePlaceImageField } from '../../../../components/Planner/usePlaceImageField' import { useTranslation } from '../../../../i18n' import { useAuthStore } from '../../../../store/authStore' import { useSettingsStore } from '../../../../store/settingsStore' @@ -72,8 +74,11 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh const { form, setForm, autoFilledRef, pendingFiles, addFiles, removeFile, handlePaste, isSaving, setIsSaving, duplicateWarning, detailsSelection, setDetailsSelection, openForm, handleChange, pickImage, adoptDescription, - warnIfDuplicate, + warnIfDuplicate, pendingImage, stageUpload, stageAttached, removeImage, } = usePlaceForm({ t, toast, canUploadFiles }) + const image = usePlaceImageField({ + place: editingPlace, imageUrl: form.image_url, pendingImage, canUploadFiles, stageUpload, stageAttached, removeImage, + }) const [resolvingPick, setResolvingPick] = useState(false) const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget')) // Set right before submit: the place has to exist before an expense can point @@ -188,7 +193,7 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh expenseIntentRef.current = false setIsSaving(true) try { - const saved = await handleSavePlace(placeFormPayload(form, pendingFiles, ctxAssignment)) + const saved = await handleSavePlace(placeFormPayload(form, pendingFiles, ctxAssignment, undefined, pendingImage)) const savedId = saved?.id ?? sheetPlace?.id ?? null if (withExpense && savedId) { onOpenExpense({ prefill: { placeId: savedId, name: form.name.trim(), category: 'activities' } }) @@ -254,6 +259,7 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh selection={detailsSelection} selectedImageUrl={form.image_url} onPickImage={pickImage} + image={image} onAdoptDescription={adoptDescription} hasDescription={!!form.description.trim()} language={language} @@ -264,6 +270,11 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh />
)} + {!placesEnrichEnabled && ( +
+ +
+ )} {t('places.formName')} * (null) - const { - busy: imgBusy, setBusy: setImgBusy, pickImage: handleImagePick, - } = usePlaceImagePick(place && !isTourPlace ? file => planner.tripActions.uploadPlaceImage(planner.tripId, place.id, file) : undefined, planner.toast) const [colorPickerOpen, setColorPickerOpen] = useState(false) - const imageInputRef = useRef(null) const close = () => { planner.setSelectedPlaceId(null) @@ -165,18 +162,6 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) { } } - const handleImageRemove = async () => { - if (!place || isTourPlace) 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.imageRemoveError')) - } finally { - setImgBusy(false) - } - } - const saveToCollection = () => { if (!place) return openSavePicker(placeToSaveTarget(place)) @@ -211,7 +196,8 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
-
+ {/* The picture opens the place's photos; it is changed in the editor. */} + {place.image_url ? (
)} - {canManagePlace && ( - <> - {/* Tap the thumbnail to set a custom image (#1136). */} - - {place.image_url && !imgBusy && ( - - )} - - - )} -
+ {category && ( diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index ff28b4940..ebad87bdd 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -221,8 +221,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null { categories, reservations, tripMembers, onSetParticipants: placeActions.setParticipants, onUpdatePlace: placeActions.updatePlace, - onUploadImage: async (placeId: number, file: File) => { await tripActions.uploadPlaceImage(tripId, placeId, file) }, - onImageFromFile: async (placeId: number, fileId: number) => { await tripActions.setPlaceImageFromFile(tripId, placeId, fileId) }, onRate: placeActions.ratePlace, } const tourDetailProps = { ...selectedPlaceProps, ...tourDetailActions } diff --git a/client/src/pages/tripPlanner/placeImageSave.ts b/client/src/pages/tripPlanner/placeImageSave.ts new file mode 100644 index 000000000..b87553420 --- /dev/null +++ b/client/src/pages/tripPlanner/placeImageSave.ts @@ -0,0 +1,41 @@ +import type { PendingImagePayload } from '../../components/Planner/placeImageChoice'; +import { isEffectivelyOffline } from '../../sync/networkMode'; +import type { Place } from '../../types'; + +interface ImageActions { + uploadPlaceImage: (tripId: number | string, placeId: number, file: File) => Promise; + setPlaceImageFromFile: (tripId: number | string, placeId: number, fileId: number) => Promise; +} + +/** + * Takes the waiting picture off what the form saves. It goes to its own route + * once the place has an id, and that route needs a connection: offline the + * form stays open with it, the way it does with files, rather than saving a + * place that quietly loses its picture. + */ +export function takePendingImage( + data: { _pendingImage?: PendingImagePayload }, + needsConnection: () => string +): PendingImagePayload | undefined { + const pending = data._pendingImage; + delete data._pendingImage; + if (pending && isEffectivelyOffline()) throw new Error(needsConnection()); + return pending; +} + +/** Makes the waiting picture the place's own: the file uploaded, or the attached file taken. */ +export async function savePendingImage( + actions: ImageActions, + tripId: number | string, + placeId: number | undefined, + pending: PendingImagePayload | undefined, + onError: (err: unknown) => void +): Promise { + if (!pending || !placeId) return; + try { + if (pending instanceof File) await actions.uploadPlaceImage(tripId, placeId, pending); + else await actions.setPlaceImageFromFile(tripId, placeId, pending.fileId); + } catch (err: unknown) { + onError(err); + } +} diff --git a/client/src/pages/tripPlanner/usePlaceEdits.ts b/client/src/pages/tripPlanner/usePlaceEdits.ts index 8e6278d39..56451622f 100644 --- a/client/src/pages/tripPlanner/usePlaceEdits.ts +++ b/client/src/pages/tripPlanner/usePlaceEdits.ts @@ -9,6 +9,7 @@ import { useAuthStore } from '../../store/authStore' import { useTripStore, type TripStoreState } from '../../store/tripStore' import { isEffectivelyOffline } from '../../sync/networkMode' import type { Accommodation, Place } from '../../types' +import { savePendingImage, takePendingImage } from './placeImageSave' import { capturePlaceVisits, deleteLosesDependents, restorePlace } from './placeUndo' import type { PlannerBase, PlannerHistory } from './plannerTypes' import type { PlannerDialogs } from './usePlannerDialogs' @@ -96,6 +97,8 @@ export function usePlaceEdits(options: PlaceEditsOptions) { // stays open with them instead of saving a place that loses them. if (pendingFiles?.length > 0 && isEffectivelyOffline()) throw new Error(t('files.placeFilesNeedConnection')) delete data._pendingFiles + const pendingImage = takePendingImage(data, () => t('places.image.needsConnection')) + const imageFailed = (err: unknown) => toast.error(translateApiError(t, err, 'places.imageUploadError')) // Where a service stop added by hand belongs on the drive. The form worked it out // from the coordinates being saved, because until a place was chosen in it there // were none to project. @@ -134,6 +137,7 @@ export function usePlaceEdits(options: PlaceEditsOptions) { try { await tripActions.addFile(tripId, fd) } catch (err) { toast.error(translateApiError(t, err, 'files.uploadError')) } } } + await savePendingImage(tripActions, tripId, editingPlace.id, pendingImage, imageFailed) toast.success(t('trip.toast.placeUpdated')) return { id: editingPlace.id } } else { @@ -189,6 +193,7 @@ export function usePlaceEdits(options: PlaceEditsOptions) { try { await tripActions.addFile(tripId, fd) } catch (err) { toast.error(translateApiError(t, err, 'files.uploadError')) } } } + await savePendingImage(tripActions, tripId, place?.id, pendingImage, imageFailed) toast.success(t('trip.toast.placeAdded')) if (place?.id) { const capturedId = place.id diff --git a/shared/src/i18n/ar/journey.ts b/shared/src/i18n/ar/journey.ts index 01727b704..415ccb1a2 100644 --- a/shared/src/i18n/ar/journey.ts +++ b/shared/src/i18n/ar/journey.ts @@ -77,6 +77,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'تكبير', 'journey.lightbox.zoomOut': 'تصغير', 'journey.lightbox.zoomReset': 'العودة إلى الصورة كاملة', + 'journey.lightbox.previous': 'الصورة السابقة', + 'journey.lightbox.next': 'الصورة التالية', 'journey.create': 'Create', // en-fallback 'journey.titlePlaceholder': 'Where are you going?', // en-fallback 'journey.empty': 'No journeys yet', // en-fallback diff --git a/shared/src/i18n/ar/places.ts b/shared/src/i18n/ar/places.ts index a0bd09ae5..3e1dac8aa 100644 --- a/shared/src/i18n/ar/places.ts +++ b/shared/src/i18n/ar/places.ts @@ -200,10 +200,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'رفع صورة', 'places.changeImage': 'تغيير الصورة', 'places.removeImage': 'إزالة الصورة', - 'places.chooseImage': 'اختر صورة', - 'places.uploadFromDevice': 'رفع من الجهاز', - 'places.fromAttachedFiles': 'من الملفات المرفقة', + 'places.image.current': 'الصورة الحالية', + 'places.image.own': 'صورتك', + 'places.image.none': 'بلا صورة', + 'places.image.needsConnection': 'تحتاج الصورة الجديدة إلى اتصال. احفظ مجددًا عند الاتصال بالإنترنت.', + 'places.openPhotos': 'عرض الصور', 'places.imageUploadError': 'تعذّر رفع الصورة', - 'places.imageRemoveError': 'تعذّر إزالة الصورة', }; export default places; diff --git a/shared/src/i18n/az/journey.ts b/shared/src/i18n/az/journey.ts index 1f4889208..0a99974d3 100644 --- a/shared/src/i18n/az/journey.ts +++ b/shared/src/i18n/az/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Yaxınlaşdır', 'journey.lightbox.zoomOut': 'Uzaqlaşdır', 'journey.lightbox.zoomReset': 'Tam fotoya qayıt', + 'journey.lightbox.previous': 'Əvvəlki foto', + 'journey.lightbox.next': 'Növbəti foto', 'journey.create': 'Yarat', 'journey.titlePlaceholder': 'Haraya gedirsiniz?', 'journey.empty': 'Hələ səyahət gündəliyi yoxdur', diff --git a/shared/src/i18n/az/places.ts b/shared/src/i18n/az/places.ts index 1336e078c..9177f9294 100644 --- a/shared/src/i18n/az/places.ts +++ b/shared/src/i18n/az/places.ts @@ -182,11 +182,12 @@ const places: TranslationStrings = { 'places.uploadImage': 'Şəkil yüklə', 'places.changeImage': 'Şəkli dəyiş', 'places.removeImage': 'Şəkli sil', - 'places.chooseImage': 'Şəkil seç', - 'places.uploadFromDevice': 'Cihazdan yüklə', - 'places.fromAttachedFiles': 'Əlavə edilmiş fayllardan', + 'places.image.current': 'Cari şəkil', + 'places.image.own': 'Sənin şəklin', + 'places.image.none': 'Şəkil yoxdur', + 'places.image.needsConnection': 'Yeni şəkil üçün bağlantı lazımdır. Onlayn olanda yenidən yadda saxla.', + 'places.openPhotos': 'Fotolara bax', 'places.imageUploadError': 'Şəkli yükləmək mümkün olmadı', - 'places.imageRemoveError': 'Şəkli silmək mümkün olmadı', }; export default places; diff --git a/shared/src/i18n/br/journey.ts b/shared/src/i18n/br/journey.ts index 069aa74cd..b3c825cc6 100644 --- a/shared/src/i18n/br/journey.ts +++ b/shared/src/i18n/br/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Aproximar', 'journey.lightbox.zoomOut': 'Afastar', 'journey.lightbox.zoomReset': 'Voltar à foto inteira', + 'journey.lightbox.previous': 'Foto anterior', + 'journey.lightbox.next': 'Próxima foto', 'journey.create': 'Criar', 'journey.titlePlaceholder': 'Para onde você vai?', 'journey.empty': 'Nenhuma jornada ainda', diff --git a/shared/src/i18n/br/places.ts b/shared/src/i18n/br/places.ts index a6776c06a..60f5b7bcf 100644 --- a/shared/src/i18n/br/places.ts +++ b/shared/src/i18n/br/places.ts @@ -168,10 +168,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'Enviar imagem', 'places.changeImage': 'Alterar imagem', 'places.removeImage': 'Remover imagem', - 'places.chooseImage': 'Escolher uma imagem', - 'places.uploadFromDevice': 'Enviar do dispositivo', - 'places.fromAttachedFiles': 'Dos arquivos anexados', + 'places.image.current': 'Imagem atual', + 'places.image.own': 'Sua imagem', + 'places.image.none': 'Sem imagem', + 'places.image.needsConnection': 'Uma nova imagem precisa de conexão. Salve de novo quando estiver online.', + 'places.openPhotos': 'Ver fotos', 'places.imageUploadError': 'Não foi possível enviar a imagem', - 'places.imageRemoveError': 'Não foi possível remover a imagem', }; export default places; diff --git a/shared/src/i18n/ca/journey.ts b/shared/src/i18n/ca/journey.ts index 8833bc29f..e4e4f4d6d 100644 --- a/shared/src/i18n/ca/journey.ts +++ b/shared/src/i18n/ca/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Apropa', 'journey.lightbox.zoomOut': 'Allunya', 'journey.lightbox.zoomReset': 'Torna a la foto sencera', + 'journey.lightbox.previous': 'Foto anterior', + 'journey.lightbox.next': 'Foto següent', 'journey.create': 'Crea', 'journey.titlePlaceholder': 'A on vas?', 'journey.empty': 'Encara no hi ha travesies', diff --git a/shared/src/i18n/ca/places.ts b/shared/src/i18n/ca/places.ts index 4a3d1eccc..a95d57394 100644 --- a/shared/src/i18n/ca/places.ts +++ b/shared/src/i18n/ca/places.ts @@ -170,10 +170,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'Puja una imatge', 'places.changeImage': 'Canvia la imatge', 'places.removeImage': 'Elimina la imatge', - 'places.chooseImage': 'Tria una imatge', - 'places.uploadFromDevice': 'Puja des del dispositiu', - 'places.fromAttachedFiles': 'Dels fitxers adjunts', + 'places.image.current': 'Imatge actual', + 'places.image.own': 'La teva imatge', + 'places.image.none': 'Sense imatge', + 'places.image.needsConnection': 'Una imatge nova necessita connexió. Torna a desar quan estiguis en línia.', + 'places.openPhotos': 'Mostra les fotos', 'places.imageUploadError': "No s'ha pogut pujar la imatge", - 'places.imageRemoveError': "No s'ha pogut eliminar la imatge", }; export default places; diff --git a/shared/src/i18n/cs/journey.ts b/shared/src/i18n/cs/journey.ts index b00ca9b98..814842d4a 100644 --- a/shared/src/i18n/cs/journey.ts +++ b/shared/src/i18n/cs/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Přiblížit', 'journey.lightbox.zoomOut': 'Oddálit', 'journey.lightbox.zoomReset': 'Zpět na celou fotku', + 'journey.lightbox.previous': 'Předchozí fotka', + 'journey.lightbox.next': 'Další fotka', 'journey.create': 'Vytvořit', 'journey.titlePlaceholder': 'Kam jedete?', 'journey.empty': 'Zatím žádné cestovní deníky', diff --git a/shared/src/i18n/cs/places.ts b/shared/src/i18n/cs/places.ts index 27ad7cfa2..a21e1d237 100644 --- a/shared/src/i18n/cs/places.ts +++ b/shared/src/i18n/cs/places.ts @@ -176,10 +176,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'Nahrát obrázek', 'places.changeImage': 'Změnit obrázek', 'places.removeImage': 'Odebrat obrázek', - 'places.chooseImage': 'Vybrat obrázek', - 'places.uploadFromDevice': 'Nahrát ze zařízení', - 'places.fromAttachedFiles': 'Z přiložených souborů', + 'places.image.current': 'Aktuální obrázek', + 'places.image.own': 'Tvůj obrázek', + 'places.image.none': 'Bez obrázku', + 'places.image.needsConnection': 'Nový obrázek potřebuje připojení. Ulož znovu, až budeš online.', + 'places.openPhotos': 'Zobrazit fotky', 'places.imageUploadError': 'Obrázek se nepodařilo nahrát', - 'places.imageRemoveError': 'Obrázek se nepodařilo odebrat', }; export default places; diff --git a/shared/src/i18n/de/journey.ts b/shared/src/i18n/de/journey.ts index aa677a73d..ad0bd4f52 100644 --- a/shared/src/i18n/de/journey.ts +++ b/shared/src/i18n/de/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Vergrößern', 'journey.lightbox.zoomOut': 'Verkleinern', 'journey.lightbox.zoomReset': 'Zurück zum ganzen Foto', + 'journey.lightbox.previous': 'Vorheriges Foto', + 'journey.lightbox.next': 'Nächstes Foto', 'journey.create': 'Erstellen', 'journey.titlePlaceholder': 'Wohin geht die Reise?', 'journey.empty': 'Noch keine Journeys', diff --git a/shared/src/i18n/de/places.ts b/shared/src/i18n/de/places.ts index 3ad44117e..2021025e3 100644 --- a/shared/src/i18n/de/places.ts +++ b/shared/src/i18n/de/places.ts @@ -162,11 +162,12 @@ const places: TranslationStrings = { 'places.uploadImage': 'Bild hochladen', 'places.changeImage': 'Bild ändern', 'places.removeImage': 'Bild entfernen', - 'places.chooseImage': 'Bild auswählen', - 'places.uploadFromDevice': 'Vom Gerät hochladen', - 'places.fromAttachedFiles': 'Aus angehängten Dateien', + 'places.image.current': 'Aktuelles Bild', + 'places.image.own': 'Dein Bild', + 'places.image.none': 'Kein Bild', + 'places.image.needsConnection': 'Ein neues Bild braucht eine Verbindung. Speichere erneut, sobald du online bist.', + 'places.openPhotos': 'Fotos ansehen', 'places.imageUploadError': 'Bild konnte nicht hochgeladen werden', - 'places.imageRemoveError': 'Bild konnte nicht entfernt werden', 'places.addAnyway': 'Trotzdem hinzufügen', 'places.enrichOnImport': 'Orte über Google anreichern', 'places.enrichOnImportHint': diff --git a/shared/src/i18n/en/journey.ts b/shared/src/i18n/en/journey.ts index 44396d75a..14d6984bf 100644 --- a/shared/src/i18n/en/journey.ts +++ b/shared/src/i18n/en/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Zoom in', 'journey.lightbox.zoomOut': 'Zoom out', 'journey.lightbox.zoomReset': 'Back to full photo', + 'journey.lightbox.previous': 'Previous photo', + 'journey.lightbox.next': 'Next photo', 'journey.create': 'Create', 'journey.titlePlaceholder': 'Where are you going?', 'journey.empty': 'No journeys yet', diff --git a/shared/src/i18n/en/places.ts b/shared/src/i18n/en/places.ts index b93d62a4c..5de82e80c 100644 --- a/shared/src/i18n/en/places.ts +++ b/shared/src/i18n/en/places.ts @@ -175,10 +175,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'Upload image', 'places.changeImage': 'Change image', 'places.removeImage': 'Remove image', - 'places.chooseImage': 'Choose a picture', - 'places.uploadFromDevice': 'Upload from device', - 'places.fromAttachedFiles': 'From attached files', + 'places.image.current': 'Current picture', + 'places.image.own': 'Your picture', + 'places.image.none': 'No picture', + 'places.image.needsConnection': 'A new picture needs a connection. Save again once you are online.', + 'places.openPhotos': 'Show photos', 'places.imageUploadError': 'Could not upload image', - 'places.imageRemoveError': 'Could not remove image', }; export default places; diff --git a/shared/src/i18n/es/journey.ts b/shared/src/i18n/es/journey.ts index 3c1f50d37..39b0be4cc 100644 --- a/shared/src/i18n/es/journey.ts +++ b/shared/src/i18n/es/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Acercar', 'journey.lightbox.zoomOut': 'Alejar', 'journey.lightbox.zoomReset': 'Volver a la foto completa', + 'journey.lightbox.previous': 'Foto anterior', + 'journey.lightbox.next': 'Foto siguiente', 'journey.create': 'Crear', 'journey.titlePlaceholder': '¿A dónde vas?', 'journey.empty': 'Aún no hay travesías', diff --git a/shared/src/i18n/es/places.ts b/shared/src/i18n/es/places.ts index 52de488ac..b9e823d94 100644 --- a/shared/src/i18n/es/places.ts +++ b/shared/src/i18n/es/places.ts @@ -169,10 +169,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'Subir imagen', 'places.changeImage': 'Cambiar imagen', 'places.removeImage': 'Eliminar imagen', - 'places.chooseImage': 'Elegir una imagen', - 'places.uploadFromDevice': 'Subir desde el dispositivo', - 'places.fromAttachedFiles': 'Desde los archivos adjuntos', + 'places.image.current': 'Imagen actual', + 'places.image.own': 'Tu imagen', + 'places.image.none': 'Sin imagen', + 'places.image.needsConnection': 'Una imagen nueva necesita conexión. Guarda de nuevo cuando estés en línea.', + 'places.openPhotos': 'Ver fotos', 'places.imageUploadError': 'No se pudo subir la imagen', - 'places.imageRemoveError': 'No se pudo eliminar la imagen', }; export default places; diff --git a/shared/src/i18n/et/journey.ts b/shared/src/i18n/et/journey.ts index d99c3eb78..65a20640e 100644 --- a/shared/src/i18n/et/journey.ts +++ b/shared/src/i18n/et/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Suurenda', 'journey.lightbox.zoomOut': 'Vähenda', 'journey.lightbox.zoomReset': 'Tagasi terve fotoni', + 'journey.lightbox.previous': 'Eelmine foto', + 'journey.lightbox.next': 'Järgmine foto', 'journey.create': 'Loo', 'journey.titlePlaceholder': 'Kuhu lähed?', 'journey.empty': 'Reisilugusid veel pole', diff --git a/shared/src/i18n/et/places.ts b/shared/src/i18n/et/places.ts index 507f31ab1..4143def1e 100644 --- a/shared/src/i18n/et/places.ts +++ b/shared/src/i18n/et/places.ts @@ -169,10 +169,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'Laadi pilt üles', 'places.changeImage': 'Muuda pilti', 'places.removeImage': 'Eemalda pilt', - 'places.chooseImage': 'Vali pilt', - 'places.uploadFromDevice': 'Laadi üles seadmest', - 'places.fromAttachedFiles': 'Manustatud failidest', + 'places.image.current': 'Praegune pilt', + 'places.image.own': 'Sinu pilt', + 'places.image.none': 'Pilti pole', + 'places.image.needsConnection': 'Uus pilt vajab ühendust. Salvesta uuesti, kui oled võrgus.', + 'places.openPhotos': 'Näita fotosid', 'places.imageUploadError': 'Pildi üleslaadimine ebaõnnestus', - 'places.imageRemoveError': 'Pildi eemaldamine ebaõnnestus', }; export default places; diff --git a/shared/src/i18n/fr/journey.ts b/shared/src/i18n/fr/journey.ts index 935c78440..7b68a7a77 100644 --- a/shared/src/i18n/fr/journey.ts +++ b/shared/src/i18n/fr/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Zoom avant', 'journey.lightbox.zoomOut': 'Zoom arrière', 'journey.lightbox.zoomReset': 'Revenir à la photo entière', + 'journey.lightbox.previous': 'Photo précédente', + 'journey.lightbox.next': 'Photo suivante', 'journey.create': 'Créer', 'journey.titlePlaceholder': 'Où allez-vous ?', 'journey.empty': 'Aucun journal pour le moment', diff --git a/shared/src/i18n/fr/places.ts b/shared/src/i18n/fr/places.ts index cbcd471f0..6ca317a31 100644 --- a/shared/src/i18n/fr/places.ts +++ b/shared/src/i18n/fr/places.ts @@ -169,10 +169,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'Importer une image', 'places.changeImage': "Changer l'image", 'places.removeImage': "Supprimer l'image", - 'places.chooseImage': 'Choisir une image', - 'places.uploadFromDevice': 'Importer depuis l’appareil', - 'places.fromAttachedFiles': 'Depuis les fichiers joints', + 'places.image.current': 'Image actuelle', + 'places.image.own': 'Ton image', + 'places.image.none': 'Pas d’image', + 'places.image.needsConnection': 'Une nouvelle image a besoin d’une connexion. Enregistre à nouveau une fois en ligne.', + 'places.openPhotos': 'Voir les photos', 'places.imageUploadError': "Impossible d'importer l'image", - 'places.imageRemoveError': "Impossible de supprimer l'image", }; export default places; diff --git a/shared/src/i18n/gr/journey.ts b/shared/src/i18n/gr/journey.ts index e882e9d77..ecf5cc769 100644 --- a/shared/src/i18n/gr/journey.ts +++ b/shared/src/i18n/gr/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Μεγέθυνση', 'journey.lightbox.zoomOut': 'Σμίκρυνση', 'journey.lightbox.zoomReset': 'Επιστροφή σε ολόκληρη τη φωτογραφία', + 'journey.lightbox.previous': 'Προηγούμενη φωτογραφία', + 'journey.lightbox.next': 'Επόμενη φωτογραφία', 'journey.create': 'Δημιουργία', 'journey.titlePlaceholder': 'Πού πηγαίνετε;', 'journey.empty': 'Δεν υπάρχουν ταξίδια ακόμα', diff --git a/shared/src/i18n/gr/places.ts b/shared/src/i18n/gr/places.ts index 32be54757..981a86c3b 100644 --- a/shared/src/i18n/gr/places.ts +++ b/shared/src/i18n/gr/places.ts @@ -169,10 +169,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'Μεταφόρτωση εικόνας', 'places.changeImage': 'Αλλαγή εικόνας', 'places.removeImage': 'Αφαίρεση εικόνας', - 'places.chooseImage': 'Επιλογή εικόνας', - 'places.uploadFromDevice': 'Μεταφόρτωση από τη συσκευή', - 'places.fromAttachedFiles': 'Από συνημμένα αρχεία', + 'places.image.current': 'Τρέχουσα εικόνα', + 'places.image.own': 'Η εικόνα σου', + 'places.image.none': 'Χωρίς εικόνα', + 'places.image.needsConnection': 'Μια νέα εικόνα χρειάζεται σύνδεση. Αποθήκευσε ξανά όταν είσαι online.', + 'places.openPhotos': 'Προβολή φωτογραφιών', 'places.imageUploadError': 'Δεν ήταν δυνατή η μεταφόρτωση της εικόνας', - 'places.imageRemoveError': 'Δεν ήταν δυνατή η αφαίρεση της εικόνας', }; export default places; diff --git a/shared/src/i18n/hu/journey.ts b/shared/src/i18n/hu/journey.ts index ac8c8acd5..1c5aa4d66 100644 --- a/shared/src/i18n/hu/journey.ts +++ b/shared/src/i18n/hu/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Nagyítás', 'journey.lightbox.zoomOut': 'Kicsinyítés', 'journey.lightbox.zoomReset': 'Vissza a teljes fotóhoz', + 'journey.lightbox.previous': 'Előző fotó', + 'journey.lightbox.next': 'Következő fotó', 'journey.create': 'Létrehozás', 'journey.titlePlaceholder': 'Hová utazol?', 'journey.empty': 'Még nincsenek útinaplók', diff --git a/shared/src/i18n/hu/places.ts b/shared/src/i18n/hu/places.ts index c758fc280..f84a3b0d3 100644 --- a/shared/src/i18n/hu/places.ts +++ b/shared/src/i18n/hu/places.ts @@ -168,10 +168,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'Kép feltöltése', 'places.changeImage': 'Kép módosítása', 'places.removeImage': 'Kép eltávolítása', - 'places.chooseImage': 'Kép kiválasztása', - 'places.uploadFromDevice': 'Feltöltés eszközről', - 'places.fromAttachedFiles': 'Csatolt fájlokból', + 'places.image.current': 'Jelenlegi kép', + 'places.image.own': 'A te képed', + 'places.image.none': 'Nincs kép', + 'places.image.needsConnection': 'Új képhez kapcsolat kell. Mentsd újra, ha online vagy.', + 'places.openPhotos': 'Fotók megtekintése', 'places.imageUploadError': 'Nem sikerült feltölteni a képet', - 'places.imageRemoveError': 'Nem sikerült eltávolítani a képet', }; export default places; diff --git a/shared/src/i18n/id/journey.ts b/shared/src/i18n/id/journey.ts index fbfd1208f..a93b0bebc 100644 --- a/shared/src/i18n/id/journey.ts +++ b/shared/src/i18n/id/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Perbesar', 'journey.lightbox.zoomOut': 'Perkecil', 'journey.lightbox.zoomReset': 'Kembali ke foto utuh', + 'journey.lightbox.previous': 'Foto sebelumnya', + 'journey.lightbox.next': 'Foto berikutnya', 'journey.create': 'Buat', 'journey.titlePlaceholder': 'Ke mana kamu pergi?', 'journey.empty': 'Belum ada journey', diff --git a/shared/src/i18n/id/places.ts b/shared/src/i18n/id/places.ts index c47adc354..8d1528030 100644 --- a/shared/src/i18n/id/places.ts +++ b/shared/src/i18n/id/places.ts @@ -159,10 +159,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'Unggah gambar', 'places.changeImage': 'Ganti gambar', 'places.removeImage': 'Hapus gambar', - 'places.chooseImage': 'Pilih gambar', - 'places.uploadFromDevice': 'Unggah dari perangkat', - 'places.fromAttachedFiles': 'Dari berkas terlampir', + 'places.image.current': 'Gambar saat ini', + 'places.image.own': 'Gambarmu', + 'places.image.none': 'Tanpa gambar', + 'places.image.needsConnection': 'Gambar baru butuh koneksi. Simpan lagi saat sudah online.', + 'places.openPhotos': 'Lihat foto', 'places.imageUploadError': 'Tidak dapat mengunggah gambar', - 'places.imageRemoveError': 'Tidak dapat menghapus gambar', }; export default places; diff --git a/shared/src/i18n/it/journey.ts b/shared/src/i18n/it/journey.ts index c7dfd2148..88e671837 100644 --- a/shared/src/i18n/it/journey.ts +++ b/shared/src/i18n/it/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Ingrandisci', 'journey.lightbox.zoomOut': 'Riduci', 'journey.lightbox.zoomReset': 'Torna alla foto intera', + 'journey.lightbox.previous': 'Foto precedente', + 'journey.lightbox.next': 'Foto successiva', 'journey.create': 'Crea', 'journey.titlePlaceholder': 'Dove stai andando?', 'journey.empty': 'Nessun diario ancora', diff --git a/shared/src/i18n/it/places.ts b/shared/src/i18n/it/places.ts index fd545db39..a7cf7f69c 100644 --- a/shared/src/i18n/it/places.ts +++ b/shared/src/i18n/it/places.ts @@ -168,10 +168,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'Carica immagine', 'places.changeImage': 'Cambia immagine', 'places.removeImage': 'Rimuovi immagine', - 'places.chooseImage': 'Scegli un’immagine', - 'places.uploadFromDevice': 'Carica dal dispositivo', - 'places.fromAttachedFiles': 'Dai file allegati', + 'places.image.current': 'Immagine attuale', + 'places.image.own': 'La tua immagine', + 'places.image.none': 'Nessuna immagine', + 'places.image.needsConnection': 'Una nuova immagine richiede una connessione. Salva di nuovo quando sei online.', + 'places.openPhotos': 'Mostra foto', 'places.imageUploadError': "Impossibile caricare l'immagine", - 'places.imageRemoveError': "Impossibile rimuovere l'immagine", }; export default places; diff --git a/shared/src/i18n/ja/journey.ts b/shared/src/i18n/ja/journey.ts index 1a2ba79fa..3758f5de6 100644 --- a/shared/src/i18n/ja/journey.ts +++ b/shared/src/i18n/ja/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': '拡大', 'journey.lightbox.zoomOut': '縮小', 'journey.lightbox.zoomReset': '写真全体に戻す', + 'journey.lightbox.previous': '前の写真', + 'journey.lightbox.next': '次の写真', 'journey.create': '作成', 'journey.titlePlaceholder': 'どこへ行きますか?', 'journey.empty': '日記はまだありません', diff --git a/shared/src/i18n/ja/places.ts b/shared/src/i18n/ja/places.ts index 8175c2f51..f65fa30f4 100644 --- a/shared/src/i18n/ja/places.ts +++ b/shared/src/i18n/ja/places.ts @@ -160,10 +160,11 @@ const places: TranslationStrings = { 'places.uploadImage': '画像をアップロード', 'places.changeImage': '画像を変更', 'places.removeImage': '画像を削除', - 'places.chooseImage': '画像を選択', - 'places.uploadFromDevice': 'デバイスからアップロード', - 'places.fromAttachedFiles': '添付ファイルから', + 'places.image.current': '現在の画像', + 'places.image.own': 'あなたの画像', + 'places.image.none': '画像なし', + 'places.image.needsConnection': '新しい画像には接続が必要です。オンラインになってからもう一度保存してください。', + 'places.openPhotos': '写真を見る', 'places.imageUploadError': '画像をアップロードできませんでした', - 'places.imageRemoveError': '画像を削除できませんでした', }; export default places; diff --git a/shared/src/i18n/ko/journey.ts b/shared/src/i18n/ko/journey.ts index f02b15b35..3d6364f90 100644 --- a/shared/src/i18n/ko/journey.ts +++ b/shared/src/i18n/ko/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': '확대', 'journey.lightbox.zoomOut': '축소', 'journey.lightbox.zoomReset': '전체 사진으로 돌아가기', + 'journey.lightbox.previous': '이전 사진', + 'journey.lightbox.next': '다음 사진', 'journey.create': '만들기', 'journey.titlePlaceholder': '어디로 가시나요?', 'journey.empty': '아직 Journey가 없습니다', diff --git a/shared/src/i18n/ko/places.ts b/shared/src/i18n/ko/places.ts index e94560c24..d0e3f710b 100644 --- a/shared/src/i18n/ko/places.ts +++ b/shared/src/i18n/ko/places.ts @@ -158,10 +158,11 @@ const places: TranslationStrings = { 'places.uploadImage': '이미지 업로드', 'places.changeImage': '이미지 변경', 'places.removeImage': '이미지 제거', - 'places.chooseImage': '이미지 선택', - 'places.uploadFromDevice': '기기에서 업로드', - 'places.fromAttachedFiles': '첨부 파일에서', + 'places.image.current': '현재 이미지', + 'places.image.own': '내 이미지', + 'places.image.none': '이미지 없음', + 'places.image.needsConnection': '새 이미지는 연결이 필요합니다. 온라인일 때 다시 저장하세요.', + 'places.openPhotos': '사진 보기', 'places.imageUploadError': '이미지를 업로드할 수 없습니다', - 'places.imageRemoveError': '이미지를 제거할 수 없습니다', }; export default places; diff --git a/shared/src/i18n/nl/journey.ts b/shared/src/i18n/nl/journey.ts index 8ba8e65ab..ae1aea92e 100644 --- a/shared/src/i18n/nl/journey.ts +++ b/shared/src/i18n/nl/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Inzoomen', 'journey.lightbox.zoomOut': 'Uitzoomen', 'journey.lightbox.zoomReset': 'Terug naar de hele foto', + 'journey.lightbox.previous': 'Vorige foto', + 'journey.lightbox.next': 'Volgende foto', 'journey.create': 'Aanmaken', 'journey.titlePlaceholder': 'Waar ga je naartoe?', 'journey.empty': 'Nog geen reisverslagen', diff --git a/shared/src/i18n/nl/places.ts b/shared/src/i18n/nl/places.ts index a15cd0bbd..06c8a0bc4 100644 --- a/shared/src/i18n/nl/places.ts +++ b/shared/src/i18n/nl/places.ts @@ -168,10 +168,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'Afbeelding uploaden', 'places.changeImage': 'Afbeelding wijzigen', 'places.removeImage': 'Afbeelding verwijderen', - 'places.chooseImage': 'Een afbeelding kiezen', - 'places.uploadFromDevice': 'Uploaden vanaf apparaat', - 'places.fromAttachedFiles': 'Uit bijgevoegde bestanden', + 'places.image.current': 'Huidige afbeelding', + 'places.image.own': 'Jouw afbeelding', + 'places.image.none': 'Geen afbeelding', + 'places.image.needsConnection': 'Een nieuwe afbeelding heeft een verbinding nodig. Sla opnieuw op zodra je online bent.', + 'places.openPhotos': 'Foto’s bekijken', 'places.imageUploadError': 'Afbeelding uploaden mislukt', - 'places.imageRemoveError': 'Afbeelding verwijderen mislukt', }; export default places; diff --git a/shared/src/i18n/pl/journey.ts b/shared/src/i18n/pl/journey.ts index f764bc19d..02fd222a7 100644 --- a/shared/src/i18n/pl/journey.ts +++ b/shared/src/i18n/pl/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Powiększ', 'journey.lightbox.zoomOut': 'Pomniejsz', 'journey.lightbox.zoomReset': 'Wróć do całego zdjęcia', + 'journey.lightbox.previous': 'Poprzednie zdjęcie', + 'journey.lightbox.next': 'Następne zdjęcie', 'journey.create': 'Utwórz', 'journey.titlePlaceholder': 'Dokąd jedziesz?', 'journey.empty': 'Brak dzienników podróży', diff --git a/shared/src/i18n/pl/places.ts b/shared/src/i18n/pl/places.ts index d07cef582..a12728f16 100644 --- a/shared/src/i18n/pl/places.ts +++ b/shared/src/i18n/pl/places.ts @@ -184,10 +184,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'Prześlij zdjęcie', 'places.changeImage': 'Zmień zdjęcie', 'places.removeImage': 'Usuń zdjęcie', - 'places.chooseImage': 'Wybierz obraz', - 'places.uploadFromDevice': 'Prześlij z urządzenia', - 'places.fromAttachedFiles': 'Z załączonych plików', + 'places.image.current': 'Bieżący obraz', + 'places.image.own': 'Twój obraz', + 'places.image.none': 'Bez obrazu', + 'places.image.needsConnection': 'Nowy obraz wymaga połączenia. Zapisz ponownie, gdy będziesz online.', + 'places.openPhotos': 'Pokaż zdjęcia', 'places.imageUploadError': 'Nie udało się przesłać zdjęcia', - 'places.imageRemoveError': 'Nie udało się usunąć zdjęcia', }; export default places; diff --git a/shared/src/i18n/ru/journey.ts b/shared/src/i18n/ru/journey.ts index a8376de98..3e3967f63 100644 --- a/shared/src/i18n/ru/journey.ts +++ b/shared/src/i18n/ru/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Увеличить', 'journey.lightbox.zoomOut': 'Уменьшить', 'journey.lightbox.zoomReset': 'Вернуться к полному фото', + 'journey.lightbox.previous': 'Предыдущее фото', + 'journey.lightbox.next': 'Следующее фото', 'journey.create': 'Создать', 'journey.titlePlaceholder': 'Куда вы едете?', 'journey.empty': 'Пока нет путешествий', diff --git a/shared/src/i18n/ru/places.ts b/shared/src/i18n/ru/places.ts index 659dfd6bb..d4755eaaa 100644 --- a/shared/src/i18n/ru/places.ts +++ b/shared/src/i18n/ru/places.ts @@ -184,10 +184,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'Загрузить изображение', 'places.changeImage': 'Изменить изображение', 'places.removeImage': 'Удалить изображение', - 'places.chooseImage': 'Выбрать изображение', - 'places.uploadFromDevice': 'Загрузить с устройства', - 'places.fromAttachedFiles': 'Из прикреплённых файлов', + 'places.image.current': 'Текущее изображение', + 'places.image.own': 'Твоё изображение', + 'places.image.none': 'Без изображения', + 'places.image.needsConnection': 'Для нового изображения нужно подключение. Сохрани ещё раз, когда будешь онлайн.', + 'places.openPhotos': 'Показать фото', 'places.imageUploadError': 'Не удалось загрузить изображение', - 'places.imageRemoveError': 'Не удалось удалить изображение', }; export default places; diff --git a/shared/src/i18n/sk/journey.ts b/shared/src/i18n/sk/journey.ts index dace52dc6..e4a56ad14 100644 --- a/shared/src/i18n/sk/journey.ts +++ b/shared/src/i18n/sk/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Priblížiť', 'journey.lightbox.zoomOut': 'Oddialiť', 'journey.lightbox.zoomReset': 'Späť na celú fotku', + 'journey.lightbox.previous': 'Predchádzajúca fotka', + 'journey.lightbox.next': 'Ďalšia fotka', 'journey.create': 'Vytvoriť', 'journey.titlePlaceholder': 'Kam sa chystáte?', 'journey.empty': 'Zatiaľ žiadne cestovné denníky', diff --git a/shared/src/i18n/sk/places.ts b/shared/src/i18n/sk/places.ts index b02138f93..1e01f0387 100644 --- a/shared/src/i18n/sk/places.ts +++ b/shared/src/i18n/sk/places.ts @@ -177,10 +177,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'Nahrať obrázok', 'places.changeImage': 'Zmeniť obrázok', 'places.removeImage': 'Odobrať obrázok', - 'places.chooseImage': 'Vybrať obrázok', - 'places.uploadFromDevice': 'Nahrať zo zariadenia', - 'places.fromAttachedFiles': 'Z priložených súborov', + 'places.image.current': 'Aktuálny obrázok', + 'places.image.own': 'Tvoj obrázok', + 'places.image.none': 'Bez obrázka', + 'places.image.needsConnection': 'Nový obrázok potrebuje pripojenie. Ulož znova, keď budeš online.', + 'places.openPhotos': 'Zobraziť fotky', 'places.imageUploadError': 'Obrázok sa nepodarilo nahrať', - 'places.imageRemoveError': 'Obrázok sa nepodarilo odobrať', }; export default places; diff --git a/shared/src/i18n/sv/journey.ts b/shared/src/i18n/sv/journey.ts index 1b398975a..94ced3a8c 100644 --- a/shared/src/i18n/sv/journey.ts +++ b/shared/src/i18n/sv/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Zooma in', 'journey.lightbox.zoomOut': 'Zooma ut', 'journey.lightbox.zoomReset': 'Tillbaka till hela fotot', + 'journey.lightbox.previous': 'Föregående foto', + 'journey.lightbox.next': 'Nästa foto', 'journey.create': 'Skapa', 'journey.titlePlaceholder': 'Vart ska du?', 'journey.empty': 'Inga journeys än', diff --git a/shared/src/i18n/sv/places.ts b/shared/src/i18n/sv/places.ts index 12df78cd1..1a2bf99eb 100644 --- a/shared/src/i18n/sv/places.ts +++ b/shared/src/i18n/sv/places.ts @@ -168,10 +168,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'Ladda upp bild', 'places.changeImage': 'Byt bild', 'places.removeImage': 'Ta bort bild', - 'places.chooseImage': 'Välj en bild', - 'places.uploadFromDevice': 'Ladda upp från enheten', - 'places.fromAttachedFiles': 'Från bifogade filer', + 'places.image.current': 'Nuvarande bild', + 'places.image.own': 'Din bild', + 'places.image.none': 'Ingen bild', + 'places.image.needsConnection': 'En ny bild behöver en anslutning. Spara igen när du är online.', + 'places.openPhotos': 'Visa foton', 'places.imageUploadError': 'Det gick inte att ladda upp bilden', - 'places.imageRemoveError': 'Det gick inte att ta bort bilden', }; export default places; diff --git a/shared/src/i18n/th/journey.ts b/shared/src/i18n/th/journey.ts index 4371d2359..1ec084683 100644 --- a/shared/src/i18n/th/journey.ts +++ b/shared/src/i18n/th/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'ซูมเข้า', 'journey.lightbox.zoomOut': 'ซูมออก', 'journey.lightbox.zoomReset': 'กลับไปดูรูปเต็ม', + 'journey.lightbox.previous': 'รูปก่อนหน้า', + 'journey.lightbox.next': 'รูปถัดไป', 'journey.create': 'สร้าง', 'journey.titlePlaceholder': 'คุณจะไปไหน?', 'journey.empty': 'ยังไม่มีการเดินทาง', diff --git a/shared/src/i18n/th/places.ts b/shared/src/i18n/th/places.ts index a6c75c3f9..8c214b55b 100644 --- a/shared/src/i18n/th/places.ts +++ b/shared/src/i18n/th/places.ts @@ -153,11 +153,12 @@ const places: TranslationStrings = { 'places.uploadImage': 'อัปโหลดรูปภาพ', 'places.changeImage': 'เปลี่ยนภาพ', 'places.removeImage': 'ลบรูปภาพ', - 'places.chooseImage': 'เลือกรูปภาพ', - 'places.uploadFromDevice': 'อัปโหลดจากอุปกรณ์', - 'places.fromAttachedFiles': 'จากไฟล์แนบ', + 'places.image.current': 'รูปปัจจุบัน', + 'places.image.own': 'รูปของคุณ', + 'places.image.none': 'ไม่มีรูป', + 'places.image.needsConnection': 'รูปใหม่ต้องใช้การเชื่อมต่อ บันทึกอีกครั้งเมื่อออนไลน์', + 'places.openPhotos': 'ดูรูปภาพ', 'places.imageUploadError': 'ไม่สามารถอัปโหลดภาพได้', - 'places.imageRemoveError': 'ไม่สามารถลบรูปภาพได้', 'places.addToSelectedDay': 'เพิ่มในวันที่เปิดอยู่', 'places.addPlaceShort': 'สถานที่ใหม่', 'places.addToDayShort': 'เพิ่มในวัน', diff --git a/shared/src/i18n/tr/journey.ts b/shared/src/i18n/tr/journey.ts index 2d6080fd0..4c4917e07 100644 --- a/shared/src/i18n/tr/journey.ts +++ b/shared/src/i18n/tr/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Yakınlaştır', 'journey.lightbox.zoomOut': 'Uzaklaştır', 'journey.lightbox.zoomReset': 'Fotoğrafın tamamına dön', + 'journey.lightbox.previous': 'Önceki fotoğraf', + 'journey.lightbox.next': 'Sonraki fotoğraf', 'journey.create': 'Oluştur', 'journey.titlePlaceholder': 'Nereye gidiyorsunuz?', 'journey.empty': 'Henüz journey yok', diff --git a/shared/src/i18n/tr/places.ts b/shared/src/i18n/tr/places.ts index 2ac0c4b5b..eccf00058 100644 --- a/shared/src/i18n/tr/places.ts +++ b/shared/src/i18n/tr/places.ts @@ -170,10 +170,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'Görsel yükle', 'places.changeImage': 'Görseli değiştir', 'places.removeImage': 'Görseli kaldır', - 'places.chooseImage': 'Bir görsel seç', - 'places.uploadFromDevice': 'Cihazdan yükle', - 'places.fromAttachedFiles': 'Ekli dosyalardan', + 'places.image.current': 'Mevcut görsel', + 'places.image.own': 'Senin görselin', + 'places.image.none': 'Görsel yok', + 'places.image.needsConnection': 'Yeni bir görsel bağlantı gerektirir. Çevrimiçi olunca yeniden kaydet.', + 'places.openPhotos': 'Fotoğrafları göster', 'places.imageUploadError': 'Görsel yüklenemedi', - 'places.imageRemoveError': 'Görsel kaldırılamadı', }; export default places; diff --git a/shared/src/i18n/uk/journey.ts b/shared/src/i18n/uk/journey.ts index de9829bf8..ea84f6cdd 100644 --- a/shared/src/i18n/uk/journey.ts +++ b/shared/src/i18n/uk/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Збільшити', 'journey.lightbox.zoomOut': 'Зменшити', 'journey.lightbox.zoomReset': 'Повернутися до всього фото', + 'journey.lightbox.previous': 'Попереднє фото', + 'journey.lightbox.next': 'Наступне фото', 'journey.create': 'Створити', 'journey.titlePlaceholder': 'Куди ви їдете?', 'journey.empty': 'Поки що немає подорожей', diff --git a/shared/src/i18n/uk/places.ts b/shared/src/i18n/uk/places.ts index 522a5e49a..bac020df4 100644 --- a/shared/src/i18n/uk/places.ts +++ b/shared/src/i18n/uk/places.ts @@ -184,10 +184,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'Завантажити зображення', 'places.changeImage': 'Змінити зображення', 'places.removeImage': 'Видалити зображення', - 'places.chooseImage': 'Вибрати зображення', - 'places.uploadFromDevice': 'Завантажити з пристрою', - 'places.fromAttachedFiles': 'З прикріплених файлів', + 'places.image.current': 'Поточне зображення', + 'places.image.own': 'Твоє зображення', + 'places.image.none': 'Без зображення', + 'places.image.needsConnection': 'Для нового зображення потрібне з’єднання. Збережи ще раз, коли будеш онлайн.', + 'places.openPhotos': 'Показати фото', 'places.imageUploadError': 'Не вдалося завантажити зображення', - 'places.imageRemoveError': 'Не вдалося видалити зображення', }; export default places; diff --git a/shared/src/i18n/vi/journey.ts b/shared/src/i18n/vi/journey.ts index d63c99d66..a173ca5d3 100644 --- a/shared/src/i18n/vi/journey.ts +++ b/shared/src/i18n/vi/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': 'Phóng to', 'journey.lightbox.zoomOut': 'Thu nhỏ', 'journey.lightbox.zoomReset': 'Quay lại ảnh đầy đủ', + 'journey.lightbox.previous': 'Ảnh trước', + 'journey.lightbox.next': 'Ảnh tiếp theo', 'journey.create': 'Tạo nên', 'journey.titlePlaceholder': 'Bạn đang đi đâu?', 'journey.empty': 'Chưa có chuyến đi nào', diff --git a/shared/src/i18n/vi/places.ts b/shared/src/i18n/vi/places.ts index c921d9ea9..4b267dd10 100644 --- a/shared/src/i18n/vi/places.ts +++ b/shared/src/i18n/vi/places.ts @@ -160,10 +160,11 @@ const places: TranslationStrings = { 'places.uploadImage': 'Tải ảnh lên', 'places.changeImage': 'Đổi ảnh', 'places.removeImage': 'Xóa ảnh', - 'places.chooseImage': 'Chọn ảnh', - 'places.uploadFromDevice': 'Tải lên từ thiết bị', - 'places.fromAttachedFiles': 'Từ tệp đính kèm', + 'places.image.current': 'Ảnh hiện tại', + 'places.image.own': 'Ảnh của bạn', + 'places.image.none': 'Không có ảnh', + 'places.image.needsConnection': 'Ảnh mới cần kết nối. Hãy lưu lại khi bạn trực tuyến.', + 'places.openPhotos': 'Xem ảnh', 'places.imageUploadError': 'Không thể tải ảnh lên', - 'places.imageRemoveError': 'Không thể xóa ảnh', }; export default places; diff --git a/shared/src/i18n/zh-TW/journey.ts b/shared/src/i18n/zh-TW/journey.ts index 35fb85eac..e1f7d406e 100644 --- a/shared/src/i18n/zh-TW/journey.ts +++ b/shared/src/i18n/zh-TW/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': '放大', 'journey.lightbox.zoomOut': '縮小', 'journey.lightbox.zoomReset': '回到完整照片', + 'journey.lightbox.previous': '上一張照片', + 'journey.lightbox.next': '下一張照片', 'journey.create': '建立', 'journey.titlePlaceholder': '你要去哪裡?', 'journey.empty': '還沒有旅程', diff --git a/shared/src/i18n/zh-TW/places.ts b/shared/src/i18n/zh-TW/places.ts index a7b5250fc..56f42cbfb 100644 --- a/shared/src/i18n/zh-TW/places.ts +++ b/shared/src/i18n/zh-TW/places.ts @@ -156,10 +156,11 @@ const places: TranslationStrings = { 'places.uploadImage': '上傳圖片', 'places.changeImage': '變更圖片', 'places.removeImage': '移除圖片', - 'places.chooseImage': '選擇圖片', - 'places.uploadFromDevice': '從裝置上傳', - 'places.fromAttachedFiles': '從附加檔案選擇', + 'places.image.current': '目前圖片', + 'places.image.own': '你的圖片', + 'places.image.none': '沒有圖片', + 'places.image.needsConnection': '新圖片需要連線。連線後請再儲存一次。', + 'places.openPhotos': '查看照片', 'places.imageUploadError': '無法上傳圖片', - 'places.imageRemoveError': '無法刪除圖片', }; export default places; diff --git a/shared/src/i18n/zh/journey.ts b/shared/src/i18n/zh/journey.ts index 2290db112..ecad4e9e3 100644 --- a/shared/src/i18n/zh/journey.ts +++ b/shared/src/i18n/zh/journey.ts @@ -9,6 +9,8 @@ const journey: TranslationStrings = { 'journey.lightbox.zoomIn': '放大', 'journey.lightbox.zoomOut': '缩小', 'journey.lightbox.zoomReset': '恢复完整照片', + 'journey.lightbox.previous': '上一张照片', + 'journey.lightbox.next': '下一张照片', 'journey.create': '创建', 'journey.titlePlaceholder': '你要去哪里?', 'journey.empty': '还没有旅程', diff --git a/shared/src/i18n/zh/places.ts b/shared/src/i18n/zh/places.ts index 30135748b..f461a555b 100644 --- a/shared/src/i18n/zh/places.ts +++ b/shared/src/i18n/zh/places.ts @@ -156,10 +156,11 @@ const places: TranslationStrings = { 'places.uploadImage': '上传图片', 'places.changeImage': '更换图片', 'places.removeImage': '移除图片', - 'places.chooseImage': '选择图片', - 'places.uploadFromDevice': '从设备上传', - 'places.fromAttachedFiles': '从附件中选择', + 'places.image.current': '当前图片', + 'places.image.own': '你的图片', + 'places.image.none': '无图片', + 'places.image.needsConnection': '新图片需要联网。联网后请再次保存。', + 'places.openPhotos': '查看照片', 'places.imageUploadError': '无法上传图片', - 'places.imageRemoveError': '无法删除图片', }; export default places;