mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-25 04:37:13 -05:00
Trophy fixes (#3394)
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -208,6 +208,7 @@ export interface PendingTrophy {
|
||||
acceptedAt: number | null;
|
||||
targetTrophyId: number | null;
|
||||
managerId: number | null;
|
||||
creatorId: number | null;
|
||||
}
|
||||
|
||||
export interface PendingTrophyApproval {
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
.trophyPreview {
|
||||
background-color: var(--color-bg-high);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
|
||||
.countInput {
|
||||
width: 4.5rem;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: "-----",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"labels.trophyModel": "",
|
||||
"labels.trophyOrganization": "",
|
||||
"labels.trophyManager": "",
|
||||
"labels.trophyCreator": "",
|
||||
"labels.trophyInformation": "",
|
||||
"labels.profileFavoriteTrophies": "",
|
||||
"labels.profileHiddenTrophies": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"labels.trophyModel": "",
|
||||
"labels.trophyOrganization": "",
|
||||
"labels.trophyManager": "",
|
||||
"labels.trophyCreator": "",
|
||||
"labels.trophyInformation": "",
|
||||
"labels.profileFavoriteTrophies": "",
|
||||
"labels.profileHiddenTrophies": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"labels.trophyModel": "",
|
||||
"labels.trophyOrganization": "",
|
||||
"labels.trophyManager": "",
|
||||
"labels.trophyCreator": "",
|
||||
"labels.trophyInformation": "",
|
||||
"labels.profileFavoriteTrophies": "",
|
||||
"labels.profileHiddenTrophies": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"labels.trophyModel": "",
|
||||
"labels.trophyOrganization": "",
|
||||
"labels.trophyManager": "",
|
||||
"labels.trophyCreator": "",
|
||||
"labels.trophyInformation": "",
|
||||
"labels.profileFavoriteTrophies": "",
|
||||
"labels.profileHiddenTrophies": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"labels.trophyModel": "",
|
||||
"labels.trophyOrganization": "",
|
||||
"labels.trophyManager": "",
|
||||
"labels.trophyCreator": "",
|
||||
"labels.trophyInformation": "",
|
||||
"labels.profileFavoriteTrophies": "",
|
||||
"labels.profileHiddenTrophies": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"labels.trophyModel": "",
|
||||
"labels.trophyOrganization": "",
|
||||
"labels.trophyManager": "",
|
||||
"labels.trophyCreator": "",
|
||||
"labels.trophyInformation": "",
|
||||
"labels.profileFavoriteTrophies": "",
|
||||
"labels.profileHiddenTrophies": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"labels.trophyModel": "",
|
||||
"labels.trophyOrganization": "",
|
||||
"labels.trophyManager": "",
|
||||
"labels.trophyCreator": "",
|
||||
"labels.trophyInformation": "",
|
||||
"labels.profileFavoriteTrophies": "",
|
||||
"labels.profileHiddenTrophies": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"labels.trophyModel": "",
|
||||
"labels.trophyOrganization": "",
|
||||
"labels.trophyManager": "",
|
||||
"labels.trophyCreator": "",
|
||||
"labels.trophyInformation": "",
|
||||
"labels.profileFavoriteTrophies": "",
|
||||
"labels.profileHiddenTrophies": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"labels.trophyModel": "",
|
||||
"labels.trophyOrganization": "",
|
||||
"labels.trophyManager": "",
|
||||
"labels.trophyCreator": "",
|
||||
"labels.trophyInformation": "",
|
||||
"labels.profileFavoriteTrophies": "",
|
||||
"labels.profileHiddenTrophies": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"labels.trophyModel": "",
|
||||
"labels.trophyOrganization": "",
|
||||
"labels.trophyManager": "",
|
||||
"labels.trophyCreator": "",
|
||||
"labels.trophyInformation": "",
|
||||
"labels.profileFavoriteTrophies": "",
|
||||
"labels.profileHiddenTrophies": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"labels.trophyModel": "",
|
||||
"labels.trophyOrganization": "",
|
||||
"labels.trophyManager": "",
|
||||
"labels.trophyCreator": "",
|
||||
"labels.trophyInformation": "",
|
||||
"labels.profileFavoriteTrophies": "",
|
||||
"labels.profileHiddenTrophies": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"labels.trophyModel": "",
|
||||
"labels.trophyOrganization": "",
|
||||
"labels.trophyManager": "",
|
||||
"labels.trophyCreator": "",
|
||||
"labels.trophyInformation": "",
|
||||
"labels.profileFavoriteTrophies": "",
|
||||
"labels.profileHiddenTrophies": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"labels.trophyModel": "",
|
||||
"labels.trophyOrganization": "",
|
||||
"labels.trophyManager": "",
|
||||
"labels.trophyCreator": "",
|
||||
"labels.trophyInformation": "",
|
||||
"labels.profileFavoriteTrophies": "",
|
||||
"labels.profileHiddenTrophies": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
16
migrations/20260912195930-pending-trophy-creator-id.ts
Normal file
16
migrations/20260912195930-pending-trophy-creator-id.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
@@ -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
10
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user