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>
122 lines
4.4 KiB
TypeScript
122 lines
4.4 KiB
TypeScript
import type { Selection } from './useSelection';
|
|
import { Archive, ArchiveRestore, Heart, Plus, Trash2, Undo2, X } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { useAlbums } from './usePhotosData';
|
|
import { useAssetActions } from './useAssetActions';
|
|
|
|
// The bar that replaces the header once anything is selected — Immich does the same, and it keeps the
|
|
// destructive buttons off screen entirely until they have something to act on.
|
|
|
|
type SelectionBarProps = {
|
|
selection: Selection;
|
|
/** Trash swaps the whole action set: restore and delete-forever, not favorite and archive. */
|
|
inTrash?: boolean;
|
|
/** When viewing one album, offer "remove from album" alongside the library-wide actions. */
|
|
albumId?: string;
|
|
/** Currently in the archive view, so the archive button should read "unarchive". */
|
|
archived?: boolean;
|
|
};
|
|
|
|
export const SelectionBar = ({ selection, inTrash = false, albumId, archived = false }: SelectionBarProps) => {
|
|
const { ids, count, clear, selectAll } = selection;
|
|
const { setFavorite, setArchived, trash, restore, deleteForever, addToAlbum, removeFromAlbum, createAlbum } =
|
|
useAssetActions();
|
|
const { data: albums } = useAlbums();
|
|
|
|
const run = (action: () => void) => {
|
|
action();
|
|
clear();
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center gap-1 border-b px-2 py-1.5">
|
|
<Button variant="ghost" size="icon" onClick={clear} title="Clear selection">
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
<span className="mr-2 text-sm font-medium">{count} selected</span>
|
|
<Button variant="ghost" size="sm" onClick={selectAll}>
|
|
Select all
|
|
</Button>
|
|
|
|
<div className="flex-1" />
|
|
|
|
{inTrash ? (
|
|
<>
|
|
<Button variant="ghost" size="sm" onClick={() => run(() => restore.mutate(ids))}>
|
|
<Undo2 className="mr-1.5 h-4 w-4" />
|
|
Restore
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => run(() => deleteForever.mutate(ids))}>
|
|
<Trash2 className="mr-1.5 h-4 w-4" />
|
|
Delete permanently
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="sm">
|
|
<Plus className="mr-1.5 h-4 w-4" />
|
|
Add to album
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
const albumName = window.prompt('New album name');
|
|
if (albumName) run(() => createAlbum.mutate({ albumName, assetIds: ids }));
|
|
}}
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
New album
|
|
</DropdownMenuItem>
|
|
{albums && albums.length > 0 && <DropdownMenuSeparator />}
|
|
{albums?.map((album) => (
|
|
<DropdownMenuItem
|
|
key={album.id}
|
|
onClick={() => run(() => addToAlbum.mutate({ albumId: album.id, ids }))}
|
|
>
|
|
{album.albumName}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{albumId && (
|
|
<Button variant="ghost" size="sm" onClick={() => run(() => removeFromAlbum.mutate({ albumId, ids }))}>
|
|
Remove from album
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title="Favorite"
|
|
onClick={() => run(() => setFavorite.mutate({ ids, isFavorite: true }))}
|
|
>
|
|
<Heart className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title={archived ? 'Unarchive' : 'Archive'}
|
|
onClick={() => run(() => setArchived.mutate({ ids, archived: !archived }))}
|
|
>
|
|
{archived ? <ArchiveRestore className="h-4 w-4" /> : <Archive className="h-4 w-4" />}
|
|
</Button>
|
|
<Button variant="ghost" size="icon" title="Move to trash" onClick={() => run(() => trash.mutate(ids))}>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|