diff --git a/apps/web/src/routes/dashboard.collections.tsx b/apps/web/src/routes/dashboard.collections.tsx index 292e760..4125529 100644 --- a/apps/web/src/routes/dashboard.collections.tsx +++ b/apps/web/src/routes/dashboard.collections.tsx @@ -2,9 +2,17 @@ * Collections — group your components into installable kits. One install * command pulls every member, each digest-verified individually. */ -import { useState } from "react"; -import { createFileRoute, useRouter } from "@tanstack/react-router"; -import { HiSquare3Stack3D as Layers, HiArrowPath as Loader2, HiPlus as Plus, HiTrash as Trash2 } from "react-icons/hi2"; +import { useMemo, useState } from "react"; +import { createFileRoute, Link, useRouter } from "@tanstack/react-router"; +import { + HiArrowPath as Loader2, + HiArrowTopRightOnSquare as ExternalLink, + HiCheckCircle as CheckCircle, + HiMagnifyingGlass as Search, + HiPlus as Plus, + HiSquare3Stack3D as Layers, + HiTrash as Trash2, +} from "react-icons/hi2"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; @@ -15,10 +23,13 @@ import { Dialog, DialogContent, DialogDescription, + DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { deleteCollection, fetchMyCollections, saveCollection, type MyCollection } from "@/lib/collections"; import { fetchMyComponents } from "@/lib/catalog-db"; import { setCollectionExternalUrl, setCollectionPrice } from "@/lib/marketplace"; @@ -36,74 +47,188 @@ export const Route = createFileRoute("/dashboard/collections")({ component: CollectionsPage, }); +type SaleFilter = "all" | "free" | "for-sale"; + function CollectionsPage() { const { collections, components, payouts } = Route.useLoaderData(); - const eligible = components; + const { user } = Route.useRouteContext(); + const username = user?.username ?? null; + const eligible = useMemo(() => components.map((component) => ({ name: component.name, title: component.title })), [components]); + const [query, setQuery] = useState(""); + const [saleFilter, setSaleFilter] = useState("all"); + + const stats = useMemo(() => ({ + collections: collections.length, + entries: collections.reduce((total, collection) => total + collection.items.length, 0), + live: collections.reduce((total, collection) => total + collection.items.filter((item) => item.reviewStatus === "approved").length, 0), + forSale: collections.filter((collection) => collection.price != null || collection.externalUrl).length, + }), [collections]); + + const filteredCollections = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + return collections.filter((collection) => { + const forSale = collection.price != null || Boolean(collection.externalUrl); + if (saleFilter === "free" && forSale) return false; + if (saleFilter === "for-sale" && !forSale) return false; + if (!normalizedQuery) return true; + return collection.title.toLowerCase().includes(normalizedQuery) + || collection.name.toLowerCase().includes(normalizedQuery) + || collection.description.toLowerCase().includes(normalizedQuery) + || collection.items.some((item) => item.title.toLowerCase().includes(normalizedQuery) || item.name.includes(normalizedQuery)); + }); + }, [collections, query, saleFilter]); return ( -
+
({ name: c.name, title: c.title }))} />} + description="Compile your components into installable kits — one command installs every member, each digest-verified individually. For example, an entire shadcn registry may be compiled into a collection. Only approved, public members serve." + action={} /> -
- {collections.length === 0 ? ( - - ) : ( - collections.map((collection) => ({ name: c.name, title: c.title }))} payoutsEnabled={payouts.payoutsEnabled} />) - )} +
+ + + + +
+ +
+
+ + setQuery(event.target.value)} placeholder="Search collections and components" aria-label="Search collections and components" className="pl-9" /> +
+ + {filteredCollections.length} {filteredCollections.length === 1 ? "collection" : "collections"} shown
+ + {collections.length === 0 ? ( + } + className="mt-6 min-h-64" + /> + ) : filteredCollections.length === 0 ? ( + { setQuery(""); setSaleFilter("all"); }}>Clear filters} + className="mt-6 min-h-48" + /> + ) : ( +
+ {filteredCollections.map((collection) => ( + + ))} +
+ )}
); } -function CollectionRow({ collection, eligible, payoutsEnabled }: { collection: MyCollection; eligible: { name: string; title: string }[]; payoutsEnabled: boolean }) { +function CollectionStat({ label, value, className = "" }: { label: string; value: number; className?: string }) { + return ( +
+

{value}

+

{label}

+
+ ); +} + +function CollectionCard({ collection, eligible, payoutsEnabled, username }: { collection: MyCollection; eligible: { name: string; title: string }[]; payoutsEnabled: boolean; username: string | null }) { const router = useRouter(); const [busy, setBusy] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [error, setError] = useState(null); + const liveCount = collection.items.filter((item) => item.reviewStatus === "approved").length; + const paid = collection.price != null || Boolean(collection.externalUrl); + + async function onDelete() { + setBusy(true); + setError(null); + try { + const result = await deleteCollection({ data: { name: collection.name } }); + if (!result.ok) { setError(result.error ?? "Could not delete this collection."); return; } + setDeleteOpen(false); + await router.invalidate(); + } catch { + setError("Could not delete this collection. Try again."); + } finally { + setBusy(false); + } + } + return ( -
-
-
-
-

{collection.title}

- - {collection.items.length} component{collection.items.length === 1 ? "" : "s"} - -
-

{collection.name}

-
-
- - - +
+
+ + +
+ +
+
+

{collection.title}

+ {collection.items.length} {collection.items.length === 1 ? "component" : "components"}
+

@{username ?? "you"}/{collection.name}

+ {collection.description ?

{collection.description}

: null} +
+ +
+ {collection.items.slice(0, 4).map((item) => { + const live = item.reviewStatus === "approved"; + return ( +
+ {item.title.slice(0, 1)} + {item.title} + {live ? : null}{live ? "Live" : "Not live"} +
+ ); + })} + {collection.items.length > 4 ?

+{collection.items.length - 4} more

: null}
-
- {collection.items.map((item) => ( - - {item.title} - - ))} + +
+ {liveCount} of {collection.items.length} live + npx modulora add @{username ?? "you"}/{collection.name}
-
+
+ {username && liveCount > 0 ? ( + + ) : ( + + )} + + + + Delete collection + + Delete “{collection.title}”?This removes the collection, not its component listings. This action cannot be undone. + {error ?

{error}

: null} + + + + +
+
+
+
); } @@ -161,14 +286,14 @@ function CollectionDialog({ eligible, existing }: { eligible: { name: string; ti setDescription(e.target.value)} placeholder="Everything you need for an admin dashboard." />
-
- +
+ Components {eligible.length === 0 ? (

No free components available yet.

) : (
{eligible.map((component) => ( -
- {error ?

{error}

: null} +
+ {error ?

{error}

: null} + @@ -274,7 +391,7 @@ function CollectionSellDialog({ collection, payoutsEnabled }: { collection: MyCo {mode === "external" ? (
setExternalUrl(e.target.value)} placeholder="https://you.dev/pro" className="h-9" /> - {error ?

{error}

: null} + {error ?

{error}

: null}
- {error ?

{error}

: null} + {error ?

{error}

: null}