feat: add core domain types for Photo Gallery SDK including media items, albums, and annotations
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
'use client';
|
||||
|
||||
import { formatDay } from '../../lib/format';
|
||||
import { groupByTime } from '../../lib/grouping';
|
||||
import { resolveLabel } from '../../lib/smartAlbums';
|
||||
import { Icon, type IconName } from '../../icons';
|
||||
import { useGallery, useGalleryStoreApi } from '../../store/context';
|
||||
import { albumMedia, liveMedia, objectLabelCounts } from '../../store/selectors';
|
||||
import type { MediaItem, ViewId } from '../../types';
|
||||
import { openContextMenu } from '../ContextMenu';
|
||||
import { confirmAction, promptAlbumName } from '../modals';
|
||||
|
||||
function SectionHeader({
|
||||
title,
|
||||
chevronTo,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
chevronTo?: () => void;
|
||||
action?: { label: string; onClick: () => void };
|
||||
}) {
|
||||
return (
|
||||
<div className="apg-collections__header">
|
||||
<div
|
||||
className="apg-collections__title"
|
||||
onClick={chevronTo}
|
||||
role={chevronTo ? 'button' : undefined}
|
||||
tabIndex={chevronTo ? 0 : undefined}
|
||||
onKeyDown={
|
||||
chevronTo
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
chevronTo();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{title}
|
||||
{chevronTo ? <Icon name="chevron-right" size={18} /> : null}
|
||||
</div>
|
||||
{action ? (
|
||||
<button type="button" className="apg-collections__action" onClick={action.onClick}>
|
||||
{action.label}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PinnedCard({
|
||||
label,
|
||||
cover,
|
||||
badge,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
cover?: MediaItem;
|
||||
badge?: IconName;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className="apg-pinned-card" onClick={onClick} aria-label={label}>
|
||||
{cover ? <img src={cover.thumbnail ?? cover.src} alt="" draggable={false} /> : null}
|
||||
{badge ? (
|
||||
<span className="apg-pinned-card__badge">
|
||||
<Icon name={badge} size={16} />
|
||||
</span>
|
||||
) : null}
|
||||
<span className="apg-pinned-card__label">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyCard({
|
||||
icon,
|
||||
title,
|
||||
text,
|
||||
}: {
|
||||
icon: IconName;
|
||||
title: string;
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="apg-empty-card">
|
||||
<span className="apg-empty-card__icon">
|
||||
<Icon name={icon} size={30} />
|
||||
</span>
|
||||
<div className="apg-empty-card__title">{title}</div>
|
||||
<div className="apg-empty-card__text">{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollectionsView() {
|
||||
const api = useGalleryStoreApi();
|
||||
const media = useGallery((s) => s.media);
|
||||
const albums = useGallery((s) => s.albums);
|
||||
const labelAliases = useGallery((s) => s.labelAliases);
|
||||
const live = liveMedia(media);
|
||||
|
||||
const userAlbums = albums.filter((a) => a.kind === 'user' || a.kind === 'folder');
|
||||
const go = (v: ViewId) => () => api.getState().setView(v);
|
||||
|
||||
const recentDays = groupByTime(live, 'day').slice(0, 8);
|
||||
const objectEntries = [...objectLabelCounts(media, labelAliases).entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 14);
|
||||
|
||||
// Right-click an object card → permanently rename its tag (car → excavator).
|
||||
const renameTag = (label: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, [
|
||||
{
|
||||
label: 'Rename Tag',
|
||||
icon: 'tag',
|
||||
onClick: () =>
|
||||
promptAlbumName('Rename Tag', label, (name) => api.getState().renameLabel(label, name), {
|
||||
placeholder: 'Tag name',
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Delete Tag',
|
||||
icon: 'trash',
|
||||
danger: true,
|
||||
onClick: () =>
|
||||
confirmAction({
|
||||
title: 'Delete Tag',
|
||||
message: `Remove the "${label}" tag? It's deleted from all photos and won't be created again.`,
|
||||
confirmLabel: 'Delete',
|
||||
danger: true,
|
||||
onConfirm: () => api.getState().deleteLabel(label),
|
||||
}),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="apg-scroll">
|
||||
<div className="apg-collections">
|
||||
{/* Albums */}
|
||||
<section className="apg-collections__section">
|
||||
<SectionHeader
|
||||
title="Albums"
|
||||
action={{
|
||||
label: 'Create',
|
||||
onClick: () =>
|
||||
promptAlbumName('New Album', '', (name) => {
|
||||
const id = api.getState().createAlbum(name);
|
||||
api.getState().setView(`album:${id}` as ViewId);
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
{userAlbums.length === 0 ? (
|
||||
<EmptyCard
|
||||
icon="collections"
|
||||
title="No Albums Available"
|
||||
text="Albums will appear here when they are added to the library or synced."
|
||||
/>
|
||||
) : (
|
||||
<div className="apg-pinned-row">
|
||||
{userAlbums.map((a) => {
|
||||
const am = albumMedia(a, media);
|
||||
return (
|
||||
<PinnedCard
|
||||
key={a.id}
|
||||
label={a.name}
|
||||
cover={am.find((m) => m.id === a.coverId) ?? am[0]}
|
||||
onClick={go(a.id as ViewId)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Objects (AI) — auto categories from on-device object detection */}
|
||||
{objectEntries.length > 0 ? (
|
||||
<section className="apg-collections__section">
|
||||
<SectionHeader title="Objects" />
|
||||
<div className="apg-pinned-row">
|
||||
{objectEntries.map(([label, count]) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className="apg-pinned-card"
|
||||
onClick={() => api.getState().setView(`sys:obj:${label}` as ViewId)}
|
||||
onContextMenu={renameTag(label)}
|
||||
aria-label={`${count} photos containing ${label}`}
|
||||
>
|
||||
{(() => {
|
||||
// Match on the resolved label so the cover works for items still
|
||||
// stored under the original detector label.
|
||||
const cover = live.find((m) =>
|
||||
m.objectLabels.some((l) => resolveLabel(l, labelAliases) === label),
|
||||
);
|
||||
return cover ? <img src={cover.thumbnail ?? cover.src} alt="" draggable={false} /> : null;
|
||||
})()}
|
||||
<span className="apg-pinned-card__label" style={{ textTransform: 'capitalize' }}>
|
||||
{label}
|
||||
<span style={{ opacity: 0.8, fontWeight: 500 }}> · {count}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Shared Albums */}
|
||||
<section className="apg-collections__section">
|
||||
<SectionHeader title="Shared Albums" action={{ label: 'Start Sharing', onClick: () => api.getState().setView('shared-albums') }} />
|
||||
<EmptyCard
|
||||
icon="people"
|
||||
title="Shared Albums"
|
||||
text="Share photos and videos with just the people you choose, and let them add photos, videos and comments."
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Recent Days */}
|
||||
<section className="apg-collections__section">
|
||||
<SectionHeader title="Recent Days" />
|
||||
{recentDays.length === 0 ? (
|
||||
<EmptyCard
|
||||
icon="clock"
|
||||
title="No Days Available"
|
||||
text="Days will appear here when more photos and videos are added to the library."
|
||||
/>
|
||||
) : (
|
||||
<div className="apg-pinned-row">
|
||||
{recentDays.map((d) => (
|
||||
<PinnedCard
|
||||
key={d.key}
|
||||
label={formatDay(d.items[0]!.takenAt)}
|
||||
cover={d.items[0]}
|
||||
onClick={() => api.getState().openLightbox(d.items[0]!.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { Icon, type IconName } from '../../icons';
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
}: {
|
||||
icon?: IconName;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: { label: string; onClick: () => void };
|
||||
}) {
|
||||
return (
|
||||
<div className="apg-empty">
|
||||
<div className="apg-empty__card">
|
||||
{icon ? (
|
||||
<span style={{ color: 'var(--apg-text-tertiary)' }}>
|
||||
<Icon name={icon} size={42} />
|
||||
</span>
|
||||
) : null}
|
||||
<div className="apg-empty__title" style={{ fontSize: 26 }}>
|
||||
{title}
|
||||
</div>
|
||||
{subtitle ? <div className="apg-empty__subtitle">{subtitle}</div> : null}
|
||||
{action ? (
|
||||
<button type="button" className="apg-btn" onClick={action.onClick}>
|
||||
{action.label}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
import { useViewMedia } from '../../hooks/useViewMedia';
|
||||
import { findDuplicateGroups } from '../../lib/grouping';
|
||||
import { Icon } from '../../icons';
|
||||
import { useGallery, useGalleryStoreApi } from '../../store/context';
|
||||
import { albumById } from '../../store/selectors';
|
||||
import { MediaGrid } from '../MediaGrid';
|
||||
import { PhotoTile } from '../PhotoTile';
|
||||
import { EmptyState } from './EmptyState';
|
||||
|
||||
const EMPTY_COPY: Record<string, { title: string; subtitle: string; icon: any }> = {
|
||||
favourites: { title: 'No Favourites', subtitle: 'Tap the heart on a photo to add it here.', icon: 'heart' },
|
||||
'recently-saved': { title: 'Nothing Saved Yet', subtitle: 'Downloaded and shared media will appear here.', icon: 'download' },
|
||||
videos: { title: 'No Videos', subtitle: 'Imported videos will appear here.', icon: 'video' },
|
||||
screenshots: { title: 'No Screenshots', subtitle: 'Screenshots are detected automatically on import.', icon: 'screenshot' },
|
||||
search: { title: 'Search Your Library', subtitle: 'Search by name, place, tag or detected object.', icon: 'search' },
|
||||
};
|
||||
|
||||
/** Generic grid screen for the simple filtered views. */
|
||||
export function GridScreen() {
|
||||
const items = useViewMedia();
|
||||
const view = useGallery((s) => s.view);
|
||||
|
||||
if (items.length === 0) {
|
||||
const copy = EMPTY_COPY[view] ?? { title: 'No Items', subtitle: '', icon: 'image' };
|
||||
return <EmptyState icon={copy.icon} title={copy.title} subtitle={copy.subtitle} />;
|
||||
}
|
||||
return (
|
||||
<div className="apg-scroll">
|
||||
<MediaGrid items={items} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlbumView() {
|
||||
const view = useGallery((s) => s.view);
|
||||
const albums = useGallery((s) => s.albums);
|
||||
const items = useViewMedia();
|
||||
const album = albumById(albums, view);
|
||||
|
||||
return (
|
||||
<div className="apg-scroll">
|
||||
<div style={{ padding: '18px 14px 4px' }}>
|
||||
<div style={{ fontSize: 26, fontWeight: 700 }}>{album?.name ?? 'Album'}</div>
|
||||
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 13 }}>
|
||||
{items.length} item{items.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="collections"
|
||||
title="No Photos"
|
||||
subtitle="Select photos in your library and add them to this album."
|
||||
/>
|
||||
) : (
|
||||
<MediaGrid items={items} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SharedAlbumsView() {
|
||||
const api = useGalleryStoreApi();
|
||||
const shares = useGallery((s) => s.shares);
|
||||
const media = useGallery((s) => s.media);
|
||||
|
||||
if (shares.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="people"
|
||||
title="Shared Albums"
|
||||
subtitle="Select photos or an album and choose Share to create a link. Your shares appear here."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="apg-scroll" style={{ padding: 16 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>Shared Albums</div>
|
||||
<div className="apg-pinned-row">
|
||||
{shares.map((sh) => {
|
||||
const cover = media.find((m) => m.id === sh.mediaIds[0]);
|
||||
return (
|
||||
<div key={sh.id} className="apg-pinned-card" style={{ cursor: 'default' }}>
|
||||
{cover ? <img src={cover.thumbnail ?? cover.src} alt="" /> : <Icon name="people" size={28} />}
|
||||
<div className="apg-pinned-card__label">{sh.title}</div>
|
||||
<div className="apg-pinned-card__badge">{sh.mediaIds.length}</div>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 'auto 0 0 0',
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
padding: 6,
|
||||
background: 'linear-gradient(transparent, rgba(0,0,0,0.55))',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="apg-btn"
|
||||
style={{ flex: 1, padding: '3px 6px', fontSize: 11 }}
|
||||
onClick={() => void navigator.clipboard?.writeText(sh.url)}
|
||||
>
|
||||
Copy Link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="apg-btn"
|
||||
style={{ padding: '3px 6px', fontSize: 11, color: 'var(--apg-danger)' }}
|
||||
onClick={() => api.getState().revokeShare(sh.id)}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityView() {
|
||||
const shares = useGallery((s) => s.shares);
|
||||
if (shares.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="chat"
|
||||
title="Activity"
|
||||
subtitle="Your sharing activity — links you create — will appear here."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="apg-scroll" style={{ padding: 16, maxWidth: 640 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>Activity</div>
|
||||
{shares.map((sh) => (
|
||||
<div
|
||||
key={sh.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '10px 12px',
|
||||
marginBottom: 8,
|
||||
background: 'var(--apg-bg-elevated)',
|
||||
borderRadius: 12,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--apg-accent)' }}>
|
||||
<Icon name="share" size={18} />
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>
|
||||
You shared “{sh.title}”
|
||||
</div>
|
||||
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 12 }}>
|
||||
{sh.mediaIds.length} item{sh.mediaIds.length === 1 ? '' : 's'} ·{' '}
|
||||
{formatRelative(sh.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelative(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const min = Math.round(diff / 60000);
|
||||
if (min < 1) return 'just now';
|
||||
if (min < 60) return `${min} min ago`;
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 24) return `${hr} hr ago`;
|
||||
return `${Math.round(hr / 24)} day(s) ago`;
|
||||
}
|
||||
|
||||
export function DuplicatesView() {
|
||||
const api = useGalleryStoreApi();
|
||||
const media = useGallery((s) => s.media);
|
||||
const groups = findDuplicateGroups(media);
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="duplicates"
|
||||
title="No Duplicates"
|
||||
subtitle="Exact and near-duplicate items will be grouped here so you can merge them."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="apg-scroll" style={{ padding: 16 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>
|
||||
{groups.length} Duplicate Group{groups.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
{groups.map((group, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: 12,
|
||||
marginBottom: 10,
|
||||
background: 'var(--apg-bg-elevated)',
|
||||
borderRadius: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="apg-grid"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${Math.min(group.length, 4)}, 64px)`,
|
||||
gap: 4,
|
||||
padding: 0,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{group.slice(0, 4).map((m) => (
|
||||
<PhotoTile key={m.id} item={m} orderedIds={group.map((g) => g.id)} />
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="apg-btn apg-btn--primary"
|
||||
onClick={() => api.getState().trash(group.slice(1).map((m) => m.id))}
|
||||
>
|
||||
<Icon name="duplicates" size={14} /> Keep 1, Trash {group.length - 1}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { useViewMedia } from '../../hooks/useViewMedia';
|
||||
import { groupByTime } from '../../lib/grouping';
|
||||
import { Icon, type IconName } from '../../icons';
|
||||
import { useGallery, useGalleryStoreApi } from '../../store/context';
|
||||
import { liveMedia } from '../../store/selectors';
|
||||
import { MediaGrid } from '../MediaGrid';
|
||||
|
||||
function Welcome({ onImport }: { onImport: () => void }) {
|
||||
// Host-neutral copy: the SDK is embedded in other products (see `title`), so the
|
||||
// empty state must not name a specific photo app or reference its settings.
|
||||
const hints: Array<{ icon: IconName; text: string }> = [
|
||||
{ icon: 'download', text: 'Click the + button to import.' },
|
||||
{ icon: 'duplicates', text: 'Drag photos and videos straight in.' },
|
||||
{ icon: 'camera', text: 'Capture a shot with your camera.' },
|
||||
{ icon: 'image', text: 'Everything you add is searchable.' },
|
||||
];
|
||||
const title = useGallery((s) => s.config.title);
|
||||
return (
|
||||
<div className="apg-empty" onClick={onImport} role="button" tabIndex={0}>
|
||||
<div className="apg-empty__title">Welcome to {title}</div>
|
||||
<div className="apg-empty__subtitle">To get started, do any of the following:</div>
|
||||
<div className="apg-empty__hints">
|
||||
{hints.map((h, i) => (
|
||||
<div className="apg-empty__hint" key={i}>
|
||||
<span style={{ color: 'var(--apg-text-tertiary)' }}>
|
||||
<Icon name={h.icon} size={40} />
|
||||
</span>
|
||||
<span>{h.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LibraryView() {
|
||||
const api = useGalleryStoreApi();
|
||||
const items = useViewMedia();
|
||||
const scale = useGallery((s) => s.libraryScale);
|
||||
const searchQuery = useGallery((s) => s.searchQuery);
|
||||
const objectFocus = useGallery((s) => s.objectFocus);
|
||||
const tagFocus = useGallery((s) => s.tagFocus);
|
||||
const personFocus = useGallery((s) => s.personFocus);
|
||||
const personName = useGallery((s) => {
|
||||
if (!s.personFocus) return null;
|
||||
const p = s.people.find((x) => x.id === s.personFocus);
|
||||
return p?.name ?? 'Unnamed person';
|
||||
});
|
||||
const totalLive = useGallery((s) => liveMedia(s.media).length);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const importFiles = async (files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
await api.getState().importFiles(files);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
void importFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (items.length === 0) {
|
||||
if (totalLive === 0) {
|
||||
content = <Welcome onImport={() => fileRef.current?.click()} />;
|
||||
} else {
|
||||
content = (
|
||||
<div className="apg-empty">
|
||||
<div className="apg-empty__card">
|
||||
<div className="apg-empty__title" style={{ fontSize: 24 }}>
|
||||
No Results
|
||||
</div>
|
||||
<div className="apg-empty__subtitle">
|
||||
{objectFocus
|
||||
? `No photos containing “${objectFocus}”.`
|
||||
: searchQuery
|
||||
? 'Try a different search term.'
|
||||
: 'No items match this filter.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else if (scale === 'all') {
|
||||
content = <MediaGrid items={items} />;
|
||||
} else {
|
||||
const granularity = scale === 'years' ? 'year' : scale === 'months' ? 'month' : 'day';
|
||||
const sections = groupByTime(items, granularity);
|
||||
content = (
|
||||
<>
|
||||
{sections.map((s) => (
|
||||
<MediaGrid key={s.key} items={s.items} title={s.title} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="apg-scroll"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
style={dragging ? { outline: '3px dashed var(--apg-accent)', outlineOffset: -8 } : undefined}
|
||||
>
|
||||
{objectFocus || tagFocus || personFocus ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '12px 12px 0',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
<Icon name={objectFocus ? 'search' : personFocus ? 'person-circle' : 'tag'} size={16} />
|
||||
{objectFocus
|
||||
? `Object: ${objectFocus}`
|
||||
: personFocus
|
||||
? `Person: ${personName}`
|
||||
: `Tag: ${tagFocus}`}
|
||||
<button
|
||||
type="button"
|
||||
className="apg-btn"
|
||||
style={{ marginLeft: 8, padding: '3px 10px', textTransform: 'none' }}
|
||||
onClick={() =>
|
||||
objectFocus
|
||||
? api.getState().setObjectFocus(null)
|
||||
: personFocus
|
||||
? api.getState().setPersonFocus(null)
|
||||
: api.getState().setTagFocus(null)
|
||||
}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{content}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => void importFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { MAP_TILES } from '../../constants';
|
||||
import { groupByTime } from '../../lib/grouping';
|
||||
import { Icon } from '../../icons';
|
||||
import { useGallery, useGalleryStoreApi } from '../../store/context';
|
||||
import {
|
||||
clusterByLocation,
|
||||
type LocationCluster,
|
||||
liveMedia,
|
||||
locatedMedia,
|
||||
searchMedia,
|
||||
} from '../../store/selectors';
|
||||
import type { MediaItem } from '../../types';
|
||||
import { MediaGrid } from '../MediaGrid';
|
||||
import { MosaicGrid } from './MosaicGrid';
|
||||
|
||||
/** Thumbnails shown in a multi-photo pin's hover strip before the "+N" chip. */
|
||||
const TIP_STRIP_MAX = 5;
|
||||
/** How long each frame of the hover mini-slider stays up. */
|
||||
const TIP_FRAME_MS = 900;
|
||||
|
||||
/** Parse a `<input type="date">` value into a local-midnight epoch, or null. */
|
||||
function parseDateInput(value: string, endOfDay: boolean): number | null {
|
||||
if (!value) return null;
|
||||
const [y, m, d] = value.split('-').map(Number);
|
||||
if (!y || !m || !d) return null;
|
||||
return endOfDay
|
||||
? new Date(y, m - 1, d, 23, 59, 59, 999).getTime()
|
||||
: new Date(y, m - 1, d, 0, 0, 0, 0).getTime();
|
||||
}
|
||||
|
||||
/** Format a Date to a `<input type="date">` value (local `YYYY-MM-DD`). */
|
||||
function toInputDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** Short human label for one `YYYY-MM-DD` value. */
|
||||
function labelDate(value: string): string {
|
||||
const [y, m, d] = value.split('-').map(Number);
|
||||
if (!y || !m || !d) return '';
|
||||
return new Date(y, m - 1, d).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/** Button label for the current range ("All dates" when unset). */
|
||||
function rangeLabel(from: string, to: string): string {
|
||||
if (!from && !to) return 'All dates';
|
||||
if (from && to) return `${labelDate(from)} – ${labelDate(to)}`;
|
||||
if (from) return `From ${labelDate(from)}`;
|
||||
return `Until ${labelDate(to)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single, polished "date range" control: a button showing the current range
|
||||
* that opens a popover of quick presets + a custom From/To pair. It drives the
|
||||
* SAME from/to strings the MapView already filters by — so search/object chips,
|
||||
* the live count, and Clear all keep working unchanged.
|
||||
*/
|
||||
function DateRangeControl({
|
||||
from,
|
||||
to,
|
||||
onChange,
|
||||
}: {
|
||||
from: string;
|
||||
to: string;
|
||||
onChange: (from: string, to: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
window.addEventListener('pointerdown', onDown, true);
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', onDown, true);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const today = new Date();
|
||||
const daysAgo = (n: number) => {
|
||||
const d = new Date();
|
||||
d.setDate(today.getDate() - n);
|
||||
return d;
|
||||
};
|
||||
const presets: Array<{ label: string; from: string; to: string }> = [
|
||||
{ label: 'All dates', from: '', to: '' },
|
||||
{ label: 'Last 7 days', from: toInputDate(daysAgo(6)), to: toInputDate(today) },
|
||||
{ label: 'Last 30 days', from: toInputDate(daysAgo(29)), to: toInputDate(today) },
|
||||
{
|
||||
label: 'This year',
|
||||
from: toInputDate(new Date(today.getFullYear(), 0, 1)),
|
||||
to: toInputDate(today),
|
||||
},
|
||||
];
|
||||
|
||||
const active = Boolean(from || to);
|
||||
|
||||
return (
|
||||
<div className="apg-daterange" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className={['apg-daterange__button', active ? 'apg-daterange__button--active' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<Icon name="clock" size={14} />
|
||||
<span className="apg-daterange__label">{rangeLabel(from, to)}</span>
|
||||
<Icon name="chevron-down" size={14} />
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="apg-daterange__pop" role="dialog" aria-label="Choose a date range">
|
||||
<div className="apg-daterange__presets">
|
||||
{presets.map((p) => {
|
||||
const on = p.from === from && p.to === to;
|
||||
return (
|
||||
<button
|
||||
key={p.label}
|
||||
type="button"
|
||||
className={['apg-daterange__preset', on ? 'apg-daterange__preset--on' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-pressed={on}
|
||||
onClick={() => {
|
||||
onChange(p.from, p.to);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
{on ? <Icon name="check" size={14} /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="apg-daterange__custom">
|
||||
<div className="apg-daterange__custom-label">Custom range</div>
|
||||
<div className="apg-daterange__fields">
|
||||
<label className="apg-daterange__field">
|
||||
<span>From</span>
|
||||
<input
|
||||
type="date"
|
||||
value={from}
|
||||
max={to || undefined}
|
||||
onChange={(e) => onChange(e.target.value, to)}
|
||||
/>
|
||||
</label>
|
||||
<label className="apg-daterange__field">
|
||||
<span>To</span>
|
||||
<input
|
||||
type="date"
|
||||
value={to}
|
||||
min={from || undefined}
|
||||
onChange={(e) => onChange(from, e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapView() {
|
||||
const api = useGalleryStoreApi();
|
||||
const mapMode = useGallery((s) => s.mapMode);
|
||||
const media = useGallery((s) => s.media);
|
||||
const mapFocus = useGallery((s) => s.mapFocus);
|
||||
const located = locatedMedia(media);
|
||||
|
||||
// The location pin the user tapped → its photos shown date-grouped in a sheet.
|
||||
const [cluster, setCluster] = useState<LocationCluster | null>(null);
|
||||
// Sheet height as a fraction of the viewport (drag the handle up → toward full).
|
||||
const [sheetH, setSheetH] = useState(0.5);
|
||||
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
|
||||
|
||||
// ---- sheet filters (search + date range + object chips), AND-combined ----
|
||||
const [query, setQuery] = useState('');
|
||||
const [fromDate, setFromDate] = useState('');
|
||||
const [toDate, setToDate] = useState('');
|
||||
const [objectFilter, setObjectFilter] = useState<string | null>(null);
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery('');
|
||||
setFromDate('');
|
||||
setToDate('');
|
||||
setObjectFilter(null);
|
||||
};
|
||||
|
||||
const onHandleDown = (e: React.PointerEvent) => {
|
||||
dragRef.current = { startY: e.clientY, startH: sheetH };
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
};
|
||||
const onHandleMove = (e: React.PointerEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const dy = dragRef.current.startY - e.clientY; // dragging up is positive
|
||||
const next = dragRef.current.startH + dy / window.innerHeight;
|
||||
setSheetH(Math.max(0.18, Math.min(0.98, next)));
|
||||
};
|
||||
const onHandleUp = () => {
|
||||
if (dragRef.current && sheetH <= 0.2) setCluster(null); // dragged down → dismiss
|
||||
dragRef.current = null;
|
||||
};
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Leaflet instances kept in refs (typed loosely to avoid an SSR import).
|
||||
const mapRef = useRef<any>(null);
|
||||
const tileRef = useRef<any>(null);
|
||||
const markersRef = useRef<any>(null);
|
||||
const leafletRef = useRef<any>(null);
|
||||
// Every hover-slider interval currently running, so teardown can kill them all
|
||||
// (a leaked interval keeps mutating detached DOM forever).
|
||||
const tipTimersRef = useRef(new Set<ReturnType<typeof setInterval>>());
|
||||
const clearTipTimers = () => {
|
||||
for (const t of tipTimersRef.current) clearInterval(t);
|
||||
tipTimersRef.current.clear();
|
||||
};
|
||||
// addMarkers reads the latest `located` + setCluster via refs (Leaflet callbacks
|
||||
// are created once, so closing over state directly would go stale).
|
||||
const locatedRef = useRef(located);
|
||||
locatedRef.current = located;
|
||||
// Current sheet height in px, read by the fly-to offset (the sheet covers the
|
||||
// bottom of the map, so the pin must end up above it).
|
||||
const sheetHRef = useRef(sheetH);
|
||||
sheetHRef.current = sheetH;
|
||||
|
||||
/** Zoom to the pin, keeping it clear of the sheet, then open the sheet. */
|
||||
const openClusterRef = useRef<(c: LocationCluster) => void>(() => {});
|
||||
openClusterRef.current = (c) => {
|
||||
const map = mapRef.current;
|
||||
if (map) {
|
||||
const current = map.getZoom?.() ?? 2;
|
||||
// Never zoom OUT: a single photo warrants street level, a cluster stays wide
|
||||
// enough that its members remain distinguishable.
|
||||
const target = Math.max(current, c.items.length > 1 ? 12 : 14);
|
||||
const size = map.getSize?.();
|
||||
const mapH: number = size?.y ?? 0;
|
||||
// The sheet is about to occupy the bottom `sheetH` of the map, so shift the
|
||||
// centre up by half of that — the pin then sits in the visible upper band.
|
||||
const sheetPx = mapH * 0.5; // the sheet always (re)opens at half height
|
||||
map.flyTo([c.lat, c.lng], target, { animate: true, duration: 0.6 });
|
||||
if (sheetPx > 0) {
|
||||
// panBy after the fly settles, else Leaflet cancels the in-flight animation.
|
||||
// Leaflet's panBy moves the map pane BY the offset, so content shifts the
|
||||
// opposite way: a POSITIVE y lifts the pin toward the top of the map,
|
||||
// which is the direction that gets it clear of the sheet.
|
||||
map.once('moveend', () => map.panBy([0, sheetPx / 2], { animate: true }));
|
||||
}
|
||||
}
|
||||
setCluster(c);
|
||||
setSheetH(0.5); // reset to half height each time a pin is opened
|
||||
resetFilters(); // a new pin starts with a clean filter
|
||||
};
|
||||
|
||||
// Opening one photo from a pin's hover strip.
|
||||
const openLightboxRef = useRef<(id: string) => void>(() => {});
|
||||
openLightboxRef.current = (id) => api.getState().openLightbox(id);
|
||||
|
||||
// Create the map once when entering a map tile mode.
|
||||
useEffect(() => {
|
||||
if (mapMode === 'grid' || !containerRef.current || mapRef.current) return;
|
||||
let cancelled = false;
|
||||
|
||||
void import('leaflet').then((mod) => {
|
||||
const L = (mod as any).default ?? mod;
|
||||
if (cancelled || !containerRef.current) return;
|
||||
leafletRef.current = L;
|
||||
const map = L.map(containerRef.current, {
|
||||
zoomControl: false,
|
||||
attributionControl: true,
|
||||
worldCopyJump: true,
|
||||
}).setView([20, 0], 2);
|
||||
mapRef.current = map;
|
||||
addTiles();
|
||||
addMarkers();
|
||||
// Re-cluster pins each time the zoom changes (precision is zoom-dependent).
|
||||
map.on('zoomend', addMarkers);
|
||||
// Center on a focused photo (from the Info mini-map), else fit to all markers.
|
||||
if (mapFocus) {
|
||||
map.setView([mapFocus.lat, mapFocus.lng], 12, { animate: false });
|
||||
} else if (located.length) {
|
||||
const bounds = L.latLngBounds(located.map((m) => [m.location!.lat, m.location!.lng]));
|
||||
map.fitBounds(bounds.pad(0.3), { animate: false });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mapMode]);
|
||||
|
||||
// Tear the map down when leaving map tile modes.
|
||||
useEffect(() => {
|
||||
if (mapMode === 'grid' && mapRef.current) {
|
||||
clearTipTimers();
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
tileRef.current = null;
|
||||
markersRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mapMode]);
|
||||
|
||||
// Destroy on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTipTimers();
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
tileRef.current = null;
|
||||
markersRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const addTiles = () => {
|
||||
const L = leafletRef.current;
|
||||
const map = mapRef.current;
|
||||
if (!L || !map) return;
|
||||
if (tileRef.current) {
|
||||
map.removeLayer(tileRef.current);
|
||||
tileRef.current = null;
|
||||
}
|
||||
const cfg = mapMode === 'satellite' ? MAP_TILES.satellite : MAP_TILES.map;
|
||||
tileRef.current = L.tileLayer(cfg.url, {
|
||||
attribution: cfg.attribution,
|
||||
maxZoom: cfg.maxZoom,
|
||||
}).addTo(map);
|
||||
};
|
||||
|
||||
const addMarkers = () => {
|
||||
const L = leafletRef.current;
|
||||
const map = mapRef.current;
|
||||
if (!L || !map) return;
|
||||
// Keep markers in a dedicated layer group so they can be re-synced wholesale.
|
||||
if (!markersRef.current) markersRef.current = L.layerGroup().addTo(map);
|
||||
// Every marker is about to be destroyed — kill their hover timers first.
|
||||
clearTipTimers();
|
||||
markersRef.current.clearLayers();
|
||||
|
||||
// Tighter clustering as you zoom in, so pins split apart (macOS behaviour).
|
||||
const zoom = map.getZoom?.() ?? 2;
|
||||
const precision = zoom < 4 ? 0 : zoom < 7 ? 1 : zoom < 11 ? 2 : 3;
|
||||
const clusters = clusterByLocation(locatedRef.current, precision);
|
||||
|
||||
for (const c of clusters) {
|
||||
const cover = c.items[0]!;
|
||||
const multi = c.items.length > 1;
|
||||
// Static markup only (no interpolated data) so Leaflet's innerHTML can't be
|
||||
// injected; the thumbnail src + count are set via DOM after the marker mounts.
|
||||
const icon = L.divIcon({
|
||||
className: 'apg-pin-wrap',
|
||||
// Static markup only (data set via DOM after mount → XSS-safe). Includes a
|
||||
// hover tooltip: one preview for a single photo, an auto-advancing strip
|
||||
// of thumbnails for a cluster.
|
||||
html:
|
||||
'<span class="apg-pin"><img class="apg-pin__img" alt=""/>' +
|
||||
'<span class="apg-pin__count"></span>' +
|
||||
'<span class="apg-pin__tip"><img class="apg-pin__tip-img" alt=""/>' +
|
||||
'<span class="apg-pin__strip"></span>' +
|
||||
'<span class="apg-pin__tip-cap"></span></span></span>',
|
||||
iconSize: [56, 64],
|
||||
iconAnchor: [28, 64],
|
||||
});
|
||||
const marker = L.marker([c.lat, c.lng], { icon, keyboard: false });
|
||||
marker.on('add', () => {
|
||||
const el: HTMLElement | null = marker.getElement();
|
||||
if (!el) return;
|
||||
const thumb = cover.thumbnail ?? cover.src;
|
||||
const img = el.querySelector('.apg-pin__img') as HTMLImageElement | null;
|
||||
if (img) img.src = thumb;
|
||||
const tipImg = el.querySelector('.apg-pin__tip-img') as HTMLImageElement | null;
|
||||
const strip = el.querySelector('.apg-pin__strip') as HTMLElement | null;
|
||||
const cap = el.querySelector('.apg-pin__tip-cap') as HTMLElement | null;
|
||||
|
||||
// Keep the hover tooltip inside the map: on enter, measure it against the map
|
||||
// container and slide it horizontally (--tip-dx) / flip it below (--below) so it
|
||||
// never overflows and gets clipped by the gallery's rounded, overflow-hidden shell.
|
||||
const pinEl = el.querySelector('.apg-pin') as HTMLElement | null;
|
||||
const tipEl = el.querySelector('.apg-pin__tip') as HTMLElement | null;
|
||||
const clampTip = () => {
|
||||
if (!tipEl) return;
|
||||
const mapEl = el.closest('.leaflet-container') as HTMLElement | null;
|
||||
if (!mapEl) return;
|
||||
tipEl.style.setProperty('--tip-dx', '0px');
|
||||
tipEl.classList.remove('apg-pin__tip--below');
|
||||
const m = mapEl.getBoundingClientRect();
|
||||
const t = tipEl.getBoundingClientRect();
|
||||
const pad = 8;
|
||||
let dx = 0;
|
||||
if (t.left < m.left + pad) dx = m.left + pad - t.left;
|
||||
else if (t.right > m.right - pad) dx = m.right - pad - t.right;
|
||||
if (dx) tipEl.style.setProperty('--tip-dx', `${Math.round(dx)}px`);
|
||||
if (t.top < m.top + pad) tipEl.classList.add('apg-pin__tip--below');
|
||||
};
|
||||
pinEl?.addEventListener('mouseenter', clampTip);
|
||||
if (cap) {
|
||||
// textContent (never innerHTML) — place names are attacker-influencable.
|
||||
const place = cover.location?.place ?? '';
|
||||
const n = c.items.length;
|
||||
cap.textContent = place ? `${place}${n > 1 ? ` · ${n} photos` : ''}` : `${n} photo${n === 1 ? '' : 's'}`;
|
||||
}
|
||||
const badge = el.querySelector('.apg-pin__count') as HTMLElement | null;
|
||||
if (badge) {
|
||||
// The badge always carries the cluster's TOTAL, and only appears when
|
||||
// there is genuinely more than one photo here.
|
||||
badge.textContent = String(c.items.length);
|
||||
badge.style.display = multi ? '' : 'none';
|
||||
}
|
||||
|
||||
if (!multi || !strip) {
|
||||
if (tipImg) tipImg.src = thumb;
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- multi-photo pin: horizontal strip + auto-advancing preview ----
|
||||
if (tipImg) tipImg.src = thumb;
|
||||
const shown = c.items.slice(0, TIP_STRIP_MAX);
|
||||
const cells: HTMLElement[] = [];
|
||||
for (const item of shown) {
|
||||
const cell = document.createElement('button');
|
||||
cell.type = 'button';
|
||||
cell.className = 'apg-pin__strip-cell';
|
||||
cell.title = item.name;
|
||||
const thumbEl = document.createElement('img');
|
||||
thumbEl.alt = '';
|
||||
thumbEl.src = item.thumbnail ?? item.src;
|
||||
cell.appendChild(thumbEl);
|
||||
// Opening a specific photo must not also open the cluster sheet.
|
||||
cell.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
openLightboxRef.current(item.id);
|
||||
});
|
||||
strip.appendChild(cell);
|
||||
cells.push(cell);
|
||||
}
|
||||
if (c.items.length > TIP_STRIP_MAX) {
|
||||
const more = document.createElement('span');
|
||||
more.className = 'apg-pin__strip-more';
|
||||
more.textContent = `+${c.items.length - TIP_STRIP_MAX}`;
|
||||
strip.appendChild(more);
|
||||
}
|
||||
|
||||
// Auto-advance the big preview (and the highlighted cell) while hovered.
|
||||
const pin = el.querySelector('.apg-pin') as HTMLElement | null;
|
||||
if (!pin) return;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let frame = 0;
|
||||
const paint = () => {
|
||||
const item = c.items[frame % c.items.length]!;
|
||||
if (tipImg) tipImg.src = item.thumbnail ?? item.src;
|
||||
cells.forEach((cell, i) =>
|
||||
cell.classList.toggle('apg-pin__strip-cell--on', i === frame % c.items.length),
|
||||
);
|
||||
};
|
||||
const stop = () => {
|
||||
if (timer === null) return;
|
||||
clearInterval(timer);
|
||||
tipTimersRef.current.delete(timer);
|
||||
timer = null;
|
||||
frame = 0;
|
||||
if (tipImg) tipImg.src = thumb;
|
||||
cells.forEach((cell) => cell.classList.remove('apg-pin__strip-cell--on'));
|
||||
};
|
||||
const start = () => {
|
||||
if (timer !== null) return;
|
||||
frame = 0;
|
||||
paint();
|
||||
timer = setInterval(() => {
|
||||
frame += 1;
|
||||
paint();
|
||||
}, TIP_FRAME_MS);
|
||||
tipTimersRef.current.add(timer);
|
||||
};
|
||||
pin.addEventListener('mouseenter', start);
|
||||
pin.addEventListener('mouseleave', stop);
|
||||
// Leaflet destroys the element on clearLayers(); `remove` fires first.
|
||||
marker.on('remove', stop);
|
||||
});
|
||||
marker.on('click', () => openClusterRef.current(c));
|
||||
markersRef.current.addLayer(marker);
|
||||
}
|
||||
};
|
||||
|
||||
// Swap tile layer when Map/Satellite toggles.
|
||||
useEffect(() => {
|
||||
if (mapRef.current) addTiles();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mapMode]);
|
||||
|
||||
// Re-sync markers when the located set changes while the map stays mounted.
|
||||
useEffect(() => {
|
||||
if (mapRef.current) addMarkers();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [media]);
|
||||
|
||||
// Recenter when a photo's location is focused (from the Info mini-map).
|
||||
useEffect(() => {
|
||||
if (mapFocus && mapRef.current) {
|
||||
mapRef.current.setView([mapFocus.lat, mapFocus.lng], 12, { animate: true });
|
||||
}
|
||||
}, [mapFocus]);
|
||||
|
||||
// Distinct object labels present in the open cluster, most common first.
|
||||
const clusterObjects = useMemo(() => {
|
||||
if (!cluster) return [] as Array<{ label: string; count: number }>;
|
||||
const counts = new Map<string, number>();
|
||||
for (const m of cluster.items) {
|
||||
for (const label of new Set(m.objectLabels)) {
|
||||
counts.set(label, (counts.get(label) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([label, count]) => ({ label, count }))
|
||||
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
||||
}, [cluster]);
|
||||
|
||||
// Search + date range + object chip, combined with AND.
|
||||
const filtered = useMemo<MediaItem[]>(() => {
|
||||
if (!cluster) return [];
|
||||
let items = cluster.items;
|
||||
if (objectFilter) items = items.filter((m) => m.objectLabels.includes(objectFilter));
|
||||
const from = parseDateInput(fromDate, false);
|
||||
const to = parseDateInput(toDate, true);
|
||||
if (from !== null) items = items.filter((m) => m.takenAt >= from);
|
||||
if (to !== null) items = items.filter((m) => m.takenAt <= to);
|
||||
// Same matcher as the global search bar, so "car" means the same thing here.
|
||||
if (query.trim()) items = searchMedia(items, query);
|
||||
return items;
|
||||
}, [cluster, query, fromDate, toDate, objectFilter]);
|
||||
|
||||
const filterActive = Boolean(query.trim() || fromDate || toDate || objectFilter);
|
||||
|
||||
if (mapMode === 'grid') {
|
||||
// The Grid tab shows ALL photos in the macOS Memories-style mosaic (not just
|
||||
// located ones) so it's a rich date-grouped collage, per the design.
|
||||
const all = liveMedia(media);
|
||||
if (all.length === 0) {
|
||||
return (
|
||||
<div className="apg-empty">
|
||||
<div className="apg-empty__card">
|
||||
<div className="apg-empty__title" style={{ fontSize: 22 }}>
|
||||
No Photos
|
||||
</div>
|
||||
<div className="apg-empty__subtitle">Imported photos appear here as a mosaic.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <MosaicGrid items={all} groupBy="month" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="apg-map">
|
||||
<div ref={containerRef} className="apg-map__leaflet" />
|
||||
<div className="apg-map__controls">
|
||||
<button
|
||||
type="button"
|
||||
className="apg-iconbtn apg-iconbtn--circle"
|
||||
aria-label="Zoom in"
|
||||
onClick={() => mapRef.current?.zoomIn()}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="apg-iconbtn apg-iconbtn--circle"
|
||||
aria-label="Zoom out"
|
||||
onClick={() => mapRef.current?.zoomOut()}
|
||||
>
|
||||
<Icon name="minus" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="apg-iconbtn apg-iconbtn--circle"
|
||||
aria-label="Reset north"
|
||||
onClick={() => mapRef.current?.setView([20, 0], 2)}
|
||||
>
|
||||
<Icon name="compass" size={22} />
|
||||
</button>
|
||||
</div>
|
||||
<a className="apg-map__legal" href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer noopener">
|
||||
Legal
|
||||
</a>
|
||||
|
||||
{cluster ? (
|
||||
<div
|
||||
className="apg-map__sheet"
|
||||
role="dialog"
|
||||
aria-label="Photos at this location"
|
||||
style={{ height: `${Math.round(sheetH * 100)}%` }}
|
||||
>
|
||||
<div
|
||||
className="apg-map__grip"
|
||||
onPointerDown={onHandleDown}
|
||||
onPointerMove={onHandleMove}
|
||||
onPointerUp={onHandleUp}
|
||||
onPointerCancel={onHandleUp}
|
||||
title="Drag to resize"
|
||||
aria-hidden
|
||||
>
|
||||
<span className="apg-map__grip-bar" />
|
||||
</div>
|
||||
<div className="apg-map__sheet-head">
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 16 }}>
|
||||
{cluster.items[0]?.location?.place ?? 'This location'}
|
||||
</div>
|
||||
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 12 }} aria-live="polite">
|
||||
{filterActive
|
||||
? `${filtered.length} of ${cluster.items.length} photo${
|
||||
cluster.items.length === 1 ? '' : 's'
|
||||
}`
|
||||
: `${cluster.items.length} photo${cluster.items.length === 1 ? '' : 's'}`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="apg-iconbtn"
|
||||
aria-label="Close"
|
||||
onClick={() => setCluster(null)}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search + date range + object chips — all AND-combined. */}
|
||||
<div className="apg-map__sheet-filters">
|
||||
<div className="apg-mapfilter__row">
|
||||
<div className="apg-mapfilter__search">
|
||||
<Icon name="search" size={15} />
|
||||
<input
|
||||
type="search"
|
||||
aria-label="Search photos at this location"
|
||||
placeholder="Search photos, objects, text…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{filterActive ? (
|
||||
<button
|
||||
type="button"
|
||||
className="apg-mapfilter__clear"
|
||||
aria-label="Clear filters"
|
||||
title="Clear filters"
|
||||
onClick={resetFilters}
|
||||
>
|
||||
<Icon name="close" size={13} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="apg-mapfilter__row apg-mapfilter__dates">
|
||||
<DateRangeControl
|
||||
from={fromDate}
|
||||
to={toDate}
|
||||
onChange={(f, t) => {
|
||||
setFromDate(f);
|
||||
setToDate(t);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{clusterObjects.length ? (
|
||||
<div className="apg-mapfilter__chips" role="group" aria-label="Filter by object">
|
||||
{clusterObjects.map(({ label, count }) => {
|
||||
const on = objectFilter === label;
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className={[
|
||||
'apg-mapfilter__chip',
|
||||
on ? 'apg-mapfilter__chip--on' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-pressed={on}
|
||||
onClick={() => setObjectFilter(on ? null : label)}
|
||||
>
|
||||
{label}
|
||||
<span className="apg-mapfilter__chip-n">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="apg-map__sheet-body apg-scroll">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="apg-mapfilter__empty">
|
||||
<Icon name="search" size={26} />
|
||||
<div className="apg-mapfilter__empty-title">No matching photos</div>
|
||||
<div className="apg-mapfilter__empty-sub">
|
||||
Try a different search, date range, or object.
|
||||
</div>
|
||||
<button type="button" className="apg-btn" onClick={resetFilters}>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
groupByTime(filtered, 'day').map((s) => (
|
||||
<MediaGrid key={s.key} items={s.items} title={s.title} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { editFilterCss } from '../../lib/edits';
|
||||
import { groupByTime } from '../../lib/grouping';
|
||||
import { useGalleryStoreApi } from '../../store/context';
|
||||
import type { MediaItem } from '../../types';
|
||||
|
||||
/**
|
||||
* macOS "Grid" / Memories-style mosaic: date-grouped sections, each led by a
|
||||
* full-width auto-sliding hero, followed by an irregular grid of tiles with
|
||||
* varied spans (portrait/landscape/square) rather than a uniform square grid.
|
||||
*/
|
||||
|
||||
// (col-span, row-span) pattern for the tiles under the hero → deliberate variety.
|
||||
const SPANS: ReadonlyArray<readonly [number, number]> = [
|
||||
[1, 2],
|
||||
[1, 2],
|
||||
[2, 1],
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
[1, 2],
|
||||
[1, 1],
|
||||
[2, 1],
|
||||
];
|
||||
|
||||
function Tile({ item, onOpen, span }: { item: MediaItem; onOpen: () => void; span: readonly [number, number] }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="apg-mosaic__tile"
|
||||
style={{ gridColumn: `span ${span[0]}`, gridRow: `span ${span[1]}` }}
|
||||
onClick={onOpen}
|
||||
aria-label={item.name}
|
||||
>
|
||||
{item.kind === 'video' ? (
|
||||
<video src={item.src} poster={item.poster} muted preload="metadata" playsInline />
|
||||
) : (
|
||||
<img
|
||||
src={item.thumbnail ?? item.src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{ filter: editFilterCss(item.edits) }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Full-width hero that cross-fades through the section's first few photos. */
|
||||
function SlideHero({ items, onOpen }: { items: MediaItem[]; onOpen: (id: string) => void }) {
|
||||
const [i, setI] = useState(0);
|
||||
const slides = items.slice(0, 6);
|
||||
useEffect(() => {
|
||||
if (slides.length < 2) return;
|
||||
const t = setInterval(() => setI((x) => (x + 1) % slides.length), 3500);
|
||||
return () => clearInterval(t);
|
||||
}, [slides.length]);
|
||||
const current = slides[i % slides.length] ?? items[0]!;
|
||||
return (
|
||||
<button type="button" className="apg-mosaic__hero" onClick={() => onOpen(current.id)} aria-label="Open photo">
|
||||
{slides.map((m, idx) => (
|
||||
<img
|
||||
key={m.id}
|
||||
src={m.thumbnail ?? m.src}
|
||||
alt=""
|
||||
style={{ opacity: idx === i % slides.length ? 1 : 0, filter: editFilterCss(m.edits) }}
|
||||
/>
|
||||
))}
|
||||
{slides.length > 1 ? (
|
||||
<span className="apg-mosaic__dots" aria-hidden>
|
||||
{slides.map((m, idx) => (
|
||||
<span key={m.id} className={idx === i % slides.length ? 'is-active' : ''} />
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MosaicGrid({ items, groupBy = 'month' }: { items: MediaItem[]; groupBy?: 'month' | 'day' }) {
|
||||
const api = useGalleryStoreApi();
|
||||
const open = (id: string) => api.getState().openLightbox(id);
|
||||
const sections = groupByTime(items, groupBy);
|
||||
|
||||
return (
|
||||
<div className="apg-scroll apg-mosaic-wrap">
|
||||
{sections.map((s) => (
|
||||
<section key={s.key} className="apg-mosaic-section">
|
||||
<div className="apg-mosaic__title">{s.title}</div>
|
||||
<SlideHero items={s.items} onOpen={open} />
|
||||
{s.items.length > 1 ? (
|
||||
<div className="apg-mosaic">
|
||||
{s.items.slice(1).map((m, idx) => (
|
||||
<Tile key={m.id} item={m} span={SPANS[idx % SPANS.length]!} onOpen={() => open(m.id)} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '../../icons';
|
||||
import { useGallery, useGalleryStoreApi } from '../../store/context';
|
||||
import { promptAlbumName } from '../modals';
|
||||
|
||||
export function PeopleView() {
|
||||
const api = useGalleryStoreApi();
|
||||
const people = useGallery((s) => s.people);
|
||||
const media = useGallery((s) => s.media);
|
||||
|
||||
if (people.length === 0) {
|
||||
return (
|
||||
<div className="apg-empty">
|
||||
<div className="apg-empty__card">
|
||||
<span style={{ color: 'var(--apg-text-tertiary)' }}>
|
||||
<Icon name="person-circle" size={44} />
|
||||
</span>
|
||||
<div className="apg-empty__title" style={{ fontSize: 24 }}>
|
||||
Finding People…
|
||||
</div>
|
||||
<div className="apg-empty__subtitle">
|
||||
People and pets are grouped automatically as photos are analyzed. Add more photos and
|
||||
they will appear here.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="apg-scroll">
|
||||
<div
|
||||
className="apg-grid"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', gap: 18, padding: 18 }}
|
||||
>
|
||||
{people.map((p) => {
|
||||
const cover = media.find((m) => m.id === (p.coverId ?? p.mediaIds[0]));
|
||||
const rename = () =>
|
||||
promptAlbumName(
|
||||
p.name ? `Rename ${p.isPet ? 'pet' : 'person'}` : `Name this ${p.isPet ? 'pet' : 'person'}`,
|
||||
p.name ?? '',
|
||||
(name) => api.getState().renamePerson(p.id, name),
|
||||
{ placeholder: p.isPet ? 'Pet name' : 'Name', confirmLabel: 'Save' },
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`View photos of ${p.name ?? 'this ' + (p.isPet ? 'pet' : 'person')}`}
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--apg-bg-elevated)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => api.getState().setPersonFocus(p.id)}
|
||||
>
|
||||
{cover ? (
|
||||
<img
|
||||
src={cover.thumbnail ?? cover.src}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<Icon name={p.isPet ? 'tag' : 'person-circle'} size={40} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={rename}
|
||||
title="Click to name"
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: p.name ? 'var(--apg-text)' : 'var(--apg-accent)',
|
||||
}}
|
||||
>
|
||||
{p.name ?? (p.isPet ? '+ Name pet' : '+ Add Name')}
|
||||
</button>
|
||||
<span style={{ fontSize: 11, color: 'var(--apg-text-tertiary)' }}>
|
||||
{p.mediaIds.length} {p.mediaIds.length === 1 ? 'photo' : 'photos'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { groupByTime } from '../../lib/grouping';
|
||||
import { Icon } from '../../icons';
|
||||
import { useGallery, useGalleryStoreApi } from '../../store/context';
|
||||
import { albumMedia, liveMedia } from '../../store/selectors';
|
||||
import type { ViewId } from '../../types';
|
||||
import { promptAlbumName } from '../modals';
|
||||
import { EmptyState } from './EmptyState';
|
||||
|
||||
/**
|
||||
* "All Albums" overview — a meaningful web-gallery view (replaces macOS's
|
||||
* iOS-only "Projects / App Store" screen). Shows every album as a cover card,
|
||||
* plus auto "Recent Days" groupings, and a Create action.
|
||||
*/
|
||||
export function AlbumsOverview() {
|
||||
const api = useGalleryStoreApi();
|
||||
const albums = useGallery((s) => s.albums);
|
||||
const media = useGallery((s) => s.media);
|
||||
const userAlbums = albums.filter((a) => a.kind === 'user' || a.kind === 'folder');
|
||||
const live = liveMedia(media);
|
||||
const recentDays = groupByTime(live, 'day').slice(0, 12);
|
||||
|
||||
const create = () =>
|
||||
promptAlbumName('New Album', '', (name) => {
|
||||
const id = api.getState().createAlbum(name);
|
||||
api.getState().setView(`album:${id}` as ViewId);
|
||||
});
|
||||
|
||||
if (userAlbums.length === 0 && live.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="collections"
|
||||
title="No Albums Yet"
|
||||
subtitle="Create an album to organize your photos, or import some to get started."
|
||||
action={{ label: 'Create Album', onClick: create }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="apg-scroll">
|
||||
<div className="apg-collections">
|
||||
<section className="apg-collections__section">
|
||||
<div className="apg-collections__header">
|
||||
<div className="apg-collections__title">My Albums</div>
|
||||
<button type="button" className="apg-collections__action" onClick={create}>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
{userAlbums.length === 0 ? (
|
||||
<div className="apg-empty-card">
|
||||
<span className="apg-empty-card__icon">
|
||||
<Icon name="collections" size={28} />
|
||||
</span>
|
||||
<div className="apg-empty-card__title">No albums yet</div>
|
||||
<div className="apg-empty-card__text">Click Create to make your first album.</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="apg-pinned-row">
|
||||
{userAlbums.map((a) => {
|
||||
const am = albumMedia(a, media);
|
||||
return (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
className="apg-pinned-card"
|
||||
onClick={() => api.getState().setView(a.id as ViewId)}
|
||||
aria-label={a.name}
|
||||
>
|
||||
{(am.find((m) => m.id === a.coverId) ?? am[0]) ? (
|
||||
<img
|
||||
src={(am.find((m) => m.id === a.coverId) ?? am[0])!.thumbnail ?? (am.find((m) => m.id === a.coverId) ?? am[0])!.src}
|
||||
alt=""
|
||||
draggable={false}
|
||||
/>
|
||||
) : null}
|
||||
<span className="apg-pinned-card__label">
|
||||
{a.name}
|
||||
<span style={{ opacity: 0.8, fontWeight: 500 }}> · {am.length}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{recentDays.length > 0 ? (
|
||||
<section className="apg-collections__section">
|
||||
<div className="apg-collections__header">
|
||||
<div className="apg-collections__title">Recent Days</div>
|
||||
</div>
|
||||
<div className="apg-pinned-row">
|
||||
{recentDays.map((d) => (
|
||||
<button
|
||||
key={d.key}
|
||||
type="button"
|
||||
className="apg-pinned-card"
|
||||
onClick={() => api.getState().openLightbox(d.items[0]!.id)}
|
||||
aria-label={d.title}
|
||||
>
|
||||
<img src={d.items[0]!.thumbnail ?? d.items[0]!.src} alt="" draggable={false} />
|
||||
<span className="apg-pinned-card__label">{d.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { TRASH_RETENTION_MS } from '../../constants';
|
||||
import { daysUntilPermanentDelete } from '../../lib/format';
|
||||
import { Icon } from '../../icons';
|
||||
import { useViewMedia } from '../../hooks/useViewMedia';
|
||||
import { useGallery, useGalleryStoreApi } from '../../store/context';
|
||||
import { MediaGrid } from '../MediaGrid';
|
||||
import { openSecuritySettings } from '../modals';
|
||||
|
||||
export function RecentlyDeletedView() {
|
||||
const api = useGalleryStoreApi();
|
||||
const items = useViewMedia();
|
||||
const lockConfigured = useGallery((s) => s.lockConfigured);
|
||||
const lockUnlocked = useGallery((s) => s.lockUnlocked);
|
||||
const lockError = useGallery((s) => s.lockError);
|
||||
const [pw, setPw] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Locked: a password is set and the user hasn't unlocked this session.
|
||||
if (lockConfigured && !lockUnlocked) {
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
await api.getState().unlockLock(pw);
|
||||
setBusy(false);
|
||||
setPw('');
|
||||
};
|
||||
return (
|
||||
<div className="apg-empty">
|
||||
<span style={{ color: 'var(--apg-text-tertiary)' }}>
|
||||
<Icon name="lock" size={56} />
|
||||
</span>
|
||||
<div className="apg-empty__title" style={{ fontSize: 21 }}>
|
||||
Enter Your Password to View Recently Deleted
|
||||
</div>
|
||||
<input
|
||||
className="apg-modal__input"
|
||||
type="password"
|
||||
autoFocus
|
||||
value={pw}
|
||||
placeholder="Password"
|
||||
style={{ maxWidth: 280 }}
|
||||
onChange={(e) => {
|
||||
setPw(e.target.value);
|
||||
api.getState().clearLockError();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void submit();
|
||||
}}
|
||||
/>
|
||||
{lockError ? (
|
||||
<div style={{ color: 'var(--apg-danger)', fontSize: 13 }} role="alert">
|
||||
{lockError === 'wrong-password'
|
||||
? 'Incorrect password.'
|
||||
: "Couldn't check the password. Please try again."}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="apg-btn apg-btn--primary"
|
||||
disabled={busy}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{busy ? 'Checking…' : 'Unlock'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="apg-empty">
|
||||
<div className="apg-empty__card">
|
||||
<span style={{ color: 'var(--apg-text-tertiary)' }}>
|
||||
<Icon name="trash" size={40} />
|
||||
</span>
|
||||
<div className="apg-empty__title" style={{ fontSize: 22 }}>
|
||||
No Recently Deleted Items
|
||||
</div>
|
||||
<div className="apg-empty__subtitle">
|
||||
Deleted items are kept here for {Math.round(TRASH_RETENTION_MS / 86400000)} days.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Soonest expiry = the smallest remaining days across all trashed items
|
||||
// (independent of display order, which is sorted by capture date).
|
||||
const nextExpiry = items.reduce((min, m) => {
|
||||
if (!m.deletedAt) return min;
|
||||
return Math.min(min, daysUntilPermanentDelete(m.deletedAt, TRASH_RETENTION_MS));
|
||||
}, Infinity);
|
||||
const expiryDays = Number.isFinite(nextExpiry) ? nextExpiry : 0;
|
||||
|
||||
return (
|
||||
<div className="apg-scroll">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '14px 14px 0',
|
||||
color: 'var(--apg-text-secondary)',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Items are deleted permanently after 30 days. Oldest expires in ~{expiryDays} day
|
||||
{expiryDays === 1 ? '' : 's'}.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="apg-btn"
|
||||
style={{ flexShrink: 0, padding: '4px 10px' }}
|
||||
onClick={openSecuritySettings}
|
||||
>
|
||||
<Icon name={lockConfigured ? 'lock' : 'unlock'} size={13} />
|
||||
{lockConfigured ? 'Locked' : 'Lock…'}
|
||||
</button>
|
||||
</div>
|
||||
<MediaGrid items={items} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { formatDate, formatTime } from '../../lib/format';
|
||||
import { Icon } from '../../icons';
|
||||
import { useGallery, useGalleryStoreApi } from '../../store/context';
|
||||
import { EmptyState } from './EmptyState';
|
||||
|
||||
/**
|
||||
* Audit browser — every photo/video that has an edit history or comments.
|
||||
* Click an item to open it with the Info panel showing the full version
|
||||
* timeline (v1 = original, never overwritten) and comment thread.
|
||||
*/
|
||||
export function VersionsView() {
|
||||
const api = useGalleryStoreApi();
|
||||
const media = useGallery((s) => s.media);
|
||||
|
||||
const tracked = media
|
||||
.filter((m) => !m.deletedAt && ((m.versions?.length ?? 0) > 0 || (m.comments?.length ?? 0) > 0))
|
||||
.sort((a, b) => (b.editedAt ?? b.importedAt) - (a.editedAt ?? a.importedAt));
|
||||
|
||||
const open = (id: string) => {
|
||||
const s = api.getState();
|
||||
s.openLightbox(id);
|
||||
s.setInfoOpen(true);
|
||||
};
|
||||
|
||||
if (tracked.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="clock"
|
||||
title="No Version History Yet"
|
||||
subtitle="Edit a photo or video (the original is always kept as version 1) or add a comment, and it will appear here with its full audit log."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="apg-scroll" style={{ padding: 16, maxWidth: 760 }}>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, marginBottom: 4 }}>Versions & Audit Log</div>
|
||||
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 13, marginBottom: 14 }}>
|
||||
{tracked.length} item{tracked.length === 1 ? '' : 's'} with edit history or comments. Originals are
|
||||
never overwritten.
|
||||
</div>
|
||||
|
||||
<div className="apg-audit-list">
|
||||
{tracked.map((m) => {
|
||||
const versions = m.versions ?? [];
|
||||
const latest = versions[versions.length - 1];
|
||||
const comments = m.comments?.length ?? 0;
|
||||
return (
|
||||
<button key={m.id} type="button" className="apg-audit-card" onClick={() => open(m.id)}>
|
||||
<span className="apg-audit-card__thumb-wrap">
|
||||
<img className="apg-audit-card__thumb" src={m.thumbnail ?? m.src} alt="" />
|
||||
{m.kind === 'video' ? (
|
||||
<span className="apg-audit-card__play">
|
||||
<Icon name="play" size={14} />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="apg-audit-card__body">
|
||||
<span className="apg-audit-card__name">{m.name}</span>
|
||||
<span className="apg-audit-card__tags">
|
||||
<span className="apg-audit-card__tag">
|
||||
<Icon name="clock" size={12} />
|
||||
{versions.length || 1} version{(versions.length || 1) === 1 ? '' : 's'}
|
||||
</span>
|
||||
{comments > 0 ? (
|
||||
<span className="apg-audit-card__tag">
|
||||
<Icon name="chat" size={12} />
|
||||
{comments} comment{comments === 1 ? '' : 's'}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{latest ? (
|
||||
<span className="apg-audit-card__latest">
|
||||
Latest: {latest.changes.join(', ')} · {formatDate(latest.createdAt)}{' '}
|
||||
{formatTime(latest.createdAt)}
|
||||
{latest.author ? ` · ${latest.author}` : ''}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user