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

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