feat: add core domain types for Photo Gallery SDK including media items, albums, and annotations

This commit is contained in:
2026-07-23 07:39:01 +05:30
parent 7bcd1a2a2d
commit 2613c767a6
107 changed files with 20699 additions and 7 deletions
@@ -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 &amp; 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>
);
}