Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 | 7x 8x 8x 8x 3x 16x 9x 6x 13x 4x 9x 3x 6x 9x 9x 9x 13x 9x 6x 13x 13x 13x | import { Link } from "@tanstack/react-router";
import { Camera } from "lucide-react";
import { Card } from "../../components/ui/card";
import { Button } from "../../components/ui/button";
import { EmptyState } from "../../components/ui/empty-state";
import { getImageUrl } from "../../lib/api";
import type { RoomCategoryWithStats } from "../../lib/api";
import { useAdminRoomCategories } from "../../hooks/queries/useAdminQueries";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { AdminBand, AdminPageHead } from "../../components/admin/AdminPageHead";
import { Alert } from "../../components/ui/alert";
import { StatusBadge } from "../../components/ui/status-badge";
// D1B: sort order — needs-attention first (null cover + photoCount > 5), then curated, then empty
function sortCategories(
categories: RoomCategoryWithStats[],
): RoomCategoryWithStats[] {
return [...categories].sort((a, b) => {
const rankA = getSortRank(a);
const rankB = getSortRank(b);
if (rankA !== rankB) return rankA - rankB;
// Within each group, sort by taxonomy sortOrder
return a.sortOrder - b.sortOrder;
});
}
function getSortRank(cat: RoomCategoryWithStats): number {
if (cat.coverMediaId === null && cat.photoCount > 5) return 0; // needs attention
if (cat.coverMediaId !== null) return 1; // curated
return 2; // empty / few photos
}
/**
* Where a category's cover came from is a state, so it is a printed code —
* 2px radius, `.label` caps, status ink on its own tint. It used to be a
* coloured bullet glyph (`● Curated` in success ink, `○ Algorithmic` in
* warning), which carries the state in a decoration rather than in the word.
*/
function CoverSourceBadge({
source,
}: {
source: RoomCategoryWithStats["coverSource"];
}) {
if (source === "curated") {
return <StatusBadge status="Curated" variant="success" />;
}
if (source === "algorithmic") {
return <StatusBadge status="Algorithmic" variant="warning" />;
}
return (
<StatusBadge
status={source === "empty" ? "Empty" : "Pending"}
variant="default"
/>
);
}
export function AdminRoomCategoriesPage() {
const { data: rawCategories, isLoading, isError } = useAdminRoomCategories();
const categories = rawCategories ? sortCategories(rawCategories) : [];
const curatedCount = categories.filter(
(c) => c.coverSource === "curated",
).length;
return (
<div className="space-y-6">
<AdminPageHead
title="Room categories"
description="Pick the cover image shown for each room category on the marketplace homepage and rooms index. Categories without a curated cover fall back to algorithmic selection."
actions={
!isLoading && !isError ? (
<span className="text-xs font-semibold uppercase tracking-wider tabular-nums text-foreground-muted">
{categories.length} categories · {curatedCount} with curated
covers
</span>
) : undefined
}
/>
<section className="space-y-2">
<AdminBand
title="All room categories"
count={isLoading || isError ? undefined : categories.length}
quiet
/>
{isLoading ? (
<div className="space-y-px">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="h-14 animate-pulse bg-background-muted" />
))}
</div>
) : isError ? (
<Alert variant="error">
Failed to load room categories. Please refresh to try again.
</Alert>
) : categories.length === 0 ? (
<EmptyState
icon={Camera}
title="No room categories found"
description="Room categories are seeded with the platform taxonomy."
/>
) : (
<Card className="overflow-hidden">
<Table density="dense">
<TableCaption>
Room categories, with their marketplace copy and photos.
</TableCaption>
<TableHeader>
<TableRow>
<TableHead style={{ width: 120 }}>Cover</TableHead>
<TableHead>Category</TableHead>
<TableHead
numeric
className="hidden md:table-cell"
style={{ width: 160 }}
>
Photos available
</TableHead>
<TableHead style={{ width: 130 }}>Source</TableHead>
<TableHead className="text-right" style={{ width: 130 }}>
Action
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.map((cat) => (
<RoomCategoryListRow key={cat.code} category={cat} />
))}
</TableBody>
</Table>
</Card>
)}
</section>
</div>
);
}
type RoomCategoryListRowProps = {
category: RoomCategoryWithStats;
};
function RoomCategoryListRow({ category }: RoomCategoryListRowProps) {
const hasPhotos = category.photoCount > 0;
return (
<TableRow className="cursor-pointer">
{/* Cover thumbnail */}
<TableCell className="pr-4">
{category.coverMediaUrl ? (
<img
src={getImageUrl(category.coverMediaUrl)}
alt={category.coverMediaAlt || category.label}
className="w-22 h-16 rounded object-cover border border-border-default"
loading="lazy"
/>
) : (
<div
// The missing-cover hatch, on tokens — the Golden Rule applies to
// a gradient as much as to a background.
className="flex h-16 w-22 items-center justify-center rounded-sm border border-border-default"
style={{
background:
"repeating-linear-gradient(45deg, var(--background-muted), var(--background-muted) 8px, var(--background-subtle) 8px, var(--background-subtle) 16px)",
}}
>
<Camera className="h-5 w-5 text-foreground-subtle" />
</div>
)}
</TableCell>
{/* Category name + project count */}
<TableCell>
<div className="font-semibold text-foreground-default">
{category.label}
</div>
<div className="mt-0.5 text-xs tabular-nums text-foreground-muted">
{category.projectCount} projects
</div>
</TableCell>
{/* Photos available (hidden on small screens) */}
<TableCell
numeric
className="hidden whitespace-nowrap text-foreground-muted md:table-cell"
>
{category.photoCount > 0 ? `${category.photoCount} photos` : "—"}
</TableCell>
{/* Source badge */}
<TableCell>
<CoverSourceBadge source={category.coverSource} />
</TableCell>
{/* Action */}
<TableCell className="text-right">
{hasPhotos ? (
<Link to={`/admin/room-categories/${category.code}`}>
<Button variant="outline" size="sm">
{category.coverMediaId ? "Edit cover" : "Pick cover"}
</Button>
</Link>
) : (
<Button
variant="ghost"
size="sm"
disabled
title="No photos available for this category yet"
>
Pick cover
</Button>
)}
</TableCell>
</TableRow>
);
}
|