photos, extracted from the platform into its own repository

The fifth plugin out, and the cheapest so far — offscale's shape rather than
music's or vault's. Nothing stayed behind and nothing had to be redesigned,
for three structural reasons recorded in PLUGIN.md:

  • No reverse dependency. Nothing in src/ imported anything of photos'.
  • Configuration was already right. The URL and API key live in photos_config,
    a per-user registry encrypted at rest and driven from the app. Vault had to
    move VAULTWARDEN_URL out of .env and invent a settings panel; here the
    sidecar already read the database, and its own comment already said why
    nothing reads process.env.
  • The frontend was already panels. The screen was a WorkspaceView around
    exactly photos-nav and photos-view, so web/panels.ts and web/layout.ts are
    the screen's contents with the wrapper removed.

The router's prefix is now derived from mountPrefix rather than the literal
'/api/photos', which was correct only by coincidence — a third-party publisher
mounts at /api/p/<publisher>/photos and the literal would forward the wrong
path to the sidecar.

Palette debt paid down as the runbook requires: 6 classes (it says 47; someone
had already done most of it), all of them state colours wearing palette
numbers, now `success` and `warning` from the closed tone set so they follow
the theme.

Immich itself is still provisioned by the app store rather than by this plugin
— the `provisioned` half of its old catalogue entry has no home until Docker
provisioning for plugins exists. Point it at an instance you already run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 19:10:39 +00:00
co-authored by Claude Opus 5
commit 2dad1907f8
39 changed files with 5321 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
import type { PhotosSectionId } from './shared';
import type { TimelineFilter } from './usePhotosData';
import { useCallback, useMemo, useRef, useState } from 'react';
import { Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { AssetViewer } from './AssetViewer';
import { SelectionBar } from './SelectionBar';
import { TimelineGrid } from './TimelineGrid';
import { useAssetActions } from './useAssetActions';
import { useBucketAssets, useTimeBuckets } from './usePhotosData';
import { useSelection } from './useSelection';
// Timeline, Favorites, Archive and Trash are the same screen with a different filter — which is also true in
// Immich, and is why the bucket API takes the filter as query params rather than having four endpoints.
const FILTERS: Partial<Record<PhotosSectionId, TimelineFilter>> = {
timeline: { visibility: 'timeline', withPartners: true, withStacked: true },
favorites: { isFavorite: true, withStacked: true },
archive: { visibility: 'archive', withStacked: true },
trash: { isTrashed: true },
};
const TITLES: Partial<Record<PhotosSectionId, string>> = {
timeline: 'Photos',
favorites: 'Favorites',
archive: 'Archive',
trash: 'Trash',
};
type TimelineSectionProps = { section: PhotosSectionId };
export const TimelineSection = ({ section }: TimelineSectionProps) => {
const { token } = useClient();
const scrollRef = useRef<HTMLDivElement | null>(null);
const [loaded, setLoaded] = useState<string[]>([]);
const { emptyTrash } = useAssetActions();
const filter = FILTERS[section] ?? FILTERS.timeline!;
const inTrash = section === 'trash';
const { data: buckets, isLoading } = useTimeBuckets(filter);
const assetsByBucket = useBucketAssets(filter, loaded);
const orderedIds = useMemo(
() => (buckets ?? []).flatMap((bucket) => (assetsByBucket[bucket.timeBucket] ?? []).map((asset) => asset.id)),
[buckets, assetsByBucket],
);
const selection = useSelection(orderedIds);
const onBucketVisible = useCallback((bucket: string) => {
setLoaded((prev) => (prev.includes(bucket) ? prev : [...prev, bucket]));
}, []);
const total = (buckets ?? []).reduce((sum, bucket) => sum + bucket.count, 0);
return (
<div className="flex h-full flex-col">
{selection.isSelecting ? (
<SelectionBar selection={selection} inTrash={inTrash} archived={section === 'archive'} />
) : (
<header className="flex items-center gap-2 border-b px-3 py-2">
<h1 className="text-sm font-semibold">{TITLES[section] ?? 'Photos'}</h1>
<span className="text-xs text-muted-foreground">{total.toLocaleString()} items</span>
<div className="flex-1" />
{inTrash && total > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => window.confirm(`Permanently delete ${total} items?`) && emptyTrash.mutate()}
>
<Trash2 className="mr-1.5 h-4 w-4" />
Empty trash
</Button>
)}
</header>
)}
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-2 pt-2">
{isLoading && <p className="p-4 text-sm text-muted-foreground">Loading…</p>}
{!isLoading && total === 0 && <p className="p-4 text-sm text-muted-foreground">Nothing here.</p>}
<TimelineGrid
buckets={buckets ?? []}
assetsByBucket={assetsByBucket}
onBucketVisible={onBucketVisible}
token={token}
selection={selection}
scrollRef={scrollRef}
/>
</div>
<AssetViewer ids={orderedIds} token={token} inTrash={inTrash} />
</div>
);
};