Files
photos/web/AlbumsSection.tsx
T
pastilhasandClaude Opus 5 70fc2b08c6 a shared link is a URL, not a key
Create, edit and revoke shared links from the album header, the selection
bar and the Sharing panel. Password, expiry, and the three flags that
decide what the person opening it can do.

The panel showed a bare key because this plugin never learned the
instance's public address. It does not need to store one: Immich has
`externalDomain` in its own admin settings, `/server/config` already
returns it, and it is the same field Immich's own copy-link button reads,
so the two cannot disagree. Empty is a real state and the UI says so
rather than inventing a host.

Payload shapes came from the live instance's spec.json, not from memory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:16:02 +01:00

186 lines
7.2 KiB
TypeScript

import { useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { ArrowLeft, ImageOff, MoreVertical, Plus, Share2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useClient } from 'hooks/useClient';
import { AssetViewer } from './AssetViewer';
import { PhotoGrid } from './PhotoGrid';
import { SelectionBar } from './SelectionBar';
import { ShareLinkDialog } from './ShareLinkDialog';
import { ALBUM_PARAM, formatDateRange, thumbUrl, toGridAsset } from './shared';
import { useAlbum, useAlbums } from './usePhotosData';
import { useAssetActions } from './useAssetActions';
import { useSelection } from './useSelection';
// Two states in one section, switched by `?album=<id>`: the wall of album covers, and one album's photos.
// A separate `/photos/albums/:id` route would work too, but the album is a selection inside the section — the
// same shape as `?selected=` elsewhere in the app — and this keeps the nav highlight where it belongs.
export const AlbumsSection = () => {
const [params] = useSearchParams();
const albumId = params.get(ALBUM_PARAM);
return albumId ? <AlbumDetailView albumId={albumId} /> : <AlbumListView />;
};
const AlbumListView = () => {
const { token } = useClient();
const { data: albums, isLoading } = useAlbums();
const { createAlbum, deleteAlbum, renameAlbum } = useAssetActions();
const sorted = useMemo(() => [...(albums ?? [])].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)), [albums]);
return (
<div className="flex h-full flex-col">
<header className="flex items-center gap-2 border-b px-3 py-2">
<h1 className="text-sm font-semibold">Albums</h1>
<span className="text-xs text-muted-foreground">{sorted.length}</span>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
onClick={() => {
const albumName = window.prompt('New album name');
if (albumName) createAlbum.mutate({ albumName });
}}
>
<Plus className="mr-1.5 h-4 w-4" />
Create album
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{isLoading && <p className="text-sm text-muted-foreground">Loading…</p>}
{!isLoading && sorted.length === 0 && <p className="text-sm text-muted-foreground">No albums yet.</p>}
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-4">
{sorted.map((album) => (
<div key={album.id} className="group relative">
<Link to={`?${ALBUM_PARAM}=${album.id}`} className="block">
<div className="aspect-square overflow-hidden rounded-lg bg-white/5">
{album.albumThumbnailAssetId ? (
<img
src={thumbUrl(album.albumThumbnailAssetId, token)}
alt=""
loading="lazy"
className="h-full w-full object-cover transition group-hover:brightness-110"
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
<ImageOff className="h-8 w-8" />
</div>
)}
</div>
<p className="mt-2 truncate text-sm font-medium">{album.albumName}</p>
<p className="truncate text-xs text-muted-foreground">
{album.assetCount} items
{album.shared ? ' · shared' : ''}
</p>
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1 h-7 w-7 bg-black/50 text-white opacity-0 group-hover:opacity-100"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
const albumName = window.prompt('Rename album', album.albumName);
if (albumName) renameAlbum.mutate({ albumId: album.id, albumName });
}}
>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => window.confirm(`Delete "${album.albumName}"?`) && deleteAlbum.mutate(album.id)}
>
Delete album
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
</div>
</div>
);
};
const AlbumDetailView = ({ albumId }: { albumId: string }) => {
const { token } = useClient();
const [params] = useSearchParams();
const { data: album, isLoading } = useAlbum(albumId);
const assets = useMemo(() => (album?.assets ?? []).map(toGridAsset), [album]);
const selection = useSelection(assets.map((asset) => asset.id));
const [sharing, setSharing] = useState(false);
const back = (() => {
const next = new URLSearchParams(params);
next.delete(ALBUM_PARAM);
const search = next.toString();
return search ? `?${search}` : '?';
})();
const range = formatDateRange(album?.startDate, album?.endDate);
return (
<div className="flex h-full flex-col">
{selection.isSelecting ? (
<SelectionBar selection={selection} albumId={albumId} />
) : (
<header className="flex items-center gap-2 border-b px-3 py-2">
<Button asChild variant="ghost" size="icon" title="Back to albums">
<Link to={back}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold">{album?.albumName ?? 'Album'}</h1>
<p className="truncate text-xs text-muted-foreground">
{album?.assetCount ?? 0} items{range ? ` · ${range}` : ''}
</p>
</div>
<div className="flex-1" />
<Button variant="ghost" size="sm" onClick={() => setSharing(true)} disabled={!album}>
<Share2 className="mr-1.5 h-4 w-4" />
Share
</Button>
</header>
)}
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{isLoading ? (
<p className="p-2 text-sm text-muted-foreground">Loading…</p>
) : (
<PhotoGrid
assets={assets}
token={token}
selection={selection}
empty={<p className="p-2 text-sm text-muted-foreground">This album is empty.</p>}
/>
)}
</div>
<AssetViewer ids={assets.map((asset) => asset.id)} token={token} />
<ShareLinkDialog
open={sharing}
onOpenChange={setSharing}
target={album ? { kind: 'album', albumId: album.id, albumName: album.albumName } : null}
/>
</div>
);
};