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:
@@ -0,0 +1,94 @@
|
||||
import { Link } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { PERSON_PARAM, QUERY_PARAM, personThumbUrl, photosSectionPath, thumbUrl } from './shared';
|
||||
import { useExplore, usePeople } from './usePhotosData';
|
||||
|
||||
// Explore: the faces row, then whatever Immich has clustered the library by — places and things.
|
||||
//
|
||||
// Immich's own explore tiles link to a structured search (`{"city":"Lisbon"}`). This proxy exposes smart and
|
||||
// metadata search only, so a tile links to a smart search for its label instead. Close in spirit, and honest
|
||||
// about it: a city tile finds photos that LOOK like that city rather than ones tagged with it.
|
||||
|
||||
const SECTION_TITLES: Record<string, string> = {
|
||||
'exifInfo.city': 'Places',
|
||||
'exifInfo.country': 'Countries',
|
||||
'smartInfo.objects': 'Things',
|
||||
'exifInfo.state': 'Regions',
|
||||
};
|
||||
|
||||
export const ExploreSection = () => {
|
||||
const { token } = useClient();
|
||||
const { data: explore, isLoading } = useExplore();
|
||||
const { data: people } = usePeople();
|
||||
|
||||
const faces = (people?.people ?? []).filter((person) => person.name).slice(0, 12);
|
||||
|
||||
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">Explore</h1>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto p-3">
|
||||
{faces.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-2 flex items-baseline gap-2">
|
||||
<h2 className="text-sm font-semibold">People</h2>
|
||||
<Link to={photosSectionPath('people')} className="text-xs text-muted-foreground hover:underline">
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{faces.map((person) => (
|
||||
<Link
|
||||
key={person.id}
|
||||
to={`${photosSectionPath('people')}?${PERSON_PARAM}=${person.id}`}
|
||||
className="w-20 text-center"
|
||||
>
|
||||
<img
|
||||
src={personThumbUrl(person.id, token)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-20 w-20 rounded-full object-cover ring-1 ring-white/10"
|
||||
/>
|
||||
<span className="mt-1 block truncate text-xs">{person.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading…</p>}
|
||||
|
||||
{(explore ?? []).map((section) => (
|
||||
<section key={section.fieldName}>
|
||||
<h2 className="mb-2 text-sm font-semibold">{SECTION_TITLES[section.fieldName] ?? section.fieldName}</h2>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||
{section.items.map((item) => (
|
||||
<Link
|
||||
key={`${section.fieldName}:${item.value}`}
|
||||
to={`${photosSectionPath('search')}?${QUERY_PARAM}=${encodeURIComponent(item.value)}`}
|
||||
className="group relative aspect-square overflow-hidden rounded-lg bg-white/5"
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(item.data.id, token)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition group-hover:scale-105"
|
||||
/>
|
||||
<span className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 text-sm font-medium text-white">
|
||||
{item.value}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{!isLoading && (explore ?? []).length === 0 && faces.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">Nothing to explore yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user