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,172 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { ArrowLeft, ImageOff, MoreVertical, Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { AssetViewer } from './AssetViewer';
|
||||
import { PhotoGrid } from './PhotoGrid';
|
||||
import { SelectionBar } from './SelectionBar';
|
||||
import { ALBUM_PARAM, formatDateRange, thumbUrl, toGridAsset } from './shared';
|
||||
import { useAlbum, useAlbums } from './usePhotosData';
|
||||
import { useAssetActions } from './useAssetActions';
|
||||
import { useSelection } from './useSelection';
|
||||
|
||||
// Two states in one section, switched by `?album=<id>`: the wall of album covers, and one album's photos.
|
||||
// A separate `/photos/albums/:id` route would work too, but the album is a selection inside the section — the
|
||||
// same shape as `?selected=` elsewhere in the app — and this keeps the nav highlight where it belongs.
|
||||
|
||||
export const AlbumsSection = () => {
|
||||
const [params] = useSearchParams();
|
||||
const albumId = params.get(ALBUM_PARAM);
|
||||
return albumId ? <AlbumDetailView albumId={albumId} /> : <AlbumListView />;
|
||||
};
|
||||
|
||||
const AlbumListView = () => {
|
||||
const { token } = useClient();
|
||||
const { data: albums, isLoading } = useAlbums();
|
||||
const { createAlbum, deleteAlbum, renameAlbum } = useAssetActions();
|
||||
|
||||
const sorted = useMemo(() => [...(albums ?? [])].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)), [albums]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<h1 className="text-sm font-semibold">Albums</h1>
|
||||
<span className="text-xs text-muted-foreground">{sorted.length}</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const albumName = window.prompt('New album name');
|
||||
if (albumName) createAlbum.mutate({ albumName });
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
Create album
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading…</p>}
|
||||
{!isLoading && sorted.length === 0 && <p className="text-sm text-muted-foreground">No albums yet.</p>}
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-4">
|
||||
{sorted.map((album) => (
|
||||
<div key={album.id} className="group relative">
|
||||
<Link to={`?${ALBUM_PARAM}=${album.id}`} className="block">
|
||||
<div className="aspect-square overflow-hidden rounded-lg bg-white/5">
|
||||
{album.albumThumbnailAssetId ? (
|
||||
<img
|
||||
src={thumbUrl(album.albumThumbnailAssetId, token)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition group-hover:brightness-110"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<ImageOff className="h-8 w-8" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 truncate text-sm font-medium">{album.albumName}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{album.assetCount} items
|
||||
{album.shared ? ' · shared' : ''}
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1 h-7 w-7 bg-black/50 text-white opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const albumName = window.prompt('Rename album', album.albumName);
|
||||
if (albumName) renameAlbum.mutate({ albumId: album.id, albumName });
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => window.confirm(`Delete "${album.albumName}"?`) && deleteAlbum.mutate(album.id)}
|
||||
>
|
||||
Delete album
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AlbumDetailView = ({ albumId }: { albumId: string }) => {
|
||||
const { token } = useClient();
|
||||
const [params] = useSearchParams();
|
||||
const { data: album, isLoading } = useAlbum(albumId);
|
||||
|
||||
const assets = useMemo(() => (album?.assets ?? []).map(toGridAsset), [album]);
|
||||
const selection = useSelection(assets.map((asset) => asset.id));
|
||||
|
||||
const back = (() => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete(ALBUM_PARAM);
|
||||
const search = next.toString();
|
||||
return search ? `?${search}` : '?';
|
||||
})();
|
||||
|
||||
const range = formatDateRange(album?.startDate, album?.endDate);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{selection.isSelecting ? (
|
||||
<SelectionBar selection={selection} albumId={albumId} />
|
||||
) : (
|
||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Button asChild variant="ghost" size="icon" title="Back to albums">
|
||||
<Link to={back}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-sm font-semibold">{album?.albumName ?? 'Album'}</h1>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{album?.assetCount ?? 0} items{range ? ` · ${range}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{isLoading ? (
|
||||
<p className="p-2 text-sm text-muted-foreground">Loading…</p>
|
||||
) : (
|
||||
<PhotoGrid
|
||||
assets={assets}
|
||||
token={token}
|
||||
selection={selection}
|
||||
empty={<p className="p-2 text-sm text-muted-foreground">This album is empty.</p>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AssetViewer ids={assets.map((asset) => asset.id)} token={token} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user