Merge pull request #4 from NicholaiVogel/feat/wishlist-ui-redesign

feat(wishlist): redesign stats bar and filter layout
This commit is contained in:
Nicholai 2026-01-22 04:00:42 -07:00 committed by GitHub
commit bacf3d5d61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 137 additions and 136 deletions

View File

@ -476,45 +476,32 @@ export async function getItemWithComments(itemId: string, userId: string) {
}
}
export async function getWishlistStats(userId: string) {
export interface WishlistStats {
totalItems: number
highPriority: number
yourSubmissions: number
totalBudget: number
}
export async function getWishlistStats(userId: string): Promise<WishlistStats> {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const allItems = await db.select().from(wishlistItems)
const allVotes = await db.select().from(wishlistVotes)
const totalItems = allItems.length
const yourItems = allItems.filter((i) => i.submittedBy === userId).length
let mostWanted = null
if (allItems.length > 0) {
const itemScores = allItems.map((item) => {
const itemVotes = allVotes.filter((v) => v.itemId === item.id)
const upvotes = itemVotes.filter((v) => v.voteType === "up").length
const downvotes = itemVotes.filter((v) => v.voteType === "down").length
return {
item,
score: upvotes - downvotes,
}
})
const topItem = itemScores.sort((a, b) => b.score - a.score)[0]
if (topItem.score > 0) {
mostWanted = { name: topItem.item.name, score: topItem.score }
}
}
const recentItems = allItems
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
.slice(0, 5).length
const highPriority = allItems.filter((i) => i.priority === "high").length
const yourSubmissions = allItems.filter((i) => i.submittedBy === userId).length
const totalBudget = allItems.reduce(
(sum, item) => sum + (item.estimatedCost ?? 0),
0
)
return {
totalItems,
yourItems,
mostWanted,
recentItems,
highPriority,
yourSubmissions,
totalBudget,
}
}

View File

@ -1,7 +1,6 @@
"use client"
import { IconPlus } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
@ -11,54 +10,73 @@ import {
} from "@/components/ui/select"
interface WishlistFiltersProps {
search: string
category: string
priority: string
sortBy: string
onSearchChange: (search: string) => void
onCategoryChange: (category: string) => void
onPriorityChange: (priority: string) => void
onSortChange: (sort: string) => void
onAddClick: () => void
}
export function WishlistFilters({
search,
category,
priority,
sortBy,
onSearchChange,
onCategoryChange,
onPriorityChange,
onSortChange,
onAddClick,
}: WishlistFiltersProps) {
return (
<div className="flex items-center justify-between gap-4 px-4 lg:px-6">
<div className="flex items-center gap-2">
<Select value={category} onValueChange={onCategoryChange}>
<SelectTrigger className="w-[140px]" size="sm">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="hardware">Hardware</SelectItem>
<SelectItem value="software">Software</SelectItem>
<SelectItem value="network">Network</SelectItem>
<SelectItem value="storage">Storage</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
<div className="flex flex-wrap items-center gap-4 px-4 lg:px-6">
<Input
placeholder="Search items..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="h-9 w-56"
/>
<Select value={sortBy} onValueChange={onSortChange}>
<SelectTrigger className="w-[130px]" size="sm">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="score">Top Score</SelectItem>
<SelectItem value="newest">Newest</SelectItem>
<SelectItem value="oldest">Oldest</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={category} onValueChange={onCategoryChange}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="hardware">Hardware</SelectItem>
<SelectItem value="software">Software</SelectItem>
<SelectItem value="network">Network</SelectItem>
<SelectItem value="storage">Storage</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
<Button size="sm" onClick={onAddClick}>
<IconPlus className="size-4" />
<span className="hidden sm:inline">Add Item</span>
</Button>
<Select value={priority} onValueChange={onPriorityChange}>
<SelectTrigger className="w-36">
<SelectValue placeholder="Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priorities</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={onSortChange}>
<SelectTrigger className="w-36">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="score">Top Score</SelectItem>
<SelectItem value="newest">Newest</SelectItem>
<SelectItem value="oldest">Oldest</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
</SelectContent>
</Select>
</div>
)
}

View File

@ -1,74 +1,42 @@
"use client"
import { IconList, IconStar, IconUser, IconClock } from "@tabler/icons-react"
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Card } from "@/components/ui/card"
import type { WishlistStats as WishlistStatsType } from "@/app/actions/wishlist"
interface WishlistStatsProps {
stats: {
totalItems: number
yourItems: number
mostWanted: { name: string; score: number } | null
recentItems: number
}
stats: WishlistStatsType
}
export function WishlistStats({ stats }: WishlistStatsProps) {
return (
<div className="grid grid-cols-1 gap-4 px-4 lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardDescription>Total Items</CardDescription>
<IconList className="text-muted-foreground size-4" />
</CardHeader>
<CardTitle className="px-6 pb-6 text-2xl font-semibold tabular-nums">
{stats.totalItems}
</CardTitle>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardDescription>Most Wanted</CardDescription>
<IconStar className="text-muted-foreground size-4" />
</CardHeader>
<CardTitle className="px-6 pb-6 text-2xl font-semibold">
{stats.mostWanted ? (
<span className="flex items-center gap-2">
<span className="truncate">{stats.mostWanted.name}</span>
<span className="text-muted-foreground text-sm font-normal">
(score: {stats.mostWanted.score})
</span>
<div className="px-4 lg:px-6">
<Card className="px-4 py-3">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span>
<span className="text-muted-foreground">Total Items:</span>{" "}
<span className="font-medium">{stats.totalItems}</span>
</span>
<span className="text-muted-foreground">|</span>
<span>
<span className="text-muted-foreground">High Priority:</span>{" "}
<span className="font-medium">{stats.highPriority}</span>
</span>
<span className="text-muted-foreground">|</span>
<span>
<span className="text-muted-foreground">Your Submissions:</span>{" "}
<span className="font-medium">{stats.yourSubmissions}</span>
</span>
<span className="text-muted-foreground">|</span>
<span>
<span className="text-muted-foreground">Est. Total Budget:</span>{" "}
<span className="font-medium">
${stats.totalBudget.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
) : (
<span className="text-muted-foreground text-base font-normal">
No votes yet
</span>
)}
</CardTitle>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardDescription>Your Submissions</CardDescription>
<IconUser className="text-muted-foreground size-4" />
</CardHeader>
<CardTitle className="px-6 pb-6 text-2xl font-semibold tabular-nums">
{stats.yourItems}
</CardTitle>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardDescription>Recent (Last 5)</CardDescription>
<IconClock className="text-muted-foreground size-4" />
</CardHeader>
<CardTitle className="px-6 pb-6 text-2xl font-semibold tabular-nums">
{stats.recentItems}
</CardTitle>
</span>
</div>
</Card>
</div>
)

View File

@ -1,6 +1,6 @@
"use client"
import { useState, useEffect, useTransition, useCallback } from "react"
import { useState, useEffect, useTransition, useCallback, useMemo } from "react"
import {
IconArrowBigDown,
IconArrowBigDownFilled,
@ -13,6 +13,7 @@ import {
IconFileSpreadsheet,
IconFileTypePdf,
IconLoader2,
IconPlus,
IconRefresh,
IconTrash,
} from "@tabler/icons-react"
@ -47,6 +48,7 @@ import {
toggleItemVote,
deleteWishlistItem,
type WishlistItemWithMeta,
type WishlistStats as WishlistStatsType,
type SortOption,
type VoteType,
} from "@/app/actions/wishlist"
@ -66,18 +68,31 @@ interface WishlistTableProps {
export function WishlistTable({ userId, userName }: WishlistTableProps) {
const [isPending, startTransition] = useTransition()
const [items, setItems] = useState<WishlistItemWithMeta[]>([])
const [stats, setStats] = useState({
const [stats, setStats] = useState<WishlistStatsType>({
totalItems: 0,
yourItems: 0,
mostWanted: null as { name: string; score: number } | null,
recentItems: 0,
highPriority: 0,
yourSubmissions: 0,
totalBudget: 0,
})
const [search, setSearch] = useState("")
const [category, setCategory] = useState("all")
const [priority, setPriority] = useState("all")
const [sortBy, setSortBy] = useState<SortOption>("score")
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [selectedItem, setSelectedItem] = useState<WishlistItemWithMeta | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const filteredItems = useMemo(() => {
return items.filter((item) => {
const matchesSearch =
search === "" ||
item.name.toLowerCase().includes(search.toLowerCase()) ||
item.description.toLowerCase().includes(search.toLowerCase())
const matchesPriority = priority === "all" || item.priority === priority
return matchesSearch && matchesPriority
})
}, [items, search, priority])
const fetchData = useCallback(() => {
startTransition(async () => {
const [itemsData, statsData] = await Promise.all([
@ -100,7 +115,7 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
const getExportData = () => {
const headers = ["Name", "Description", "Category", "Priority", "Est. Cost", "Score", "Submitted By", "Created"]
const rows = items.map((item) => [
const rows = filteredItems.map((item) => [
item.name,
item.description,
item.category,
@ -178,7 +193,7 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
<Button
variant="outline"
size="sm"
disabled={items.length === 0}
disabled={filteredItems.length === 0}
className="gap-2"
>
<IconDownload className="size-4" />
@ -206,17 +221,24 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
<IconRefresh className={cn("size-4", isPending && "animate-spin")} />
<span className="hidden sm:inline">Refresh</span>
</Button>
<Button size="sm" onClick={() => setAddDialogOpen(true)} className="gap-2">
<IconPlus className="size-4" />
<span className="hidden sm:inline">Add Item</span>
</Button>
</div>
</div>
<WishlistStats stats={stats} />
<WishlistFilters
search={search}
category={category}
priority={priority}
sortBy={sortBy}
onSearchChange={setSearch}
onCategoryChange={setCategory}
onPriorityChange={setPriority}
onSortChange={(sort) => setSortBy(sort as SortOption)}
onAddClick={() => setAddDialogOpen(true)}
/>
<div className="px-4 lg:px-6">
@ -233,6 +255,12 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
Be the first to add something!
</p>
</div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12">
<p className="text-muted-foreground text-center">
No items match your filters.
</p>
</div>
) : (
<div className="rounded-lg border">
<Table>
@ -248,7 +276,7 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
{filteredItems.map((item) => (
<WishlistTableRow
key={item.id}
item={item}