Files
silo-server/web/src/pages/Collections.tsx
T
88aa769fe6 feat(collections): surface server collections on the user Collections tab (#156)
* feat(collections): surface server collections on the user Collections tab

The user-facing Collections tab only showed personal collections, which are
usually empty — leaving most users with a confusingly blank page. Server
(admin-curated) collections were reachable only inside each individual
library's tab.

Add a new GET /collections/server endpoint that aggregates visible library
collections across every accessible library (honoring access scope, capped per
library with a total_count for a See all link), and restructure Collections.tsx
into two titled sections: Your collections (personal) and Server collections
(horizontal teaser rows per library, linking into each library's Collections
tab). Extract the shared CollectionPosterCard so the per-library grid and the
new rows share one implementation.

* fix(collections): match server-collections loading skeleton to row layout

The Server collections section renders as one horizontal teaser row per
library, but the loading skeleton showed a poster grid — so data arriving
visibly reflowed the page from a grid into rows. Mirror the final layout
(section header + per-library rows of poster cards) in the skeleton, and
drop the now-unused COLLECTION_POSTER_GRID_CLASSES import.

Addresses CodeRabbit review comment on PR #156.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Align server collections with shared carousel behavior

- Add opt-out edge padding to reusable media carousels
- Render server collection rows with shared carousel controls and spacing

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:51:12 -04:00

428 lines
15 KiB
TypeScript

import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router";
import {
Calendar,
Film,
Globe,
GripVertical,
Library,
Pencil,
Plus,
RefreshCw,
Sparkles,
Trash2,
} from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { CSS } from "@dnd-kit/utilities";
import type { Collection, ServerCollectionsLibrary, UserCollectionType } from "@/api/types";
import {
useCollectionGroups,
useCollections,
useCreateCollectionGroup,
useDeleteCollection,
useDeleteCollectionGroup,
useReorderCollectionGroups,
useReorderCollections,
useServerCollections,
useUpdateCollection,
useUpdateCollectionGroup,
} from "@/hooks/queries/collections";
import { CollectionPosterCard } from "@/components/collections/CollectionPosterCard";
import MediaCarousel from "@/components/MediaCarousel";
import { useSyncUserCollection } from "@/hooks/queries/userCollectionImports";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
import { CollectionTemplateGallery } from "@/components/CollectionTemplateGallery";
import {
GroupedCollectionsBoard,
useGroupedCollectionCard,
} from "@/components/collections/GroupedCollectionsBoard";
import { slugifyGroupSlug } from "@/lib/collectionGroups";
import {
buildUserCollectionCatalogHref,
buildUserCollectionEditorPath,
} from "./userCollectionsShared";
type ImportedCollectionType = Extract<UserCollectionType, "mdblist" | "tmdb" | "trakt">;
const SYNCABLE_TYPES = new Set<ImportedCollectionType>(["mdblist", "tmdb", "trakt"]);
function isImportedType(t: UserCollectionType): t is ImportedCollectionType {
return SYNCABLE_TYPES.has(t as ImportedCollectionType);
}
export default function Collections() {
return <CollectionList />;
}
function CollectionList() {
const { data, isLoading } = useCollections();
const { data: groupsData } = useCollectionGroups();
const collections = useMemo(() => data ?? [], [data]);
const groups = useMemo(() => groupsData ?? [], [groupsData]);
const [confirmDeleteCollection, setConfirmDeleteCollection] = useState<Collection | null>(null);
const [galleryOpen, setGalleryOpen] = useState(false);
const navigate = useNavigate();
const deleteMutation = useDeleteCollection();
const syncMutation = useSyncUserCollection();
const reorderMutation = useReorderCollections();
const updateMutation = useUpdateCollection();
const createGroupMutation = useCreateCollectionGroup();
const renameGroupMutation = useUpdateCollectionGroup();
const deleteGroupMutation = useDeleteCollectionGroup();
const reorderGroupsMutation = useReorderCollectionGroups();
useDocumentTitle("Collections");
if (isLoading)
return (
<div className="page-shell space-y-4 py-4 sm:py-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-[1.6rem]" />
))}
</div>
</div>
);
return (
<div className="page-shell space-y-6 py-4 sm:py-6">
<ConfirmDialog
open={confirmDeleteCollection !== null}
onOpenChange={(open) => {
if (!open) setConfirmDeleteCollection(null);
}}
title="Delete collection"
description={`Delete collection "${confirmDeleteCollection?.name}"? This action cannot be undone.`}
confirmLabel="Delete"
variant="destructive"
onConfirm={() => {
if (confirmDeleteCollection) deleteMutation.mutate(confirmDeleteCollection.id);
setConfirmDeleteCollection(null);
}}
/>
<CollectionTemplateGallery mode="user" open={galleryOpen} onOpenChange={setGalleryOpen} />
<div className="page-header">
<div className="space-y-3">
<h1 className="page-title text-[clamp(2rem,5vw,3.25rem)]">Collections</h1>
<p className="page-subtitle text-sm sm:text-base">
Build personal or shared shelves around moods, series arcs, or anything else worth
grouping.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
<Sparkles className="mr-1 h-4 w-4" /> Browse Templates
</Button>
<Button size="sm" onClick={() => navigate(buildUserCollectionEditorPath("new"))}>
<Plus className="mr-1 h-4 w-4" /> New Collection
</Button>
</div>
</div>
<section className="space-y-4">
<h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">Your collections</h2>
{collections.length === 0 ? (
<div className="surface-panel flex flex-col items-center justify-center gap-3 rounded-[2rem] py-16 text-center">
<Library className="text-muted-foreground/50 h-10 w-10" />
<div className="space-y-1">
<p className="text-sm font-medium">No collections yet</p>
<p className="text-muted-foreground max-w-sm text-xs">
Start from a curated TMDB, Trakt, or MDBList template — or build your own from
scratch.
</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button variant="outline" size="sm" onClick={() => setGalleryOpen(true)}>
<Sparkles className="mr-1 h-4 w-4" /> Start from a template
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => navigate(buildUserCollectionEditorPath("new"))}
>
<Plus className="mr-1 h-4 w-4" /> Create from scratch
</Button>
</div>
</div>
) : (
<GroupedCollectionsBoard
items={collections}
groups={groups}
renderItem={(collection) => {
const syncable = isImportedType(collection.collection_type);
const isSyncing = syncMutation.isPending && syncMutation.variables === collection.id;
return (
<SortableCollectionCard
collection={collection}
syncable={syncable}
isSyncing={isSyncing}
onSync={() => syncMutation.mutate(collection.id)}
onEdit={() => navigate(buildUserCollectionEditorPath(collection.id))}
onDelete={() => setConfirmDeleteCollection(collection)}
/>
);
}}
onReorderInGroup={(groupId, orderedIds) =>
reorderMutation.mutate({ orderedIds, groupId })
}
onMoveItemAcross={(itemId, toGroupId) =>
updateMutation.mutate({ id: itemId, body: { group_id: toGroupId } })
}
onReorderGroups={(orderedIds) => reorderGroupsMutation.mutate(orderedIds)}
onAddGroup={(title) =>
createGroupMutation.mutate({ slug: slugifyGroupSlug(title), name: title })
}
onRenameGroup={(id, title) => renameGroupMutation.mutate({ id, name: title })}
onDeleteGroup={(id) => deleteGroupMutation.mutate(id)}
/>
)}
</section>
<ServerCollectionsSection />
</div>
);
}
// ServerCollectionsSection renders admin-curated collections aggregated across
// every accessible library, one horizontal teaser row per library. Each row's
// title (and the "Explore all" action when the library has more) links into
// that library's full Collections tab.
function ServerCollectionsSection() {
const { data, isLoading } = useServerCollections();
const libraries = data ?? [];
if (isLoading) {
// Mirror the loaded layout (per-library horizontal rows) so data arriving
// doesn't shift the page from a grid into rows.
return (
<section className="space-y-6">
<div className="space-y-1">
<h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">Server collections</h2>
<p className="text-muted-foreground text-sm">
Curated shelves from across every library on this server.
</p>
</div>
<div className="space-y-8">
{Array.from({ length: 2 }).map((_, row) => (
<div key={row} className="space-y-5">
<Skeleton className="h-7 w-40" />
<div className="flex gap-4 overflow-hidden lg:gap-5">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="w-[130px] shrink-0 sm:w-[150px] lg:w-[178px]">
<Skeleton className="aspect-[2/3] rounded-xl" />
<Skeleton className="mt-2.5 h-4 w-3/4" />
</div>
))}
</div>
</div>
))}
</div>
</section>
);
}
if (libraries.length === 0) return null;
return (
<section className="space-y-6">
<div className="space-y-1">
<h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">Server collections</h2>
<p className="text-muted-foreground text-sm">
Curated shelves from across every library on this server.
</p>
</div>
<div className="space-y-8">
{libraries.map((library) => (
<ServerLibraryRow key={library.library_id} library={library} />
))}
</div>
</section>
);
}
// One library's teaser row of server collections, rendered with the shared
// MediaCarousel so it matches every other content row in the app (hover scroll
// arrows, edge fades, drag/snap, keyboard nav). edgePadding is off because this
// section sits inside the Collections page's `page-shell`, which already
// supplies horizontal padding — the row title and cards align to that column.
function ServerLibraryRow({ library }: { library: ServerCollectionsLibrary }) {
const navigate = useNavigate();
const collectionsHref = `/library/${library.library_id}?tab=collections`;
const hasMore = library.total_count > library.collections.length;
return (
<MediaCarousel
title={library.library_name}
titleHref={collectionsHref}
onViewAll={hasMore ? () => navigate(collectionsHref) : undefined}
edgePadding={false}
>
{library.collections.map((collection) => (
<div key={collection.id} className="w-[130px] sm:w-[150px] lg:w-[178px]">
<CollectionPosterCard
collection={collection}
kind="regular"
libraryId={library.library_id}
/>
</div>
))}
</MediaCarousel>
);
}
function SortableCollectionCard({
collection,
syncable,
isSyncing,
onSync,
onEdit,
onDelete,
}: {
collection: Collection;
syncable: boolean;
isSyncing: boolean;
onSync: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useGroupedCollectionCard(collection.id);
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
return (
<Card
ref={setNodeRef}
style={style}
className="surface-panel hover:border-primary group relative rounded-[1.6rem] border-0 transition-all hover:-translate-y-1"
>
<CardHeader className="flex-row items-center justify-between space-y-0">
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
aria-label={`Drag ${collection.name}`}
className="hover:bg-surface-hover relative z-10 -ml-1 cursor-grab touch-none rounded-md p-1 opacity-0 transition group-focus-within:opacity-100 group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100"
{...attributes}
{...listeners}
>
<GripVertical className="text-muted-foreground h-4 w-4" />
</button>
<div className="min-w-0 space-y-2">
<CardTitle className="text-base">
<Link
to={buildUserCollectionCatalogHref(collection.id, collection.name)}
className="cursor-pointer after:absolute after:inset-0"
>
{collection.name}
</Link>
</CardTitle>
<CollectionBadges collection={collection} />
</div>
</div>
<div className="relative z-10 flex gap-1 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100">
{syncable ? (
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
aria-label="Sync collection"
disabled={isSyncing}
onClick={(event) => {
event.stopPropagation();
onSync();
}}
>
<RefreshCw className={`h-3 w-3 ${isSyncing ? "animate-spin" : ""}`} />
</Button>
) : null}
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
aria-label="Edit collection"
onClick={(event) => {
event.stopPropagation();
onEdit();
}}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
aria-label="Delete collection"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</CardHeader>
</Card>
);
}
const TYPE_LABELS: Record<UserCollectionType, string> = {
manual: "manual",
smart: "smart",
mdblist: "MDBList",
tmdb: "TMDB",
trakt: "Trakt",
};
const TYPE_ICONS: Record<UserCollectionType, typeof Film> = {
manual: Film,
smart: Sparkles,
mdblist: Globe,
tmdb: Globe,
trakt: Globe,
};
const SYNC_STATUS_BADGES: Partial<
Record<
NonNullable<Collection["last_sync_status"]>,
{ variant: "outline" | "destructive"; label: string }
>
> = {
warning: { variant: "outline", label: "Sync warning" },
failed: { variant: "destructive", label: "Sync failed" },
};
function CollectionBadges({ collection }: { collection: Collection }) {
const TypeIcon = TYPE_ICONS[collection.collection_type] ?? Film;
const typeLabel = TYPE_LABELS[collection.collection_type] ?? collection.collection_type;
const statusBadge = collection.last_sync_status
? SYNC_STATUS_BADGES[collection.last_sync_status]
: undefined;
return (
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
<TypeIcon className="mr-1 h-3 w-3" />
{typeLabel}
</Badge>
{collection.is_shared ? <Badge variant="outline">Shared</Badge> : null}
{collection.sync_schedule ? (
<Badge variant="outline">
<Calendar className="mr-1 h-3 w-3" />
{collection.sync_schedule}
</Badge>
) : null}
{statusBadge ? <Badge variant={statusBadge.variant}>{statusBadge.label}</Badge> : null}
</div>
);
}