Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/src/skins/naming.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"undefined",
"admin",
"tfmc",
# Profile's editor is /profile/skins/new, so no skin can be called that.
# Profile's editor is ?tab=skins&skin=new, so no skin can be called that.
"new",
}
)
Expand Down
9 changes: 4 additions & 5 deletions frontend/app/components/drinks/BrewForm.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"use client";

import { FormEvent, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
createSubmission,
getCatalog,
Expand All @@ -20,7 +19,6 @@ import {
effectLabel,
} from "../../../lib/drinks/constants";
import { setLastSubmissionId, setSession, type DrinksSession } from "../../../lib/drinks/session";
import { drinkPath } from "../../../lib/profile/paths";
import NameColourPicker from "../shared/NameColourPicker";
import LoreLinesEditor from "../shared/LoreLinesEditor";
import ModelPreview from "../skins/ModelPreview";
Expand All @@ -34,6 +32,8 @@ type EffectRow = { type: string; level: number; duration: number };

type Props = {
session: DrinksSession;
/** Shows the new drink once it's in. */
onSubmitted: (id: string) => void;
};

const fieldClass =
Expand All @@ -42,8 +42,7 @@ const fieldClass =
const btnGhost = "text-xs text-[var(--tfmc-accent)] hover:underline disabled:opacity-50";
const btnDanger = "text-xs text-[#e8a0a0] hover:underline";

export default function BrewForm({ session }: Props) {
const router = useRouter();
export default function BrewForm({ session, onSubmitted }: Props) {
const [allowTexture, setAllowTexture] = useState(session.allow_drink_texture === true);
const [allowDrinkMessage, setAllowDrinkMessage] = useState(session.allow_drink_message === true);
const [colourStops, setColourStops] = useState(Math.max(0, Math.floor(session.name_colour_stops ?? 0)));
Expand Down Expand Up @@ -266,7 +265,7 @@ export default function BrewForm({ session }: Props) {
existingTextureId: appearance === "reuse" ? existingTextureId.trim() : null,
});
setLastSubmissionId(created.id);
router.push(drinkPath(created.id));
onSubmitted(created.id);
} catch (err) {
setError(err instanceof DrinksApiError ? err.message : err instanceof Error ? err.message : "Submit failed");
} finally {
Expand Down
275 changes: 275 additions & 0 deletions frontend/app/components/profile/ProfileItems.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
"use client";

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 { 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";
import * as drinks from "../../../lib/drinks/session";
import { profileHref } from "../../../lib/profile/paths";
import { resolveOwnerToken, type WardrobeSessionStore } from "../../../lib/profile/session";

type Props = {
/** "new" for the editor, else a submission id. */
id: string;
/** ← back to the wardrobe. */
onBack: () => void;
/** Back to the wardrobe in place, when there's nothing to show. */
onLeave: () => void;
/** Swaps this view for one skin or drink, in place (after a submit). */
onShow: (id: string) => void;
};

const quietClass = "text-sm text-[var(--tfmc-mist)] underline-offset-2 hover:text-[var(--tfmc-cream)] hover:underline";

/** A skin or drink inside its Profile tab: back to the wardrobe, a title, then the editor or the item. */
function ItemFrame({
tab,
title,
onBack,
children,
}: {
tab: "skins" | "drinks";
title: string;
onBack: () => void;
children: ReactNode;
}) {
function back(event: MouseEvent<HTMLAnchorElement>) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;
event.preventDefault();
onBack();
}
return (
<div>
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<a href={profileHref(tab)} onClick={back} className={quietClass}>
← {tab === "skins" ? "Skins" : "Drinks"}
</a>
<h2 className="min-w-0 font-[family-name:var(--font-fraunces)] text-2xl text-[var(--tfmc-cream)]">{title}</h2>
</div>
{children}
</div>
);
}

function Loading() {
return <p className="mt-6 text-sm text-[var(--tfmc-mist)]">Loading…</p>;
}

/** The skin editor, for a session Profile started or an in-game code opened. */
function NewSkin({ onBack, onLeave, onShow }: Omit<Props, "id">) {
const [session, setSessionState] = useState<skins.SkinsSession | null>(null);
const [metaSynced, setMetaSynced] = useState(true);

useEffect(() => {
const existing = skins.getSession();
if (!skins.isSessionValid(existing)) {
if (existing) skins.clearSession();
onLeave();
return;
}
// Back to a submitted skin's editor: its code is spent, so show the skin instead.
const lastId = skins.getLastSubmissionId();
if (lastId) {
onShow(lastId);
return;
}
setSessionState(existing);
}, [onLeave, onShow]);

const token = session?.session_token;
useEffect(() => {
if (!token) return;
let cancelled = false;
getPlayerMeta(token)
.then((meta) => {
if (cancelled) return;
const current = skins.getSession();
if (!current || current.session_token !== token) return;
const next: skins.SkinsSession = {
...current,
name_colour_stops: meta.name_colour_stops,
max_3d_pair_bytes: meta.max_3d_pair_bytes,
skin_token_cooldown_days: meta.skin_token_cooldown_days,
skin_kinds: meta.skin_kinds,
allow_armor_3d_helmet: meta.allow_armor_3d_helmet,
};
skins.setSession(next);
setSessionState(next);
setMetaSynced(meta.meta_synced !== false);
})
.catch(() => {
// Keep the redeem-time snapshot.
});
return () => {
cancelled = true;
};
}, [token]);

return (
<ItemFrame tab="skins" title="New skin" onBack={onBack}>
{session ? (
<UploadForm
sessionToken={session.session_token}
staff={session.staff === true}
nameColourStops={session.name_colour_stops}
max3dPairBytes={session.max_3d_pair_bytes}
skinKinds={session.skin_kinds ?? null}
allowArmor3dHelmet={session.allow_armor_3d_helmet === true}
colourLockedMessage={
!metaSynced && (session.name_colour_stops ?? 0) <= 0
? "Join the server once to unlock your rank perks."
: undefined
}
onSubmitted={onShow}
/>
) : (
<Loading />
)}
</ItemFrame>
);
}

/** The drink editor, for a session Profile started or an in-game code opened. */
function NewDrink({ onBack, onLeave, onShow }: Omit<Props, "id">) {
const [session, setSessionState] = useState<drinks.DrinksSession | null>(null);

useEffect(() => {
const existing = drinks.getSession();
if (!drinks.isSessionValid(existing)) {
if (existing) drinks.clearSession();
onLeave();
return;
}
// Back to a submitted drink's editor: its code is spent, so show the drink instead.
const lastId = drinks.getLastSubmissionId();
if (lastId) {
onShow(lastId);
return;
}
setSessionState(existing);
}, [onLeave, onShow]);

return (
<ItemFrame tab="drinks" title="New drink" onBack={onBack}>
{session ? <BrewForm session={session} onSubmitted={onShow} /> : <Loading />}
</ItemFrame>
);
}

/** One skin or drink and where it is in review, loaded with Profile's session or a code's. */
function useOwnedItem<Row, S extends { session_token: string }>(
id: string,
store: WardrobeSessionStore<S>,
get: (id: string, token: string) => Promise<Row>,
onLeave: () => void,
) {
const [row, setRow] = useState<Row | null>(null);
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);

const load = useCallback(async () => {
const owner = resolveOwnerToken(store);
if (!owner) {
onLeave();
return;
}
setLoading(true);
setError(null);
try {
setRow(await get(id, owner));
setToken(owner);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load");
setRow(null);
} finally {
setLoading(false);
}
}, [get, id, onLeave, store]);

useEffect(() => {
void load();
}, [load]);

return { row, token, error, loading, load };
}

function ItemBody({
loading,
error,
card,
onRefresh,
}: {
loading: boolean;
error: string | null;
card: ReactNode;
onRefresh: () => void;
}) {
if (loading) return <Loading />;
if (error) {
return (
<p className="mt-6 text-sm text-[#e8a0a0]" role="alert">
{error}
</p>
);
}
if (!card) return null;
return (
<>
{card}
<button type="button" onClick={onRefresh} className={`${quietClass} mt-8`}>
Refresh status
</button>
</>
);
}

function SkinView({ id, onBack, onLeave }: Omit<Props, "onShow">) {
const { row, token, error, loading, load } = useOwnedItem<SubmissionPublic, skins.SkinsSession>(
id,
skins,
getSkin,
onLeave,
);
return (
<ItemFrame tab="skins" title={row?.display_name || "Skin"} onBack={onBack}>
<ItemBody
loading={loading}
error={error}
card={row && token ? <SkinStatusCard row={row} sessionToken={token} /> : null}
onRefresh={() => void load()}
/>
</ItemFrame>
);
}

function DrinkView({ id, onBack, onLeave }: Omit<Props, "onShow">) {
const { row, error, loading, load } = useOwnedItem<DrinkSubmissionPublic, drinks.DrinksSession>(
id,
drinks,
getDrink,
onLeave,
);
return (
<ItemFrame tab="drinks" title={row?.display_name || "Drink"} onBack={onBack}>
<ItemBody
loading={loading}
error={error}
card={row ? <DrinkStatusCard row={row} /> : null}
onRefresh={() => void load()}
/>
</ItemFrame>
);
}

export function SkinItem({ id, ...nav }: Props) {
return id === "new" ? <NewSkin {...nav} /> : <SkinView key={id} id={id} {...nav} />;
}

export function DrinkItem({ id, ...nav }: Props) {
return id === "new" ? <NewDrink {...nav} /> : <DrinkView key={id} id={id} {...nav} />;
}
Loading
Loading