Trophy fixes (#3394)

This commit is contained in:
hfcRed
2026-09-19 03:46:34 -04:00
committed by GitHub
parent f6aee8733c
commit bfcd3db322
58 changed files with 369 additions and 321 deletions

File diff suppressed because one or more lines are too long

View File

@@ -208,6 +208,7 @@ export interface PendingTrophy {
acceptedAt: number | null;
targetTrophyId: number | null;
managerId: number | null;
creatorId: number | null;
}
export interface PendingTrophyApproval {

View File

@@ -1,9 +1,3 @@
.trophyPreview {
background-color: var(--color-bg-high);
border: var(--border-style);
border-radius: var(--radius-field);
}
.countInput {
width: 4.5rem;
}

View File

@@ -166,16 +166,3 @@
padding: 0 var(--s-1);
height: var(--selector-size);
}
.trophyPreview {
background-color: var(--color-bg-high);
border-radius: var(--radius-box);
}
.trophyPreviewFallback {
width: 19rem;
max-width: 100%;
aspect-ratio: 1 / 1;
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
}

View File

@@ -1,6 +1,6 @@
import clsx from "clsx";
import { ShieldMinus, Users } from "lucide-react";
import { lazy, Suspense } from "react";
import { lazy } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { SendouButton } from "~/components/elements/Button";
@@ -352,9 +352,7 @@ function PrizesPill({
}
>
{trophy ? (
<Suspense fallback={<div className={styles.trophyPreviewFallback} />}>
<Trophy model={trophy} className={styles.trophyPreview} />
</Suspense>
<Trophy model={trophy} />
) : badges ? (
<BadgeDisplay badges={badges} showText={false} compact />
) : null}

View File

@@ -407,10 +407,7 @@ function TrophyField() {
</div>
{selectedTrophy ? (
<div className="stack md items-center">
<Trophy
model={selectedTrophy.model}
className={styles.trophyPreview}
/>
<Trophy model={selectedTrophy.model} />
<div className="stack horizontal md items-center">
<span>{selectedTrophy.name}</span>
<SendouButton

View File

@@ -24,18 +24,6 @@
width: 100%;
}
.trophyExample {
width: 140px;
}
.trophyExampleSmall {
width: 90px;
}
.trophyExampleLarge {
width: 200px;
}
.scheduleNarrow {
max-width: 360px;
}

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,3 @@
import clsx from "clsx";
import { Check, Plus, RotateCcw, Search, SquarePen, Trash } from "lucide-react";
import { useState } from "react";
import { Ability } from "~/components/Ability";
@@ -2728,20 +2727,11 @@ function TrophySection({ id }: { id: string }) {
<TrophyContextProvider>
<div className="stack md">
<ComponentRow label="Interactive (drag to rotate)">
<Trophy
tile
model={EXAMPLE_TROPHY_MODEL}
className={styles.trophyExample}
/>
<Trophy model={EXAMPLE_TROPHY_MODEL} />
</ComponentRow>
<ComponentRow label="Preview (static)">
<Trophy
tile
model={EXAMPLE_TROPHY_MODEL}
className={styles.trophyExample}
preview
/>
<Trophy model={EXAMPLE_TROPHY_MODEL} preview />
</ComponentRow>
<ComponentRow label="With Tier">
@@ -2749,9 +2739,7 @@ function TrophySection({ id }: { id: string }) {
{([1, 4, 9] as const).map((tier) => (
<Trophy
key={tier}
tile
model={EXAMPLE_TROPHY_MODEL}
className={styles.trophyExample}
tier={tier}
preview
/>
@@ -2760,42 +2748,7 @@ function TrophySection({ id }: { id: string }) {
</ComponentRow>
<ComponentRow label="Tentative Tier">
<Trophy
tile
model={EXAMPLE_TROPHY_MODEL}
className={styles.trophyExample}
tentativeTier={2}
preview
/>
</ComponentRow>
<ComponentRow label="Different Sizes">
<div className="stack horizontal sm items-end flex-wrap">
<Trophy
tile
model={EXAMPLE_TROPHY_MODEL}
className={clsx(
styles.trophyExample,
styles.trophyExampleSmall,
)}
preview
/>
<Trophy
tile
model={EXAMPLE_TROPHY_MODEL}
className={styles.trophyExample}
preview
/>
<Trophy
tile
model={EXAMPLE_TROPHY_MODEL}
className={clsx(
styles.trophyExample,
styles.trophyExampleLarge,
)}
preview
/>
</div>
<Trophy model={EXAMPLE_TROPHY_MODEL} tentativeTier={2} preview />
</ComponentRow>
</div>
</TrophyContextProvider>

View File

@@ -62,14 +62,6 @@
padding-inline-start: var(--s-3);
}
.trophyGridButton {
all: unset;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.trophyModalTournaments {
display: flex;
flex-direction: column;

View File

@@ -40,7 +40,6 @@ import {
Trophy,
TrophyContextProvider,
TrophyGrid,
TrophyPlaceholder,
} from "~/features/trophies/components/Trophy";
import { TrophyShowcaseModal } from "~/features/trophies/components/TrophyShowcase";
import { TrophyTournamentHistory } from "~/features/trophies/components/TrophyTournamentHistory";
@@ -734,27 +733,22 @@ function RewardsTrophyGrid({
return (
<TrophyContextProvider>
<TrophyGrid>
{trophies.map((trophy, i) =>
i < visibleCount ? (
<button
key={trophy.id}
type="button"
className={styles.trophyGridButton}
onClick={() => setOpenTrophy(trophy)}
aria-label={trophy.name}
>
<Trophy
tile
model={trophy.model}
tier={trophy.tier}
tentativeTier={trophy.tentativeTier}
preview
/>
</button>
) : (
<TrophyPlaceholder key={trophy.id} />
),
)}
{trophies.map((trophy, i) => (
<button
key={trophy.id}
type="button"
onClick={() => setOpenTrophy(trophy)}
aria-label={trophy.name}
>
<Trophy
model={trophy.model}
tier={trophy.tier}
tentativeTier={trophy.tentativeTier}
preview
deferred={i >= visibleCount}
/>
</button>
))}
</TrophyGrid>
{openTrophy ? (
<TrophyShowcaseModal

View File

@@ -52,6 +52,36 @@ describe("trophy approvals", () => {
expect(await trophyCount()).toBe(1);
});
test("the named creator becomes the trophy's creator", async () => {
const artist = await UserFactory.create();
const submitter = await UserFactory.create();
const organization = await TournamentOrganizationFactory.create({
ownerId: submitter.id,
});
const pending = await TrophyFactory.createPending({
organizationId: organization.id,
submitterUserId: submitter.id,
creatorId: artist.id,
});
let accepted: null | {
id: number;
} = null;
for (const userId of reviewerIds.slice(0, TROPHY_APPROVALS_REQUIRED)) {
accepted = await TrophyRepository.addApproval({
pendingTrophyId: pending.id,
userId,
});
}
const trophy = await db
.selectFrom("Trophy")
.select(["creatorId", "managerId"])
.where("id", "=", accepted!.id)
.executeTakeFirstOrThrow();
expect(trophy).toEqual({ creatorId: artist.id, managerId: submitter.id });
});
test("ignores repeated approvals from the same user", async () => {
await TrophyRepository.addApproval({
pendingTrophyId,

View File

@@ -494,7 +494,7 @@ export async function existsByName(args: {
export async function findManagedBy(userId: number) {
return db
.selectFrom("Trophy")
.select(["id", "name", "model", "organizationId", "managerId"])
.select(["id", "name", "model", "organizationId", "managerId", "creatorId"])
.where("managerId", "=", userId)
.execute();
}
@@ -502,7 +502,7 @@ export async function findManagedBy(userId: number) {
export async function findAllForEditing() {
return db
.selectFrom("Trophy")
.select(["id", "name", "model", "organizationId", "managerId"])
.select(["id", "name", "model", "organizationId", "managerId", "creatorId"])
.where("code", "is", null)
.execute();
}
@@ -616,6 +616,7 @@ export async function createPending(args: {
submitterUserId: number;
targetTrophyId?: number;
managerId?: number;
creatorId?: number;
}) {
return db
.insertInto("PendingTrophy")
@@ -631,6 +632,7 @@ export async function createPending(args: {
declinedByUserId: null,
targetTrophyId: args.targetTrophyId ?? null,
managerId: args.managerId ?? null,
creatorId: args.creatorId ?? null,
})
.returning("id")
.executeTakeFirstOrThrow();
@@ -660,6 +662,7 @@ const withTarget = (eb: ExpressionBuilder<DB, "PendingTrophy">) => {
eb
.selectFrom("Trophy")
.leftJoin("User", "User.id", "Trophy.managerId")
.leftJoin("User as Creator", "Creator.id", "Trophy.creatorId")
.leftJoin(
"TournamentOrganization",
"TournamentOrganization.id",
@@ -671,7 +674,9 @@ const withTarget = (eb: ExpressionBuilder<DB, "PendingTrophy">) => {
"Trophy.model",
"Trophy.organizationId",
"Trophy.managerId",
"Trophy.creatorId",
"User.username as managerUsername",
"Creator.username as creatorUsername",
"TournamentOrganization.name as organizationName",
"TournamentOrganization.slug as organizationSlug",
])
@@ -688,6 +693,15 @@ const withTargetManager = (eb: ExpressionBuilder<DB, "PendingTrophy">) => {
).as("manager");
};
const withPendingCreator = (eb: ExpressionBuilder<DB, "PendingTrophy">) => {
return jsonObjectFrom(
eb
.selectFrom("User")
.select(["User.id", "User.username", "User.discordId"])
.whereRef("User.id", "=", "PendingTrophy.creatorId"),
).as("creator");
};
function pendingBaseQuery() {
return db
.selectFrom("PendingTrophy")
@@ -720,6 +734,7 @@ function pendingBaseQuery() {
"PendingTrophy.acceptedAt",
"PendingTrophy.targetTrophyId",
"PendingTrophy.managerId",
"PendingTrophy.creatorId",
"Submitter.username as submitterUsername",
"Submitter.discordId as submitterDiscordId",
"Decliner.username as declinedByUsername",
@@ -728,6 +743,7 @@ function pendingBaseQuery() {
withApprovals(eb),
withTarget(eb),
withTargetManager(eb),
withPendingCreator(eb),
]);
}
@@ -842,6 +858,7 @@ export async function addApproval(args: {
"submitterUserId",
"targetTrophyId",
"managerId",
"creatorId",
])
.where("id", "=", args.pendingTrophyId)
.where("declinedAt", "is", null)
@@ -864,6 +881,9 @@ export async function addApproval(args: {
model: pending.model,
organizationId: pending.organizationId,
managerId: pending.managerId ?? pending.submitterUserId,
...(pending.creatorId !== null
? { creatorId: pending.creatorId }
: {}),
})
.where("id", "=", pending.targetTrophyId)
.execute();
@@ -876,7 +896,7 @@ export async function addApproval(args: {
name: pending.name,
model: pending.model,
organizationId: pending.organizationId,
creatorId: pending.submitterUserId,
creatorId: pending.creatorId ?? pending.submitterUserId,
managerId: pending.managerId ?? pending.submitterUserId,
})
.returning("id")

View File

@@ -67,6 +67,7 @@ export const action: ActionFunction = async ({ request }) => {
submitterUserId: user.id,
targetTrophyId: data.targetTrophyId,
managerId: data.managerId,
creatorId: data.creatorId ?? undefined,
});
await notifyReviewersOfSubmission({
@@ -90,6 +91,7 @@ export const action: ActionFunction = async ({ request }) => {
description: data.description ?? "",
organizationId: data.organizationId,
submitterUserId: user.id,
creatorId: data.creatorId ?? user.id,
});
await notifyReviewersOfSubmission({

View File

@@ -1,9 +1,6 @@
.container {
position: relative;
overflow: hidden;
}
.tile {
border: var(--border-width) solid var(--tier-bg);
border-radius: var(--radius-field);
background-color: var(--color-bg-high);
@@ -11,14 +8,17 @@
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, 100px);
grid-template-columns: repeat(var(--trophy-grid-columns, auto-fit), 100px);
justify-content: center;
gap: var(--s-2);
}
.placeholder {
width: 100%;
aspect-ratio: 1 / 1;
/* every tile sits in a link or a button that must fill its cell so the container has a size before the canvas exists */
> :is(a, button) {
all: unset;
display: block;
position: relative;
cursor: pointer;
}
}
.tierPill {
@@ -50,11 +50,34 @@
width: 100% !important;
height: auto !important;
aspect-ratio: 1 / 1;
opacity: 1;
transition: opacity 0.2s ease;
opacity: 0;
transition: opacity 0.15s ease;
}
@starting-style {
opacity: 0;
.visible {
opacity: 1;
}
.loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.spinner {
width: var(--s-6);
height: var(--s-6);
border: calc(var(--border-width) * 2) solid var(--color-bg-higher);
border-top-color: var(--color-text-high);
border-radius: 50%;
animation: spin 1.5s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -16,6 +16,7 @@ import {
} from "react";
import { TierPill } from "~/components/TierPill";
import { useTheme } from "~/features/theme/core/provider";
import { usePrefersReducedMotion } from "~/hooks/usePrefersReducedMotion";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import { decompressTrophyModel } from "../trophies-utils";
import style from "./Trophy.module.css";
@@ -27,11 +28,11 @@ type TrophyCtxValue =
const TrophyCtx = createContext<TrophyCtxValue | undefined>(undefined);
/**
* Shares one PicoCAD2 WebGL context across every `Trophy` inside. Without a provider each `Trophy`
* creates its own, but browsers cap active WebGL contexts at 16, so big grids or rapid remounts
* break rendering. One page-wide singleton is held for the page lifetime (per-mount contexts stack
* faster than the browser frees them), and while it is loading descendants render a spacer instead
* of a canvas that would create their own context.
* Shares one PicoCAD2 WebGL context across every `Trophy`. Browsers cap active WebGL contexts at 16,
* so big grids or rapid remounts with a context per trophy break rendering, and a context created
* per mount loses its compiled shaders with every navigation. One page-wide singleton is held for
* the page lifetime. A Trophy outside a provider uses it directly, and inside one descendants
* render a spacer while it is loading instead of a canvas.
*/
let sharedContext: PicoCAD2Context | undefined;
@@ -60,19 +61,30 @@ export function TrophyContextProvider({
return <TrophyCtx.Provider value={value}>{children}</TrophyCtx.Provider>;
}
export function TrophyGrid({ children }: { children: React.ReactNode }) {
return <div className={style.grid}>{children}</div>;
}
export function TrophyPlaceholder() {
return <div className={style.placeholder} />;
export function TrophyGrid({
children,
columns,
}: {
children: React.ReactNode;
columns?: number;
}) {
return (
<div
className={style.grid}
style={
columns
? ({ "--trophy-grid-columns": columns } as React.CSSProperties)
: undefined
}
>
{children}
</div>
);
}
export function Trophy({
model,
className,
preview,
tile,
tier,
tentativeTier,
disableCameraControls,
@@ -81,11 +93,10 @@ export function Trophy({
onRenderStats,
colorScheme: forcedColorScheme,
fps = 60,
deferred,
}: {
model: string;
className?: string;
preview?: boolean;
tile?: boolean;
tier?: number | null;
tentativeTier?: number | null;
disableCameraControls?: boolean;
@@ -94,6 +105,7 @@ export function Trophy({
onRenderStats?: (stats: RenderStats) => void;
colorScheme?: ColorScheme;
fps?: number;
deferred?: boolean;
}) {
const ctxValue = useContext(TrophyCtx);
const context = ctxValue?.context;
@@ -101,6 +113,10 @@ export function Trophy({
ctxValue !== undefined && ctxValue.context === undefined;
const viewerRef = useRef<PicoCAD2Viewer | null>(null);
const [error, setError] = useState<boolean>(false);
const [drawn, setDrawn] = useState(false);
const [everDrawn, setEverDrawn] = useState(false);
const [activeModel, setActiveModel] = useState(model);
const reducedMotion = usePrefersReducedMotion();
const onRenderStatsRef = useRef(onRenderStats);
onRenderStatsRef.current = onRenderStats;
@@ -109,9 +125,12 @@ export function Trophy({
if (prevModelRef.current !== model) {
prevModelRef.current = model;
setError(false);
if (!drawn || reducedMotion) setActiveModel(model);
setDrawn(false);
}
const modelState = decompressTrophyModel(model);
const swapping = activeModel !== model;
const modelState = decompressTrophyModel(activeModel);
const siteColorScheme = useTrophyColorScheme();
const colorScheme = forcedColorScheme ?? siteColorScheme;
@@ -128,7 +147,7 @@ export function Trophy({
const viewer = new PicoCAD2Viewer({
canvas,
context,
context: context ?? getSharedTrophyContext(),
resolution: { width: 128, height: 128, scale: 4 },
clampCameraDistance: {
enabled: true,
@@ -164,10 +183,18 @@ export function Trophy({
(staticOnSoftwareRendering && isSoftwareRendering())
) {
viewer.whenReady().then(() => {
if (viewerRef.current !== viewer) return;
viewer.draw();
viewer.dispose();
viewerRef.current = null;
const drawOnce = () => {
if (viewerRef.current !== viewer) return;
if (!viewer.draw()) {
requestAnimationFrame(drawOnce);
return;
}
viewer.dispose();
viewerRef.current = null;
setDrawn(true);
setEverDrawn(true);
};
drawOnce();
});
return;
}
@@ -179,6 +206,11 @@ export function Trophy({
}
viewer.startRenderLoop(false);
viewer.whenReady().then(() => {
if (viewerRef.current !== viewer) return;
setDrawn(true);
setEverDrawn(true);
});
if (disableCameraControls) return;
@@ -206,9 +238,6 @@ export function Trophy({
);
const effectiveTier = tier ?? tentativeTier ?? null;
const containerClassName = clsx(style.container, className, {
[style.tile]: tile,
});
const containerStyle = effectiveTier
? ({
"--tier-bg": `var(--tier-bg-${effectiveTier})`,
@@ -234,7 +263,7 @@ export function Trophy({
if (error || modelState === null) {
return (
<div className={containerClassName} style={containerStyle}>
<div className={style.container} style={containerStyle}>
<div className={clsx(style.trophy, style.error)}>
<Ban size={48} />
</div>
@@ -244,22 +273,27 @@ export function Trophy({
);
}
if (isLoadingSharedContext) {
return (
<div className={containerClassName} style={containerStyle}>
<div className={style.trophy} />
</div>
);
}
return (
<div className={containerClassName} style={containerStyle}>
<canvas
ref={canvasRef}
className={clsx(style.trophy, {
[style.interactive]: !preview && !disableCameraControls,
})}
/>
<div className={style.container} style={containerStyle} aria-busy={!drawn}>
{deferred || isLoadingSharedContext ? (
<div className={style.trophy} />
) : (
<canvas
ref={canvasRef}
className={clsx(style.trophy, {
[style.visible]: drawn,
[style.interactive]: !preview && !disableCameraControls,
})}
onTransitionEnd={() => {
if (swapping) setActiveModel(model);
}}
/>
)}
{everDrawn || swapping ? null : (
<div className={style.loading}>
<div className={style.spinner} />
</div>
)}
{tierPill}
{cornerPill}
</div>

View File

@@ -5,25 +5,6 @@
width: max-content;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 100px);
justify-content: center;
gap: var(--s-2);
padding: var(--s-2);
border: var(--border-style);
border-radius: var(--radius-box);
}
.trophyButton {
all: unset;
cursor: pointer;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.win {
display: flex;
flex-direction: column;

View File

@@ -17,7 +17,7 @@ import {
useProgressiveRender,
} from "../trophies-utils";
import { TournamentSummaryRow } from "./TournamentSummaryRow";
import { Trophy, TrophyContextProvider, TrophyPlaceholder } from "./Trophy";
import { Trophy, TrophyContextProvider, TrophyGrid } from "./Trophy";
import styles from "./TrophyDisplay.module.css";
import { TrophyShowcaseModal } from "./TrophyShowcase";
@@ -68,36 +68,31 @@ export function TrophyDisplay({
data-testid="trophy-display"
className={clsx(className, styles.root)}
>
<div className={styles.grid}>
{itemsToDisplay.map((trophy, i) =>
i < visibleCount ? (
<button
key={trophy.id}
type="button"
className={styles.trophyButton}
onClick={() => setOpenTrophy(trophy)}
aria-label={trophy.name}
>
<Trophy
tile
model={trophy.model}
tier={trophy.tier ?? null}
preview={!!openTrophy}
staticOnSoftwareRendering
disableCameraControls
fps={30}
pill={
trophy.count && trophy.count > 1
? `×${trophy.count}`
: undefined
}
/>
</button>
) : (
<TrophyPlaceholder key={trophy.id} />
),
)}
</div>
<TrophyGrid columns={3}>
{itemsToDisplay.map((trophy, i) => (
<button
key={trophy.id}
type="button"
onClick={() => setOpenTrophy(trophy)}
aria-label={trophy.name}
>
<Trophy
model={trophy.model}
tier={trophy.tier ?? null}
preview={!!openTrophy}
staticOnSoftwareRendering
disableCameraControls
fps={30}
deferred={i >= visibleCount}
pill={
trophy.count && trophy.count > 1
? `×${trophy.count}`
: undefined
}
/>
</button>
))}
</TrophyGrid>
{!everythingVisible ? (
<DotPagination
pagesCount={pagesCount}

View File

@@ -46,19 +46,6 @@
font-size: var(--font-xs);
}
.trophyPreview {
border-radius: var(--radius-field);
background-color: var(--color-bg-high);
margin-top: var(--s-4);
&:has(div) {
width: 100px;
height: auto;
aspect-ratio: 1 / 1;
border: var(--border-width) solid var(--tier-bg);
}
}
.previewThemes {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -75,11 +62,6 @@
border: var(--border-style);
border-radius: var(--radius-field);
background-color: var(--color-bg);
& .trophyPreview {
margin-top: 0;
width: 100%;
}
}
.previewThemeLabel {
@@ -241,12 +223,6 @@
border: var(--border-style);
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
opacity: 1;
transition: opacity 0.2s ease;
@starting-style {
opacity: 0;
}
}
.pendingMain {

View File

@@ -64,5 +64,32 @@ describe("trophy submissions before release", () => {
await TrophyRepository.pendingBySubmitter(REGULAR_USER_TEST_ID);
expect(pending.length).toBe(1);
expect(pending[0].name).toBe("Regular Trophy");
expect(pending[0].creatorId).toBe(REGULAR_USER_TEST_ID);
});
test("a submission can name someone else as the creator", async () => {
const organization = await TournamentOrganizationFactory.create({
ownerId: ADMIN_ID,
});
const artist = await UserFactory.create();
const result = await submitAction(
{
_action: "CREATE",
name: "Commissioned Trophy",
model: decompressFromBase64(TrophyFactory.MODELS[0]) ?? "",
organizationId: organization.id,
creatorId: artist.id,
description: null,
},
{ user: "regular" },
);
expect(result).toBe(null);
const pending =
await TrophyRepository.pendingBySubmitter(REGULAR_USER_TEST_ID);
expect(pending[0].creatorId).toBe(artist.id);
expect(pending[0].creator?.id).toBe(artist.id);
});
});

View File

@@ -197,10 +197,14 @@ function TrophyTermsGate({ children }: { children: React.ReactNode }) {
}
function NewTrophyForm() {
const { t } = useTranslation(["trophies"]);
const { t } = useTranslation(["trophies", "forms"]);
const data = useLoaderData<typeof loader>();
return (
<SendouForm schema={createTrophyFormSchema}>
<SendouForm
schema={createTrophyFormSchema}
defaultValues={{ creatorId: data.currentUserId }}
>
{({ FormField }) => (
<>
<FormField name="name" />
@@ -213,6 +217,16 @@ function NewTrophyForm() {
/>
)}
</FormField>
<FormField name="creatorId">
{({ error, value, onChange }: CustomFieldRenderProps) => (
<UserField
label={t("forms:labels.trophyCreator")}
error={error}
value={value as number | null}
onChange={onChange}
/>
)}
</FormField>
<FormField name="model">
{({ name, error, value, onChange }: CustomFieldRenderProps) => (
<ModelField
@@ -224,9 +238,6 @@ function NewTrophyForm() {
)}
</FormField>
<FormField name="description" />
<FormMessage type="info">
{t("trophies:new.form.creatorNotice")}
</FormMessage>
</>
)}
</SendouForm>
@@ -273,6 +284,7 @@ function UpdateTrophyForm({
}: {
trophy: NewTrophyLoaderData["editableTrophies"][number];
}) {
const { t } = useTranslation(["forms"]);
const decompressedModel = decompressTrophyModel(trophy.model) ?? "";
return (
@@ -284,6 +296,7 @@ function UpdateTrophyForm({
model: decompressedModel,
organizationId: trophy.organizationId,
managerId: trophy.managerId,
creatorId: trophy.creatorId,
description: "",
}}
>
@@ -301,7 +314,18 @@ function UpdateTrophyForm({
</FormField>
<FormField name="managerId">
{({ error, value, onChange }: CustomFieldRenderProps) => (
<ManagerField
<UserField
label={t("forms:labels.trophyManager")}
error={error}
value={value as number | null}
onChange={onChange}
/>
)}
</FormField>
<FormField name="creatorId">
{({ error, value, onChange }: CustomFieldRenderProps) => (
<UserField
label={t("forms:labels.trophyCreator")}
error={error}
value={value as number | null}
onChange={onChange}
@@ -325,20 +349,20 @@ function UpdateTrophyForm({
);
}
function ManagerField({
function UserField({
label,
error,
value,
onChange,
}: {
label: string;
error?: string;
value: number | null;
onChange: (value: number | null) => void;
}) {
const { t } = useTranslation(["forms"]);
return (
<div>
<Label required>{t("forms:labels.trophyManager")}</Label>
<Label required>{label}</Label>
<UserSearch
initialUserId={value ?? undefined}
onChange={(user) => onChange(user?.id ?? null)}
@@ -417,14 +441,12 @@ function ModelField({
</span>
<Trophy
model={preview.compressedModel}
className={styles.trophyPreview}
preview
tier={1}
colorScheme={theme}
/>
<Trophy
model={preview.compressedModel}
className={styles.trophyPreview}
onRenderStats={reportRenderStats}
colorScheme={theme}
/>
@@ -619,12 +641,13 @@ function TrophyList({
return (
<TrophyContextProvider>
<div className={styles.pendingList}>
{items.slice(0, visibleCount).map((item) => (
{items.map((item, i) => (
<TrophyListRow
key={item.id}
pending={item}
currentUserId={data.currentUserId}
canReview={data.canReview}
deferred={i >= visibleCount}
/>
))}
</div>
@@ -636,10 +659,12 @@ function TrophyListRow({
pending,
currentUserId,
canReview,
deferred,
}: {
pending: NewTrophyLoaderData["pendingTrophies"][number];
currentUserId: number;
canReview: boolean;
deferred: boolean;
}) {
const { t } = useTranslation(["trophies", "common"]);
const { submit, state } = useActionSubmit(pendingTrophyActionSchema);
@@ -680,7 +705,7 @@ function TrophyListRow({
className={styles.trophyPreviewButton}
onClick={() => setPreviewOpen(true)}
>
<Trophy model={pending.model} preview />
<Trophy model={pending.model} preview deferred={deferred} />
</button>
<SendouDialog
heading={pending.name}
@@ -688,11 +713,7 @@ function TrophyListRow({
onClose={() => setPreviewOpen(false)}
showCloseButton
>
<Trophy
model={pending.model}
className={styles.trophyPreview}
onRenderStats={reportRenderStats}
/>
<Trophy model={pending.model} onRenderStats={reportRenderStats} />
</SendouDialog>
<div className={styles.pendingMain}>
<div className={styles.pendingHeader}>
@@ -721,6 +742,23 @@ function TrophyListRow({
) : (
pending.submitterUsername
)}
{pending.creator &&
pending.creator.id !==
(pending.manager?.id ?? pending.submitterUserId) ? (
<>
{" • "}
<Trans
t={t}
i18nKey="trophies:details.createdBy"
values={{ name: pending.creator.username }}
>
Created by
<Link to={userPage({ discordId: pending.creator.discordId })}>
{pending.creator.username}
</Link>
</Trans>
</>
) : null}
{pending.organizationName ? (
<>
{" • "}
@@ -966,6 +1004,13 @@ function PendingTrophyDiff({
newValue: newManagerName,
changed: target.managerId !== newManagerId,
},
{
label: t("forms:labels.trophyCreator"),
oldValue: target.creatorUsername ?? "—",
newValue: pending.creator?.username ?? target.creatorUsername ?? "—",
changed:
pending.creatorId !== null && target.creatorId !== pending.creatorId,
},
{
label: t("forms:labels.trophyModel"),
oldValue: "-----",

View File

@@ -17,7 +17,6 @@ import {
Trophy,
TrophyContextProvider,
TrophyGrid,
TrophyPlaceholder,
} from "../components/Trophy";
import { loader } from "../loaders/trophies.server";
import {
@@ -74,32 +73,26 @@ export default function TrophiesPage() {
/>
<TrophyGrid>
<TrophyContextProvider>
{filteredTrophies.map((trophy, i) =>
i < visibleCount ? (
<NavLink to={String(trophy.id)} key={trophy.id}>
<Trophy
tile
model={trophy.model}
tier={trophy.tier}
tentativeTier={trophy.tentativeTier}
preview
pill={
hasUpcomingTournamentSoon(
trophy.upcomingTournamentAt,
) ? (
<CalendarClock
size={16}
role="img"
aria-label={t("trophies:details.upcoming")}
/>
) : undefined
}
/>
</NavLink>
) : (
<TrophyPlaceholder key={trophy.id} />
),
)}
{filteredTrophies.map((trophy, i) => (
<NavLink to={String(trophy.id)} key={trophy.id}>
<Trophy
model={trophy.model}
tier={trophy.tier}
tentativeTier={trophy.tentativeTier}
preview
deferred={i >= visibleCount}
pill={
hasUpcomingTournamentSoon(trophy.upcomingTournamentAt) ? (
<CalendarClock
size={16}
role="img"
aria-label={t("trophies:details.upcoming")}
/>
) : undefined
}
/>
</NavLink>
))}
</TrophyContextProvider>
</TrophyGrid>
</div>

View File

@@ -56,6 +56,7 @@ export const createTrophyFormSchema = v.object({
}),
model: trophyModelField(),
organizationId: customField({ initialValue: null }, id),
creatorId: customField({ initialValue: null }, v.nullish(id)),
description: textAreaOptional({
label: "labels.trophyInformation",
maxLength: TROPHY_DESCRIPTION_MAX_LENGTH,
@@ -73,6 +74,7 @@ export const updateTrophyFormSchema = v.object({
model: trophyModelField(),
organizationId: customField({ initialValue: null }, id),
managerId: customField({ initialValue: null }, id),
creatorId: customField({ initialValue: null }, v.nullish(id)),
description: textAreaOptional({
label: "labels.trophyInformation",
maxLength: TROPHY_DESCRIPTION_MAX_LENGTH,

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "3D model state",
"labels.trophyOrganization": "Organization",
"labels.trophyManager": "Manager",
"labels.trophyCreator": "3D model creator",
"labels.trophyInformation": "Additional information",
"labels.profileFavoriteTrophies": "Favorite trophies",
"labels.profileHiddenTrophies": "Hidden trophies",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "View trophy page",
"special.supporter.description": "Awarded for supporting sendou.ink",
"special.xp.description": "Awarded for reaching {{value}} X Power",
"new.form.creatorNotice": "The user who uploads a trophy is registered as its creator. This can not be changed. If you commissioned a trophy from someone and they want to be listed as its creator, they have to upload it themselves.",
"new.form.limitReached": "You have reached the limit of {{limit}} pending trophies. Delete or wait for an existing submission to be reviewed before submitting another.",
"new.form.preview.light": "Light mode",
"new.form.preview.dark": "Dark mode",
@@ -21,7 +20,7 @@
"new.specs.recommended": "Recommended",
"new.specs.cameraTarget": "Camera target X and Z must be 0",
"new.specs.centered": "Model must be visually centered on all axes",
"new.specs.zoom": "Zoom must be adjusted so the model fills out the viewport as much as possible without touching any edges",
"new.specs.zoom": "Zoom must be adjusted so the model fills out the preview as much as possible without touching any edges",
"new.specs.angles": "Model must be viewable from all angles",
"new.specs.noStandaloneFlag": "Model can't be a standalone flag",
"new.specs.noTrademarked": "Do not depict trademarked material",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "Estado del modelo 3D",
"labels.trophyOrganization": "Organización",
"labels.trophyManager": "Mánager",
"labels.trophyCreator": "",
"labels.trophyInformation": "Información adicional",
"labels.profileFavoriteTrophies": "Trofeos favoritos",
"labels.profileHiddenTrophies": "Trofeos ocultos",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "Ver página del trofeo",
"special.supporter.description": "Otorgado por apoyar a sendou.ink",
"special.xp.description": "Otorgado por alcanzar {{value}} de Energía X",
"new.form.creatorNotice": "El usuario que sube un trofeo queda registrado como su creador. Esto no se puede cambiar. Si le encargaste un trofeo a alguien y quiere aparecer como su creador, tiene que subirlo esa persona.",
"new.form.limitReached": "Has alcanzado el límite de {{limit}} trofeos pendientes. Borra o espera a que se revise un envío existente antes de enviar otro.",
"new.form.preview.light": "Modo claro",
"new.form.preview.dark": "Modo oscuro",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "Estado del modelo 3D",
"labels.trophyOrganization": "Organización",
"labels.trophyManager": "Mánager",
"labels.trophyCreator": "",
"labels.trophyInformation": "Información adicional",
"labels.profileFavoriteTrophies": "Trofeos favoritos",
"labels.profileHiddenTrophies": "Trofeos ocultos",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "Ver página del trofeo",
"special.supporter.description": "Otorgado por apoyar a sendou.ink",
"special.xp.description": "Otorgado por alcanzar {{value}} de Energía X",
"new.form.creatorNotice": "El usuario que sube un trofeo queda registrado como su creador. Esto no se puede cambiar. Si le encargaste un trofeo a alguien y quiere aparecer como su creador, tiene que subirlo esa persona.",
"new.form.limitReached": "Has alcanzado el límite de {{limit}} trofeos pendientes. Borra o espera a que se revise un envío existente antes de enviar otro.",
"new.form.preview.light": "Modo claro",
"new.form.preview.dark": "Modo oscuro",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -459,6 +459,7 @@
"labels.trophyModel": "",
"labels.trophyOrganization": "",
"labels.trophyManager": "",
"labels.trophyCreator": "",
"labels.trophyInformation": "",
"labels.profileFavoriteTrophies": "",
"labels.profileHiddenTrophies": "",

View File

@@ -13,7 +13,6 @@
"display.viewTrophyPage": "",
"special.supporter.description": "",
"special.xp.description": "",
"new.form.creatorNotice": "",
"new.form.limitReached": "",
"new.form.preview.light": "",
"new.form.preview.dark": "",

View File

@@ -0,0 +1,16 @@
import type { Kysely } from "kysely";
/**
* A submission names the model's creator. Before, the submitter always became
* the creator, so a commissioned trophy had to be uploaded by its artist.
*/
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx.schema
.alterTable("PendingTrophy")
.addColumn("creatorId", "integer", (col) =>
col.references("User.id").onDelete("set null"),
)
.execute();
});
}

View File

@@ -88,7 +88,7 @@
"nprogress": "0.2.0",
"openskill": "5.0.1",
"p-limit": "7.3.1",
"picocad2-web": "2.0.2",
"picocad2-web": "2.0.3",
"qrcode.react": "4.2.0",
"react": "19.3.0",
"react-chartjs-2": "5.3.1",

10
pnpm-lock.yaml generated
View File

@@ -118,8 +118,8 @@ importers:
specifier: 7.3.1
version: 7.3.1
picocad2-web:
specifier: 2.0.2
version: 2.0.2
specifier: 2.0.3
version: 2.0.3
qrcode.react:
specifier: 4.2.0
version: 4.2.0(react@19.3.0)
@@ -3701,8 +3701,8 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
picocad2-web@2.0.2:
resolution: {integrity: sha512-lx3IHV41J+qQaY3B54D9UaLogluSNBXe8mtRd2FSLu45KiBjg9aNxkGj7Y/ZzqPDwVI3HVACL+vDwJyJGksv2g==}
picocad2-web@2.0.3:
resolution: {integrity: sha512-IaUTXiNwH1Ji4U5J57AiHm+unKmKEwV3UH9ab47qqlAcDRSThdt3mE7uzKnH/J0MXhuSRU9lfAdh6nuRHA+b9Q==}
engines: {node: '>=18.0.0'}
picocolors@1.1.1:
@@ -7910,7 +7910,7 @@ snapshots:
pathe@2.0.3: {}
picocad2-web@2.0.2:
picocad2-web@2.0.3:
dependencies:
gl-matrix: 3.4.4
twgl.js: 7.0.0