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:
@@ -0,0 +1,107 @@
|
||||
import type { GridAsset } from './shared';
|
||||
import { useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { CheckCircle2, Circle, Heart, Layers, Play } from 'lucide-react';
|
||||
import { ASSET_PARAM, formatDuration, thumbUrl } from './shared';
|
||||
|
||||
// One tile in the justified grid.
|
||||
//
|
||||
// The tile is a real <Link> to `?asset=<id>` — the lightbox is addressable, so a photo can be cmd-clicked
|
||||
// into a tab, linked to, and closed with the back button. The select checkbox is a sibling <button> layered
|
||||
// over it, never nested inside the anchor: a button inside an anchor is invalid HTML and swallows the
|
||||
// keyboard behaviour of both.
|
||||
|
||||
type AssetTileProps = {
|
||||
asset: GridAsset;
|
||||
token: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
selected: boolean;
|
||||
/** True once anything is selected — the checkbox stops being hover-only and the click selects, not opens. */
|
||||
selecting: boolean;
|
||||
onToggle: (id: string, ev: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
export const AssetTile = ({ asset, token, width, height, selected, selecting, onToggle }: AssetTileProps) => {
|
||||
const [params] = useSearchParams();
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const duration = formatDuration(asset.duration);
|
||||
|
||||
const href = (() => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set(ASSET_PARAM, asset.id);
|
||||
return `?${next.toString()}`;
|
||||
})();
|
||||
|
||||
const badges = (
|
||||
<>
|
||||
{asset.isFavorite && <Heart className="absolute bottom-1.5 left-1.5 h-4 w-4 fill-white text-white drop-shadow" />}
|
||||
{asset.stackCount > 1 && (
|
||||
<span className="absolute right-1.5 top-1.5 flex items-center gap-0.5 rounded bg-black/60 px-1 text-[10px] font-medium text-white">
|
||||
<Layers className="h-3 w-3" />
|
||||
{asset.stackCount}
|
||||
</span>
|
||||
)}
|
||||
{!asset.isImage && (
|
||||
<span className="absolute bottom-1.5 right-1.5 flex items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
||||
<Play className="h-2.5 w-2.5 fill-white" />
|
||||
{duration ?? ''}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
{/* A neutral block until the thumbnail decodes, so the row doesn't flash white on a dark page. */}
|
||||
<div className={`absolute inset-0 bg-white/5 transition-opacity ${loaded ? 'opacity-0' : 'opacity-100'}`} />
|
||||
<img
|
||||
src={thumbUrl(asset.id, token)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onLoad={() => setLoaded(true)}
|
||||
className={`h-full w-full object-cover transition-opacity duration-200 ${loaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
{badges}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0 overflow-hidden rounded-sm" style={{ width, height }}>
|
||||
{selecting ? (
|
||||
// Once a selection exists, a plain click extends it rather than navigating — matching Immich, where
|
||||
// you can rubber-band through a month without the lightbox opening on every tile.
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => onToggle(asset.id, ev)}
|
||||
className="group absolute inset-0 block h-full w-full cursor-pointer"
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
) : (
|
||||
<Link to={href} className="group absolute inset-0 block h-full w-full">
|
||||
{inner}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => onToggle(asset.id, ev)}
|
||||
title={selected ? 'Deselect' : 'Select'}
|
||||
className={`absolute left-1.5 top-1.5 rounded-full text-white transition-opacity ${
|
||||
selected ? 'opacity-100' : 'opacity-0 hover:opacity-100 focus-visible:opacity-100'
|
||||
} ${selecting ? 'opacity-100' : ''}`}
|
||||
>
|
||||
{selected ? (
|
||||
<CheckCircle2 className="h-5 w-5 fill-primary text-white" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 drop-shadow" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{selected && <span className="pointer-events-none absolute inset-0 rounded-sm ring-2 ring-inset ring-primary" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user