334 lines
12 KiB
TypeScript
334 lines
12 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, UserCollectionType } from "@/api/types";
|
|
import {
|
|
useCollectionGroups,
|
|
useCollections,
|
|
useCreateCollectionGroup,
|
|
useDeleteCollection,
|
|
useDeleteCollectionGroup,
|
|
useReorderCollectionGroups,
|
|
useReorderCollections,
|
|
useUpdateCollection,
|
|
useUpdateCollectionGroup,
|
|
} from "@/hooks/queries/collections";
|
|
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>
|
|
|
|
{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)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|