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
+101
View File
@@ -0,0 +1,101 @@
import type { Asset } from './shared';
import { Link } from 'react-router';
import { Camera, Image as ImageIcon, MapPin } from 'lucide-react';
import { PERSON_PARAM, formatBytes, formatLongDate, formatTime, personThumbUrl, photosSectionPath } from './shared';
// The lightbox's info drawer, in the order Immich's own panel uses: description, date, file, camera, location,
// people. Sections with nothing in them are omitted rather than shown empty — a library full of screenshots
// has no camera data and shouldn't render a row of dashes for every one.
type AssetInfoPanelProps = { asset: Asset | undefined; token: string | null };
export const AssetInfoPanel = ({ asset, token }: AssetInfoPanelProps) => {
if (!asset) return <div className="p-4 text-sm text-muted-foreground">Loading…</div>;
const exif = asset.exifInfo;
const megapixels = asset.width && asset.height ? (asset.width * asset.height) / 1_000_000 : 0;
const place = [exif?.city, exif?.state, exif?.country].filter(Boolean).join(', ');
const aperture = exif?.fNumber ? `ƒ/${exif.fNumber}` : null;
const shot = [
aperture,
exif?.exposureTime ? `${exif.exposureTime}s` : null,
exif?.focalLength ? `${Math.round(exif.focalLength)}mm` : null,
exif?.iso ? `ISO ${exif.iso}` : null,
].filter(Boolean);
return (
<div className="space-y-5 p-4 text-sm">
{exif?.description && <p className="text-muted-foreground">{exif.description}</p>}
<section>
<p className="font-medium">{formatLongDate(asset.localDateTime)}</p>
<p className="text-xs text-muted-foreground">{formatTime(asset.localDateTime)}</p>
</section>
<Section icon={<ImageIcon className="h-4 w-4" />} title={asset.originalFileName}>
<Row
label={`${asset.width} × ${asset.height}`}
value={megapixels >= 0.1 ? `${megapixels.toFixed(1)} MP` : null}
/>
<Row label="Size" value={formatBytes(exif?.fileSizeInByte)} />
<Row label="Type" value={asset.originalMimeType ?? asset.type} />
<p className="break-all pt-1 text-xs text-muted-foreground">{asset.originalPath}</p>
</Section>
{(exif?.make || exif?.model || shot.length > 0) && (
<Section icon={<Camera className="h-4 w-4" />} title={[exif?.make, exif?.model].filter(Boolean).join(' ')}>
{exif?.lensModel && <p className="text-xs text-muted-foreground">{exif.lensModel}</p>}
{shot.length > 0 && <p className="text-xs text-muted-foreground">{shot.join(' · ')}</p>}
</Section>
)}
{(place || (exif?.latitude != null && exif?.longitude != null)) && (
<Section icon={<MapPin className="h-4 w-4" />} title={place || 'Location'}>
{exif?.latitude != null && exif?.longitude != null && (
<p className="text-xs text-muted-foreground">
{exif.latitude.toFixed(5)}, {exif.longitude.toFixed(5)}
</p>
)}
</Section>
)}
{asset.people && asset.people.length > 0 && (
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">People</h3>
<div className="flex flex-wrap gap-3">
{asset.people.map((person) => (
<Link
key={person.id}
to={`${photosSectionPath('people')}?${PERSON_PARAM}=${person.id}`}
className="w-16 text-center"
>
<img
src={personThumbUrl(person.id, token)}
alt=""
className="h-16 w-16 rounded-full object-cover ring-1 ring-white/10"
/>
<span className="mt-1 block truncate text-xs">{person.name || 'Unnamed'}</span>
</Link>
))}
</div>
</section>
)}
</div>
);
};
type SectionProps = { icon: React.ReactNode; title: string; children?: React.ReactNode };
const Section = ({ icon, title, children }: SectionProps) => (
<section className="flex gap-3">
<span className="mt-0.5 shrink-0 text-muted-foreground">{icon}</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{title}</p>
{children}
</div>
</section>
);
const Row = ({ label, value }: { label: string; value: string | null }) => (
<p className="text-xs text-muted-foreground">{value ? `${label} · ${value}` : label}</p>
);