diff --git a/app/(dashboard)/dashboard/explore/page.tsx b/app/(dashboard)/dashboard/explore/page.tsx new file mode 100644 index 0000000..800d133 --- /dev/null +++ b/app/(dashboard)/dashboard/explore/page.tsx @@ -0,0 +1,922 @@ +"use client"; + +import { Suspense, useState, useMemo } from "react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { + LayoutGrid, + List, + Flame, + Snowflake, + X, + ArrowDown, + ArrowUp, + Star, + Search, + SlidersHorizontal, + Zap, +} from "lucide-react"; +import { MODELS } from "@/lib/dashboard/mock-data"; +import Button from "@/components/ui/Button"; +import SearchInput from "@/components/ui/SearchInput"; +import Drawer from "@/components/ui/Drawer"; +import Select from "@/components/ui/Select"; +import { getModelIcon, formatRuns, formatPrice } from "@/lib/dashboard/utils"; +import { useStarredModels } from "@/lib/dashboard/useStarredModels"; +import DashboardFooter from "@/components/dashboard/DashboardFooter"; +import ModelCard from "@/components/dashboard/ModelCard"; +import type { Model, ModelCategory } from "@/lib/dashboard/types"; + +const VALID_CATEGORIES: ModelCategory[] = [ + "Video Generation", + "Video Editing", + "Video Understanding", + "Live Transcoding", + "Image Generation", + "Speech", + "Language", +]; + +// ─── Constants ─── + +type AvailabilityFilter = "all" | "warm" | "cold"; + +const VIDEO_CATEGORIES: { label: ModelCategory; icon: ReturnType }[] = [ + { label: "Video Generation", icon: getModelIcon("Video Generation") }, + { label: "Video Editing", icon: getModelIcon("Video Editing") }, + { label: "Video Understanding", icon: getModelIcon("Video Understanding") }, + { label: "Live Transcoding", icon: getModelIcon("Live Transcoding") }, +]; + +const OTHER_CATEGORIES: { label: ModelCategory; icon: ReturnType }[] = [ + { label: "Image Generation", icon: getModelIcon("Image Generation") }, + { label: "Speech", icon: getModelIcon("Speech") }, + { label: "Language", icon: getModelIcon("Language") }, +]; + +const SORT_OPTIONS = [ + { value: "recommended", label: "Recommended" }, + { value: "latency", label: "Latency" }, + { value: "uptime", label: "Uptime" }, + { value: "price", label: "Price" }, + { value: "recent", label: "New" }, +]; + +const PRICE_BUCKETS = 20; + +// ─── Badges ─── + +function StatusBadge({ status }: { status: "hot" | "cold" }) { + if (status === "hot") { + return ( + + + + + + Warm + + ); + } + return ( + + + Cold + + ); +} + +// ─── Empty State ─── + +function ExploreEmptyState({ + onClearFilters, +}: { + onClearFilters: () => void; +}) { + return ( +
+
+ +
+

+ No capabilities match your filters +

+

+ Try loosening your filters — the network is open. +

+
+ +
+ Missing something? + + Publish a capability + +
+
+
+ ); +} + +// ─── List Item ─── + +function ModelListItem({ model }: { model: Model }) { + const Icon = getModelIcon(model.category); + + return ( + +
+ {model.coverImage ? ( + + ) : ( + + )} +
+ +
+

+ {model.name} + {model.precision && ( + + {model.precision} + + )} +

+

+ {model.provider} +

+
+ +
+ + + {model.category} + + +
+ +
+

{formatPrice(model)}

+

+ {formatRuns(model.runs7d)} runs +

+
+ + ); +} + +// ─── Price Range Filter ─── + +function buildPriceHistogram(models: Model[]) { + const prices = models.map((m) => m.pricing.amount); + const maxPrice = Math.max(...prices, 0.01); + const bucketSize = maxPrice / PRICE_BUCKETS; + const buckets = Array.from({ length: PRICE_BUCKETS }, (_, i) => ({ + range: +(bucketSize * (i + 1)).toFixed(4), + count: 0, + })); + for (const p of prices) { + const idx = Math.min(Math.floor(p / bucketSize), PRICE_BUCKETS - 1); + buckets[idx].count++; + } + return { buckets, maxPrice }; +} + +function PriceRangeFilter({ + min, + max, + onChange, + models, +}: { + min: number; + max: number; + onChange: (min: number, max: number) => void; + models: Model[]; +}) { + const { buckets, maxPrice } = useMemo(() => buildPriceHistogram(models), [models]); + const minPrice = (min / 100) * maxPrice; + const maxPriceValue = (max / 100) * maxPrice; + + const minBucketIdx = Math.min( + Math.floor((min / 100) * PRICE_BUCKETS), + PRICE_BUCKETS - 1, + ); + const maxBucketIdx = Math.min( + Math.floor((max / 100) * PRICE_BUCKETS), + PRICE_BUCKETS - 1, + ); + + const isFiltered = min > 0 || max < 100; + + return ( +
+
+

+ Price Range +

+ {isFiltered && ( + + )} +
+
+
+ {buckets.map((bucket, i) => { + const maxCount = Math.max(...buckets.map((b) => b.count), 1); + const height = bucket.count > 0 ? Math.max((bucket.count / maxCount) * 100, 8) : 0; + const active = i >= minBucketIdx && i <= maxBucketIdx; + return ( +
+ ); + })} +
+
+
+
+ { + const v = Number(e.target.value); + if (v < max) onChange(v, max); + }} + className="price-range-thumb pointer-events-none absolute inset-0 w-full appearance-none bg-transparent [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:pointer-events-auto" + style={{ zIndex: min > 90 ? 4 : 3 }} + /> + { + const v = Number(e.target.value); + if (v > min) onChange(min, v); + }} + className="price-range-thumb pointer-events-none absolute inset-0 w-full appearance-none bg-transparent [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:pointer-events-auto" + style={{ zIndex: 3 }} + /> +
+
+ ${minPrice.toFixed(3)} + ${maxPriceValue.toFixed(3)} +
+
+
+ ); +} + +// ─── Explore Page ─── + +export default function ExplorePage() { + return ( + + + + ); +} + +function ExplorePageInner() { + const searchParams = useSearchParams(); + const initialCategory = (() => { + const qp = searchParams.get("category"); + return qp && VALID_CATEGORIES.includes(qp as ModelCategory) + ? (qp as ModelCategory) + : null; + })(); + const initialFavorites = searchParams.get("starred") === "1"; + const { isStarred, starredIds } = useStarredModels(); + const [search, setSearch] = useState(""); + const [view, setView] = useState<"grid" | "list">("grid"); + const [category, setCategory] = useState(initialCategory); + const [sort, setSort] = useState("recommended"); + const [sortDir, setSortDir] = useState<"desc" | "asc">("desc"); + const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); + const [availabilityFilter, setAvailabilityFilter] = useState("all"); + const [favoritesOnly, setFavoritesOnly] = useState(initialFavorites); + const [realtimeOnly, setRealtimeOnly] = useState(searchParams.get("realtime") === "1"); + const [priceMin, setPriceMin] = useState(0); + const [priceMax, setPriceMax] = useState(100); + const dataMaxPrice = useMemo( + () => Math.max(...MODELS.map((m) => m.pricing.amount), 0.01), + [], + ); + + const filtered = useMemo(() => { + const result = MODELS.filter((m) => { + if (availabilityFilter === "warm" && m.status !== "hot") return false; + if (availabilityFilter === "cold" && m.status !== "cold") return false; + if (favoritesOnly && !isStarred(m.id)) return false; + if (realtimeOnly && !m.realtime) return false; + const matchesSearch = + !search || + m.name.toLowerCase().includes(search.toLowerCase()) || + m.provider.toLowerCase().includes(search.toLowerCase()); + const matchesCategory = !category || m.category === category; + const price = m.pricing.amount; + const matchesPrice = price >= (priceMin / 100) * dataMaxPrice && price <= (priceMax / 100) * dataMaxPrice; + return matchesSearch && matchesCategory && matchesPrice; + }); + + result.sort((a, b) => { + if (a.featured !== b.featured) return a.featured ? -1 : 1; + const dir = sortDir === "asc" ? 1 : -1; + switch (sort) { + case "latency": + return (a.latency - b.latency) * dir; + case "price": + return (a.pricing.amount - b.pricing.amount) * dir; + case "uptime": + return (b.uptime - a.uptime) * dir; + case "recent": { + const aTs = a.releasedAt ? new Date(a.releasedAt).getTime() : 0; + const bTs = b.releasedAt ? new Date(b.releasedAt).getTime() : 0; + return (bTs - aTs) * dir; + } + default: { + // Recommended: tier by (realtime, warm), then by runs7d. Tier is always + // best-first regardless of direction; only the popularity tiebreaker flips + // when the user toggles asc/desc. + const tier = (m: Model) => (m.realtime ? 0 : 2) + (m.status === "hot" ? 0 : 1); + const tierDiff = tier(a) - tier(b); + if (tierDiff !== 0) return tierDiff; + return (b.runs7d - a.runs7d) * dir; + } + } + }); + + return result; + }, [search, category, sort, sortDir, availabilityFilter, favoritesOnly, realtimeOnly, isStarred, priceMin, priceMax, dataMaxPrice]); + + const activeFilters = [ + ...(category + ? [{ label: category, onClear: () => setCategory(null) }] + : []), + ...(availabilityFilter !== "all" + ? [{ label: availabilityFilter === "warm" ? "Warm" : "Cold", onClear: () => setAvailabilityFilter("all") }] + : []), + ...(favoritesOnly + ? [{ label: "Starred", onClear: () => setFavoritesOnly(false) }] + : []), + ...(realtimeOnly + ? [{ label: "Realtime", onClear: () => setRealtimeOnly(false) }] + : []), + ]; + + return ( +
+
+ {/* Filter sidebar */} +
+
+ {/* Source filter */} +
+ {/* Capability type */} +

+ Tasks +

+
+ + +
+ + {VIDEO_CATEGORIES.map(({ label: cat, icon: CatIcon }) => ( + + ))} + +
+ + {OTHER_CATEGORIES.map(({ label: cat, icon: CatIcon }) => ( + + ))} +
+
+ +
+ + {/* Availability */} +
+

+ Availability +

+
+ + +
+
+ +
+ + {/* Realtime — capability filter, Livepeer moat */} +
+

+ Realtime +

+ +
+ +
+ + {/* Starred */} +
+

+ Starred +

+ +
+ +
+ + { setPriceMin(min); setPriceMax(max); }} + models={MODELS} + /> + + {activeFilters.length > 0 && ( + <> +
+ + + )} +
+
+ + {/* Content + Footer */} +
+ {/* Toolbar — the page header is intentionally omitted; the active nav tab + + breadcrumb already establish "Explore" context on both breakpoints */} +
+ {/* Search — full-width on mobile, capped on desktop */} + + + {/* Active filter pills — inline next to Search on desktop; stacks below on mobile */} + {activeFilters.length > 0 && ( +
+ {activeFilters.map((f) => ( + + ))} +
+ )} + + {/* Controls — data controls (filter, sort, dir) then display (view). All uniform h-9 mobile / h-8 desktop. */} +
+ {/* Filters button — mobile only (desktop has sidebar) */} + + + {/* Sort + direction paired so they wrap together when tight */} +
+ setSearch(e.target.value)} - className="w-full rounded-md border border-white/[0.12] bg-white/[0.03] backdrop-blur-sm py-1.5 pl-9 pr-8 text-sm text-white/60 placeholder:text-white/30 transition-colors duration-200 focus:bg-white/[0.05] focus:border-white/20 focus:outline-none sm:w-56 select-none" - /> - - {search && ( - setSearch("")} - className="absolute right-2.5 top-1/2 -translate-y-1/2 cursor-pointer text-white/50 transition-colors hover:text-white/80" - aria-label="Clear search" - > - - - - - )} - -
+
{/* App grid */} diff --git a/app/ecosystem/submit/page.tsx b/app/(marketing)/ecosystem/submit/page.tsx similarity index 100% rename from app/ecosystem/submit/page.tsx rename to app/(marketing)/ecosystem/submit/page.tsx diff --git a/app/foundation/layout.tsx b/app/(marketing)/foundation/layout.tsx similarity index 100% rename from app/foundation/layout.tsx rename to app/(marketing)/foundation/layout.tsx diff --git a/app/foundation/page.tsx b/app/(marketing)/foundation/page.tsx similarity index 100% rename from app/foundation/page.tsx rename to app/(marketing)/foundation/page.tsx diff --git a/app/icon.svg b/app/(marketing)/icon.svg similarity index 100% rename from app/icon.svg rename to app/(marketing)/icon.svg diff --git a/app/(marketing)/layout.tsx b/app/(marketing)/layout.tsx new file mode 100644 index 0000000..53494d4 --- /dev/null +++ b/app/(marketing)/layout.tsx @@ -0,0 +1,16 @@ +import Header from "@/components/layout/Header"; +import Footer from "@/components/layout/Footer"; + +export default function MarketingLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> +
+
{children}
+