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>
115 lines
4.5 KiB
TypeScript
115 lines
4.5 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { Link, useSearchParams } from 'react-router';
|
|
import { Search, Sparkles } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { AssetViewer } from './AssetViewer';
|
|
import { PhotoGrid } from './PhotoGrid';
|
|
import { SelectionBar } from './SelectionBar';
|
|
import { ALBUM_PARAM, QUERY_PARAM, photosSectionPath, thumbUrl, toGridAsset } from './shared';
|
|
import { useSearch } from './usePhotosData';
|
|
import { useSelection } from './useSelection';
|
|
|
|
// Search. The query lives in `?q=`, so a search is a link — the same reason the lightbox lives in `?asset=`.
|
|
//
|
|
// Smart search is Immich's CLIP model ("red bicycle", "birthday cake") and is the default because it is the
|
|
// thing Immich has that a file browser does not. The toggle falls back to filename/metadata matching.
|
|
|
|
export const SearchSection = () => {
|
|
const { token } = useClient();
|
|
const [params, setParams] = useSearchParams();
|
|
const submitted = params.get(QUERY_PARAM) ?? '';
|
|
|
|
const [draft, setDraft] = useState(submitted);
|
|
const [smart, setSmart] = useState(true);
|
|
|
|
// The box follows the URL, so a link into /photos/search?q=… arrives with its own query already typed.
|
|
useEffect(() => setDraft(submitted), [submitted]);
|
|
|
|
const { data, isFetching } = useSearch({ query: submitted, smart, enabled: submitted.length > 0 });
|
|
|
|
const assets = useMemo(() => (data?.assets.items ?? []).map(toGridAsset), [data]);
|
|
const selection = useSelection(assets.map((asset) => asset.id));
|
|
|
|
const submit = (ev: React.FormEvent) => {
|
|
ev.preventDefault();
|
|
setParams((current) => {
|
|
const next = new URLSearchParams(current);
|
|
if (draft.trim()) next.set(QUERY_PARAM, draft.trim());
|
|
else next.delete(QUERY_PARAM);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
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">
|
|
<form onSubmit={submit} className="flex flex-1 items-center gap-2">
|
|
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<Input
|
|
value={draft}
|
|
onChange={(ev) => setDraft(ev.target.value)}
|
|
placeholder={smart ? 'Describe a photo — "sunset over water"' : 'Filename or path'}
|
|
className="h-8"
|
|
/>
|
|
<Button type="submit" size="sm" variant="secondary">
|
|
Search
|
|
</Button>
|
|
</form>
|
|
<Button
|
|
variant={smart ? 'default' : 'ghost'}
|
|
size="sm"
|
|
title="Smart search uses Immich's CLIP model instead of matching filenames"
|
|
onClick={() => setSmart((value) => !value)}
|
|
>
|
|
<Sparkles className="mr-1.5 h-4 w-4" />
|
|
Smart
|
|
</Button>
|
|
</header>
|
|
)}
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
|
{!submitted && <p className="p-2 text-sm text-muted-foreground">Search your library.</p>}
|
|
{submitted && isFetching && <p className="p-2 text-sm text-muted-foreground">Searching…</p>}
|
|
|
|
{data && data.albums.items.length > 0 && (
|
|
<section className="mb-4">
|
|
<h2 className="mb-2 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Albums</h2>
|
|
<div className="flex flex-wrap gap-3">
|
|
{data.albums.items.map((album) => (
|
|
<Link key={album.id} to={`${photosSectionPath('albums')}?${ALBUM_PARAM}=${album.id}`} className="w-32">
|
|
<div className="aspect-square overflow-hidden rounded-lg bg-white/5">
|
|
{album.albumThumbnailAssetId && (
|
|
<img
|
|
src={thumbUrl(album.albumThumbnailAssetId, token)}
|
|
alt=""
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
)}
|
|
</div>
|
|
<p className="mt-1 truncate text-xs">{album.albumName}</p>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{submitted && !isFetching && (
|
|
<PhotoGrid
|
|
assets={assets}
|
|
token={token}
|
|
selection={selection}
|
|
empty={<p className="p-2 text-sm text-muted-foreground">No matches.</p>}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<AssetViewer ids={assets.map((asset) => asset.id)} token={token} />
|
|
</div>
|
|
);
|
|
};
|