mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
style(client): run prettier on Immich picker files before functional changes
No logic change; isolates formatting noise from the upcoming picker fixes so those diffs stay reviewable.
This commit is contained in:
@@ -1,189 +1,236 @@
|
||||
import { useEffect, useState, useRef, useMemo } from 'react'
|
||||
import { X, Check, Calendar, ChevronRight, Camera } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { memoriesApi } from '../../api/client'
|
||||
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore'
|
||||
import { groupPhotosByDate, sortProviderPhotos, type GeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
import { Calendar, Camera, Check, ChevronRight, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { memoriesApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import {
|
||||
groupPhotosByDate,
|
||||
sortProviderPhotos,
|
||||
type GeoPoint,
|
||||
} from '../../pages/journeyDetail/JourneyDetailPage.helpers';
|
||||
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore';
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker';
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger';
|
||||
|
||||
export type ProviderPhotoGroup = { assetIds: string[]; passphrase?: string; mediaTypes?: string[] }
|
||||
export type ProviderPhotoGroup = { assetIds: string[]; passphrase?: string; mediaTypes?: string[] };
|
||||
|
||||
export function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd, initialDate, contextLocation, initialEntryId, embedded = false }: {
|
||||
provider: string
|
||||
userId: number
|
||||
entries: JourneyEntry[]
|
||||
trips: JourneyTrip[]
|
||||
existingAssetIds: Set<string>
|
||||
onClose: () => void
|
||||
onAdd: (groups: ProviderPhotoGroup[], entryId: number | null) => Promise<void>
|
||||
initialDate?: string
|
||||
contextLocation?: (GeoPoint & { name?: string }) | null
|
||||
initialEntryId?: number | null
|
||||
embedded?: boolean
|
||||
export function ProviderPicker({
|
||||
provider,
|
||||
userId,
|
||||
entries,
|
||||
trips,
|
||||
existingAssetIds,
|
||||
onClose,
|
||||
onAdd,
|
||||
initialDate,
|
||||
contextLocation,
|
||||
initialEntryId,
|
||||
embedded = false,
|
||||
}: {
|
||||
provider: string;
|
||||
userId: number;
|
||||
entries: JourneyEntry[];
|
||||
trips: JourneyTrip[];
|
||||
existingAssetIds: Set<string>;
|
||||
onClose: () => void;
|
||||
onAdd: (groups: ProviderPhotoGroup[], entryId: number | null) => Promise<void>;
|
||||
initialDate?: string;
|
||||
contextLocation?: (GeoPoint & { name?: string }) | null;
|
||||
initialEntryId?: number | null;
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [filter, setFilter] = useState<'day' | 'trip' | 'custom' | 'all' | 'album'>(initialDate ? 'day' : 'trip')
|
||||
const [photos, setPhotos] = useState<any[]>([])
|
||||
const [albums, setAlbums] = useState<Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>>([])
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
|
||||
const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState<string | undefined>(undefined)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [searchPage, setSearchPage] = useState(1)
|
||||
const [searchFrom, setSearchFrom] = useState('')
|
||||
const [searchTo, setSearchTo] = useState('')
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(new Map())
|
||||
const [customFrom, setCustomFrom] = useState('')
|
||||
const [customTo, setCustomTo] = useState('')
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(initialEntryId ?? null)
|
||||
const [addToOpen, setAddToOpen] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState<'day' | 'trip' | 'custom' | 'all' | 'album'>(initialDate ? 'day' : 'trip');
|
||||
const [photos, setPhotos] = useState<any[]>([]);
|
||||
const [albums, setAlbums] = useState<
|
||||
Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>
|
||||
>([]);
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null);
|
||||
const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState<string | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [searchPage, setSearchPage] = useState(1);
|
||||
const [searchFrom, setSearchFrom] = useState('');
|
||||
const [searchTo, setSearchTo] = useState('');
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(
|
||||
new Map()
|
||||
);
|
||||
const [customFrom, setCustomFrom] = useState('');
|
||||
const [customTo, setCustomTo] = useState('');
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(initialEntryId ?? null);
|
||||
const [addToOpen, setAddToOpen] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// compute trip range
|
||||
const tripRange = useMemo(() => {
|
||||
let from = '', to = ''
|
||||
let from = '',
|
||||
to = '';
|
||||
for (const t of trips) {
|
||||
if (t.start_date && (!from || t.start_date < from)) from = t.start_date
|
||||
if (t.end_date && (!to || t.end_date > to)) to = t.end_date
|
||||
if (t.start_date && (!from || t.start_date < from)) from = t.start_date;
|
||||
if (t.end_date && (!to || t.end_date > to)) to = t.end_date;
|
||||
}
|
||||
return { from, to }
|
||||
}, [trips])
|
||||
return { from, to };
|
||||
}, [trips]);
|
||||
|
||||
const cancelPending = () => {
|
||||
if (abortRef.current) { abortRef.current.abort() }
|
||||
abortRef.current = new AbortController()
|
||||
return abortRef.current.signal
|
||||
}
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
abortRef.current = new AbortController();
|
||||
return abortRef.current.signal;
|
||||
};
|
||||
|
||||
const searchPhotos = async (from: string, to: string, page: number = 1, append: boolean = false) => {
|
||||
const signal = cancelPending()
|
||||
if (page === 1) { setLoading(true); setPhotos([]) } else { setLoadingMore(true) }
|
||||
setSearchFrom(from)
|
||||
setSearchTo(to)
|
||||
setSearchPage(page)
|
||||
const signal = cancelPending();
|
||||
if (page === 1) {
|
||||
setLoading(true);
|
||||
setPhotos([]);
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
}
|
||||
setSearchFrom(from);
|
||||
setSearchTo(to);
|
||||
setSearchPage(page);
|
||||
try {
|
||||
const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal)
|
||||
const assets = data.assets || []
|
||||
setPhotos(prev => append ? [...prev, ...assets] : assets)
|
||||
setHasMore(!!data.hasMore)
|
||||
const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal);
|
||||
const assets = data.assets || [];
|
||||
setPhotos((prev) => (append ? [...prev, ...assets] : assets));
|
||||
setHasMore(!!data.hasMore);
|
||||
} catch {
|
||||
// A cancelled request is about to be replaced by the next one, so leave
|
||||
// its paging state alone.
|
||||
if (!signal.aborted) setHasMore(false)
|
||||
if (!signal.aborted) setHasMore(false);
|
||||
}
|
||||
if (!signal.aborted) { setLoading(false); setLoadingMore(false) }
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMorePhotos = () => {
|
||||
if (loadingMore || !hasMore) return
|
||||
searchPhotos(searchFrom, searchTo, searchPage + 1, true)
|
||||
}
|
||||
if (loadingMore || !hasMore) return;
|
||||
searchPhotos(searchFrom, searchTo, searchPage + 1, true);
|
||||
};
|
||||
|
||||
const loadAlbumPhotos = async (album: { id: string; passphrase?: string }) => {
|
||||
const signal = cancelPending()
|
||||
setLoading(true)
|
||||
setPhotos([])
|
||||
setHasMore(false)
|
||||
const signal = cancelPending();
|
||||
setLoading(true);
|
||||
setPhotos([]);
|
||||
setHasMore(false);
|
||||
try {
|
||||
setPhotos((await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || [])
|
||||
} catch { /* ignore */ }
|
||||
if (!signal.aborted) setLoading(false)
|
||||
}
|
||||
setPhotos((await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || []);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (!signal.aborted) setLoading(false);
|
||||
};
|
||||
|
||||
const loadAlbums = async () => {
|
||||
try {
|
||||
setAlbums((await memoriesApi.albums(provider)).albums || [])
|
||||
setAlbums((await memoriesApi.albums(provider)).albums || []);
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
// load on mount / filter change
|
||||
useEffect(() => {
|
||||
if (filter === 'day' && initialDate) {
|
||||
searchPhotos(initialDate, initialDate)
|
||||
searchPhotos(initialDate, initialDate);
|
||||
} else if (filter === 'trip' && tripRange.from && tripRange.to) {
|
||||
searchPhotos(tripRange.from, tripRange.to)
|
||||
searchPhotos(tripRange.from, tripRange.to);
|
||||
} else if (filter === 'all') {
|
||||
searchPhotos('', '')
|
||||
searchPhotos('', '');
|
||||
} else if (filter === 'album' && albums.length === 0) {
|
||||
loadAlbums()
|
||||
loadAlbums();
|
||||
}
|
||||
}, [filter])
|
||||
}, [filter]);
|
||||
|
||||
const handleCustomSearch = () => {
|
||||
if (customFrom && customTo) searchPhotos(customFrom, customTo)
|
||||
}
|
||||
if (customFrom && customTo) searchPhotos(customFrom, customTo);
|
||||
};
|
||||
|
||||
const sortedPhotos = useMemo(
|
||||
() => sortProviderPhotos(photos, contextLocation),
|
||||
[photos, contextLocation?.lat, contextLocation?.lng],
|
||||
)
|
||||
[photos, contextLocation?.lat, contextLocation?.lng]
|
||||
);
|
||||
|
||||
const toggleAsset = (id: string) => {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev)
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
next.delete(id);
|
||||
} else {
|
||||
const mediaType = (photos as any[]).find(p => p.id === id)?.mediaType
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType })
|
||||
const mediaType = (photos as any[]).find((p) => p.id === id)?.mediaType;
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType });
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const targetLabel = targetEntryId
|
||||
? entries.find(e => e.id === targetEntryId)?.title || entries.find(e => e.id === targetEntryId)?.entry_date || t('journey.stats.entries')
|
||||
: t('journey.picker.newGallery')
|
||||
? entries.find((e) => e.id === targetEntryId)?.title ||
|
||||
entries.find((e) => e.id === targetEntryId)?.entry_date ||
|
||||
t('journey.stats.entries')
|
||||
: t('journey.picker.newGallery');
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={embedded ? 'journey-provider-picker-embedded' : undefined}
|
||||
className={embedded
|
||||
? 'w-full h-full min-h-0 flex flex-col overflow-hidden'
|
||||
: 'fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]'}
|
||||
className={
|
||||
embedded
|
||||
? 'flex h-full min-h-0 w-full flex-col overflow-hidden'
|
||||
: 'fixed inset-0 z-[9999] flex items-end justify-center overscroll-none bg-[rgba(9,9,11,0.75)] md:items-center md:p-5'
|
||||
}
|
||||
role="presentation"
|
||||
onClick={embedded ? undefined : onClose}
|
||||
onTouchMove={e => { if (!embedded && e.target === e.currentTarget) e.preventDefault() }}
|
||||
onTouchMove={(e) => {
|
||||
if (!embedded && e.target === e.currentTarget) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={embedded
|
||||
? 'bg-white dark:bg-zinc-900 w-full h-full flex flex-col overflow-hidden'
|
||||
: 'bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden'}
|
||||
className={
|
||||
embedded
|
||||
? 'flex h-full w-full flex-col overflow-hidden bg-white dark:bg-zinc-900'
|
||||
: 'flex max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] w-full max-w-[720px] flex-col overflow-hidden rounded-t-2xl bg-white shadow-[0_20px_40px_rgba(0,0,0,0.2)] md:max-h-[85vh] md:max-w-[960px] md:rounded-2xl dark:bg-zinc-900'
|
||||
}
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
{/* Header */}
|
||||
{!embedded && <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
||||
{provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider}
|
||||
</h2>
|
||||
<button type="button" onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>}
|
||||
{!embedded && (
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
||||
{provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
<div className="flex-shrink-0 border-b border-zinc-200 px-6 py-3 dark:border-zinc-700">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1.5 mb-3">
|
||||
<div className="mb-3 flex gap-1.5">
|
||||
{[
|
||||
...(initialDate ? [{ id: 'day' as const, label: t('journey.picker.day') || 'This day' }] : []),
|
||||
{ id: 'trip' as const, label: t('journey.picker.tripPeriod') },
|
||||
{ id: 'custom' as const, label: t('journey.picker.dateRange') },
|
||||
{ id: 'all' as const, label: t('journey.picker.allPhotos'), short: t('common.all') },
|
||||
{ id: 'album' as const, label: t('journey.picker.albums') },
|
||||
].map(f => (
|
||||
<button type="button"
|
||||
].map((f) => (
|
||||
<button
|
||||
type="button"
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-[12px] font-medium transition-colors ${
|
||||
className={`rounded-lg px-3 py-1.5 text-[12px] font-medium transition-colors ${
|
||||
filter === f.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
@@ -192,180 +239,252 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
<span className="hidden sm:inline">{f.label}</span>
|
||||
<span className="sm:hidden">{f.short}</span>
|
||||
</>
|
||||
) : f.label}
|
||||
) : (
|
||||
f.label
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filter content — always visible row */}
|
||||
{(!embedded || filter !== 'day') && <div className="min-h-[36px] flex items-center">
|
||||
{filter === 'day' && initialDate && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(initialDate + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
{contextLocation?.name && <span className="text-zinc-400">· near {contextLocation.name}</span>}
|
||||
</div>
|
||||
)}
|
||||
{filter === 'trip' && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
{tripRange.from && tripRange.to ? (
|
||||
<>
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
<span className="text-zinc-400">—</span>
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
<span className="ml-1 text-zinc-400">
|
||||
({Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) + 1} days)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(!embedded || filter !== 'day') && (
|
||||
<div className="flex min-h-[36px] items-center">
|
||||
{filter === 'day' && initialDate && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(initialDate + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
{contextLocation?.name && <span className="text-zinc-400">· near {contextLocation.name}</span>}
|
||||
</div>
|
||||
)}
|
||||
{filter === 'trip' && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
{tripRange.from && tripRange.to ? (
|
||||
<>
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="text-zinc-400">—</span>
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="ml-1 text-zinc-400">
|
||||
(
|
||||
{Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) +
|
||||
1}{' '}
|
||||
days)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filter === 'custom' && (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div className="flex-1"><DatePicker value={customFrom} onChange={setCustomFrom} /></div>
|
||||
<span className="text-zinc-400 text-[12px]">—</span>
|
||||
<div className="flex-1"><DatePicker value={customTo} onChange={setCustomTo} /></div>
|
||||
<button type="button" onClick={handleCustomSearch}
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[12px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 flex-shrink-0">
|
||||
{t('journey.picker.search')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filter === 'album' && (
|
||||
<div className="flex gap-2 overflow-x-auto flex-1">
|
||||
{albums.map((a: any) => (
|
||||
<button type="button"
|
||||
key={a.id}
|
||||
onClick={() => { setSelectedAlbum(a.id); setSelectedAlbumPassphrase(a.passphrase); loadAlbumPhotos(a) }}
|
||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap flex-shrink-0 border ${
|
||||
selectedAlbum === a.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
|
||||
: 'border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
{filter === 'custom' && (
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<DatePicker value={customFrom} onChange={setCustomFrom} />
|
||||
</div>
|
||||
<span className="text-[12px] text-zinc-400">—</span>
|
||||
<div className="flex-1">
|
||||
<DatePicker value={customTo} onChange={setCustomTo} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCustomSearch}
|
||||
className="flex-shrink-0 rounded-lg bg-zinc-900 px-3 py-1.5 text-[12px] font-medium text-white hover:bg-zinc-800 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100"
|
||||
>
|
||||
{a.albumName || a.name || 'Album'}{a.assetCount != null ? ` (${a.assetCount})` : ''}
|
||||
{t('journey.picker.search')}
|
||||
</button>
|
||||
))}
|
||||
{albums.length === 0 && !loading && <span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filter === 'album' && (
|
||||
<div className="flex flex-1 gap-2 overflow-x-auto">
|
||||
{albums.map((a: any) => (
|
||||
<button
|
||||
type="button"
|
||||
key={a.id}
|
||||
onClick={() => {
|
||||
setSelectedAlbum(a.id);
|
||||
setSelectedAlbumPassphrase(a.passphrase);
|
||||
loadAlbumPhotos(a);
|
||||
}}
|
||||
className={`flex-shrink-0 rounded-lg border px-2.5 py-1 text-[11px] font-medium whitespace-nowrap ${
|
||||
selectedAlbum === a.id
|
||||
? 'border-zinc-900 bg-zinc-900 text-white dark:border-white dark:bg-white dark:text-zinc-900'
|
||||
: 'border-zinc-200 text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{a.albumName || a.name || 'Album'}
|
||||
{a.assetCount != null ? ` (${a.assetCount})` : ''}
|
||||
</button>
|
||||
))}
|
||||
{albums.length === 0 && !loading && (
|
||||
<span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add-to entry selector */}
|
||||
{!embedded && <div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
|
||||
<button type="button"
|
||||
onClick={() => setAddToOpen(!addToOpen)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[12px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<span className={targetEntryId ? '' : 'font-semibold'}>{targetLabel}</span>
|
||||
<ChevronRight size={12} className="rotate-90 text-zinc-400" />
|
||||
</button>
|
||||
{addToOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9]" role="presentation" onClick={() => setAddToOpen(false)} />
|
||||
<div className="absolute left-12 top-full mt-1 z-10 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg py-1.5 min-w-[200px] max-h-[240px] overflow-y-auto">
|
||||
<button type="button"
|
||||
onClick={() => { setTargetEntryId(null); setAddToOpen(false) }}
|
||||
className={`w-full text-left px-3 py-2 text-[12px] flex items-center gap-2 ${
|
||||
!targetEntryId
|
||||
? 'bg-zinc-100 dark:bg-zinc-700 font-semibold text-zinc-900 dark:text-white'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<Camera size={12} />
|
||||
{t('journey.picker.newGallery')}
|
||||
</button>
|
||||
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').length > 0 && (
|
||||
<div className="h-px bg-zinc-200 dark:bg-zinc-700 my-1" />
|
||||
)}
|
||||
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').map(e => (
|
||||
<button type="button"
|
||||
key={e.id}
|
||||
onClick={() => { setTargetEntryId(e.id); setAddToOpen(false) }}
|
||||
className={`w-full text-left px-3 py-2 text-[12px] truncate ${
|
||||
targetEntryId === e.id
|
||||
? 'bg-zinc-100 dark:bg-zinc-700 font-semibold text-zinc-900 dark:text-white'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
|
||||
{!embedded && (
|
||||
<div className="flex-shrink-0 border-b border-zinc-200 bg-zinc-50 px-6 py-2.5 dark:border-zinc-700 dark:bg-zinc-800/50">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold tracking-[0.12em] text-zinc-500 uppercase">
|
||||
{t('journey.picker.addTo')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddToOpen(!addToOpen)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-zinc-200 px-3 py-1.5 text-[12px] font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<span className={targetEntryId ? '' : 'font-semibold'}>{targetLabel}</span>
|
||||
<ChevronRight size={12} className="rotate-90 text-zinc-400" />
|
||||
</button>
|
||||
{addToOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9]" role="presentation" onClick={() => setAddToOpen(false)} />
|
||||
<div className="absolute top-full left-12 z-10 mt-1 max-h-[240px] min-w-[200px] overflow-y-auto rounded-xl border border-zinc-200 bg-white py-1.5 shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTargetEntryId(null);
|
||||
setAddToOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] ${
|
||||
!targetEntryId
|
||||
? 'bg-zinc-100 font-semibold text-zinc-900 dark:bg-zinc-700 dark:text-white'
|
||||
: 'text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{e.title || e.location_name || new Date(e.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' })}
|
||||
<Camera size={12} />
|
||||
{t('journey.picker.newGallery')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{entries.filter(
|
||||
(e) => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]'
|
||||
).length > 0 && <div className="my-1 h-px bg-zinc-200 dark:bg-zinc-700" />}
|
||||
{entries
|
||||
.filter((e) => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]')
|
||||
.map((e) => (
|
||||
<button
|
||||
type="button"
|
||||
key={e.id}
|
||||
onClick={() => {
|
||||
setTargetEntryId(e.id);
|
||||
setAddToOpen(false);
|
||||
}}
|
||||
className={`w-full truncate px-3 py-2 text-left text-[12px] ${
|
||||
targetEntryId === e.id
|
||||
? 'bg-zinc-100 font-semibold text-zinc-900 dark:bg-zinc-700 dark:text-white'
|
||||
: 'text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{e.title ||
|
||||
e.location_name ||
|
||||
new Date(e.entry_date + 'T12:00:00').toLocaleDateString(undefined, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
)}
|
||||
|
||||
{/* Select all bar — sticky above grid */}
|
||||
{!loading && sortedPhotos.length > 0 && (() => {
|
||||
const selectable = sortedPhotos.filter((a: any) => !existingAssetIds.has(a.id))
|
||||
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
|
||||
if (selectable.length === 0) return null
|
||||
return (
|
||||
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex-shrink-0">
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
setSelected(new Map())
|
||||
} else {
|
||||
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType: a.mediaType }])))
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
|
||||
allSelected
|
||||
? 'bg-zinc-900 dark:bg-white border-zinc-900 dark:border-white'
|
||||
: 'border-zinc-300 dark:border-zinc-600'
|
||||
}`}>
|
||||
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
|
||||
</div>
|
||||
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{!loading &&
|
||||
sortedPhotos.length > 0 &&
|
||||
(() => {
|
||||
const selectable = sortedPhotos.filter((a: any) => !existingAssetIds.has(a.id));
|
||||
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id));
|
||||
if (selectable.length === 0) return null;
|
||||
return (
|
||||
<div className="flex-shrink-0 border-b border-zinc-200 bg-white px-4 py-2 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
setSelected(new Map());
|
||||
} else {
|
||||
setSelected(
|
||||
new Map(
|
||||
selectable.map((a: any) => [
|
||||
a.id,
|
||||
{
|
||||
albumId: selectedAlbum ?? undefined,
|
||||
passphrase: selectedAlbumPassphrase,
|
||||
mediaType: a.mediaType,
|
||||
},
|
||||
])
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 px-2.5 py-1 text-[11px] font-medium text-zinc-500 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<div
|
||||
className={`flex h-3.5 w-3.5 items-center justify-center rounded border ${
|
||||
allSelected
|
||||
? 'border-zinc-900 bg-zinc-900 dark:border-white dark:bg-white'
|
||||
: 'border-zinc-300 dark:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
|
||||
</div>
|
||||
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Photo grid */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain p-4 min-h-0">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-900" />
|
||||
</div>
|
||||
) : sortedPhotos.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-[13px] text-zinc-500">
|
||||
{filter === 'trip' && !tripRange.from ? t('journey.trips.noTripsLinkedSettings') : t('journey.detail.noPhotos')}
|
||||
{filter === 'trip' && !tripRange.from
|
||||
? t('journey.trips.noTripsLinkedSettings')
|
||||
: t('journey.detail.noPhotos')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{groupPhotosByDate(sortedPhotos).map(group => (
|
||||
{groupPhotosByDate(sortedPhotos).map((group) => (
|
||||
<div key={group.date}>
|
||||
{(!embedded || filter !== 'day') && (
|
||||
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 mb-2 mt-4 first:mt-0">
|
||||
<p className="mt-4 mb-2 text-[11px] font-medium text-zinc-500 first:mt-0 dark:text-zinc-400">
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5 mb-1">
|
||||
<div className="mb-1 grid grid-cols-3 gap-1.5 sm:grid-cols-4 md:grid-cols-5">
|
||||
{group.assets.map((asset: any) => {
|
||||
const isSelected = selected.has(asset.id)
|
||||
const alreadyAdded = existingAssetIds.has(asset.id)
|
||||
const isSelected = selected.has(asset.id);
|
||||
const alreadyAdded = existingAssetIds.has(asset.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -374,42 +493,42 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
aria-pressed={isSelected}
|
||||
aria-label={asset.city || group.label}
|
||||
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
|
||||
className={`relative block w-full aspect-square rounded-lg overflow-hidden ${
|
||||
className={`relative block aspect-square w-full overflow-hidden rounded-lg ${
|
||||
alreadyAdded
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: isSelected
|
||||
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
|
||||
? 'cursor-pointer ring-2 ring-zinc-900 ring-offset-2 dark:ring-white dark:ring-offset-zinc-900'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
onError={e => {
|
||||
const img = e.currentTarget
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`
|
||||
if (!img.src.includes('/original')) img.src = original
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`;
|
||||
if (!img.src.includes('/original')) img.src = original;
|
||||
}}
|
||||
/>
|
||||
{alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
|
||||
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-500 text-white">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{isSelected && !alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
|
||||
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-900 text-white dark:bg-white dark:text-zinc-900">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{asset.city && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
|
||||
<p className="text-[8px] text-white truncate">{asset.city}</p>
|
||||
<div className="absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/50 to-transparent p-1">
|
||||
<p className="truncate text-[8px] text-white">{asset.city}</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -421,29 +540,40 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
|
||||
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-t border-zinc-200 bg-zinc-50 px-6 py-4 dark:border-zinc-700 dark:bg-zinc-800/50">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-200/60 px-2.5 py-1 text-[11px] leading-none text-zinc-500 dark:bg-zinc-700/60 dark:text-zinc-400">
|
||||
<span className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-zinc-900 px-1 text-[10px] leading-none font-bold text-white dark:bg-white dark:text-zinc-900">
|
||||
{selected.size}
|
||||
</span>
|
||||
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-zinc-200 px-3.5 py-2 text-[13px] font-medium text-zinc-700 hover:bg-zinc-100 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-700"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const groupMap = new Map<string | undefined, { assetIds: string[]; mediaTypes: string[] }>()
|
||||
const groupMap = new Map<string | undefined, { assetIds: string[]; mediaTypes: string[] }>();
|
||||
for (const [assetId, { passphrase, mediaType }] of selected.entries()) {
|
||||
const g = groupMap.get(passphrase) || { assetIds: [], mediaTypes: [] }
|
||||
g.assetIds.push(assetId)
|
||||
g.mediaTypes.push(mediaType === 'video' ? 'video' : 'image')
|
||||
groupMap.set(passphrase, g)
|
||||
const g = groupMap.get(passphrase) || { assetIds: [], mediaTypes: [] };
|
||||
g.assetIds.push(assetId);
|
||||
g.mediaTypes.push(mediaType === 'video' ? 'video' : 'image');
|
||||
groupMap.set(passphrase, g);
|
||||
}
|
||||
const groups = [...groupMap.entries()].map(([passphrase, g]) => ({ assetIds: g.assetIds, mediaTypes: g.mediaTypes, passphrase }))
|
||||
onAdd(groups, targetEntryId)
|
||||
const groups = [...groupMap.entries()].map(([passphrase, g]) => ({
|
||||
assetIds: g.assetIds,
|
||||
mediaTypes: g.mediaTypes,
|
||||
passphrase,
|
||||
}));
|
||||
onAdd(groups, targetEntryId);
|
||||
}}
|
||||
disabled={selected.size === 0}
|
||||
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
className="rounded-lg bg-zinc-900 px-3.5 py-2 text-[13px] font-medium text-white hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100"
|
||||
>
|
||||
{t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
</button>
|
||||
@@ -451,5 +581,5 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
import { GRADIENTS } from './JourneyDetailPage.constants'
|
||||
import { GeoOnceError } from '../../hooks/useGeolocation'
|
||||
import { GeoOnceError } from '../../hooks/useGeolocation';
|
||||
import type { JourneyEntry } from '../../store/journeyStore';
|
||||
import { GRADIENTS } from './JourneyDetailPage.constants';
|
||||
|
||||
// Shared by the desktop entry editor and the mobile entry sheet so a failed
|
||||
// one-shot position fix surfaces the same translated message everywhere.
|
||||
export function geoOnceErrorKey(err: unknown): string {
|
||||
const code = err instanceof GeoOnceError ? err.code : 'unavailable'
|
||||
return code === 'permission-denied' ? 'journey.editor.locationPermissionDenied'
|
||||
: code === 'timeout' ? 'journey.editor.locationTimeout'
|
||||
: code === 'insecure-context' ? 'journey.editor.locationInsecureContext'
|
||||
: 'journey.editor.locationUnavailable'
|
||||
const code = err instanceof GeoOnceError ? err.code : 'unavailable';
|
||||
return code === 'permission-denied'
|
||||
? 'journey.editor.locationPermissionDenied'
|
||||
: code === 'timeout'
|
||||
? 'journey.editor.locationTimeout'
|
||||
: code === 'insecure-context'
|
||||
? 'journey.editor.locationInsecureContext'
|
||||
: 'journey.editor.locationUnavailable';
|
||||
}
|
||||
|
||||
export function pickGradient(id: number): string {
|
||||
return GRADIENTS[id % GRADIENTS.length]
|
||||
return GRADIENTS[id % GRADIENTS.length];
|
||||
}
|
||||
|
||||
export function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
|
||||
const groups = new Map<string, JourneyEntry[]>()
|
||||
const groups = new Map<string, JourneyEntry[]>();
|
||||
for (const e of entries) {
|
||||
const d = e.entry_date
|
||||
if (!groups.has(d)) groups.set(d, [])
|
||||
groups.get(d)!.push(e)
|
||||
const d = e.entry_date;
|
||||
if (!groups.has(d)) groups.set(d, []);
|
||||
groups.get(d)!.push(e);
|
||||
}
|
||||
return groups
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function createDraftJourneyEntry(journeyId: number, now = new Date()): JourneyEntry {
|
||||
const entryDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
const entryTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
||||
const entryDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
const entryTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
return {
|
||||
id: 0,
|
||||
journey_id: journeyId,
|
||||
@@ -41,30 +44,30 @@ export function createDraftJourneyEntry(journeyId: number, now = new Date()): Jo
|
||||
photos: [],
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
|
||||
const date = new Date(d + 'T00:00:00')
|
||||
const date = new Date(d + 'T00:00:00');
|
||||
// Pass the app's selected locale so weekday/month follow the UI language
|
||||
// instead of the browser's navigator.language.
|
||||
return {
|
||||
weekday: date.toLocaleDateString(locale, { weekday: 'long' }),
|
||||
month: date.toLocaleDateString(locale, { month: 'long' }),
|
||||
day: date.getDate(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function photoUrl(p: { photo_id: number }, size: 'thumbnail' | 'original' = 'thumbnail'): string {
|
||||
return `/api/photos/${p.photo_id}/${size}`
|
||||
return `/api/photos/${p.photo_id}/${size}`;
|
||||
}
|
||||
|
||||
export function groupPhotosByDate(photos: any[]): { date: string; label: string; assets: any[] }[] {
|
||||
const map = new Map<string, any[]>()
|
||||
const map = new Map<string, any[]>();
|
||||
for (const asset of photos) {
|
||||
const key = asset.takenAt ? asset.takenAt.slice(0, 10) : '__unknown__'
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(asset)
|
||||
const key = asset.takenAt ? asset.takenAt.slice(0, 10) : '__unknown__';
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(asset);
|
||||
}
|
||||
// Group order must not depend on the order of `photos`. Once the caller sorts by
|
||||
// distance to the entry, first-seen order would put the day holding the nearest
|
||||
@@ -72,47 +75,59 @@ export function groupPhotosByDate(photos: any[]): { date: string; label: string;
|
||||
// within a day is left as handed in, so the distance sort still applies there.
|
||||
return [...map.entries()]
|
||||
.sort((a, b) => {
|
||||
if (a[0] === '__unknown__') return 1
|
||||
if (b[0] === '__unknown__') return -1
|
||||
return b[0].localeCompare(a[0])
|
||||
if (a[0] === '__unknown__') return 1;
|
||||
if (b[0] === '__unknown__') return -1;
|
||||
return b[0].localeCompare(a[0]);
|
||||
})
|
||||
.map(([date, assets]) => ({
|
||||
date,
|
||||
label: date === '__unknown__'
|
||||
? 'Unknown date'
|
||||
: new Date(date + 'T00:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }),
|
||||
assets,
|
||||
}))
|
||||
date,
|
||||
label:
|
||||
date === '__unknown__'
|
||||
? 'Unknown date'
|
||||
: new Date(date + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}),
|
||||
assets,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface ProviderPhotoAsset {
|
||||
id: string
|
||||
takenAt?: string | null
|
||||
lat?: number | null
|
||||
lng?: number | null
|
||||
[key: string]: unknown
|
||||
id: string;
|
||||
takenAt?: string | null;
|
||||
lat?: number | null;
|
||||
lng?: number | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface GeoPoint {
|
||||
lat: number
|
||||
lng: number
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export function isValidGeoPoint(point: Partial<GeoPoint> | null | undefined): point is GeoPoint {
|
||||
return !!point && Number.isFinite(point.lat) && Number.isFinite(point.lng)
|
||||
&& point.lat >= -90 && point.lat <= 90 && point.lng >= -180 && point.lng <= 180
|
||||
return (
|
||||
!!point &&
|
||||
Number.isFinite(point.lat) &&
|
||||
Number.isFinite(point.lng) &&
|
||||
point.lat >= -90 &&
|
||||
point.lat <= 90 &&
|
||||
point.lng >= -180 &&
|
||||
point.lng <= 180
|
||||
);
|
||||
}
|
||||
|
||||
/** Return the great-circle distance in metres between two coordinates. */
|
||||
export function distanceBetweenGeoPoints(a: GeoPoint, b: GeoPoint): number {
|
||||
const earthRadius = 6371000
|
||||
const toRadians = (value: number) => value * Math.PI / 180
|
||||
const dLat = toRadians(b.lat - a.lat)
|
||||
const dLng = toRadians(b.lng - a.lng)
|
||||
const latA = toRadians(a.lat)
|
||||
const latB = toRadians(b.lat)
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(latA) * Math.cos(latB) * Math.sin(dLng / 2) ** 2
|
||||
return 2 * earthRadius * Math.asin(Math.sqrt(Math.min(1, h)))
|
||||
const earthRadius = 6371000;
|
||||
const toRadians = (value: number) => (value * Math.PI) / 180;
|
||||
const dLat = toRadians(b.lat - a.lat);
|
||||
const dLng = toRadians(b.lng - a.lng);
|
||||
const latA = toRadians(a.lat);
|
||||
const latB = toRadians(b.lat);
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(latA) * Math.cos(latB) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * earthRadius * Math.asin(Math.sqrt(Math.min(1, h)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,7 +135,7 @@ export function distanceBetweenGeoPoints(a: GeoPoint, b: GeoPoint): number {
|
||||
* Journey location. The original provider order is the stable fallback.
|
||||
*/
|
||||
export function sortProviderPhotos<T extends ProviderPhotoAsset>(photos: T[], location?: GeoPoint | null): T[] {
|
||||
if (!isValidGeoPoint(location)) return photos
|
||||
if (!isValidGeoPoint(location)) return photos;
|
||||
|
||||
return photos
|
||||
.map((photo, index) => ({
|
||||
@@ -131,10 +146,10 @@ export function sortProviderPhotos<T extends ProviderPhotoAsset>(photos: T[], lo
|
||||
: null,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.distance === null && b.distance === null) return a.index - b.index
|
||||
if (a.distance === null) return 1
|
||||
if (b.distance === null) return -1
|
||||
return a.distance - b.distance || a.index - b.index
|
||||
if (a.distance === null && b.distance === null) return a.index - b.index;
|
||||
if (a.distance === null) return 1;
|
||||
if (b.distance === null) return -1;
|
||||
return a.distance - b.distance || a.index - b.index;
|
||||
})
|
||||
.map(item => item.photo)
|
||||
.map((item) => item.photo);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user