Nicholai cfdd6b7c5c feat(routing): switch public artist routing to slugs and fix admin parsing
Routing: update homepage ArtistsSection and ArtistsPageSection to link to /artists/{slug} and /book?artist={slug}. Artists grid already used slugs.\n\nAdmin: remove JSON.parse on specialties; treat as arrays with backward-compat.\n\nMigration: generate UUIDs with crypto.randomUUID(), ensure unique slugs, preserve user↔artist↔portfolio mapping.\n\nDB: parse specialties to arrays consistently and include createdAt for admin use.\n\nDev: wrangler dev port changes to avoid conflicts; MIGRATE_TOKEN set in wrangler.toml.\n\nDocs: add artist_routing_fix_implementation_plan.md.
2025-10-06 19:22:26 -06:00

403 lines
12 KiB
TypeScript

"use client"
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Plus, MoreHorizontal, ArrowUpDown, ChevronDown } from 'lucide-react'
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table"
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useToast } from '@/hooks/use-toast'
import type { Artist } from '@/types/database'
export default function ArtistsPage() {
const router = useRouter()
const { toast } = useToast()
const [artists, setArtists] = useState<Artist[]>([])
const [loading, setLoading] = useState(true)
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [rowSelection, setRowSelection] = useState({})
// Define columns for the data table
const columns: ColumnDef<Artist>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => (
<div className="font-medium">{row.getValue("name")}</div>
),
},
{
accessorKey: "specialties",
header: "Specialties",
cell: ({ row }) => {
const specialties = row.getValue("specialties") as string[] | string
const specialtiesArray: string[] =
Array.isArray(specialties)
? specialties
: typeof specialties === "string" && specialties.trim().startsWith("[")
? JSON.parse(specialties)
: []
return (
<div className="flex flex-wrap gap-1">
{specialtiesArray.slice(0, 2).map((specialty: string) => (
<Badge key={specialty} variant="secondary" className="text-xs">
{specialty}
</Badge>
))}
{specialtiesArray.length > 2 && (
<Badge variant="outline" className="text-xs">
+{specialtiesArray.length - 2}
</Badge>
)}
</div>
)
},
},
{
accessorKey: "hourlyRate",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Rate
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const rate = row.getValue("hourlyRate") as number
return rate ? `$${rate}/hr` : 'Not set'
},
},
{
accessorKey: "isActive",
header: "Status",
cell: ({ row }) => {
const isActive = row.getValue("isActive") as boolean
return (
<Badge variant={isActive ? "default" : "secondary"}>
{isActive ? "Active" : "Inactive"}
</Badge>
)
},
},
{
accessorKey: "createdAt",
header: "Created",
cell: ({ row }) => {
const date = new Date(row.getValue("createdAt"))
return date.toLocaleDateString()
},
},
{
id: "actions",
enableHiding: false,
cell: ({ row }) => {
const artist = row.original
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => router.push(`/admin/artists/${artist.id}`)}
>
Edit artist
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => router.push(`/admin/artists/${artist.id}/portfolio`)}
>
Manage portfolio
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleToggleStatus(artist)}
className={artist.isActive ? "text-red-600" : "text-green-600"}
>
{artist.isActive ? "Deactivate" : "Activate"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
]
const table = useReactTable({
data: artists,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
})
const fetchArtists = async () => {
try {
const response = await fetch('/api/artists')
if (!response.ok) throw new Error('Failed to fetch artists')
const data = await response.json()
setArtists(data.artists || [])
} catch (error) {
console.error('Error fetching artists:', error)
toast({
title: 'Error',
description: 'Failed to load artists',
variant: 'destructive',
})
} finally {
setLoading(false)
}
}
const handleToggleStatus = async (artist: Artist) => {
try {
const response = await fetch(`/api/artists/${artist.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
isActive: !artist.isActive,
}),
})
if (!response.ok) throw new Error('Failed to update artist')
toast({
title: 'Success',
description: `Artist ${artist.isActive ? 'deactivated' : 'activated'} successfully`,
})
// Refresh the list
fetchArtists()
} catch (error) {
console.error('Error updating artist:', error)
toast({
title: 'Error',
description: 'Failed to update artist status',
variant: 'destructive',
})
}
}
useEffect(() => {
fetchArtists()
}, [])
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-lg">Loading artists...</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Artists</h1>
<p className="text-muted-foreground">
Manage your tattoo artists and their information
</p>
</div>
<Button onClick={() => router.push('/admin/artists/new')}>
<Plus className="mr-2 h-4 w-4" />
Add Artist
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>All Artists</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Filters and Controls */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Input
placeholder="Filter artists..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("name")?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Data Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer"
onClick={() => router.push(`/admin/artists/${row.original.id}`)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No artists found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex items-center justify-end space-x-2">
<div className="text-muted-foreground flex-1 text-sm">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
)
}