mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': 'Δεν υπάρχουν ταξίδια ακόμα',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': '日記はまだありません',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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가 없습니다',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': 'Пока нет путешествий',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': 'ยังไม่มีการเดินทาง',
|
||||
|
||||
@@ -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': 'เพิ่มในวัน',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': 'Поки що немає подорожей',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': '還沒有旅程',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': '还没有旅程',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user