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,82 @@
|
||||
import { Link } from 'react-router';
|
||||
import { Check, ChevronsUpDown, Loader2, Settings2 } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { photosSectionPath } from './shared';
|
||||
import { usePhotosAccountActions, usePhotosAccounts, usePhotosHealth } from './usePhotosData';
|
||||
|
||||
// Which Immich library you are looking at, and how to change it.
|
||||
//
|
||||
// The subtitle under "Photos" was already the connection state, so the switcher lives there rather than
|
||||
// adding a second control saying nearly the same thing. With one account it is exactly what it was: a line
|
||||
// of text plus a way into the settings screen.
|
||||
//
|
||||
// Switching invalidates the whole ['photos'] key, because it changes the answer to every query in the
|
||||
// workspace without changing any of their inputs — the grid, the albums, the map are all the other library's
|
||||
// now. Anything less and the previous account's thumbnails stay on screen.
|
||||
|
||||
export const AccountSwitcher = () => {
|
||||
const { data } = usePhotosAccounts();
|
||||
const { data: health } = usePhotosHealth();
|
||||
const { activate } = usePhotosAccountActions();
|
||||
|
||||
const accounts = data?.accounts ?? [];
|
||||
const active = accounts.find((account) => account.isActive) ?? null;
|
||||
|
||||
// Before the registry answers, fall back to health — it is the query that was already driving this line.
|
||||
const status = active?.label ?? (health?.ok ? (health.version ?? 'connected') : 'not connected');
|
||||
|
||||
if (accounts.length < 2) {
|
||||
return (
|
||||
<Link
|
||||
to={photosSectionPath('settings')}
|
||||
className="block truncate text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{status}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
|
||||
<span className="truncate">{status}</span>
|
||||
{activate.isPending ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<ChevronsUpDown className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60">
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">Immich accounts</DropdownMenuLabel>
|
||||
{accounts.map((account) => (
|
||||
<DropdownMenuItem
|
||||
key={account.id}
|
||||
disabled={account.isActive || activate.isPending}
|
||||
onSelect={() => void activate.mutateAsync(account.id).catch(() => undefined)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Check className={`h-3.5 w-3.5 shrink-0 ${account.isActive ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{account.label}</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">{account.url}</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={photosSectionPath('settings')} className="gap-2">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
Manage accounts
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user