Files
photos/web/SearchSection.tsx
pastilhasandClaude Opus 5 2dad1907f8 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>
2026-08-18 19:10:39 +00:00

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>
);
};