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>
186 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
};
|