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>
96 lines
3.4 KiB
TypeScript
96 lines
3.4 KiB
TypeScript
import type { LucideIcon } from 'lucide-react';
|
|
import { NavLink } from 'react-router';
|
|
import { Archive, Compass, Heart, Images, Map as MapIcon, Plug, Search, Share2, Trash2, Users } from 'lucide-react';
|
|
import { AccountSwitcher } from './AccountSwitcher';
|
|
import { PHOTOS_SECTIONS, photosSectionPath, type PhotosSectionId } from './shared';
|
|
import { useAssetStats } from './usePhotosData';
|
|
|
|
// Left panel of the /photos workspace, mirroring Immich's own sidebar: library sections up top, the sharing
|
|
// and cleanup ones below a divider, and the library size at the bottom.
|
|
//
|
|
// Sections are react-router NavLinks to /photos/<section> — active state from the router, cmd-click works.
|
|
|
|
const ICONS: Record<PhotosSectionId, LucideIcon> = {
|
|
timeline: Images,
|
|
explore: Compass,
|
|
map: MapIcon,
|
|
search: Search,
|
|
albums: Images,
|
|
people: Users,
|
|
favorites: Heart,
|
|
archive: Archive,
|
|
sharing: Share2,
|
|
trash: Trash2,
|
|
settings: Plug,
|
|
};
|
|
|
|
// Immich draws the same line: browsing the library, then everything else.
|
|
const LIBRARY: PhotosSectionId[] = ['timeline', 'explore', 'map', 'search', 'albums', 'people', 'favorites'];
|
|
|
|
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
|
|
|
|
export const PhotosNav = () => {
|
|
const { data: stats } = useAssetStats();
|
|
|
|
const group = (ids: PhotosSectionId[]) =>
|
|
PHOTOS_SECTIONS.filter((section) => ids.includes(section.id)).map(({ id, label }) => {
|
|
const Icon = ICONS[id];
|
|
return (
|
|
<NavLink
|
|
key={id}
|
|
to={photosSectionPath(id)}
|
|
className={({ isActive }) =>
|
|
`${ROW} ${
|
|
isActive
|
|
? 'bg-primary/10 font-medium text-primary'
|
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
}`
|
|
}
|
|
>
|
|
{({ isActive }) => (
|
|
<>
|
|
{isActive && (
|
|
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
|
|
)}
|
|
<Icon
|
|
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
|
/>
|
|
{label}
|
|
</>
|
|
)}
|
|
</NavLink>
|
|
);
|
|
});
|
|
|
|
const rest = PHOTOS_SECTIONS.map((section) => section.id).filter((id) => !LIBRARY.includes(id));
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
|
<div className="flex items-center gap-3 px-4 py-4">
|
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-success/15 text-success ring-1 ring-black/5">
|
|
<Images className="h-5 w-5" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="truncate text-sm font-semibold leading-tight">Photos</div>
|
|
<AccountSwitcher />
|
|
</div>
|
|
</div>
|
|
|
|
<nav className="flex flex-col gap-0.5 px-2">{group(LIBRARY)}</nav>
|
|
<div className="mx-4 my-2 border-t" />
|
|
<nav className="flex flex-col gap-0.5 px-2 pb-3">{group(rest)}</nav>
|
|
|
|
<div className="flex-1" />
|
|
|
|
{stats && (
|
|
<div className="border-t px-4 py-3 text-xs text-muted-foreground">
|
|
<p>{stats.total.toLocaleString()} items</p>
|
|
<p>
|
|
{stats.images.toLocaleString()} photos · {stats.videos.toLocaleString()} videos
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|