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.
This commit is contained in:
Maurice
2026-10-23 19:07:53 +02:00
parent 357bcb72e3
commit 35566690e9
80 changed files with 1090 additions and 973 deletions
-2
View File
@@ -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",
+1 -1
View File
@@ -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,
+3 -3
View File
@@ -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,
+41 -15
View File
@@ -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<HTMLDivElement>(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 (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={label}
tabIndex={-1}
style={{
outline: 'none',
position: 'fixed', inset: 0, zIndex: 10000,
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
display: 'flex', flexDirection: 'column',
@@ -144,15 +164,15 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
{/* Prev button — visible on hover (desktop), always visible (mobile) */}
{hasPrev && (
<button type="button" onClick={prev} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', left: 16, zIndex: 5,
<button type="button" onClick={prev} aria-label={t('journey.lightbox.previous')} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 focus-visible:opacity-100 transition-opacity" style={{
position: 'absolute', insetInlineStart: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<ChevronLeft size={22} />
<ChevronLeft size={22} className="rtl:rotate-180" />
</button>
)}
@@ -179,27 +199,33 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
{/* Next button */}
{hasNext && (
<button type="button" onClick={next} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', right: 16, zIndex: 5,
<button type="button" onClick={next} aria-label={t('journey.lightbox.next')} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 focus-visible:opacity-100 transition-opacity" style={{
position: 'absolute', insetInlineEnd: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<ChevronRight size={22} />
<ChevronRight size={22} className="rtl:rotate-180" />
</button>
)}
{/* Caption — bottom center overlay */}
{photo.caption && (
<div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 5, maxWidth: '70%', textAlign: 'center' }}>
<div style={{ position: 'absolute', bottom: 20, insetInline: 0, zIndex: 5, display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}>
<p style={{
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontStyle: 'italic',
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5,
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5, maxWidth: '70%', textAlign: 'center',
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
padding: '6px 14px', borderRadius: 10,
}}>{photo.caption}</p>
padding: '6px 14px', borderRadius: 10, pointerEvents: 'auto',
}}>
{photo.captionHref ? (
<a href={photo.captionHref} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit' }} className="hover:underline">
{photo.caption}
</a>
) : photo.caption}
</p>
</div>
)}
</div>
@@ -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<string, unknown>
}
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<string, MapsPlaceEnrichmentResult>()
/** 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({
)}
<div className={dialog ? 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-3 pb-3' : 'flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5'}>
{/* The place's picture first: its own ones are there whatever the sources answer. */}
<PictureBlock
photos={state === 'ready' && !data?.disabled ? (data?.photos ?? []) : []}
chosen={image?.chosen ?? chosenImage(selectedImageUrl, null, null)}
onPickImage={onPickImage}
own={image?.own}
dialog={dialog}
t={t}
/>
{/* 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 && (
<>
<PhotoStrip photos={data?.photos ?? []} selectedImageUrl={selectedImageUrl} onPickImage={onPickImage} dialog={dialog} t={t} />
<RatingRow rating={data?.rating ?? null} locale={locale} dialog={dialog} />
<OpeningHoursBlock
hours={data?.hours ?? null}
@@ -389,119 +356,31 @@ export default function PlaceDetailsColumn({
)
}
/** The name to put under a picture. Google gives no author, so it gets its own name. */
function creditOf(photo: PlacePhotoCandidate): string {
return photo.attribution || sourceLabelFor(photo.source)
}
/**
* The picture grid.
*
* A grid rather than one large image: this sits beside a form in a dialog, and
* a picture that takes half the column pushes the facts and the description out
* of sight. Three columns at 320px gives ~96px tiles, which is enough to tell
* the pictures apart while leaving the rest of the column visible.
*
* Only the picture in play is credited in full — crediting all of them at once
* cost two lines each and drowned the column, and the licence obligation
* attaches to the one that gets used. Every tile still carries the full credit
* as its tooltip and links to its source page.
*/
function PhotoStrip({
/** The picker under its label; nothing while there is no picture to offer. */
function PictureBlock({
photos,
selectedImageUrl,
chosen,
onPickImage,
dialog = false,
own,
dialog,
t,
}: {
photos: PlacePhotoCandidate[]
selectedImageUrl?: string
chosen: ChosenImage
onPickImage: (url: string | null) => void
dialog?: boolean
own?: PlaceImageOwn
dialog: boolean
t: TranslationFn
}): React.ReactElement | null {
const [hovered, setHovered] = useState<string | null>(null)
if (photos.length === 0) return null
const shown = photos.find((p) => p.url === (hovered ?? selectedImageUrl)) ?? photos[0]
const body = (
<>
<div className="grid grid-cols-3 gap-1.5">
{photos.map((photo) => (
<PhotoTile
key={photo.key}
photo={photo}
selected={selectedImageUrl === photo.url}
onPick={onPickImage}
onHover={setHovered}
dialog={dialog}
t={t}
/>
))}
</div>
<p className={dialog ? 'mt-2 flex min-w-0 items-center gap-2 text-caption leading-tight text-content-faint' : 'text-caption leading-tight text-content-faint truncate'}>
<PhotoCredit photo={shown} dialog={dialog} />
</p>
</>
)
const picker = <PlaceImagePicker photos={photos} chosen={chosen} onPickUrl={onPickImage} own={own} dialog={dialog} t={t} />
if (photos.length === 0 && !own) return null
return (
<Block label={t('places.details.pickImage')} dialog={dialog}>
{dialog ? <div className={`${COLUMN_CARD} p-2`}>{body}</div> : body}
{dialog ? <div className={`${COLUMN_CARD} p-2`}>{picker}</div> : picker}
</Block>
)
}
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 (
<button
type="button"
onClick={() => onPick(selected ? null : photo.url)}
onMouseEnter={() => onHover(photo.url)}
onMouseLeave={() => onHover(null)}
onFocus={() => onHover(photo.url)}
onBlur={() => onHover(null)}
aria-pressed={selected}
aria-label={`${t('places.details.pickImage')} — ${credit}`}
title={dialog ? undefined : `${credit}${photo.license ? ` · ${photo.license}` : ''}`}
className={`group relative block w-full aspect-square overflow-hidden rounded-lg transition-shadow ${
selected
? `ring-2 ring-accent ring-offset-2 ${dialog ? 'ring-offset-surface-card' : 'ring-offset-surface-secondary'}`
: 'ring-1 ring-edge hover:ring-content-muted'
}`}
>
<img
src={photo.url}
alt=""
loading="lazy"
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.06]"
/>
{selected && (
<span className="absolute top-1 end-1 rounded-full bg-accent p-0.5 shadow-card">
<Check className="w-2.5 h-2.5 text-accent-on" />
</span>
)}
</button>
)
}
/**
* 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 (
<>
<span className="min-w-0 truncate">
{photo.sourceUrl ? (
<a href={photo.sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{credit}
</a>
) : (
credit
)}
</span>
{photo.license && (
<span className="flex-none">
{photo.licenseUrl ? (
<a href={photo.licenseUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{photo.license}
</a>
) : (
photo.license
)}
</span>
)}
</>
)
}
return (
<>
{photo.sourceUrl ? (
<a href={photo.sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{credit}
</a>
) : (
credit
)}
{photo.license && (
<>
{' · '}
{photo.licenseUrl ? (
<a href={photo.licenseUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{photo.license}
</a>
) : (
photo.license
)}
</>
)}
</>
)
}
/**
* Shown when the free sources found nothing and no Google key is configured.
@@ -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<HTMLInputElement | null>;
onAdd: (e: React.ChangeEvent<HTMLInputElement>) => void;
onRemove: (index: number) => void;
t: TranslationFn;
}) {
return (
<div className="min-w-0">
<div className={LABEL}>{t('files.title')}</div>
<input ref={fileRef} type="file" multiple className="hidden" onChange={onAdd} />
<button type="button" onClick={() => fileRef.current?.click()} className={WHITE_BUTTON} style={fs(13, 'body')}>
<Paperclip size={14} aria-hidden="true" />
{t('files.attach')}
</button>
{files.length > 0 && (
<div className="mt-1.5 flex flex-col gap-1" data-testid="pending-files">
{files.map((file, idx) => (
<div
key={idx}
className="flex items-center gap-2 rounded-[8px] border border-edge-faint bg-surface-card px-2.5 py-1.5"
style={fs(12, 'body')}
>
<Paperclip size={11} className="flex-none text-content-faint" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-content-secondary">{file.name}</span>
<Tooltip label={t('common.delete')}>
<button
type="button"
onClick={() => onRemove(idx)}
aria-label={t('common.delete')}
className="flex flex-none text-content-faint transition-colors hover:text-danger"
>
<X size={12} />
</button>
</Tooltip>
</div>
))}
</div>
)}
</div>
);
}
/**
* 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 (
<div className="min-w-0">
<div className={LABEL}>{t('places.details.pickImage')}</div>
<div className={`${COLUMN_CARD} p-2`}>
<PlaceImagePicker photos={[]} chosen={image.chosen} onPickUrl={onPickUrl} own={image.own} dialog t={t} />
</div>
</div>
);
}
@@ -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<PlaceFormData, 'lat' | 'lng' | 'c
category_id: string | null
/** Files chosen before the place existed: uploaded once the save has given it an id. */
_pendingFiles?: File[]
/** A picture waiting to become the place's own: a file from the device, or an attached file's id. */
_pendingImage?: PendingImagePayload
}
/**
@@ -322,6 +326,7 @@ export function placeFormPayload<E extends object = object>(
pendingFiles: File[],
assignment: Pick<Assignment, 'notes'> | 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<E extends object = object>(
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
@@ -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<string | null>(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 && <PlaceImageSection image={S.image} onPickUrl={S.pickImage} t={t} />}
{/* The link and the files side by side, as on a booking. */}
<div className={canUploadFiles ? GRID_2 : ''}>
<EditorField label={t('places.formWebsite')} htmlFor={`${fieldId}-website`}>
@@ -1117,44 +1125,3 @@ function MarkdownField({ id, label, value, onChange, placeholder, maxLength }: {
</DialogSection>
)
}
/** 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<HTMLInputElement | null>
onAdd: (e: React.ChangeEvent<HTMLInputElement>) => void
onRemove: (index: number) => void
t: Translate
}) {
return (
<div className="min-w-0">
<div className={LABEL}>{t('files.title')}</div>
<input ref={fileRef} type="file" multiple className="hidden" onChange={onAdd} />
<button
type="button"
onClick={() => fileRef.current?.click()}
className={WHITE_BUTTON}
style={fs(13, 'body')}
>
<Paperclip size={14} aria-hidden="true" />
{t('files.attach')}
</button>
{files.length > 0 && (
<div className="mt-1.5 flex flex-col gap-1" data-testid="pending-files">
{files.map((file, idx) => (
<div key={idx} className="flex items-center gap-2 rounded-[8px] border border-edge-faint bg-surface-card px-2.5 py-1.5" style={fs(12, 'body')}>
<Paperclip size={11} className="flex-none text-content-faint" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-content-secondary">{file.name}</span>
<Tooltip label={t('common.delete')}>
<button type="button" onClick={() => onRemove(idx)} aria-label={t('common.delete')}
className="flex flex-none text-content-faint transition-colors hover:text-danger">
<X size={12} />
</button>
</Tooltip>
</div>
))}
</div>
)}
</div>
)
}
@@ -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 (
<span className="absolute end-1 top-1 rounded-full bg-accent p-0.5 shadow-card">
<Check className="h-2.5 w-2.5 text-accent-on" />
</span>
);
}
/** 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<Credit>(null);
const fileRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<>
<div className="grid grid-cols-3 gap-1.5">
{own?.canUpload && (
<Tooltip label={t('places.uploadImage')} placement="top">
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={reading}
aria-pressed={chosen.kind === 'upload'}
aria-label={t('places.uploadImage')}
className={`${TILE} ${ring(chosen.kind === 'upload', dialog)} ${own.pendingPreview ? '' : 'border border-dashed border-edge bg-surface-card'}`}
onMouseEnter={() => own.pendingPreview && setHovered({ kind: 'own', label: t('places.image.own') })}
onMouseLeave={() => setHovered(null)}
>
{own.pendingPreview ? (
<>
<img src={own.pendingPreview} alt="" className="h-full w-full object-cover" />
{chosen.kind === 'upload' && <Picked />}
</>
) : (
<span className="flex h-full w-full flex-col items-center justify-center gap-1 px-1 text-center text-caption leading-tight text-content-muted group-hover:text-content">
{reading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ImagePlus className="h-4 w-4" strokeWidth={2} />
)}
{t('places.uploadImage')}
</span>
)}
</button>
</Tooltip>
)}
{current && (
<button
type="button"
onClick={() => (chosen.kind === 'current' ? own?.onRemove() : onPickUrl(current))}
aria-pressed={chosen.kind === 'current'}
aria-label={t('places.image.current')}
onMouseEnter={() => setHovered({ kind: 'own', label: t('places.image.current') })}
onMouseLeave={() => setHovered(null)}
className={`${TILE} ${ring(chosen.kind === 'current', dialog)}`}
>
<img src={current} alt="" className="h-full w-full object-cover" />
{chosen.kind === 'current' && <Picked />}
</button>
)}
{attached.map((file) => (
<AttachedTile
key={file.id}
file={file}
selected={chosen.kind === 'attached' && chosen.fileId === file.id}
onPick={() => own?.onPickAttached(file)}
onHover={(on) => setHovered(on ? { kind: 'own', label: file.original_name } : null)}
dialog={dialog}
/>
))}
{photos.map((photo) => (
<PhotoTile
key={photo.key}
photo={photo}
selected={chosen.kind === 'url' && chosen.url === photo.url}
onPick={onPickUrl}
onHover={(on) => setHovered(on ? { kind: 'photo', photo } : null)}
dialog={dialog}
t={t}
/>
))}
</div>
{own?.canUpload && (
<input
ref={fileRef}
type="file"
accept="image/*,.heic,.heif"
className="hidden"
onChange={(e) => void pick(e)}
/>
)}
{(shown || removable) && (
<div
className={
dialog
? 'mt-2 flex min-w-0 items-center gap-2 text-caption leading-tight text-content-faint'
: 'mt-1.5 flex min-w-0 items-center gap-2 text-caption leading-tight text-content-faint'
}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
{shown?.kind === 'photo' && <PhotoCredit photo={shown.photo} />}
{shown?.kind === 'own' && <span className="min-w-0 truncate">{shown.label}</span>}
</span>
{removable && (
<button
type="button"
onClick={own.onRemove}
className="inline-flex flex-none items-center gap-1 rounded-md px-1.5 py-0.5 text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
>
<ImageOff className="h-3 w-3" />
{t('places.removeImage')}
</button>
)}
</div>
)}
</>
);
}
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 (
<button
type="button"
onClick={() => onPick(selected ? null : photo.url)}
onMouseEnter={() => onHover(true)}
onMouseLeave={() => onHover(false)}
onFocus={() => onHover(true)}
onBlur={() => onHover(false)}
aria-pressed={selected}
aria-label={`${t('places.details.pickImage')}: ${credit}`}
title={dialog ? undefined : photo.license ? `${credit}, ${photo.license}` : credit}
className={`${TILE} ${ring(selected, dialog)}`}
>
<img
src={photo.url}
alt=""
loading="lazy"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.06]"
/>
{selected && <Picked />}
</button>
);
}
/** 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<string | null>(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 (
<button
type="button"
onClick={onPick}
onMouseEnter={() => onHover(true)}
onMouseLeave={() => onHover(false)}
onFocus={() => onHover(true)}
onBlur={() => onHover(false)}
aria-pressed={selected}
aria-label={file.original_name}
className={`${TILE} bg-surface-card ${ring(selected, dialog)}`}
>
{src && <img src={src} alt="" className="h-full w-full object-cover" />}
{selected && <Picked />}
</button>
);
}
/**
* 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 (
<>
<span className="min-w-0 truncate">
{photo.sourceUrl ? (
<a href={photo.sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{credit}
</a>
) : (
credit
)}
</span>
{photo.license && (
<span className="flex-none">
{photo.licenseUrl ? (
<a href={photo.licenseUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{photo.license}
</a>
) : (
photo.license
)}
</span>
)}
</>
);
}
@@ -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<Place>) => void
/** Upload a custom thumbnail (#1136); enables the click-to-change avatar in trip mode. */
onUploadImage?: (placeId: number, file: File) => Promise<void>
/** Takes a picture already attached to the place as its image (#1242). */
onImageFromFile?: (placeId: number, fileId: number) => Promise<void>
/** Cast/clear the current user's star vote (#1435); enables the rating row. */
onRate?: (placeId: number, rating: number | null) => Promise<void> | 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<HTMLInputElement>) => void
startNameEdit: () => void
onUpdatePlace?: (placeId: number, data: Partial<Place>) => void
onUploadImage?: (placeId: number, file: File) => Promise<void>
attachedImages: TripFile[]
onImageFromFile?: (placeId: number, fileId: number) => Promise<void>
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
<div className="flex items-start gap-3.5">
<div className="flex w-[57px] flex-none flex-col items-center">
<div className={`rounded-full p-[2.5px] ${ring}`}>
{onUploadImage
? <PlaceAvatarUpload place={place} category={category} size={52}
onUpload={(file: File) => onUploadImage(place.id, file)}
onRemove={() => onUpdatePlace(place.id, { image_url: null })}
attachedImages={attachedImages}
onPickAttached={onImageFromFile ? (fileId: number) => onImageFromFile(place.id, fileId) : undefined} />
: <PlaceAvatar place={place} category={category} size={52} />}
<PlacePhotoGallery place={place} language={language} label={t('places.openPhotos')} className="rounded-full">
<PlaceAvatar place={place} category={category} size={52} />
</PlacePhotoGallery>
</div>
<PhotoCredit imageUrl={place.image_url} />
</div>
@@ -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 <body>: 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 <body>: 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(
<PhotoLightbox photos={photos} onClose={close} />,
document.body,
)}
<Tooltip label={label} placement="top">
<button
type="button"
onClick={open}
aria-label={label}
aria-busy={loading}
className={`relative block cursor-zoom-in transition-opacity focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${loading ? 'opacity-70' : ''} ${className}`}
>
{children}
{loading && (
<span className="absolute inset-0 grid place-items-center">
<Loader2 className="h-4 w-4 animate-spin text-content" />
</span>
)}
</button>
</Tooltip>
{isOpen &&
photos.length > 0 &&
createPortal(<PhotoLightbox photos={photos} onClose={close} label={label} />, document.body)}
</>
)
);
}
@@ -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<string, unknown>
details?: Record<string, unknown>;
}
/**
@@ -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<string, MapsPlaceEnrichmentResult>()
const enrichmentCache = new Map<string, MapsPlaceEnrichmentResult>();
/** 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);
}
@@ -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 };
}
@@ -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;
}
@@ -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<void>((_resolve, reject) => (fail = reject)));
const toast = { error: vi.fn() };
const { result } = renderHook(() => usePlaceImagePick(upload, toast));
let running: Promise<void> = 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);
});
});
@@ -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, string | number>) => 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<unknown>) | undefined, toast: PlaceActionToast) {
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const pickImage = async (e: ChangeEvent<HTMLInputElement>) => {
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 };
}
+45 -4
View File
@@ -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<typeof useTranslation>['t'];
type Toast = ReturnType<typeof useToast>;
@@ -40,6 +41,20 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) {
// user's and survives. See mergeResult.
const autoFilledRef = useRef<Set<ResultField>>(new Set());
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
// The picture that becomes the place's own when the form is saved.
const [pendingImage, setPendingImageState] = useState<PendingPlaceImage | null>(null);
const previewRef = useRef<string | null>(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<string | null>(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,
};
@@ -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<PlacePhotoCandidate[] | null>(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<PlacePhotoCandidate[] | null>(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<PlaceDetailsSelection | 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
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<GalleryPhoto[]>(() => {
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 };
}
@@ -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<Place, 'id' | 'image_url'> | 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),
};
}
@@ -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(<PlaceAvatarUpload place={placeWithImage} onUpload={vi.fn()} onRemove={vi.fn()} />);
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(<PlaceAvatarUpload place={placeNoImage} onUpload={vi.fn()} onRemove={vi.fn()} />);
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(<PlaceAvatarUpload place={placeNoImage} onUpload={onUpload} onRemove={vi.fn()} />);
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(<PlaceAvatarUpload place={placeNoImage} onUpload={vi.fn()} onRemove={vi.fn()} />);
expect(screen.queryByRole('button', { name: 'Remove image' })).toBeNull();
rerender(<PlaceAvatarUpload place={placeWithImage} onUpload={vi.fn()} onRemove={vi.fn()} />);
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(<PlaceAvatarUpload place={placeWithImage} onUpload={vi.fn()} onRemove={onRemove} />);
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(<PlaceAvatarUpload place={placeNoImage} onUpload={vi.fn()} onRemove={vi.fn()} />);
expect(screen.getByRole('button', { name: 'Upload image' })).toBeTruthy();
rerender(<PlaceAvatarUpload place={placeWithImage} onUpload={vi.fn()} onRemove={vi.fn()} />);
expect(screen.getByRole('button', { name: 'Change image' })).toBeTruthy();
});
});
@@ -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<Place, 'id' | 'name' | 'image_url' | 'google_place_id' | 'osm_id' | 'lat' | 'lng'>
category?: Category | null
size?: number
onUpload: (file: File) => Promise<void>
/** Clears the custom image; the auto-fetched default thumbnail then returns (#1136). */
onRemove: () => Promise<void> | void
/** Pictures already attached to the place, offered as its image beside an upload (#1242). */
attachedImages?: TripFile[]
onPickAttached?: (fileId: number) => Promise<void>
}
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<HTMLInputElement>(null)
const anchorRef = useRef<HTMLDivElement>(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 (
<div style={{ position: 'relative', width: size, height: size }}>
<Tooltip label={hasCustom ? t('places.changeImage') : t('places.uploadImage')} placement="top">
<div
className="group"
style={{ position: 'relative', width: size, height: size, borderRadius: '50%', cursor: busy ? 'default' : 'pointer' }}
ref={anchorRef}
onClick={openPicker}
role="button"
tabIndex={0}
aria-haspopup={offersAttached ? 'dialog' : undefined}
aria-expanded={offersAttached ? chooserOpen : undefined}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPicker() } }}
aria-label={hasCustom ? t('places.changeImage') : t('places.uploadImage')}
>
<PlaceAvatar place={place} category={category} size={size} />
{/* Hover overlay — a camera cue that this thumbnail is editable. */}
<div
className="opacity-0 group-hover:opacity-100"
style={{
position: 'absolute', inset: 0, borderRadius: '50%',
background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'opacity 0.15s', pointerEvents: 'none',
}}
>
{busy ? <Loader2 size={Math.round(size * 0.34)} className="animate-spin" color="#fff" /> : <Camera size={Math.round(size * 0.34)} color="#fff" />}
</div>
</div>
</Tooltip>
{/* Remove button — only when a custom upload is set. */}
{hasCustom && !busy && (
<Tooltip label={t('places.removeImage')} placement="top">
<button
type="button"
onClick={handleRemove}
aria-label={t('places.removeImage')}
style={{
position: 'absolute', top: -3, insetInlineEnd: -3, width: 18, height: 18, borderRadius: '50%',
background: '#ef4444', color: '#fff', border: '2px solid var(--bg-elevated, #fff)',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: 0, zIndex: 1,
}}
>
<X size={10} strokeWidth={3} />
</button>
</Tooltip>
)}
<input ref={fileRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" style={{ display: 'none' }} onChange={handleFile} />
{chooserOpen && (
<ImageChooser
anchor={anchorRef.current}
images={attachedImages}
onUpload={() => { setChooserOpen(false); fileRef.current?.click() }}
onPick={id => { void pickAttached(id) }}
onClose={() => setChooserOpen(false)}
/>
)}
</div>
)
}
/** 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<HTMLDivElement>(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(
<div ref={ref} role="dialog" aria-label={t('places.chooseImage')}
className="trek-popover-enter fixed z-[var(--z-toast)] flex w-[264px] flex-col gap-2 rounded-[14px] border border-edge-secondary bg-surface-card p-2 shadow-popover"
style={{ top: pos.top, left: pos.left }}>
<button type="button" onClick={onUpload}
className="flex items-center gap-2 rounded-[10px] px-2.5 py-2 text-start text-body font-semibold text-content hover:bg-surface-hover">
<Upload size={14} strokeWidth={2.2} className="text-content-muted" />
{t('places.uploadFromDevice')}
</button>
<div className="px-2.5 text-caption font-semibold uppercase tracking-[0.04em] text-content-faint">{t('places.fromAttachedFiles')}</div>
<div className="grid grid-cols-4 gap-1.5 px-1 pb-1">
{images.map(f => <AttachedThumb key={f.id} file={f} onPick={() => onPick(f.id)} />)}
</div>
</div>,
document.body,
)
}
function AttachedThumb({ file, onPick }: { file: TripFile; onPick: () => void }) {
const [src, setSrc] = useState<string | null>(null)
useEffect(() => {
let current = true
fetchImageAsBlob(file.url).then(u => { if (current) setSrc(u) }).catch(() => {})
return () => { current = false }
}, [file.url])
return (
<Tooltip label={file.original_name} placement="top">
<button type="button" onClick={onPick} aria-label={file.original_name}
className="aspect-square overflow-hidden rounded-[9px] bg-surface-secondary ring-1 ring-edge-faint transition-shadow hover:ring-2 hover:ring-accent">
{src && <img src={src} alt="" className="h-full w-full object-cover" />}
</button>
</Tooltip>
)
}
@@ -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
/>
</div>
)}
{!placesEnrichEnabled && (
<div className="mt-3">
<PlaceImageSection image={image} onPickUrl={pickImage} t={t} />
</div>
)}
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('places.formName')} *</Eyebrow>
<input
@@ -1,7 +1,7 @@
import { useMemo, useRef, useState } from 'react'
import DawarichIcon from '../../../../components/shared/DawarichIcon'
import {
Bookmark, Camera, ChevronRight, ExternalLink, Loader2, Map as MapIcon, Navigation, Paperclip,
Bookmark, ChevronRight, ExternalLink, Map as MapIcon, Navigation, Paperclip,
Pencil, Phone, Plus, Route, RouteOff, Sparkles, Trash2, Upload, X,
} from 'lucide-react'
import MSheet from '../../../components/MSheet'
@@ -12,7 +12,7 @@ import { useSaveToCollectionStore } from '../../../../store/saveToCollectionStor
import { placeToSaveTarget } from '../../../../components/Collections/saveTarget'
import { useAssistantAsk } from '../../../../components/Assistant/useAssistantAsk'
import {
participantsWith, participantsWithout, placeActions, splitParticipants, usePlaceFileUpload, usePlaceImagePick,
participantsWith, participantsWithout, placeActions, splitParticipants, usePlaceFileUpload,
} from '../../../../components/Planner/usePlaceActions'
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
import PlaceRating from '../../../../components/shared/StarRating'
@@ -27,6 +27,7 @@ import { navigationTargetLabel, getNavigationTargets, openNavigationTarget } fro
import { NavigationMenu } from '../../../../components/shared/NavigationMenu'
import { getPlaceBookings } from '../../../../utils/dayMerge'
import type { Assignment, Day, Reservation, TripMember } from '../../../../types'
import PlacePhotoGallery from '../../../../components/Planner/PlacePhotoGallery'
import { ActionCircle, Eyebrow, INNER_CLS } from './MTripSheetUi'
/**
@@ -37,7 +38,7 @@ import { ActionCircle, Eyebrow, INNER_CLS } from './MTripSheetUi'
* attached files, plus the inspector action row.
*/
export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
const { t } = useTranslation()
const { t, language } = useTranslation()
const place = planner.selectedPlace ?? null
// Match the desktop inspector gate: while the Tours facet is loading, wait
// before choosing a surface; once identified as a tour, its purpose-built
@@ -60,11 +61,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
const [participantPickerOpen, setParticipantPickerOpen] = useState(false)
const [navOpen, setNavOpen] = useState(false)
const navBtnRef = useRef<HTMLButtonElement>(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<HTMLInputElement | null>(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) {
<div className="flex-none px-[18px] pt-4">
<div className="flex items-start gap-3">
<div className="flex flex-none flex-col items-center gap-[5px]">
<div className="relative h-[52px] w-[52px]">
{/* The picture opens the place's photos; it is changed in the editor. */}
<PlacePhotoGallery place={place} language={language} label={t('places.openPhotos')} className="rounded-[16px]">
{place.image_url ? (
<div
className="h-[52px] w-[52px] rounded-[16px] border-[1.5px] border-[color:var(--m-avbr)] bg-cover bg-center"
@@ -222,35 +208,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
<CatIcon size={20} strokeWidth={1.8} className="text-m-muted" />
</div>
)}
{canManagePlace && (
<>
{/* Tap the thumbnail to set a custom image (#1136). */}
<button
type="button"
onClick={() => { if (!imgBusy) imageInputRef.current?.click() }}
aria-label={place.image_url ? t('places.changeImage') : t('places.uploadImage')}
className="absolute inset-0 flex items-center justify-center rounded-[16px]"
style={{ background: imgBusy ? 'rgba(0,0,0,0.45)' : 'transparent' }}
>
<span className="flex h-[22px] w-[22px] items-center justify-center rounded-full" style={{ background: 'rgba(0,0,0,0.55)', color: '#fff' }}>
{imgBusy ? <Loader2 size={12} className="animate-spin" /> : <Camera size={12} />}
</span>
</button>
{place.image_url && !imgBusy && (
<button
type="button"
onClick={handleImageRemove}
aria-label={t('places.removeImage')}
className="absolute flex items-center justify-center rounded-full"
style={{ top: -5, insetInlineEnd: -5, width: 18, height: 18, background: '#ef4444', color: '#fff', border: '2px solid var(--m-sheet)' }}
>
<X size={9} strokeWidth={3} />
</button>
)}
<input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" className="hidden" onChange={handleImagePick} />
</>
)}
</div>
</PlacePhotoGallery>
{category && (
<span className="flex max-w-[76px] items-center gap-1 rounded-full border border-[color:var(--m-faint)] px-2 py-[2px] font-geist text-[0.625rem] font-semibold text-m-muted">
<CatIcon size={10} strokeWidth={2} className="flex-none" />
-2
View File
@@ -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 }
@@ -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<Place>;
setPlaceImageFromFile: (tripId: number | string, placeId: number, fileId: number) => Promise<Place>;
}
/**
* 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<void> {
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);
}
}
@@ -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
+2
View File
@@ -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
+5 -4
View File
@@ -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;
+2
View File
@@ -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',
+5 -4
View File
@@ -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;
+2
View File
@@ -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',
+5 -4
View File
@@ -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;
+2
View File
@@ -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',
+5 -4
View File
@@ -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;
+2
View File
@@ -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',
+5 -4
View File
@@ -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;
+2
View File
@@ -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',
+5 -4
View File
@@ -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':
+2
View File
@@ -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',
+5 -4
View File
@@ -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;
+2
View File
@@ -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',
+5 -4
View File
@@ -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;
+2
View File
@@ -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',
+5 -4
View File
@@ -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;
+2
View File
@@ -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',
+5 -4
View File
@@ -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;
+2
View File
@@ -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': 'Δεν υπάρχουν ταξίδια ακόμα',
+5 -4
View File
@@ -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;
+2
View File
@@ -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',
+5 -4
View File
@@ -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;
+2
View File
@@ -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',
+5 -4
View File
@@ -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;
+2
View File
@@ -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',
+5 -4
View File
@@ -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;
+2
View File
@@ -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': '日記はまだありません',
+5 -4
View File
@@ -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;
+2
View File
@@ -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가 없습니다',
+5 -4
View File
@@ -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;
+2
View File
@@ -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',
+5 -4
View File
@@ -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;
+2
View File
@@ -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',
+5 -4
View File
@@ -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;
+2
View File
@@ -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': 'Пока нет путешествий',
+5 -4
View File
@@ -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;
+2
View File
@@ -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',
+5 -4
View File
@@ -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;
+2
View File
@@ -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',
+5 -4
View File
@@ -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;
+2
View File
@@ -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': 'ยังไม่มีการเดินทาง',
+5 -4
View File
@@ -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': 'เพิ่มในวัน',
+2
View File
@@ -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',
+5 -4
View File
@@ -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;
+2
View File
@@ -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': 'Поки що немає подорожей',
+5 -4
View File
@@ -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;
+2
View File
@@ -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',
+5 -4
View File
@@ -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;
+2
View File
@@ -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': '還沒有旅程',
+5 -4
View File
@@ -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;
+2
View File
@@ -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': '还没有旅程',
+5 -4
View File
@@ -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;