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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user