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>
102 lines
4.3 KiB
TypeScript
102 lines
4.3 KiB
TypeScript
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>
|
||
);
|