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,254 @@
|
||||
import type { Asset } from './shared';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Heart,
|
||||
Info,
|
||||
Trash2,
|
||||
Undo2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ASSET_PARAM, originalUrl, thumbUrl, videoUrl } from './shared';
|
||||
import { AssetInfoPanel } from './AssetInfoPanel';
|
||||
import { useAsset } from './usePhotosData';
|
||||
import { useAssetActions } from './useAssetActions';
|
||||
|
||||
// The lightbox. Which asset is open lives in `?asset=<id>`, so it is linkable, survives a reload, and closes
|
||||
// with the browser's back button — the rule from docs/navigation-audit.md applied to the one piece of this
|
||||
// screen that most wants to be a modal with a useState behind it.
|
||||
|
||||
type AssetViewerProps = {
|
||||
/** Every asset currently on screen, in display order — this is what prev/next walk. */
|
||||
ids: string[];
|
||||
token: string | null;
|
||||
/** Trash shows restore/delete-forever instead of archive/delete. */
|
||||
inTrash?: boolean;
|
||||
};
|
||||
|
||||
export const AssetViewer = ({ ids, token, inTrash = false }: AssetViewerProps) => {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const openId = params.get(ASSET_PARAM);
|
||||
const { data: asset } = useAsset(openId);
|
||||
|
||||
const index = openId ? ids.indexOf(openId) : -1;
|
||||
const prev = index > 0 ? ids[index - 1] : undefined;
|
||||
const next = index >= 0 && index < ids.length - 1 ? ids[index + 1] : undefined;
|
||||
|
||||
const goTo = useCallback(
|
||||
(id: string | undefined) => {
|
||||
if (!id) return;
|
||||
setParams(
|
||||
(current) => {
|
||||
const nextParams = new URLSearchParams(current);
|
||||
nextParams.set(ASSET_PARAM, id);
|
||||
return nextParams;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setParams],
|
||||
);
|
||||
|
||||
// Replace, not push: stepping through fifty photos shouldn't bury the grid fifty entries deep in history.
|
||||
// Opening the viewer pushed one entry, so one Back still returns to where you were.
|
||||
const close = useCallback(() => {
|
||||
setParams((current) => {
|
||||
const nextParams = new URLSearchParams(current);
|
||||
nextParams.delete(ASSET_PARAM);
|
||||
return nextParams;
|
||||
});
|
||||
}, [setParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openId) return;
|
||||
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
if (ev.key === 'Escape') close();
|
||||
else if (ev.key === 'ArrowLeft') goTo(prev);
|
||||
else if (ev.key === 'ArrowRight') goTo(next);
|
||||
else if (ev.key === 'i') setShowInfo((value) => !value);
|
||||
else return;
|
||||
ev.preventDefault();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [openId, prev, next, close, goTo]);
|
||||
|
||||
if (!openId) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-black/95">
|
||||
<ViewerHeader
|
||||
asset={asset}
|
||||
id={openId}
|
||||
token={token}
|
||||
inTrash={inTrash}
|
||||
showInfo={showInfo}
|
||||
onToggleInfo={() => setShowInfo((value) => !value)}
|
||||
onClose={close}
|
||||
onAdvance={() => (next ? goTo(next) : close())}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="relative flex min-w-0 flex-1 items-center justify-center">
|
||||
<NavButton side="left" disabled={!prev} onClick={() => goTo(prev)} />
|
||||
|
||||
{asset?.type === 'VIDEO' ? (
|
||||
<video
|
||||
key={openId}
|
||||
src={videoUrl(openId, token)}
|
||||
poster={thumbUrl(openId, token, 'preview')}
|
||||
controls
|
||||
autoPlay
|
||||
className="max-h-full max-w-full"
|
||||
/>
|
||||
) : (
|
||||
// `preview` rather than `original`: originals are frequently 30MB HEIC/RAW the browser may not even
|
||||
// decode, and Immich's own viewer shows the preview for the same reason. Download gets the original.
|
||||
<img
|
||||
key={openId}
|
||||
src={thumbUrl(openId, token, 'preview')}
|
||||
alt={asset?.originalFileName ?? ''}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
)}
|
||||
|
||||
<NavButton side="right" disabled={!next} onClick={() => goTo(next)} />
|
||||
</div>
|
||||
|
||||
{showInfo && (
|
||||
<aside className="w-80 shrink-0 overflow-y-auto border-l border-white/10 bg-background">
|
||||
<AssetInfoPanel asset={asset} token={token} />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type NavButtonProps = { side: 'left' | 'right'; disabled: boolean; onClick: () => void };
|
||||
|
||||
const NavButton = ({ side, disabled, onClick }: NavButtonProps) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
aria-label={side === 'left' ? 'Previous' : 'Next'}
|
||||
className={`absolute ${side === 'left' ? 'left-2' : 'right-2'} z-10 rounded-full bg-black/40 p-2 text-white transition hover:bg-black/70 disabled:pointer-events-none disabled:opacity-0`}
|
||||
>
|
||||
{side === 'left' ? <ChevronLeft className="h-6 w-6" /> : <ChevronRight className="h-6 w-6" />}
|
||||
</button>
|
||||
);
|
||||
|
||||
type ViewerHeaderProps = {
|
||||
asset: Asset | undefined;
|
||||
id: string;
|
||||
token: string | null;
|
||||
inTrash: boolean;
|
||||
showInfo: boolean;
|
||||
onToggleInfo: () => void;
|
||||
onClose: () => void;
|
||||
onAdvance: () => void;
|
||||
};
|
||||
|
||||
const ViewerHeader = ({ asset, id, token, inTrash, showInfo, onToggleInfo, onClose, onAdvance }: ViewerHeaderProps) => {
|
||||
const { setFavorite, setArchived, trash, restore, deleteForever } = useAssetActions();
|
||||
const archived = asset?.visibility === 'archive';
|
||||
|
||||
// Anything that removes the asset from the current view advances to the next one first — otherwise the
|
||||
// viewer is left pointing at an id that no longer exists in the grid behind it.
|
||||
const removeThen = (run: () => void) => {
|
||||
run();
|
||||
onAdvance();
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex items-center gap-1 px-2 py-2 text-white">
|
||||
<Button variant="ghost" size="icon" onClick={onClose} title="Close (Esc)" className="text-white">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<span className="ml-1 min-w-0 flex-1 truncate text-sm text-white/70">{asset?.originalFileName}</span>
|
||||
|
||||
{inTrash ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Restore"
|
||||
className="text-white"
|
||||
onClick={() => removeThen(() => restore.mutate([id]))}
|
||||
>
|
||||
<Undo2 className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Delete permanently"
|
||||
className="text-white"
|
||||
onClick={() => removeThen(() => deleteForever.mutate([id]))}
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={asset?.isFavorite ? 'Remove from favorites' : 'Favorite'}
|
||||
className="text-white"
|
||||
onClick={() => setFavorite.mutate({ ids: [id], isFavorite: !asset?.isFavorite })}
|
||||
>
|
||||
<Heart className={`h-5 w-5 ${asset?.isFavorite ? 'fill-white' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={archived ? 'Unarchive' : 'Archive'}
|
||||
className="text-white"
|
||||
onClick={() => removeThen(() => setArchived.mutate({ ids: [id], archived: !archived }))}
|
||||
>
|
||||
{archived ? <ArchiveRestore className="h-5 w-5" /> : <Archive className="h-5 w-5" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Move to trash"
|
||||
className="text-white"
|
||||
onClick={() => removeThen(() => trash.mutate([id]))}
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={originalUrl(id, token)}
|
||||
download={asset?.originalFileName ?? ''}
|
||||
title="Download original"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-white hover:bg-white/10"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
</a>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Info (i)"
|
||||
className={`text-white ${showInfo ? 'bg-white/15' : ''}`}
|
||||
onClick={onToggleInfo}
|
||||
>
|
||||
<Info className="h-5 w-5" />
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user