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>
130 lines
5.0 KiB
TypeScript
130 lines
5.0 KiB
TypeScript
import { useMemo, useRef, useState } from 'react';
|
|
import { Link, useSearchParams } from 'react-router';
|
|
import { ArrowLeft } 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 { PERSON_PARAM, personThumbUrl } from './shared';
|
|
import { useBucketAssets, usePeople, usePerson, useTimeBuckets } from './usePhotosData';
|
|
import { useSelection } from './useSelection';
|
|
|
|
// Faces. The wall of people, and one person's photos behind `?person=<id>`.
|
|
//
|
|
// A person's photos come from the same bucketed timeline endpoint with `personId` set, so a face with 12,000
|
|
// photos loads the same way the main timeline does rather than as one enormous response.
|
|
|
|
export const PeopleSection = () => {
|
|
const [params] = useSearchParams();
|
|
const personId = params.get(PERSON_PARAM);
|
|
return personId ? <PersonDetailView personId={personId} /> : <PeopleListView />;
|
|
};
|
|
|
|
const PeopleListView = () => {
|
|
const { token } = useClient();
|
|
const { data, isLoading } = usePeople();
|
|
|
|
// Immich hides unnamed faces behind a toggle; the named ones are what anyone actually browses by.
|
|
const [showUnnamed, setShowUnnamed] = useState(false);
|
|
const people = useMemo(
|
|
() => (data?.people ?? []).filter((person) => showUnnamed || person.name),
|
|
[data, showUnnamed],
|
|
);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<header className="flex items-center gap-2 border-b px-3 py-2">
|
|
<h1 className="text-sm font-semibold">People</h1>
|
|
<span className="text-xs text-muted-foreground">{people.length}</span>
|
|
<div className="flex-1" />
|
|
<Button variant="ghost" size="sm" onClick={() => setShowUnnamed((value) => !value)}>
|
|
{showUnnamed ? 'Hide unnamed' : 'Show unnamed'}
|
|
</Button>
|
|
</header>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
|
{isLoading && <p className="text-sm text-muted-foreground">Loading…</p>}
|
|
{!isLoading && people.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">
|
|
No people yet — Immich populates this as faces are recognised.
|
|
</p>
|
|
)}
|
|
|
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(110px,1fr))] gap-4">
|
|
{people.map((person) => (
|
|
<Link key={person.id} to={`?${PERSON_PARAM}=${person.id}`} className="group text-center">
|
|
<img
|
|
src={personThumbUrl(person.id, token)}
|
|
alt=""
|
|
loading="lazy"
|
|
className="mx-auto aspect-square w-full rounded-full object-cover ring-1 ring-white/10 transition group-hover:ring-primary"
|
|
/>
|
|
<span className="mt-2 block truncate text-sm">{person.name || 'Unnamed'}</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const PersonDetailView = ({ personId }: { personId: string }) => {
|
|
const { token } = useClient();
|
|
const [params] = useSearchParams();
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
const [loaded, setLoaded] = useState<string[]>([]);
|
|
|
|
const { data: person } = usePerson(personId);
|
|
const filter = useMemo(() => ({ personId }), [personId]);
|
|
const { data: buckets } = 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 back = (() => {
|
|
const next = new URLSearchParams(params);
|
|
next.delete(PERSON_PARAM);
|
|
const search = next.toString();
|
|
return search ? `?${search}` : '?';
|
|
})();
|
|
|
|
const total = (buckets ?? []).reduce((sum, bucket) => sum + bucket.count, 0);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
{selection.isSelecting ? (
|
|
<SelectionBar selection={selection} />
|
|
) : (
|
|
<header className="flex items-center gap-2 border-b px-3 py-2">
|
|
<Button asChild variant="ghost" size="icon" title="Back to people">
|
|
<Link to={back}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
<img src={personThumbUrl(personId, token)} alt="" className="h-7 w-7 rounded-full object-cover" />
|
|
<h1 className="truncate text-sm font-semibold">{person?.name || 'Unnamed'}</h1>
|
|
<span className="text-xs text-muted-foreground">{total.toLocaleString()} items</span>
|
|
</header>
|
|
)}
|
|
|
|
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-2 pt-2">
|
|
<TimelineGrid
|
|
buckets={buckets ?? []}
|
|
assetsByBucket={assetsByBucket}
|
|
onBucketVisible={(bucket) => setLoaded((prev) => (prev.includes(bucket) ? prev : [...prev, bucket]))}
|
|
token={token}
|
|
selection={selection}
|
|
scrollRef={scrollRef}
|
|
/>
|
|
</div>
|
|
|
|
<AssetViewer ids={orderedIds} token={token} />
|
|
</div>
|
|
);
|
|
};
|