Files
photos/web/AssetInfoPanel.tsx
T
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

102 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);