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) => ( +
    1. {step}
    2. + ))} +
    + ), + } + : 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 ( + + ); + })} +
    + ); +} + +/** 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 ( +
    +

    In game

    + +
    + ); +} + +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 ( -
      - {items.map((item) => { - const href = `/character/${encodeURIComponent(item.character_id)}/kits`; - return ( -
    • -
      - - {itemName(item)} - - - {statusLabel(item)} - -
      -

      {item.character_name}

      - {item.deny_reason ?

      {item.deny_reason}

      : null} -
    • - ); - })} -
    - ); -} 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} - - - ); + 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 ? ( -
    +

    {error}

    ) : null}
    -
    - - - {playerSkinFile ? ( + {stage ? null : ( +
    + - ) : null} -
    - {status === "ready" ?

    Drag to rotate, scroll to zoom.

    : null} + {playerSkinFile ? ( + + ) : 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 ? ( -
    +

    {error}

    ) : null}
    - {mannequinPreview ? ( + {mannequinPreview && !stage ? (
    ) : 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 ? ( -
    -
    - Loading preview… -
    - ) : sheetError ? ( -

    - {sheetError} -

    - ) : sheetUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - {`Review - ) : 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}