New uploads are drafts: only their creator and moderators see them, nobody can get or wear them, and they're deleted if not published within 30 days (their catalogs get delete changes). The creator reviews the info and publishes it. Editing a product's info is a permission check now (product:edit): moderators can edit anyone's with a reason the creator sees, lock fields (product:lock; locked fields need product:edit-locked) and lower ratings (product:rate-lower, not below its files'). Every change is kept in the product's info history, with who and why, and can be undone by the same rules. Products can be unlisted (out of search and lists, the link still works) and tagged; lists filter by tag and catalog. The console has a Products section (everyone's for moderators, by status) and a drafts to-do. A product's own category is left for later: the hub has no list of IMVU's categories to pick from. Co-Authored-By: Claude Opus 5.5 <[email protected]>
94 lines
2.9 KiB
TypeScript
94 lines
2.9 KiB
TypeScript
// Pieces shared by the product pages.
|
|
import Link from "next/link";
|
|
import { Tag } from "@/components/status";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import type { ProductSummary } from "@/lib/api";
|
|
|
|
export function RatingTag({ rating }: { rating: string }) {
|
|
return rating === "adult" ? <Tag tone="adult">Adult</Tag> : null;
|
|
}
|
|
|
|
/** A product's state, where it isn't plainly published and listed: draft, unlisted, deleted, removed, locked. */
|
|
export function StateTags({
|
|
product: p,
|
|
}: {
|
|
product: Pick<ProductSummary, "status" | "listed" | "deleted" | "removed" | "locked">;
|
|
}) {
|
|
return (
|
|
<>
|
|
{p.status === "draft" && <Tag tone="strong">Draft</Tag>}
|
|
{p.status === "published" && !p.listed && <Tag>Unlisted</Tag>}
|
|
{p.deleted && <Tag tone="danger">Deleted</Tag>}
|
|
{p.removed && <Tag tone="danger">Removed</Tag>}
|
|
{p.locked.length > 0 && <Tag>Locked: {p.locked.join(", ")}</Tag>}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** 1536 → "1.5 KB". */
|
|
export function formatSize(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
const units = ["KB", "MB", "GB"];
|
|
let n = bytes / 1024;
|
|
let i = 0;
|
|
while (n >= 1024 && i < units.length - 1) {
|
|
n /= 1024;
|
|
i++;
|
|
}
|
|
return `${n < 10 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
|
|
}
|
|
|
|
/** The image of the IMVU product a product derives from (IMVU's, from IMVU), if it has one. */
|
|
export function ProductImage({ src, className }: { src: string | null | undefined; className?: string }) {
|
|
if (!src) return null;
|
|
return (
|
|
<img
|
|
src={src}
|
|
alt=""
|
|
referrerPolicy="no-referrer"
|
|
loading="lazy"
|
|
className={`aspect-[5/4] w-full bg-muted object-contain ${className ?? ""}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function ProductCard({
|
|
product,
|
|
children,
|
|
}: {
|
|
product: Pick<ProductSummary, "id" | "name" | "creator" | "rating" | "latest" | "image" | "categories" | "listed">;
|
|
children?: React.ReactNode;
|
|
}) {
|
|
const category = product.categories.at(-1)?.name;
|
|
return (
|
|
<Card className="h-full">
|
|
<ProductImage src={product.image} />
|
|
<CardContent className="space-y-2">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<Link href={`/products/${product.id}`} className="font-medium underline-offset-4 hover:underline">
|
|
{product.name}
|
|
</Link>
|
|
<span className="flex gap-1">
|
|
{!product.listed && <Tag>Unlisted</Tag>}
|
|
<RatingTag rating={product.rating} />
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{[product.creator && `by ${product.creator}`, category, `revision ${product.latest}`].filter(Boolean).join(" · ")}
|
|
</p>
|
|
{children}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function Empty({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<Card>
|
|
<CardContent>
|
|
<p className="py-6 text-sm text-muted-foreground">{children}</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|