From ea232ee5be46ad20bde154e2c3b9a4cfba91056a Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Mon, 12 Oct 2026 15:22:29 +0000
Subject: [PATCH 1/3] feat(profile): show skins, drinks and custom items as
proper item pages
A skin's page now shows the skin in the editor's 3D viewer, fed the
owner's own files through a new owner-only GET
/skins/submissions/{id}/files/{filename}, instead of the staff review
sheet with its debug captions. Armour sets switch between sets and show
their inventory icons; books switch covers.
A drink's page shows the potion, the in-game tooltip and the recipe:
ingredients, brewing steps and effects. Drinks with their own texture
now show it on their card and page through GET
/drinks/submissions/{id}/texture.
Custom items are cards like Skins and Drinks, and open inside the tab
(?tab=items&item=/) instead of the character's kit
list. GET /profile sends each item's kit_id so the page finds its kit.
Co-Authored-By: Claude Opus 5.5
---
backend/src/api/drinks_routes.py | 16 +
backend/src/api/skins_routes.py | 25 ++
backend/src/api/test_skin_owner_files.py | 72 +++++
backend/src/characters/lore_items.py | 1 +
backend/src/skins/drinks.py | 22 ++
backend/src/skins/test_drinks.py | 49 +++
frontend/app/components/drinks/StatusCard.tsx | 72 -----
.../app/components/profile/DrinkShowcase.tsx | 225 ++++++++++++++
.../app/components/profile/ItemShowcase.tsx | 214 +++++++++++++
.../components/profile/ProfileCustomItems.tsx | 278 +++++++++++++++++
.../profile/ProfileCustomItemsList.tsx | 81 -----
.../app/components/profile/ProfileItems.tsx | 54 +---
.../components/profile/ProfilePanel.test.tsx | 73 ++++-
.../app/components/profile/ProfilePanel.tsx | 15 +-
.../components/profile/ProfileWardrobes.tsx | 2 +-
.../app/components/profile/SkinShowcase.tsx | 284 ++++++++++++++++++
frontend/app/components/profile/Wardrobe.tsx | 13 +-
.../components/profile/WardrobePictures.tsx | 38 ++-
.../app/components/skins/ArmorPreview.tsx | 99 +++---
.../app/components/skins/ModelPreview.tsx | 38 ++-
frontend/app/components/skins/StatusCard.tsx | 164 ----------
frontend/lib/drinks/api.ts | 9 +
frontend/lib/profile/api.ts | 2 +
frontend/lib/profile/paths.ts | 25 +-
frontend/lib/skins/api.ts | 23 +-
frontend/lib/skins/ownerFiles.test.ts | 28 ++
frontend/lib/skins/ownerFiles.ts | 48 +++
27 files changed, 1519 insertions(+), 451 deletions(-)
create mode 100644 backend/src/api/test_skin_owner_files.py
delete mode 100644 frontend/app/components/drinks/StatusCard.tsx
create mode 100644 frontend/app/components/profile/DrinkShowcase.tsx
create mode 100644 frontend/app/components/profile/ItemShowcase.tsx
create mode 100644 frontend/app/components/profile/ProfileCustomItems.tsx
delete mode 100644 frontend/app/components/profile/ProfileCustomItemsList.tsx
create mode 100644 frontend/app/components/profile/SkinShowcase.tsx
delete mode 100644 frontend/app/components/skins/StatusCard.tsx
create mode 100644 frontend/lib/skins/ownerFiles.test.ts
create mode 100644 frontend/lib/skins/ownerFiles.ts
diff --git a/backend/src/api/drinks_routes.py b/backend/src/api/drinks_routes.py
index 69414cdb..cd6104e4 100644
--- a/backend/src/api/drinks_routes.py
+++ b/backend/src/api/drinks_routes.py
@@ -32,6 +32,7 @@
get_drink_catalog,
get_drink_for_plugin,
get_drink_submission_for_owner,
+ owner_drink_texture,
list_deletable_drinks,
list_drinks_pending_apply,
list_pending_drinks,
@@ -194,6 +195,21 @@ def get_submission(
return row
+@drinks_router.get("/submissions/{submission_id}/texture")
+def get_owner_texture(
+ submission_id: str,
+ authorization: str | None = Header(default=None),
+):
+ """The owner's custom texture for a drink, for its Profile picture."""
+ session = _owner_session_from_auth(authorization)
+ if not is_safe_segment(submission_id):
+ raise HTTPException(status_code=404, detail="Submission not found")
+ path = owner_drink_texture(submission_id, session["player_uuid"])
+ if path is None:
+ raise HTTPException(status_code=404, detail="No texture")
+ return FileResponse(path, media_type="image/png", headers={"Cache-Control": "private, max-age=300"})
+
+
@drinks_router.get("/staff/pending")
def staff_pending(
x_staff_key: str | None = Header(default=None, alias=HEADER_STAFF_KEY),
diff --git a/backend/src/api/skins_routes.py b/backend/src/api/skins_routes.py
index 138f70b6..e014a078 100644
--- a/backend/src/api/skins_routes.py
+++ b/backend/src/api/skins_routes.py
@@ -792,6 +792,31 @@ def get_thumbnail(
)
+# The owner's uploads only; staff sheets and server files stay staff-side.
+_OWNER_FILE_SUFFIXES = (".png", ".json")
+_OWNER_HIDDEN_FILES = frozenset({"meta.json", "review_sheet.png", "name_preview.png"})
+
+
+@skins_router.get("/submissions/{submission_id}/files/{filename}")
+def owner_file(
+ submission_id: str,
+ filename: str,
+ authorization: str | None = Header(default=None),
+):
+ """A texture or model the owner uploaded, for the 3D viewer on their skin's page."""
+ session = _session_from_auth(authorization)
+ name = filename.lower()
+ if name in _OWNER_HIDDEN_FILES or not name.endswith(_OWNER_FILE_SUFFIXES):
+ raise HTTPException(status_code=404, detail="File not found")
+ if get_submission_for_owner(submission_id, session["player_uuid"]) is None:
+ raise HTTPException(status_code=404, detail="Submission not found")
+ path = _resolve_submission_file_safely(submission_id, filename)
+ if path is None:
+ raise HTTPException(status_code=404, detail="File not found")
+ media = "image/png" if name.endswith(".png") else "application/json"
+ return FileResponse(path, media_type=media, headers={"Cache-Control": "private, max-age=300"})
+
+
@skins_router.post("/submissions/{submission_id}/approve")
def post_approve(
submission_id: str,
diff --git a/backend/src/api/test_skin_owner_files.py b/backend/src/api/test_skin_owner_files.py
new file mode 100644
index 00000000..02c38db0
--- /dev/null
+++ b/backend/src/api/test_skin_owner_files.py
@@ -0,0 +1,72 @@
+"""GET /skins/submissions/{id}/files/{name} serves a skin's uploads to its owner only."""
+
+from __future__ import annotations
+
+import importlib
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import patch
+
+_BACKEND = Path(__file__).resolve().parents[2]
+if str(_BACKEND) not in sys.path:
+ sys.path.insert(0, str(_BACKEND))
+
+from fastapi import HTTPException
+
+OWNER = "owner-uuid"
+
+
+class SkinOwnerFileTest(unittest.TestCase):
+ def setUp(self) -> None:
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.skins_dir = Path(self.tmp.name).resolve() / "skins"
+ sub = self.skins_dir / "abc_staff"
+ sub.mkdir(parents=True)
+ for name in ("abc_staff.png", "abc_staff.json", "meta.json", "review_sheet.png", "notes.txt"):
+ (sub / name).write_bytes(b"x")
+ self.routes = importlib.import_module("src.api.skins_routes")
+
+ def owner_row(submission_id: str, player_uuid: str):
+ return {"id": submission_id} if submission_id == "abc_staff" and player_uuid == OWNER else None
+
+ def resolver(submission_id: str, filename: str):
+ path = self.skins_dir / submission_id / filename
+ return path if path.is_file() else None
+
+ for p in (
+ patch.object(self.routes, "_session_from_auth", lambda auth: {"player_uuid": auth}),
+ patch.object(self.routes, "get_submission_for_owner", owner_row),
+ patch.object(self.routes, "skins_db", SimpleNamespace(SKINS_DIR=self.skins_dir)),
+ patch.dict(self.routes.owner_file.__globals__, {"resolve_submission_file": resolver}),
+ ):
+ p.start()
+ self.addCleanup(p.stop)
+
+ def status(self, submission_id: str, filename: str, who: str = OWNER) -> int:
+ try:
+ return self.routes.owner_file(submission_id, filename, who).status_code
+ except HTTPException as e:
+ return e.status_code
+
+ def test_owner_gets_textures_and_models(self) -> None:
+ self.assertEqual(200, self.status("abc_staff", "abc_staff.png"))
+ self.assertEqual(200, self.status("abc_staff", "abc_staff.json"))
+
+ def test_other_players_get_nothing(self) -> None:
+ self.assertEqual(404, self.status("abc_staff", "abc_staff.png", who="someone-else"))
+
+ def test_staff_and_server_files_stay_hidden(self) -> None:
+ for name in ("meta.json", "review_sheet.png", "name_preview.png", "notes.txt"):
+ self.assertEqual(404, self.status("abc_staff", name), name)
+
+ def test_traversal_is_refused(self) -> None:
+ for sid, name in (("..", "abc_staff.png"), ("abc_staff", "..\\..\\x.png"), ("..\\..", "x.png")):
+ self.assertEqual(404, self.status(sid, name), (sid, name))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/backend/src/characters/lore_items.py b/backend/src/characters/lore_items.py
index 592bc90d..cc6412f5 100644
--- a/backend/src/characters/lore_items.py
+++ b/backend/src/characters/lore_items.py
@@ -1988,6 +1988,7 @@ def list_player_custom_items(
"character_id": cid,
"character_name": name_by_id.get(cid) or cid,
"kit_key": kit_key,
+ "kit_id": str(row["kit_id"] or "") if "kit_id" in keys else "",
"display_name": str(row["display_name"] or kit_key),
"state": state,
"submission_id": row["submission_id"],
diff --git a/backend/src/skins/drinks.py b/backend/src/skins/drinks.py
index c82e291f..62c22a08 100644
--- a/backend/src/skins/drinks.py
+++ b/backend/src/skins/drinks.py
@@ -867,6 +867,28 @@ def get_drink_submission_for_owner(
return _public_row(row)
+def owner_drink_texture(submission_id: str, player_uuid: str) -> Path | None:
+ """The owner's custom drink texture: the upload, else the shared texture it reuses."""
+ row = _get_row(submission_id)
+ if row is None or str(row["player_uuid"]).lower() != str(player_uuid).lower():
+ return None
+ root = db.DRINKS_DIR.resolve()
+ candidates = [_submissions_root() / submission_id / "texture.png"]
+ texture_id = str(row["texture_id"] or "").strip()
+ if texture_id:
+ with connect() as conn:
+ tex = conn.execute(
+ "SELECT png_path FROM drink_textures WHERE id = ?", (texture_id,)
+ ).fetchone()
+ if tex is not None and tex["png_path"]:
+ candidates.append(db.DRINKS_DIR / str(tex["png_path"]))
+ for path in candidates:
+ resolved = path.resolve()
+ if resolved.is_file() and resolved.is_relative_to(root):
+ return resolved
+ return None
+
+
def list_drink_submissions_for_player(
player_uuid: str,
realm_id: str | None = None,
diff --git a/backend/src/skins/test_drinks.py b/backend/src/skins/test_drinks.py
index b825d8a3..5ef57608 100644
--- a/backend/src/skins/test_drinks.py
+++ b/backend/src/skins/test_drinks.py
@@ -541,6 +541,55 @@ def test_texture_review_sheet(self) -> None:
assert data is not None
self.assertTrue(data.startswith(b"\x89PNG"))
+ def test_owner_drink_texture(self) -> None:
+ from skins.db import connect
+ from skins.drinks import create_drink_submission, owner_drink_texture
+
+ self._link_player()
+ self._seed_catalog()
+ upsert_rpc_player_meta(
+ {"player_uuid": "player-1", "allow_drink_texture": True}
+ )
+ session = self._issue_and_redeem()
+ out = create_drink_submission(
+ session,
+ {
+ "name": "Tex Brew",
+ "ingredients": [{"id": "grape", "amount": 1}],
+ "effects": [],
+ },
+ png_bytes=TINY_PNG,
+ )
+ path = owner_drink_texture(out["id"], "PLAYER-1")
+ self.assertIsNotNone(path)
+ assert path is not None
+ self.assertEqual(TINY_PNG, path.read_bytes())
+ self.assertIsNone(owner_drink_texture(out["id"], "someone-else"))
+
+ # A drink reusing a texture has no upload of its own; the shared file stands in.
+ path.unlink()
+ with connect() as conn:
+ texture_id = conn.execute(
+ "SELECT texture_id FROM drink_submissions WHERE id = ?", (out["id"],)
+ ).fetchone()[0]
+ self.assertTrue(texture_id)
+ shared = owner_drink_texture(out["id"], "player-1")
+ self.assertIsNotNone(shared)
+ assert shared is not None
+ self.assertEqual(f"{texture_id}.png", shared.name)
+
+ def test_owner_drink_texture_colour_only(self) -> None:
+ from skins.drinks import create_drink_submission, owner_drink_texture
+
+ self._link_player()
+ self._seed_catalog()
+ session = self._issue_and_redeem()
+ out = create_drink_submission(
+ session,
+ {"name": "Plain Ale", "ingredients": [{"id": "grape", "amount": 1}], "color": "#AA3344"},
+ )
+ self.assertIsNone(owner_drink_texture(out["id"], "player-1"))
+
def test_review_sheet_missing_appearance(self) -> None:
from skins.db import connect
from skins.drink_review_sheet import (
diff --git a/frontend/app/components/drinks/StatusCard.tsx b/frontend/app/components/drinks/StatusCard.tsx
deleted file mode 100644
index bfc85409..00000000
--- a/frontend/app/components/drinks/StatusCard.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-"use client";
-
-import type { DrinkSubmissionPublic } from "../../../lib/drinks/api";
-import { formatLocal } from "../../../lib/skins/formatTime";
-
-function statusMessage(row: DrinkSubmissionPublic): string {
- switch (row.status) {
- case "pending":
- return "Submitted. Staff will review your recipe. You will get a Discord DM " + "when it is approved or denied.";
- case "denied":
- return row.deny_reason?.trim() ? `Denied: ${row.deny_reason.trim()}` : "Denied. No reason given.";
- case "pending_pack":
- return "Approved. Your icon goes live soon.";
- case "approved":
- return "Approved. Waiting to become available in-game.";
- case "applied":
- return "Live. Brew it in-game.";
- default:
- return row.status;
- }
-}
-
-type Props = {
- row: DrinkSubmissionPublic;
-};
-
-export default function StatusCard({ row }: Props) {
- const recipe = row.recipe || {};
- const color = typeof recipe.color === "string" ? recipe.color : null;
-
- return (
-
-
{statusMessage(row)}
-
-
-
-
Drink name
- {row.display_name}
-
- {color ? (
-
-
Colour
-
-
- {color}
-
-
- ) : null}
-
-
Created
- {formatLocal(row.created_at)}
-
- {row.reviewed_at ? (
-
-
Reviewed
- {formatLocal(row.reviewed_at)}
-
- ) : null}
- {row.applied_at ? (
-
-
Applied
- {formatLocal(row.applied_at)}
-
- ) : null}
-
-
- );
-}
diff --git a/frontend/app/components/profile/DrinkShowcase.tsx b/frontend/app/components/profile/DrinkShowcase.tsx
new file mode 100644
index 00000000..2bc9bd57
--- /dev/null
+++ b/frontend/app/components/profile/DrinkShowcase.tsx
@@ -0,0 +1,225 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { getCatalog, type DrinkLoreLine, type DrinkSubmissionPublic } from "../../../lib/drinks/api";
+import { effectLabel, WOOD_OPTIONS } from "../../../lib/drinks/constants";
+import { previewColourStopRuns } from "../../../lib/skins/namePreview";
+import { DrinkPicture } from "./WardrobePictures";
+import {
+ Facts,
+ formatDay,
+ Heading,
+ plainName,
+ quietActionClass,
+ Showcase,
+ StagePicture,
+ StatusBlock,
+ Tooltip,
+} from "./ItemShowcase";
+
+function statusMessage(status: string): string {
+ switch (status) {
+ case "pending":
+ return "Waiting for staff review. Discord tells you when it’s decided.";
+ case "pending_pack":
+ return "Approved. Its icon goes live soon.";
+ case "approved":
+ return "Approved. You can brew it in game soon.";
+ case "applied":
+ return "Live. Brew it in game.";
+ case "denied":
+ case "rejected":
+ return "Denied.";
+ default:
+ return status;
+ }
+}
+
+function num(value: unknown): number {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : 0;
+}
+
+function plural(n: number, word: string): string {
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
+}
+
+type Ingredient = { id: string; amount: number };
+
+function ingredientsOf(recipe: Record): Ingredient[] {
+ const raw = Array.isArray(recipe.ingredients) ? recipe.ingredients : [];
+ return raw
+ .map((row) => (row && typeof row === "object" ? (row as Record) : {}))
+ .map((row) => ({ id: String(row.id ?? ""), amount: Math.max(1, num(row.amount)) }))
+ .filter((row) => row.id);
+}
+
+function effectsOf(recipe: Record): string[] {
+ const raw = Array.isArray(recipe.effects) ? recipe.effects : [];
+ return raw
+ .map((effect) => {
+ if (typeof effect === "string") return effectLabel(effect.split("/")[0] ?? effect);
+ if (effect && typeof effect === "object" && "type" in effect) {
+ const { type, level } = effect as { type: unknown; level?: unknown };
+ const lvl = num(level);
+ return `${effectLabel(String(type))}${lvl > 1 ? ` ${lvl}` : ""}`;
+ }
+ return "";
+ })
+ .filter(Boolean);
+}
+
+function loreOf(recipe: Record): string[] {
+ const raw = Array.isArray(recipe.lore) ? recipe.lore : [];
+ return raw
+ .map((line) => (typeof line === "string" ? line : String((line as DrinkLoreLine)?.text ?? "")))
+ .filter((line) => line.trim());
+}
+
+/** Cook, distil, age: the steps a brewer follows, in order. */
+function brewingSteps(recipe: Record): string[] {
+ const steps: string[] = [];
+ const cook = num(recipe.cooking_time);
+ if (cook) steps.push(`Cook for ${plural(cook, "minute")}`);
+ const runs = num(recipe.distill_runs);
+ if (runs) steps.push(`Distil ${runs === 1 ? "once" : `${runs} times`}`);
+ const age = num(recipe.age);
+ if (age) {
+ // Older recipes store the wood's name, newer ones its BreweryX index.
+ const raw = String(recipe.wood ?? "").toLowerCase();
+ const wood = WOOD_OPTIONS.find((w) => w.id === raw || w.label.toLowerCase().replace(/ /g, "_") === raw);
+ const barrel = wood && wood.id !== "0" ? `${wood.label.toLowerCase()} barrel` : "barrel";
+ steps.push(`Age ${plural(age, "year")} in ${/^[aeiou]/.test(barrel) ? "an" : "a"} ${barrel}`);
+ }
+ return steps;
+}
+
+/** Ingredient names from the brewing catalogue, once it has loaded. */
+function useIngredientLabels(): Record {
+ const [labels, setLabels] = useState>({});
+ useEffect(() => {
+ let live = true;
+ getCatalog()
+ .then((catalog) => {
+ if (!live) return;
+ setLabels(Object.fromEntries(catalog.ingredients.map((i) => [i.id, i.label || i.id])));
+ })
+ .catch(() => undefined);
+ return () => {
+ live = false;
+ };
+ }, []);
+ return labels;
+}
+
+function prettyId(id: string): string {
+ const bare = id.includes(":") ? id.slice(id.indexOf(":") + 1) : id;
+ const words = bare.replace(/[_-]+/g, " ").trim();
+ return words ? words[0]!.toUpperCase() + words.slice(1) : id;
+}
+
+export default function DrinkShowcase({
+ row,
+ sessionToken,
+ onRefresh,
+}: {
+ row: DrinkSubmissionPublic;
+ /** The owner's drinks or Profile session. */
+ sessionToken: string;
+ onRefresh: () => void;
+}) {
+ const recipe = row.recipe || {};
+ const color = typeof recipe.color === "string" ? recipe.color : null;
+ const labels = useIngredientLabels();
+ const colours = Array.isArray(recipe.name_colours) ? recipe.name_colours.map(String) : [];
+ const name = colours.length ? previewColourStopRuns(row.display_name, colours) : plainName(row.display_name);
+ const ingredients = ingredientsOf(recipe);
+ const steps = brewingSteps(recipe);
+ const effects = effectsOf(recipe);
+ const alcohol = num(recipe.alcohol);
+ const difficulty = num(recipe.difficulty);
+ const waiting = ["pending", "pending_pack", "approved"].includes(row.status);
+
+ return (
+
+
+
+
+
+ }
+ >
+
+
+ Check again
+
+ ) : null
+ }
+ />
+
+
+ {ingredients.map((i) => (
+
+ {i.amount} × {labels[i.id] || prettyId(i.id)}
+
+ ))}
+
+ ),
+ }
+ : null,
+ steps.length
+ ? {
+ label: "Brewing",
+ value: (
+
+ {steps.map((step) => (
+ {step}
+ ))}
+
+ ),
+ }
+ : null,
+ effects.length ? { label: "Effects", value: effects.join(", ") } : null,
+ color
+ ? {
+ label: "Colour",
+ value: (
+
+
+ {color}
+
+ ),
+ }
+ : null,
+ formatDay(row.created_at) ? { label: "Made", value: formatDay(row.created_at) } : null,
+ formatDay(row.reviewed_at) ? { label: "Reviewed", value: formatDay(row.reviewed_at) } : null,
+ formatDay(row.applied_at) ? { label: "Live since", value: formatDay(row.applied_at) } : null,
+ ]}
+ />
+
+ );
+}
diff --git a/frontend/app/components/profile/ItemShowcase.tsx b/frontend/app/components/profile/ItemShowcase.tsx
new file mode 100644
index 00000000..91a5c921
--- /dev/null
+++ b/frontend/app/components/profile/ItemShowcase.tsx
@@ -0,0 +1,214 @@
+"use client";
+
+import type { ReactNode } from "react";
+import FormattedMcRuns from "../shared/FormattedMcRuns";
+import { parseLoreRuns, type LoreRun } from "../../../lib/characters/lorePreview";
+import { STATUS_CHIPS } from "./Wardrobe";
+
+/** One skin, drink or custom item: the picture on a stage, and what it is beside it. */
+export function Showcase({
+ stage,
+ controls,
+ children,
+}: {
+ stage: ReactNode;
+ controls?: ReactNode;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+ {stage}
+
+ {controls ?
{controls}
: null}
+
+
{children}
+
+ );
+}
+
+/** A picture centred on the stage, scaled up without smoothing. */
+export function StagePicture({ children }: { children: ReactNode }) {
+ return {children}
;
+}
+
+export function StageNote({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** A row of choices under the stage: armour sets, book covers. */
+export function Segmented({
+ label,
+ options,
+ value,
+ onChange,
+}: {
+ label: string;
+ options: readonly { id: T; label: string }[];
+ value: T;
+ onChange: (id: T) => void;
+}) {
+ return (
+
+ {options.map((opt) => {
+ const selected = opt.id === value;
+ return (
+ onChange(opt.id)}
+ className={`rounded-sm px-3 py-1 text-sm transition-colors ${
+ selected
+ ? "bg-[color-mix(in_srgb,var(--tfmc-cream)_12%,transparent)] text-[var(--tfmc-cream)]"
+ : "text-[var(--tfmc-stone)] hover:text-[var(--tfmc-cream)]"
+ }`}
+ >
+ {opt.label}
+
+ );
+ })}
+
+ );
+}
+
+/** Inventory-style slots for flat item icons, labelled underneath. */
+export function IconSlots({ icons }: { icons: { label: string; src: string | null }[] }) {
+ return (
+
+ {icons.map((icon) => (
+
+
+ {icon.src ? (
+ // eslint-disable-next-line @next/next/no-img-element -- object URLs
+
+ ) : null}
+
+ {icon.label}
+
+ ))}
+
+ );
+}
+
+export function Heading({ title, detail }: { title: string; detail?: ReactNode }) {
+ return (
+
+
+ {title}
+
+ {detail ?
{detail}
: null}
+
+ );
+}
+
+/** The name and lore as the item shows them in game. */
+export function Tooltip({ name, lore = [] }: { name: LoreRun[]; lore?: string[] }) {
+ return (
+
+ );
+}
+
+function TooltipBox({ name, lore }: { name: LoreRun[]; lore: string[] }) {
+ return (
+
+
+
+
+
+ {lore.length ? (
+
+ {lore.map((line, i) => (
+
+ {line.trim() ? : " "}
+
+ ))}
+
+ ) : null}
+
+
+ );
+}
+
+export function plainName(text: string): LoreRun[] {
+ return [{ text, color: "#ffffff" }];
+}
+
+/** The chip from the wardrobe card, then one plain sentence; a denial reason gets its own box. */
+export function StatusBlock({
+ status,
+ message,
+ reason,
+ action,
+}: {
+ status: string;
+ message: string;
+ reason?: string | null;
+ action?: ReactNode;
+}) {
+ const chip = STATUS_CHIPS[status.toLowerCase()];
+ return (
+
+
+ {chip ? (
+
+ {chip.label}
+
+ ) : null}
+
{message}
+
+ {reason ? (
+
+ {reason}
+
+ ) : null}
+ {action}
+
+ );
+}
+
+export type Fact = { label: string; value: ReactNode };
+
+export function Facts({ facts }: { facts: (Fact | null | false | undefined)[] }) {
+ const shown = facts.filter((f): f is Fact => Boolean(f));
+ return (
+
+ {shown.map((fact) => (
+
+
{fact.label}
+ {fact.value}
+
+ ))}
+
+ );
+}
+
+const DAY = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "long", year: "numeric" });
+
+/** "12 October 2026", or null for a missing or unreadable date. */
+export function formatDay(value: string | null | undefined): string | null {
+ if (!value) return null;
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? null : DAY.format(date);
+}
+
+export const quietActionClass =
+ "text-sm text-[var(--tfmc-mist)] underline-offset-2 hover:text-[var(--tfmc-cream)] hover:underline disabled:opacity-50";
diff --git a/frontend/app/components/profile/ProfileCustomItems.tsx b/frontend/app/components/profile/ProfileCustomItems.tsx
new file mode 100644
index 00000000..a29cf1ee
--- /dev/null
+++ b/frontend/app/components/profile/ProfileCustomItems.tsx
@@ -0,0 +1,278 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import {
+ CharactersApiError,
+ listCharacterKits,
+ loreItemDefaultTextureUrl,
+ type CharacterKit,
+ type CharacterKitItem,
+} from "../../../lib/characters/api";
+import { hasInlineFormatCodes, parseNameRuns } from "../../../lib/characters/lorePreview";
+import type { ProfileCustomItem } from "../../../lib/profile/api";
+import { customItemId, parseCustomItemId, profileHref } from "../../../lib/profile/paths";
+import { previewColourStopRuns } from "../../../lib/skins/namePreview";
+import { HangerGlyph, WardrobeCard } from "./Wardrobe";
+import { SkinPicture } from "./WardrobePictures";
+import { ItemFrame, Loading } from "./ProfileItems";
+import { Facts, formatDay, Heading, Showcase, StagePicture, StatusBlock, Tooltip } from "./ItemShowcase";
+
+// The backend falls back to kit_key when an item has no name; never show a raw key.
+const KEY_LIKE = /^[a-z0-9]+(?:[._:-][a-z0-9]+)+$/;
+
+function itemName(item: ProfileCustomItem): string {
+ const name = String(item.display_name || "").trim();
+ if (!name || name.toLowerCase() === item.kit_key.trim().toLowerCase() || KEY_LIKE.test(name)) return "Custom item";
+ return name;
+}
+
+/** The wardrobe chip for an item's customise state. */
+function chipStatus(item: ProfileCustomItem): string {
+ const state = String(item.state || "").toLowerCase();
+ if (state === "pending_skin") {
+ return String(item.submission_status || "").toLowerCase() === "approved" ? "approved" : "pending";
+ }
+ return state;
+}
+
+function statusMessage(item: ProfileCustomItem, claimable: boolean): string {
+ switch (chipStatus(item)) {
+ case "pending":
+ return "Its skin is waiting for staff review. Discord tells you when it’s decided.";
+ case "approved":
+ return "Its skin is approved and goes live within 24 hours.";
+ case "ready":
+ return claimable ? "Ready. Claim the kit in game to get it." : "Ready for the next time you claim the kit.";
+ case "applied":
+ return "Live in game.";
+ case "denied":
+ return "Denied. Edit it to try a different skin.";
+ case "draft":
+ return "Not sent yet.";
+ default:
+ return item.state;
+ }
+}
+
+/** The skin the player made for the item, else the kit's own texture. */
+function ItemPicture({ item, sessionToken }: { item: ProfileCustomItem; sessionToken: string }) {
+ if (item.submission_id) return ;
+ return ;
+}
+
+function KitTexture({ kitKey, sessionToken }: { kitKey: string; sessionToken: string }) {
+ const [src, setSrc] = useState(null);
+ const [missing, setMissing] = useState(false);
+ useEffect(() => {
+ let url: string | null = null;
+ let live = true;
+ fetch(loreItemDefaultTextureUrl(kitKey), { headers: { Authorization: `Bearer ${sessionToken}` } })
+ .then((res) => (res.ok ? res.blob() : null))
+ .then((blob) => {
+ if (!live) return;
+ if (!blob) return setMissing(true);
+ url = URL.createObjectURL(blob);
+ setSrc(url);
+ })
+ .catch(() => live && setMissing(true));
+ return () => {
+ live = false;
+ if (url) URL.revokeObjectURL(url);
+ };
+ }, [kitKey, sessionToken]);
+ if (src) {
+ // eslint-disable-next-line @next/next/no-img-element -- object URL
+ return ;
+ }
+ return missing ? : null;
+}
+
+export function CustomItemList({
+ items,
+ sessionToken,
+ onOpen,
+}: {
+ items: ProfileCustomItem[];
+ sessionToken: string;
+ onOpen: (id: string) => void;
+}) {
+ if (items.length === 0) {
+ return (
+
+ No custom items yet. Open a character’s kit to rename an item or give it your own skin.
+
+ );
+ }
+ return (
+
+
+ {items.length === 1 ? "1 custom item" : `${items.length} custom items`}
+
+
+ {items.map((item) => {
+ const id = customItemId(item.character_id, item.kit_key);
+ return (
+ ,
+ }}
+ />
+ );
+ })}
+
+
+ );
+}
+
+type Found = { kit: CharacterKit; part: CharacterKitItem };
+
+function findPart(kits: CharacterKit[], kitKey: string, kitId?: string): Found | null {
+ const key = kitKey.toLowerCase();
+ const ordered = [...kits.filter((k) => k.id === kitId), ...kits.filter((k) => k.id !== kitId)];
+ for (const kit of ordered) {
+ const part = kit.items.find((p) => p.kit_key?.toLowerCase() === key);
+ if (part) return { kit, part };
+ }
+ return null;
+}
+
+function nameRuns(name: string, colours: string[], styles: string[]) {
+ if (colours.length) return previewColourStopRuns(name, colours, styles);
+ return parseNameRuns(hasInlineFormatCodes(name) ? name : `§f${name}`);
+}
+
+export function CustomItemView({
+ id,
+ items,
+ sessionToken,
+ onBack,
+}: {
+ id: string;
+ items: ProfileCustomItem[];
+ sessionToken: string;
+ onBack: () => void;
+}) {
+ const ref = parseCustomItemId(id);
+ const item = ref ? (items.find((i) => i.character_id === ref.characterId && i.kit_key === ref.kitKey) ?? null) : null;
+ const [found, setFound] = useState(null);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ // Keyed by the item's ids: Profile reloads its rows while anything is in review.
+ const characterId = item?.character_id;
+ const kitKey = item?.kit_key;
+ const kitId = item?.kit_id;
+ useEffect(() => {
+ if (!characterId || !kitKey) {
+ setLoading(false);
+ return;
+ }
+ let live = true;
+ listCharacterKits(sessionToken, characterId)
+ .then((data) => live && setFound(findPart(data.kits, kitKey, kitId)))
+ .catch((err) => {
+ if (live) setError(err instanceof CharactersApiError ? err.message : "Couldn’t load this item.");
+ })
+ .finally(() => live && setLoading(false));
+ return () => {
+ live = false;
+ };
+ }, [characterId, kitId, kitKey, sessionToken]);
+
+ if (!item) {
+ return (
+
+ This item isn’t on your profile.
+
+ );
+ }
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ const draft = found?.part.customise;
+ const name = itemName(item);
+ const base = found?.part.preview;
+ const customLore = (draft?.lore ?? []).map((l) => l.trim()).filter(Boolean);
+ const baseLore = base?.lore ?? [];
+ // In game the item keeps its own lore, then a gap, then the player's lines.
+ const lore = customLore.length && baseLore.length ? [...baseLore, " ", ...customLore] : [...baseLore, ...customLore];
+ const status = chipStatus(item);
+ const editable = Boolean(found && found.kit.claimable && found.part.editable && status !== "pending");
+ const editHref = found
+ ? `/character/${encodeURIComponent(item.character_id)}/kits/${encodeURIComponent(found.kit.id)}/edit/${encodeURIComponent(item.kit_key)}`
+ : null;
+
+ return (
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+
+ }
+ >
+
+
+ Edit item
+
+ ) : null
+ }
+ />
+
+ r.text)
+ .join(""),
+ }
+ : null,
+ status === "ready" && formatDay(item.ready_at)
+ ? { label: "Ready since", value: formatDay(item.ready_at) }
+ : null,
+ formatDay(item.applied_at) ? { label: "Live since", value: formatDay(item.applied_at) } : null,
+ ]}
+ />
+
+
+ );
+}
diff --git a/frontend/app/components/profile/ProfileCustomItemsList.tsx b/frontend/app/components/profile/ProfileCustomItemsList.tsx
deleted file mode 100644
index ea311c7e..00000000
--- a/frontend/app/components/profile/ProfileCustomItemsList.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import type { ProfileCustomItem } from "../../../lib/profile/api";
-
-function statusLabel(item: ProfileCustomItem): string {
- const state = String(item.state || "")
- .trim()
- .toLowerCase();
- const sub = String(item.submission_status || "")
- .trim()
- .toLowerCase();
- if (state === "pending_skin") {
- if (sub === "approved") return "Approved — not yet available";
- return "Awaiting approval";
- }
- switch (state) {
- case "ready":
- return "Ready";
- case "denied":
- return "Denied";
- case "applied":
- return "Applied";
- case "draft":
- return "Draft";
- default:
- return "Status unavailable";
- }
-}
-
-// The backend falls back to kit_key when an item has no name; never show a raw key.
-const KEY_LIKE = /^[a-z0-9]+(?:[._:-][a-z0-9]+)+$/;
-
-function itemName(item: ProfileCustomItem): string {
- const name = String(item.display_name || "").trim();
- if (!name) return "Custom item";
- if (
- name.toLowerCase() ===
- String(item.kit_key || "")
- .trim()
- .toLowerCase()
- )
- return "Custom item";
- if (KEY_LIKE.test(name)) return "Custom item";
- return name;
-}
-
-type Props = {
- items: ProfileCustomItem[];
-};
-
-export default function ProfileCustomItemsList({ items }: Props) {
- if (items.length === 0) {
- return No custom items yet.
;
- }
-
- return (
-
- );
-}
diff --git a/frontend/app/components/profile/ProfileItems.tsx b/frontend/app/components/profile/ProfileItems.tsx
index edc9f2c2..7f9a0eab 100644
--- a/frontend/app/components/profile/ProfileItems.tsx
+++ b/frontend/app/components/profile/ProfileItems.tsx
@@ -2,9 +2,9 @@
import { useCallback, useEffect, useState, type MouseEvent, type ReactNode } from "react";
import UploadForm from "../skins/UploadForm";
-import SkinStatusCard from "../skins/StatusCard";
import BrewForm from "../drinks/BrewForm";
-import DrinkStatusCard from "../drinks/StatusCard";
+import DrinkShowcase from "./DrinkShowcase";
+import SkinShowcase from "./SkinShowcase";
import { getPlayerMeta, getSubmission as getSkin, type SubmissionPublic } from "../../../lib/skins/api";
import * as skins from "../../../lib/skins/session";
import { getSubmission as getDrink, type DrinkSubmissionPublic } from "../../../lib/drinks/api";
@@ -25,7 +25,7 @@ type Props = {
const quietClass = "text-sm text-[var(--tfmc-mist)] underline-offset-2 hover:text-[var(--tfmc-cream)] hover:underline";
-const BACK_LABELS = { characters: "Characters", skins: "Skins", drinks: "Drinks" } as const;
+const BACK_LABELS = { characters: "Characters", skins: "Skins", drinks: "Drinks", items: "Custom items" } as const;
/** An item inside its Profile tab: back to the tab's list, a title if the item has none of its own, then the item. */
export function ItemFrame({
@@ -176,13 +176,13 @@ function useOwnedItem(
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
+ // Checking again keeps the item on screen; only the first load shows Loading.
const load = useCallback(async () => {
const owner = resolveOwnerToken(store);
if (!owner) {
onLeave();
return;
}
- setLoading(true);
setError(null);
try {
setRow(await get(id, owner));
@@ -202,17 +202,7 @@ function useOwnedItem(
return { row, token, error, loading, load };
}
-function ItemBody({
- loading,
- error,
- card,
- onRefresh,
-}: {
- loading: boolean;
- error: string | null;
- card: ReactNode;
- onRefresh: () => void;
-}) {
+function ItemBody({ loading, error, children }: { loading: boolean; error: string | null; children: ReactNode }) {
if (loading) return ;
if (error) {
return (
@@ -221,15 +211,7 @@ function ItemBody({
);
}
- if (!card) return null;
- return (
- <>
- {card}
-
- Refresh status
-
- >
- );
+ return children;
}
function SkinView({ id, onBack, onLeave }: Omit) {
@@ -240,32 +222,26 @@ function SkinView({ id, onBack, onLeave }: Omit) {
onLeave,
);
return (
-
- : null}
- onRefresh={() => void load()}
- />
+
+
+ {row && token ? void load()} /> : null}
+
);
}
function DrinkView({ id, onBack, onLeave }: Omit) {
- const { row, error, loading, load } = useOwnedItem(
+ const { row, token, error, loading, load } = useOwnedItem(
id,
drinks,
getDrink,
onLeave,
);
return (
-
- : null}
- onRefresh={() => void load()}
- />
+
+
+ {row && token ? void load()} /> : null}
+
);
}
diff --git a/frontend/app/components/profile/ProfilePanel.test.tsx b/frontend/app/components/profile/ProfilePanel.test.tsx
index 33379f30..bfa2d2cc 100644
--- a/frontend/app/components/profile/ProfilePanel.test.tsx
+++ b/frontend/app/components/profile/ProfilePanel.test.tsx
@@ -22,11 +22,12 @@ import { StartRefusedError } from "../../../lib/profile/start";
import {
deletePendingCreate,
getCreationCatalog,
+ listCharacterKits,
logoutCharacter,
type CreationCatalog,
} from "../../../lib/characters/api";
import {
- getReviewSheet,
+ getSubmissionFile,
getSkinThumbnail,
getSubmission,
redeemCode,
@@ -85,7 +86,7 @@ vi.mock("../../../lib/skins/api", async (importOriginal) => ({
...(await importOriginal()),
getSkinThumbnail: vi.fn(),
getSubmission: vi.fn(),
- getReviewSheet: vi.fn(),
+ getSubmissionFile: vi.fn(),
getPlayerMeta: vi.fn(() => new Promise(() => undefined)),
redeemCode: vi.fn(),
startSkinFromProfile: vi.fn(),
@@ -110,6 +111,7 @@ vi.mock("../../../lib/characters/api", async (importOriginal) => ({
characterFace: vi.fn(async () => null),
characterFigure: vi.fn(async () => null),
deletePendingCreate: vi.fn(),
+ listCharacterKits: vi.fn(),
}));
const UUID = "0615a817-8cb4-4aef-95f7-f6c9bf7611b8";
@@ -928,7 +930,7 @@ it("opens a skin in its tab, and Back returns to the wardrobe", async () => {
reviewed_at: null,
applied_at: null,
});
- vi.mocked(getReviewSheet).mockReturnValue(new Promise(() => undefined));
+ vi.mocked(getSubmissionFile).mockReturnValue(new Promise(() => undefined));
visit("skins");
render( );
fireEvent.click(await screen.findByRole("link", { name: /Emberfang/ }));
@@ -954,7 +956,7 @@ it("opens a skin's address in the tab, and a submit shows the new skin in the ed
reviewed_at: null,
applied_at: null,
});
- vi.mocked(getReviewSheet).mockReturnValue(new Promise(() => undefined));
+ vi.mocked(getSubmissionFile).mockReturnValue(new Promise(() => undefined));
setSkinsSession({ ...SKIN_CODE, from_profile: true });
visit("skins", "new");
render( );
@@ -988,7 +990,7 @@ it("closes an open skin when the address leaves it, as the header's Profile link
reviewed_at: null,
applied_at: null,
});
- vi.mocked(getReviewSheet).mockReturnValue(new Promise(() => undefined));
+ vi.mocked(getSubmissionFile).mockReturnValue(new Promise(() => undefined));
visit("skins", "sub-1");
render( );
expect(await screen.findByRole("heading", { level: 2, name: "Emberfang" })).toBeTruthy();
@@ -1212,3 +1214,64 @@ it("lets a Discord account without a Minecraft link open a wardrobe with a code"
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["Skins", "Linked accounts"]);
expect(screen.queryByRole("button", { name: "Enter code" })).toBeNull();
});
+
+const CUSTOM_ITEM = {
+ character_id: "c1",
+ character_name: "Aldric",
+ kit_key: "iron_hunting_knife",
+ kit_id: "starter",
+ display_name: "Exhumer's Spade",
+ state: "denied",
+ deny_reason: "Too bright",
+};
+
+it("opens a custom item in its own tab rather than the character's kits", async () => {
+ vi.mocked(getAccount).mockResolvedValue(account({ minecraft: linked() }));
+ vi.mocked(getProfileDashboard).mockResolvedValue(wardrobe({ custom_items: [CUSTOM_ITEM] }));
+ vi.mocked(listCharacterKits).mockResolvedValue({
+ character_id: "c1",
+ kits: [
+ {
+ id: "starter",
+ display_name: "Starter",
+ cooldown_hours: 0,
+ once_per_character: true,
+ status: "eligible",
+ claimable: true,
+ items: [
+ {
+ path: "MI.iron_hunting_knife",
+ amount: 1,
+ editable: true,
+ kit_key: "iron_hunting_knife",
+ preview: { display_name: "Iron Hunting Knife", lore: ["§7A knife."], material: "IRON_SWORD" },
+ customise: {
+ display_name: "Exhumer's Spade",
+ lore: ["Used for too long"],
+ existing_skin_id: null,
+ submission_id: null,
+ submission_status: null,
+ state: "denied",
+ },
+ },
+ ],
+ },
+ ],
+ });
+ visit("items");
+ render( );
+ const card = await screen.findByRole("link", { name: /Exhumer's Spade/ });
+ expect(card.getAttribute("href")).toBe("/profile?tab=items&item=c1%2Firon_hunting_knife");
+ fireEvent.click(card);
+ expect(await screen.findByRole("heading", { level: 2, name: "Exhumer's Spade" })).toBeTruthy();
+ expect(window.location.search).toBe("?tab=items&item=c1%2Firon_hunting_knife");
+ expect(listCharacterKits).toHaveBeenCalledWith("linked-token", "c1");
+ expect(screen.getByText("Too bright")).toBeTruthy();
+ expect(screen.getByText("Used for too long")).toBeTruthy();
+ expect(screen.getByRole("link", { name: "Edit item" }).getAttribute("href")).toBe(
+ "/character/c1/kits/starter/edit/iron_hunting_knife",
+ );
+ window.history.back();
+ expect(await screen.findByRole("link", { name: /Exhumer's Spade/ })).toBeTruthy();
+ expect(window.location.search).toBe("?tab=items");
+});
diff --git a/frontend/app/components/profile/ProfilePanel.tsx b/frontend/app/components/profile/ProfilePanel.tsx
index 95217c87..13de3064 100644
--- a/frontend/app/components/profile/ProfilePanel.tsx
+++ b/frontend/app/components/profile/ProfilePanel.tsx
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { useSearchParams } from "next/navigation";
import CharacterList from "../character/CharacterList";
-import ProfileCustomItemsList from "./ProfileCustomItemsList";
+import { CustomItemList, CustomItemView } from "./ProfileCustomItems";
import ProfileRedeemForm from "./ProfileRedeemForm";
import { CodeWardrobe, DrinkWardrobe, SkinWardrobe } from "./ProfileWardrobes";
import { DrinkItem, SkinItem } from "./ProfileItems";
@@ -446,6 +446,8 @@ export default function ProfilePanel({
const drinkNav = useMemo(() => itemNav("drinks"), [itemNav]);
+ const customItemNav = useMemo(() => itemNav("items"), [itemNav]);
+
function onRedeemed(signIn: SignIn) {
if (signIn.profile) setSessionState(signIn.profile);
else setCodes(readCodeSessions());
@@ -693,7 +695,7 @@ export default function ProfilePanel({
) : null}
>
)
- ) : openItem && shown !== "characters" ? (
+ ) : openItem && (shown === "skins" || shown === "drinks") ? (
shown === "skins" ? (
) : (
@@ -759,8 +761,15 @@ export default function ProfilePanel({
sessionToken={token!}
onOpen={drinkNav.onOpen}
/>
+ ) : openItem ? (
+
) : (
-
+
)}
>
)}
diff --git a/frontend/app/components/profile/ProfileWardrobes.tsx b/frontend/app/components/profile/ProfileWardrobes.tsx
index d3b33b9c..43a613f6 100644
--- a/frontend/app/components/profile/ProfileWardrobes.tsx
+++ b/frontend/app/components/profile/ProfileWardrobes.tsx
@@ -110,7 +110,7 @@ export function DrinkWardrobe({
detail: "Drink",
status: row.status,
denyReason: row.deny_reason,
- picture: ,
+ picture: ,
}))}
/>
);
diff --git a/frontend/app/components/profile/SkinShowcase.tsx b/frontend/app/components/profile/SkinShowcase.tsx
new file mode 100644
index 00000000..e634ac6e
--- /dev/null
+++ b/frontend/app/components/profile/SkinShowcase.tsx
@@ -0,0 +1,284 @@
+"use client";
+
+import { useEffect, useMemo, useState, type ReactNode } from "react";
+import ArmorPreview from "../skins/ArmorPreview";
+import ModelPreview from "../skins/ModelPreview";
+import { kindLabel } from "../skins/KindPicker";
+import { getSubmissionFile, type SubmissionPublic } from "../../../lib/skins/api";
+import { armorTierLabel, baseSetLabel } from "../../../lib/skins/baseSets";
+import { previewColourStopRuns } from "../../../lib/skins/namePreview";
+import { ownerFileNames } from "../../../lib/skins/ownerFiles";
+import { ARMOR_ICON_FIELDS } from "../../../lib/skins/sizes";
+import { SkinPicture } from "./WardrobePictures";
+import {
+ Facts,
+ formatDay,
+ Heading,
+ IconSlots,
+ plainName,
+ quietActionClass,
+ Segmented,
+ Showcase,
+ StageNote,
+ StagePicture,
+ StatusBlock,
+ Tooltip,
+} from "./ItemShowcase";
+
+type Files = Record;
+
+const FLAT_KINDS = ["handheld", "large_handheld", "bow", "large_bow", "crossbow"];
+const ICON_LABELS: Record = { helmet: "Helmet", chestplate: "Chest", leggings: "Legs", boots: "Boots" };
+
+function statusMessage(row: SubmissionPublic): string {
+ switch (row.status) {
+ case "pending":
+ return row.staff
+ ? "Waiting to approve itself."
+ : "Waiting for staff review. Discord tells you when it’s decided.";
+ case "approved":
+ return row.staff
+ ? "Approved. It joins the shop pack within 24 hours."
+ : "Approved. It goes live within 24 hours.";
+ case "applied":
+ return row.staff ? "Live in the shop pack." : "Live on the server.";
+ case "denied":
+ case "rejected":
+ return "Denied.";
+ case "revoked":
+ return "Removed from the server.";
+ default:
+ return row.status;
+ }
+}
+
+/** The skin's own uploads, fetched once per skin; null until they arrive. */
+function useOwnerFiles(row: SubmissionPublic, sessionToken: string): Files | null {
+ const [files, setFiles] = useState(null);
+ // Checking the status again returns a new row; the files only change with the skin.
+ const { kind, slug } = row;
+ const tiers = (row.tiers ?? []).join(",");
+ const names = useMemo(
+ () => ownerFileNames({ kind, slug, tiers: tiers ? tiers.split(",") : [] }),
+ [kind, slug, tiers],
+ );
+ useEffect(() => {
+ let live = true;
+ setFiles(null);
+ const entries = Object.entries(names);
+ void Promise.all(entries.map(([, name]) => getSubmissionFile(row.id, name, sessionToken).catch(() => null))).then(
+ (found) => {
+ if (live) setFiles(Object.fromEntries(entries.map(([field], i) => [field, found[i] ?? null])));
+ },
+ );
+ return () => {
+ live = false;
+ };
+ }, [names, row.id, sessionToken]);
+ return files;
+}
+
+/** Object URLs for the armour icons, revoked when the set changes. */
+function useIconUrls(files: (File | null)[]): (string | null)[] {
+ const [urls, setUrls] = useState<(string | null)[]>([]);
+ const [a, b, c, d] = files;
+ useEffect(() => {
+ const next = [a, b, c, d].map((file) => (file ? URL.createObjectURL(file) : null));
+ setUrls(next);
+ return () => next.forEach((url) => url && URL.revokeObjectURL(url));
+ }, [a, b, c, d]);
+ return urls;
+}
+
+function Turnable({ children }: { children: ReactNode }) {
+ return (
+ <>
+ {children}
+ Drag to turn
+ >
+ );
+}
+
+type ViewProps = { row: SubmissionPublic; files: Files; fallback: ReactNode; children: ReactNode };
+
+function ArmourView({ row, files, fallback, children }: ViewProps) {
+ const tiers = row.tiers ?? [];
+ const [tier, setTier] = useState(tiers[0] ?? "");
+ const file = (field: string) => files[`${tier}_${field}`] ?? null;
+ const iconUrls = useIconUrls(ARMOR_ICON_FIELDS.map((field) => file(field)));
+ const layers = file("layer_1") || file("layer_2");
+ return (
+
+
+
+ ) : (
+ fallback
+ )
+ }
+ controls={
+ <>
+ {tiers.length > 1 ? (
+ ({ id: t, label: row.tier_aliases?.[t] || armorTierLabel(t) }))}
+ value={tier}
+ onChange={setTier}
+ />
+ ) : null}
+ ({ label: ICON_LABELS[field]!, src: iconUrls[i] ?? null }))}
+ />
+ >
+ }
+ >
+ {children}
+
+ );
+}
+
+function BookView({ files, fallback, children }: Omit) {
+ const [cover, setCover] = useState<"unsigned" | "signed">("unsigned");
+ const texture = files[cover];
+ return (
+
+
+
+ ) : (
+ fallback
+ )
+ }
+ controls={
+ files.signed ? (
+
+ ) : null
+ }
+ >
+ {children}
+
+ );
+}
+
+function ItemView({ row, files, fallback, children }: ViewProps) {
+ const [failed, setFailed] = useState(false);
+ const { kind } = row;
+ const flat = FLAT_KINDS.includes(kind);
+ const grip = row.grip_preset != null ? Number(row.grip_preset) : NaN;
+ const stage =
+ files.texture && !failed ? (
+
+ message && setFailed(true)}
+ />
+
+ ) : (
+ fallback
+ );
+ return {children} ;
+}
+
+export default function SkinShowcase({
+ row,
+ sessionToken,
+ onRefresh,
+}: {
+ row: SubmissionPublic;
+ /** The owner's skins or Profile session. */
+ sessionToken: string;
+ onRefresh: () => void;
+}) {
+ const files = useOwnerFiles(row, sessionToken);
+ const fallback = (
+
+
+
+ );
+ const name = row.name_colours?.length
+ ? previewColourStopRuns(row.display_name, row.name_colours, row.name_styles ?? [])
+ : plainName(row.display_name);
+ const waiting = row.status === "pending" || row.status === "approved";
+ const sets = row.tiers?.length ? row.tiers.map((t) => row.tier_aliases?.[t] || armorTierLabel(t)).join(", ") : null;
+
+ const aside = (
+ <>
+
+
+ Check again
+
+ ) : null
+ }
+ />
+ {row.add_name || row.name_colours?.length ? : null}
+
+ >
+ );
+
+ if (!files) return {aside} ;
+ const props = { row, files, fallback, children: aside };
+ if (row.kind === "armor_set") return ;
+ if (row.kind === "book") return ;
+ return ;
+}
diff --git a/frontend/app/components/profile/Wardrobe.tsx b/frontend/app/components/profile/Wardrobe.tsx
index 210c32a0..34724165 100644
--- a/frontend/app/components/profile/Wardrobe.tsx
+++ b/frontend/app/components/profile/Wardrobe.tsx
@@ -33,7 +33,7 @@ const quietButtonClass =
const tileClass =
"flex h-full min-h-44 w-full flex-col items-center justify-center gap-2 rounded-sm border border-dashed border-[color-mix(in_srgb,var(--tfmc-cream)_25%,transparent)] px-3 text-center text-sm";
-const CHIPS: Record = {
+export const STATUS_CHIPS: Record = {
pending: {
label: "In review",
className: "bg-[color-mix(in_srgb,var(--tfmc-cream)_16%,transparent)] text-[var(--tfmc-cream)]",
@@ -43,6 +43,11 @@ const CHIPS: Record = {
applied: { label: "Live", className: "bg-[var(--tfmc-accent)] text-[var(--tfmc-forest-deep)]" },
denied: { label: "Denied", className: "bg-[color-mix(in_srgb,#e8a0a0_22%,transparent)] text-[#f0b8b8]" },
rejected: { label: "Denied", className: "bg-[color-mix(in_srgb,#e8a0a0_22%,transparent)] text-[#f0b8b8]" },
+ ready: { label: "Ready", className: "bg-[#c8b27a] text-[var(--tfmc-forest-deep)]" },
+ draft: {
+ label: "Draft",
+ className: "bg-[color-mix(in_srgb,var(--tfmc-cream)_10%,transparent)] text-[var(--tfmc-stone)]",
+ },
revoked: {
label: "Removed",
className: "bg-[color-mix(in_srgb,var(--tfmc-cream)_10%,transparent)] text-[var(--tfmc-stone)]",
@@ -50,7 +55,7 @@ const CHIPS: Record = {
};
function Chip({ status }: { status: string }) {
- const chip = CHIPS[status.toLowerCase()];
+ const chip = STATUS_CHIPS[status.toLowerCase()];
if (!chip) return null;
return (
void }) {
+export function WardrobeCard({ item, onOpen }: { item: WardrobeItem; onOpen: (id: string) => void }) {
const denied = ["denied", "rejected", "revoked"].includes(item.status.toLowerCase());
// A plain click opens it in the tab; a new-tab click follows the address.
function open(event: MouseEvent) {
@@ -221,7 +226,7 @@ export default function Wardrobe({ noun, items, allowance, onStart, codeForm, on
{newTile}
{items.map((item) => (
-
+
))}
diff --git a/frontend/app/components/profile/WardrobePictures.tsx b/frontend/app/components/profile/WardrobePictures.tsx
index 9380ed8f..400926e3 100644
--- a/frontend/app/components/profile/WardrobePictures.tsx
+++ b/frontend/app/components/profile/WardrobePictures.tsx
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { getSkinThumbnail } from "../../../lib/skins/api";
+import { getDrinkTexture } from "../../../lib/drinks/api";
import { composeTintedPotionCanvas, loadDrinkAssetImages } from "../../../lib/drinks/potionTint";
import { HangerGlyph } from "./Wardrobe";
@@ -56,26 +57,37 @@ export function SkinPicture({ id, sessionToken }: { id: string; sessionToken: st
return missing ? : null;
}
-/** The vanilla potion in the drink's colour. */
-export function DrinkPicture({ color }: { color: string | null }) {
+/** The vanilla potion in the drink's colour, else the drink's own texture. */
+export function DrinkPicture({ id, color, sessionToken }: { id: string; color: string | null; sessionToken: string }) {
const [src, setSrc] = useState(null);
- const [missing, setMissing] = useState(!color);
+ const [missing, setMissing] = useState(false);
useEffect(() => {
- if (!color) return;
let live = true;
- loadDrinkAssetImages()
- .then((assets) => {
- const canvas = composeTintedPotionCanvas(color, assets);
- if (!live) return;
- if (canvas) setSrc(canvas.toDataURL("image/png"));
- else setMissing(true);
- })
- .catch(() => live && setMissing(true));
+ let url: string | null = null;
+ const done = (next: string | null) => {
+ if (!live) return;
+ if (next) setSrc(next);
+ else setMissing(true);
+ };
+ if (color) {
+ loadDrinkAssetImages()
+ .then((assets) => done(composeTintedPotionCanvas(color, assets)?.toDataURL("image/png") ?? null))
+ .catch(() => done(null));
+ } else {
+ getDrinkTexture(id, sessionToken)
+ .then((next) => {
+ url = next;
+ if (!live && next) URL.revokeObjectURL(next);
+ done(next);
+ })
+ .catch(() => done(null));
+ }
return () => {
live = false;
+ if (url) URL.revokeObjectURL(url);
};
- }, [color]);
+ }, [color, id, sessionToken]);
if (src) return ;
return missing ? : null;
diff --git a/frontend/app/components/skins/ArmorPreview.tsx b/frontend/app/components/skins/ArmorPreview.tsx
index c7b01940..69290a7f 100644
--- a/frontend/app/components/skins/ArmorPreview.tsx
+++ b/frontend/app/components/skins/ArmorPreview.tsx
@@ -27,19 +27,26 @@ type Props = {
helmet3d?: boolean;
helmetModelFile?: File | null;
helmetTextureFile?: File | null;
+ /** Fills its parent on a transparent background, without the editor's label and skin controls. */
+ stage?: boolean;
className?: string;
};
type Status = "idle" | "loading" | "ready" | "error";
+const frameClass =
+ "overflow-hidden rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_18%,transparent)] bg-[var(--tfmc-forest-deep)]";
+
export default function ArmorPreview({
layer1File,
layer2File,
helmet3d = false,
helmetModelFile = null,
helmetTextureFile = null,
+ stage = false,
className = "",
}: Props) {
+ const stageRef = useRef(stage);
const hostRef = useRef(null);
const steveLiveRef = useRef(null);
const showOuterLayerRef = useRef(true);
@@ -78,8 +85,9 @@ export default function ArmorPreview({
let steveTexture: THREE.Texture | null = null;
let helmetItemTex: THREE.Texture | null = null;
let removeResize: (() => void) | null = null;
+ const transparent = stageRef.current;
const scene = new THREE.Scene();
- scene.background = new THREE.Color(0x0f1c16);
+ scene.background = transparent ? null : new THREE.Color(0x0f1c16);
setStatus("loading");
setError(null);
@@ -144,9 +152,9 @@ export default function ArmorPreview({
}
const widthPx = host.clientWidth || 320;
- const heightPx = 240;
+ const heightPx = (transparent && host.clientHeight) || 240;
- renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false });
+ renderer = new THREE.WebGLRenderer({ antialias: false, alpha: transparent });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(widthPx, heightPx, false);
renderer.outputColorSpace = THREE.SRGBColorSpace;
@@ -156,7 +164,8 @@ export default function ArmorPreview({
const camera = new THREE.PerspectiveCamera(35, widthPx / heightPx, 0.1, 500);
const focus = new THREE.Vector3(0, 14, 0);
- const frameSize = 32;
+ // The taller showcase stage needs the camera further back to keep the feet in view.
+ const frameSize = transparent ? 42 : 32;
camera.position.set(focus.x + frameSize * 1.4, focus.y + frameSize * 0.55, focus.z + frameSize * 1.6);
camera.lookAt(focus);
@@ -172,7 +181,7 @@ export default function ArmorPreview({
const onResize = () => {
if (!renderer || !host) return;
const w = host.clientWidth || 320;
- const h = 240;
+ const h = (transparent && host.clientHeight) || 240;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
@@ -221,12 +230,12 @@ export default function ArmorPreview({
return (
-
Armor preview
-
+ {stage ? null :
Armor preview }
+
{status === "loading" ? (
@@ -238,50 +247,56 @@ export default function ArmorPreview({
) : null}
{status === "error" && error ? (
-
-
-
- Preview skin
- {
- const file = e.target.files?.[0] ?? null;
- setPlayerSkinFile(file);
- e.target.value = "";
- }}
- className="max-w-full text-xs text-[var(--tfmc-mist)] file:mr-2 file:rounded-sm file:border-0 file:bg-[var(--tfmc-moss)] file:px-2.5 file:py-1 file:text-xs file:text-[var(--tfmc-cream)]"
- />
-
-
setShowOuterLayer((v) => !v)}
- className={`self-end rounded-sm border px-2.5 py-1 text-xs transition ${
- showOuterLayer
- ? "border-[color-mix(in_srgb,var(--tfmc-accent)_45%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-accent)_18%,transparent)] text-[var(--tfmc-cream)]"
- : "border-[color-mix(in_srgb,var(--tfmc-cream)_20%,transparent)] text-[var(--tfmc-mist)] hover:border-[var(--tfmc-accent)] hover:text-[var(--tfmc-cream)]"
- }`}
- >
- Outer layer
-
- {playerSkinFile ? (
+ {stage ? null : (
+
+
+ Preview skin
+ {
+ const file = e.target.files?.[0] ?? null;
+ setPlayerSkinFile(file);
+ e.target.value = "";
+ }}
+ className="max-w-full text-xs text-[var(--tfmc-mist)] file:mr-2 file:rounded-sm file:border-0 file:bg-[var(--tfmc-moss)] file:px-2.5 file:py-1 file:text-xs file:text-[var(--tfmc-cream)]"
+ />
+
setPlayerSkinFile(null)}
- className="self-end rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_20%,transparent)] px-2.5 py-1 text-xs text-[var(--tfmc-mist)] transition hover:border-[var(--tfmc-accent)] hover:text-[var(--tfmc-cream)]"
+ aria-pressed={showOuterLayer}
+ onClick={() => setShowOuterLayer((v) => !v)}
+ className={`self-end rounded-sm border px-2.5 py-1 text-xs transition ${
+ showOuterLayer
+ ? "border-[color-mix(in_srgb,var(--tfmc-accent)_45%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-accent)_18%,transparent)] text-[var(--tfmc-cream)]"
+ : "border-[color-mix(in_srgb,var(--tfmc-cream)_20%,transparent)] text-[var(--tfmc-mist)] hover:border-[var(--tfmc-accent)] hover:text-[var(--tfmc-cream)]"
+ }`}
>
- Use default
+ Outer layer
- ) : null}
-
- {status === "ready" ?
Drag to rotate, scroll to zoom.
: null}
+ {playerSkinFile ? (
+
setPlayerSkinFile(null)}
+ className="self-end rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_20%,transparent)] px-2.5 py-1 text-xs text-[var(--tfmc-mist)] transition hover:border-[var(--tfmc-accent)] hover:text-[var(--tfmc-cream)]"
+ >
+ Use default
+
+ ) : null}
+
+ )}
+ {status === "ready" && !stage ? (
+
Drag to rotate, scroll to zoom.
+ ) : null}
);
}
diff --git a/frontend/app/components/skins/ModelPreview.tsx b/frontend/app/components/skins/ModelPreview.tsx
index a1340a7f..26aa7472 100644
--- a/frontend/app/components/skins/ModelPreview.tsx
+++ b/frontend/app/components/skins/ModelPreview.tsx
@@ -61,6 +61,8 @@ type Props = {
/** Tinted potion preview color (#RRGGBB); avoids rebuilding mesh on each change. */
potionTintColor?: string | null;
onPreviewError?: (message: string | null) => void;
+ /** Fills its parent on a transparent background, without the editor's label and skin controls. */
+ stage?: boolean;
className?: string;
};
@@ -361,8 +363,10 @@ export default function ModelPreview({
gripY = null,
potionTintColor = null,
onPreviewError,
+ stage = false,
className = "",
}: Props) {
+ const stageRef = useRef(stage);
const gun = isGunKind(kind);
const flat = isFlatKind(kind);
const displayKind = flatDisplayKind(kind, flatDisplayPreset);
@@ -393,7 +397,8 @@ export default function ModelPreview({
const [status, setStatus] = useState
("idle");
const [error, setError] = useState(null);
- const [slot, setSlot] = useState(gun ? "carry_right" : "thirdperson_righthand");
+ // A showcase opens on the item itself; the editor opens in hand.
+ const [slot, setSlot] = useState(stage ? "model" : gun ? "carry_right" : "thirdperson_righthand");
const [frame, setFrame] = useState("texture");
const [shieldMode, setShieldMode] = useState<"idle" | "blocking">("idle");
const [playerSkinFile, setPlayerSkinFile] = useState(null);
@@ -666,13 +671,14 @@ export default function ModelPreview({
setStatus("loading");
reportError(null);
+ const transparent = stageRef.current;
const scene = new THREE.Scene();
- scene.background = new THREE.Color(0x0f1c16);
+ scene.background = transparent ? null : new THREE.Color(0x0f1c16);
scene.add(new THREE.AmbientLight(0xffffff, 1.1));
const widthPx = host.clientWidth || 320;
- const heightPx = 240;
- const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false });
+ const heightPx = (transparent && host.clientHeight) || 240;
+ const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: transparent });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(widthPx, heightPx, false);
renderer.outputColorSpace = THREE.SRGBColorSpace;
@@ -688,7 +694,7 @@ export default function ModelPreview({
const onResize = () => {
if (!host) return;
const w = host.clientWidth || 320;
- const h = 240;
+ const h = (transparent && host.clientHeight) || 240;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
@@ -863,7 +869,7 @@ export default function ModelPreview({
return (
-
Model preview
+ {stage ? null :
Model preview }
) : null}
-
+
{status === "loading" ? (
@@ -970,14 +982,16 @@ export default function ModelPreview({
) : null}
{status === "error" && error ? (
-
- {mannequinPreview ? (
+ {mannequinPreview && !stage ? (
Preview skin
@@ -1015,7 +1029,9 @@ export default function ModelPreview({
) : null}
) : null}
- {status === "ready" ?
Drag to rotate, scroll to zoom.
: null}
+ {status === "ready" && !stage ? (
+
Drag to rotate, scroll to zoom.
+ ) : null}
);
}
diff --git a/frontend/app/components/skins/StatusCard.tsx b/frontend/app/components/skins/StatusCard.tsx
deleted file mode 100644
index 51d8e053..00000000
--- a/frontend/app/components/skins/StatusCard.tsx
+++ /dev/null
@@ -1,164 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import type { SubmissionPublic } from "../../../lib/skins/api";
-import { getReviewSheet, SkinsApiError } from "../../../lib/skins/api";
-import { armorTierLabel, baseSetLabel } from "../../../lib/skins/baseSets";
-import { formatLocal } from "../../../lib/skins/formatTime";
-import { kindLabel } from "./KindPicker";
-
-function statusMessage(row: SubmissionPublic): string {
- if (row.staff) {
- switch (row.status) {
- case "pending":
- return "Staff submission - waiting for auto-approve.";
- case "denied":
- return row.deny_reason?.trim() ? `Denied: ${row.deny_reason.trim()}` : "Denied. No reason given.";
- case "approved":
- return "Approved. Waiting to be applied on the server (curated pack; within 24 hours).";
- case "applied":
- return "Live on the server in the curated shop pack.";
- case "revoked":
- return "Removed from the server.";
- default:
- return row.status;
- }
- }
- switch (row.status) {
- case "pending":
- return "Submitted for staff review. Your confirmation DM can take up to 5 minutes.";
- case "denied":
- return row.deny_reason?.trim() ? `Denied: ${row.deny_reason.trim()}` : "Denied. No reason given.";
- case "approved":
- return "Approved. Your skin will be live within 24 hours.";
- case "applied":
- return "Live on the server.";
- case "revoked":
- return "Removed from the server.";
- default:
- return row.status;
- }
-}
-
-type Props = {
- row: SubmissionPublic;
- /** The owner's skins or Profile session. */
- sessionToken: string;
-};
-
-export default function StatusCard({ row, sessionToken }: Props) {
- const [sheetUrl, setSheetUrl] = useState
(null);
- const [sheetError, setSheetError] = useState(null);
- const [sheetLoading, setSheetLoading] = useState(true);
-
- useEffect(() => {
- let revoked = false;
- let objectUrl: string | null = null;
-
- async function loadSheet() {
- setSheetLoading(true);
- setSheetError(null);
- try {
- const url = await getReviewSheet(row.id, sessionToken);
- if (revoked) {
- URL.revokeObjectURL(url);
- return;
- }
- objectUrl = url;
- setSheetUrl(url);
- } catch (err) {
- if (revoked) return;
- setSheetError(
- err instanceof SkinsApiError ? err.message : err instanceof Error ? err.message : "Could not load preview",
- );
- } finally {
- if (!revoked) setSheetLoading(false);
- }
- }
-
- void loadSheet();
- return () => {
- revoked = true;
- if (objectUrl) URL.revokeObjectURL(objectUrl);
- };
- }, [row.id, sessionToken]);
-
- return (
-
-
{statusMessage(row)}
-
-
-
Preview
- {sheetLoading ? (
-
- ) : sheetError ? (
-
- {sheetError}
-
- ) : sheetUrl ? (
- // eslint-disable-next-line @next/next/no-img-element
-
- ) : null}
-
-
-
-
-
Type
- {kindLabel(row.kind)}
-
- {row.tiers?.length ? (
-
-
Armour sets
- {row.tiers.map(armorTierLabel).join(", ")}
-
- ) : row.base_set ? (
-
-
Item type
- {baseSetLabel(row.base_set)}
-
- ) : null}
-
-
Item name
- {row.display_name}
-
- {row.name_colours?.length || row.name_styles?.length ? (
-
-
Name look
-
- {row.name_colours?.length
- ? `${row.name_colours.length} colour${row.name_colours.length === 1 ? "" : "s"}`
- : "no colours"}
- {row.name_styles?.length ? ` · ${row.name_styles.join(", ")}` : ""}
-
-
- ) : null}
-
-
Created
- {formatLocal(row.created_at)}
-
- {row.reviewed_at ? (
-
-
Reviewed
- {formatLocal(row.reviewed_at)}
-
- ) : null}
- {row.applied_at ? (
-
-
Applied
- {formatLocal(row.applied_at)}
-
- ) : null}
-
-
- );
-}
diff --git a/frontend/lib/drinks/api.ts b/frontend/lib/drinks/api.ts
index 131f6edf..1e16f8e2 100644
--- a/frontend/lib/drinks/api.ts
+++ b/frontend/lib/drinks/api.ts
@@ -221,6 +221,15 @@ export async function createSubmission(input: {
return data as DrinkSubmissionPublic;
}
+/** Object URL of the owner's custom drink texture, or null for a colour-only drink. */
+export async function getDrinkTexture(id: string, sessionToken: string): Promise {
+ const res = await apiFetch(`${getApiBase()}/drinks/submissions/${encodeURIComponent(id)}/texture`, {
+ headers: { Authorization: `Bearer ${sessionToken}` },
+ });
+ if (!res.ok) return null;
+ return URL.createObjectURL(await res.blob());
+}
+
export async function getSubmission(id: string, sessionToken: string): Promise {
const res = await apiFetch(`${getApiBase()}/drinks/submissions/${encodeURIComponent(id)}`, {
headers: { Authorization: `Bearer ${sessionToken}` },
diff --git a/frontend/lib/profile/api.ts b/frontend/lib/profile/api.ts
index 5cf61687..ca55fb7e 100644
--- a/frontend/lib/profile/api.ts
+++ b/frontend/lib/profile/api.ts
@@ -41,6 +41,8 @@ export type ProfileCustomItem = {
character_id: string;
character_name: string;
kit_key: string;
+ /** The kit the item comes from; empty on rows saved before kits were recorded. */
+ kit_id?: string;
display_name: string;
state: string;
submission_id?: string | null;
diff --git a/frontend/lib/profile/paths.ts b/frontend/lib/profile/paths.ts
index 406d521f..6d0b5008 100644
--- a/frontend/lib/profile/paths.ts
+++ b/frontend/lib/profile/paths.ts
@@ -1,11 +1,17 @@
import type { ProfileTab } from "./tabs";
/**
- * Profile's addresses. A character, skin or drink opens inside its tab: `?tab=skins&skin=new` is the
- * editor, `&skin=` one skin and `?tab=characters&character=` one character.
+ * Profile's addresses. A character, skin, drink or custom item opens inside its tab: `?tab=skins&skin=new`
+ * is the editor, `&skin=` one skin, `?tab=characters&character=` one character and
+ * `?tab=items&item=/` one custom item.
*/
-type ItemParam = "character" | "skin" | "drink";
-const ITEM_PARAMS: Partial> = { characters: "character", skins: "skin", drinks: "drink" };
+type ItemParam = "character" | "skin" | "drink" | "item";
+const ITEM_PARAMS: Partial> = {
+ characters: "character",
+ skins: "skin",
+ drinks: "drink",
+ items: "item",
+};
/** The query parameter naming the item open in a tab, if that tab has one. */
export function itemParam(tab: ProfileTab | null): ItemParam | null {
@@ -29,3 +35,14 @@ export function skinPath(id: string): string {
export function drinkPath(id: string): string {
return profileHref("drinks", id);
}
+
+/** A custom item's id in Profile's address: its character, then its kit key. */
+export function customItemId(characterId: string, kitKey: string): string {
+ return `${characterId}/${kitKey}`;
+}
+
+export function parseCustomItemId(id: string): { characterId: string; kitKey: string } | null {
+ const cut = id.lastIndexOf("/");
+ if (cut <= 0 || cut === id.length - 1) return null;
+ return { characterId: id.slice(0, cut), kitKey: id.slice(cut + 1) };
+}
diff --git a/frontend/lib/skins/api.ts b/frontend/lib/skins/api.ts
index 0b246cb0..d8e3b06d 100644
--- a/frontend/lib/skins/api.ts
+++ b/frontend/lib/skins/api.ts
@@ -419,18 +419,6 @@ export async function getCatalog(): Promise {
};
}
-export async function getReviewSheet(id: string, sessionToken: string): Promise {
- const res = await apiFetch(`${getApiBase()}/skins/submissions/${id}/review-sheet`, {
- headers: { Authorization: `Bearer ${sessionToken}` },
- });
- if (!res.ok) {
- const data = await parseJson(res);
- throw new SkinsApiError(detailMessage(data, `Could not load review sheet (${res.status})`), res.status);
- }
- const blob = await res.blob();
- return URL.createObjectURL(blob);
-}
-
/** Object URL of the owner's wardrobe picture, or null when there is none yet. */
export async function getSkinThumbnail(id: string, sessionToken: string): Promise {
const res = await apiFetch(`${getApiBase()}/skins/submissions/${encodeURIComponent(id)}/thumbnail`, {
@@ -440,6 +428,17 @@ export async function getSkinThumbnail(id: string, sessionToken: string): Promis
return URL.createObjectURL(await res.blob());
}
+/** One of the owner's uploaded textures or models, or null when the skin has no such file. */
+export async function getSubmissionFile(id: string, filename: string, sessionToken: string): Promise {
+ const res = await apiFetch(
+ `${getApiBase()}/skins/submissions/${encodeURIComponent(id)}/files/${encodeURIComponent(filename)}`,
+ { headers: { Authorization: `Bearer ${sessionToken}` } },
+ );
+ if (!res.ok) return null;
+ const blob = await res.blob();
+ return new File([blob], filename, { type: blob.type });
+}
+
export async function getSubmission(id: string, sessionToken: string): Promise {
const res = await apiFetch(`${getApiBase()}/skins/submissions/${id}`, {
headers: { Authorization: `Bearer ${sessionToken}` },
diff --git a/frontend/lib/skins/ownerFiles.test.ts b/frontend/lib/skins/ownerFiles.test.ts
new file mode 100644
index 00000000..419dd59c
--- /dev/null
+++ b/frontend/lib/skins/ownerFiles.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+import { ownerFileNames } from "./ownerFiles";
+
+describe("ownerFileNames", () => {
+ it("names each armour set's layers, icons and 3D helmet", () => {
+ const names = ownerFileNames({ kind: "armor_set", slug: "x", tiers: ["iron", "mage"] });
+ expect(names.iron_layer_1).toBe("iron_layer_1.png");
+ expect(names.mage_boots).toBe("mage_boots.png");
+ expect(names.iron_helmet_model).toBe("iron_helmet_model.json");
+ });
+
+ it("names bow frames as storage writes them", () => {
+ expect(ownerFileNames({ kind: "crossbow", slug: "arm" })).toEqual({
+ texture: "arm.png",
+ pull_0: "arm_0.png",
+ pull_1: "arm_1.png",
+ pull_2: "arm_2.png",
+ charged: "arm_charged.png",
+ });
+ });
+
+ it("pairs 3D models with their textures, and books with both covers", () => {
+ expect(ownerFileNames({ kind: "helmet_3d", slug: "hat" })).toEqual({ texture: "hat.png", model: "hat.json" });
+ expect(ownerFileNames({ kind: "gun", slug: "g" }).aim_model).toBe("g_aim.json");
+ expect(ownerFileNames({ kind: "book", slug: "j" })).toEqual({ unsigned: "j_unsigned.png", signed: "j_signed.png" });
+ expect(ownerFileNames({ kind: "large_handheld", slug: "staff" })).toEqual({ texture: "staff.png" });
+ });
+});
diff --git a/frontend/lib/skins/ownerFiles.ts b/frontend/lib/skins/ownerFiles.ts
new file mode 100644
index 00000000..c98cc0af
--- /dev/null
+++ b/frontend/lib/skins/ownerFiles.ts
@@ -0,0 +1,48 @@
+import type { SubmissionPublic } from "./api";
+import { ARMOR_FIELDS } from "./sizes";
+
+/**
+ * The stored files a skin's 3D viewer needs, keyed by the upload form's field names
+ * (armour fields are `_`). Mirrors backend/src/skins/storage.py.
+ */
+export function ownerFileNames(row: Pick): Record {
+ const { kind, slug } = row;
+ switch (kind) {
+ case "armor_set": {
+ const names: Record = {};
+ for (const tier of row.tiers ?? []) {
+ for (const field of ARMOR_FIELDS) names[`${tier}_${field}`] = `${tier}_${field}.png`;
+ names[`${tier}_helmet_texture`] = `${tier}_helmet_texture.png`;
+ names[`${tier}_helmet_model`] = `${tier}_helmet_model.json`;
+ }
+ return names;
+ }
+ case "bow":
+ case "large_bow":
+ return { texture: `${slug}.png`, pull_0: `${slug}_0.png`, pull_1: `${slug}_1.png`, pull_2: `${slug}_2.png` };
+ case "crossbow":
+ return {
+ texture: `${slug}.png`,
+ pull_0: `${slug}_0.png`,
+ pull_1: `${slug}_1.png`,
+ pull_2: `${slug}_2.png`,
+ charged: `${slug}_charged.png`,
+ };
+ case "item_3d":
+ case "shield":
+ case "helmet_3d":
+ case "mask":
+ return { texture: `${slug}.png`, model: `${slug}.json` };
+ case "gun":
+ return {
+ texture: `${slug}.png`,
+ carry_model: `${slug}_carry.json`,
+ reload_model: `${slug}_reload.json`,
+ aim_model: `${slug}_aim.json`,
+ };
+ case "book":
+ return { unsigned: `${slug}_unsigned.png`, signed: `${slug}_signed.png` };
+ default:
+ return { texture: `${slug}.png` };
+ }
+}
From 25af2c6f69887a000d9a077bf4a9ca3cddaa126d Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Mon, 12 Oct 2026 15:24:03 +0000
Subject: [PATCH 2/3] fix(profile): wrap long tooltip lore lines instead of
clipping them
Co-Authored-By: Claude Opus 5.5
---
frontend/app/components/profile/ItemShowcase.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/frontend/app/components/profile/ItemShowcase.tsx b/frontend/app/components/profile/ItemShowcase.tsx
index 91a5c921..b0d826bf 100644
--- a/frontend/app/components/profile/ItemShowcase.tsx
+++ b/frontend/app/components/profile/ItemShowcase.tsx
@@ -124,15 +124,15 @@ export function Tooltip({ name, lore = [] }: { name: LoreRun[]; lore?: string[]
function TooltipBox({ name, lore }: { name: LoreRun[]; lore: string[] }) {
return (
-
+
-
+
{lore.length ? (
-
+
{lore.map((line, i) => (
-
+
{line.trim() ? : " "}
))}
From b83800b24fc9139d3a65ad87f3b4915349c2bc58 Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Mon, 12 Oct 2026 15:27:16 +0000
Subject: [PATCH 3/3] fix(profile): keep wardrobe cards one height; denial
reasons stay on the item page
Co-Authored-By: Claude Opus 5.5
---
frontend/app/components/profile/ProfileCustomItems.tsx | 1 -
frontend/app/components/profile/ProfilePanel.test.tsx | 2 ++
frontend/app/components/profile/ProfileWardrobes.tsx | 2 --
frontend/app/components/profile/Wardrobe.tsx | 2 --
4 files changed, 2 insertions(+), 5 deletions(-)
diff --git a/frontend/app/components/profile/ProfileCustomItems.tsx b/frontend/app/components/profile/ProfileCustomItems.tsx
index a29cf1ee..60761971 100644
--- a/frontend/app/components/profile/ProfileCustomItems.tsx
+++ b/frontend/app/components/profile/ProfileCustomItems.tsx
@@ -122,7 +122,6 @@ export function CustomItemList({
name: itemName(item),
detail: item.character_name,
status: chipStatus(item),
- denyReason: item.deny_reason,
picture: ,
}}
/>
diff --git a/frontend/app/components/profile/ProfilePanel.test.tsx b/frontend/app/components/profile/ProfilePanel.test.tsx
index bfa2d2cc..d450b6a8 100644
--- a/frontend/app/components/profile/ProfilePanel.test.tsx
+++ b/frontend/app/components/profile/ProfilePanel.test.tsx
@@ -1261,6 +1261,8 @@ it("opens a custom item in its own tab rather than the character's kits", async
visit("items");
render( );
const card = await screen.findByRole("link", { name: /Exhumer's Spade/ });
+ // The card keeps to the grid's height; the reason is on the item's page.
+ expect(screen.queryByText("Too bright")).toBeNull();
expect(card.getAttribute("href")).toBe("/profile?tab=items&item=c1%2Firon_hunting_knife");
fireEvent.click(card);
expect(await screen.findByRole("heading", { level: 2, name: "Exhumer's Spade" })).toBeTruthy();
diff --git a/frontend/app/components/profile/ProfileWardrobes.tsx b/frontend/app/components/profile/ProfileWardrobes.tsx
index 43a613f6..1a264f7c 100644
--- a/frontend/app/components/profile/ProfileWardrobes.tsx
+++ b/frontend/app/components/profile/ProfileWardrobes.tsx
@@ -68,7 +68,6 @@ export function SkinWardrobe({
name: row.display_name || "Untitled skin",
detail: kindLabel(row.kind),
status: row.status,
- denyReason: row.deny_reason,
picture: ,
}))}
/>
@@ -109,7 +108,6 @@ export function DrinkWardrobe({
name: row.display_name || "Untitled drink",
detail: "Drink",
status: row.status,
- denyReason: row.deny_reason,
picture: ,
}))}
/>
diff --git a/frontend/app/components/profile/Wardrobe.tsx b/frontend/app/components/profile/Wardrobe.tsx
index 34724165..493590b9 100644
--- a/frontend/app/components/profile/Wardrobe.tsx
+++ b/frontend/app/components/profile/Wardrobe.tsx
@@ -10,7 +10,6 @@ export type WardrobeItem = {
/** What it is: "Sword", "Armour set", "Drink". */
detail: string;
status: string;
- denyReason?: string | null;
picture: ReactNode;
};
@@ -125,7 +124,6 @@ export function WardrobeCard({ item, onOpen }: { item: WardrobeItem; onOpen: (id
{item.name}
{item.detail}
- {item.denyReason ?
{item.denyReason}
: null}