Files
photos/web/SelectionBar.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

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>
);
};