From a06db72c655caecae27e9e90a329b29e32de2f0b Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 11 Oct 2026 18:44:02 +0200 Subject: [PATCH] Update: admin panel fixes, badge icons, studio improvements, gitea runner v5 --- .gitea/workflows/ci.yaml | 12 +-- src/actions/admin-badges.ts | 76 +++++++++++------- src/app/admin/badges/page.tsx | 8 ++ src/app/admin/studio/error.tsx | 80 +++++++++++++++++++ .../admin/import/furni/repair-icons/route.ts | 13 +++ src/components/admin/studio/studio-client.tsx | 32 ++++++-- .../admin/studio/studio-furni-icons.ts | 11 ++- 7 files changed, 185 insertions(+), 47 deletions(-) create mode 100644 src/app/admin/studio/error.tsx diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index bb6a19f2..5c51b0fa 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -25,7 +25,7 @@ jobs: runs-on: self-hosted steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: ${{ gitea.repository }} token: ${{ gitea.token }} @@ -64,7 +64,7 @@ jobs: runs-on: self-hosted steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: ${{ gitea.repository }} token: ${{ gitea.token }} @@ -92,7 +92,7 @@ jobs: runs-on: self-hosted steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: ${{ gitea.repository }} token: ${{ gitea.token }} @@ -108,7 +108,7 @@ jobs: runs-on: self-hosted steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: ${{ gitea.repository }} token: ${{ gitea.token }} @@ -141,7 +141,7 @@ jobs: runs-on: self-hosted steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: ${{ gitea.repository }} token: ${{ gitea.token }} @@ -175,7 +175,7 @@ jobs: runs-on: self-hosted steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: ${{ gitea.repository }} token: ${{ gitea.token }} diff --git a/src/actions/admin-badges.ts b/src/actions/admin-badges.ts index 1f24bb9f..8fa388f4 100644 --- a/src/actions/admin-badges.ts +++ b/src/actions/admin-badges.ts @@ -10,38 +10,54 @@ import { rcon } from "@/lib/services/rcon"; export async function giveBadge(formData: FormData): Promise { await requirePermission(PERMS.CATALOG_EDIT); - const userId = Number(formData.get("userId")); - const code = String(formData.get("code") ?? "") - .normalize("NFC") - .trim() - .slice(0, 32); - if (!(userId > 0) || code.length === 0) return; + // Support comma-separated userIds and/or codes for bulk operations + const userIdInput = String(formData.get("userId") ?? "").trim(); + const codeInput = String(formData.get("code") ?? "").trim(); + const userIds = userIdInput ? userIdInput.split(",").map((s) => s.trim()).filter(Boolean) : []; + const codes = codeInput ? codeInput.split(",").map((s) => s.trim()).filter(Boolean) : []; - // Fire the emulator command so the badge appears live for online users. - await rcon.giveBadge(userId, code); - - // Persist the badge directly so it survives a relog / offline grant. - // users_badges has no unique (user_id, badge_code) constraint, so guard - // against duplicates and compute the next free slot ourselves. - try { - const [existing] = await db - .select({ id: UsersBadges.id }) - .from(UsersBadges) - .where( - and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)), - ) - .limit(1); - if (!existing) { - const [agg] = await db - .select({ maxSlot: max(UsersBadges.slotId) }) - .from(UsersBadges) - .where(eq(UsersBadges.userId, userId)); - const slotId = (agg?.maxSlot ?? 0) + 1; - await db.insert(UsersBadges).values({ userId, slotId, badgeCode: code }); - } - } catch { - // Best-effort: the RCON grant already succeeded for online users. + if (userIds.length === 0 || codes.length === 0) { + return; } + // Process each user/code combination + for (const userId of userIds) { + for (const code of codes) { + const cid = String(Number(code) || code); + if (!(Number(userId) > 0) || code.length === 0) continue; + + // Fire the emulator command so the badge appears live for online users. + try { + await rcon.giveBadge(Number(userId), cid); + } catch { + // ignore rcon errors per pair + } + + // Persist the badge directly so it survives a relog / offline grant. + // users_badges has no unique (user_id, badge_code) constraint, so guard + // against duplicates and compute the next free slot ourselves. + try { + const [existing] = await db + .select({ id: UsersBadges.id }) + .from(UsersBadges) + .where( + and(eq(UsersBadges.userId, Number(userId)), eq(UsersBadges.badgeCode, code)), + ) + .limit(1); + if (!existing) { + const [agg] = await db + .select({ maxSlot: max(UsersBadges.slotId) }) + .from(UsersBadges) + .where(eq(UsersBadges.userId, Number(userId))); + const slotId = (agg?.maxSlot ?? 0) + 1; + await db.insert(UsersBadges).values({ userId: Number(userId), slotId, badgeCode: code }); + } + } catch { + // Best-effort: the RCON grant already succeeded for online users. + } + } + revalidatePath("/admin/badges"); } +; +} diff --git a/src/app/admin/badges/page.tsx b/src/app/admin/badges/page.tsx index b615c6e1..85375971 100644 --- a/src/app/admin/badges/page.tsx +++ b/src/app/admin/badges/page.tsx @@ -116,6 +116,7 @@ export default async function AdminBadges({ {t("colCode")} {t("colName")} {t("colDescription")} + {t("colIcon")} @@ -129,6 +130,13 @@ export default async function AdminBadges({ {b.badgeDescription} + + {b.badgeName} +
void; +}) { + const t = useTranslations("pages.error"); + const tNav = useTranslations("pages.admin.nav"); + + const [reference, setReference] = useState(error.digest); + useEffect(() => { + setReference(error.digest ?? reportBrowserError(error, "studio.boundary")); + if (error.digest) reportBrowserError(error, "studio.boundary"); + }, [error]); + + return ( +
+

+ {t("code")} +

+

{t("title")}

+

+ {t("subtitle")} +

+

+ {t("body")} +

+
+ + +
+ {reference ? ( +

+ {t("reference", { digest: reference })} +

+ ) : null} +
+ ); +} diff --git a/src/app/api/admin/import/furni/repair-icons/route.ts b/src/app/api/admin/import/furni/repair-icons/route.ts index 0b3517df..8e8a7e72 100644 --- a/src/app/api/admin/import/furni/repair-icons/route.ts +++ b/src/app/api/admin/import/furni/repair-icons/route.ts @@ -1,4 +1,5 @@ import { withAdmin } from "@/lib/api-handler"; +import { purgeEdgeCache } from "@/lib/edge-cache"; import { PERMS } from "@/lib/permissions"; import { repairMissingIcons } from "@/lib/services/repair-icons"; @@ -21,6 +22,18 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => { try { await repairMissingIcons(send); + // After icon repair, purge the Cloudflare edge cache so updated + // icons are immediately visible instead of waiting for the 1-year + // s-maxage to expire. + try { + await purgeEdgeCache(["cms-furni-icons"], "repair-missing-icons"); + send({ type: "purge", tag: "cms-furni-icons" }); + } catch (purgeError) { + send({ + type: "error", + message: `repairMissingIcons completed but purge failed: ${(purgeError as Error).message}`, + }); + } } catch (err) { send({ type: "error", diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index f0d5ddb3..d0cf924a 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -124,6 +124,7 @@ const GRID_ROW_H = 176; // Persisted UI preferences (survive reloads). const LS_VIEW_MODE = "studio:viewMode"; const LS_SORT_BY = "studio:sortBy"; +const LS_FILTERS = "studio.filters"; const SORT_KEYS = [ "default", "name", @@ -228,10 +229,31 @@ export function StudioClient({ const [activeSource, setActiveSource] = useState(""); const [activeSourceName, setActiveSourceName] = useState(""); - // Filters - const [typeFilter, setTypeFilter] = useState("all"); - const [categoryFilter, setCategoryFilter] = useState("all"); - const [statusFilter, setStatusFilter] = useState("all"); + // Filter persistence in localStorage + const storedFilters = JSON.parse( + localStorage.getItem(LS_FILTERS) || + '{"typeFilter":"all","categoryFilter":"all","statusFilter":"all"}', + ); + const [typeFilter, setTypeFilter] = useState( + storedFilters.typeFilter || "all", + ); + const [categoryFilter, setCategoryFilter] = useState( + storedFilters.categoryFilter || "all", + ); + const [statusFilter, setStatusFilter] = useState( + storedFilters.statusFilter || "all", + ); + // Sync to localStorage on change + useEffect(() => { + localStorage.setItem( + LS_FILTERS, + JSON.stringify({ + typeFilter, + categoryFilter, + statusFilter, + }), + ); + }, [typeFilter, categoryFilter, statusFilter]); const [sortBy, setSortBy] = useState(() => readLs(LS_SORT_BY, "default", SORT_KEYS), ); @@ -378,7 +400,7 @@ export function StudioClient({ // ── Grid virtualization ───────────────────────────────────────── const [gridCols, setGridCols] = useState(1); - useEffect(() => { + useLayoutEffect(() => { const el = listRef.current; if (viewMode !== "grid" || !el) return; const update = () => { diff --git a/src/components/admin/studio/studio-furni-icons.ts b/src/components/admin/studio/studio-furni-icons.ts index 8879aead..f377f0b9 100644 --- a/src/components/admin/studio/studio-furni-icons.ts +++ b/src/components/admin/studio/studio-furni-icons.ts @@ -4,10 +4,8 @@ import type { FurniItem } from "./studio-types"; export function getFurniImageUrl(item: FurniItem) { if (item.iconUrl) return item.iconUrl; - const base = item.classname.includes("*") - ? item.classname.substring(0, item.classname.indexOf("*")) - : item.classname; - return `https://images.habbo.com/dcr/hof_furni/${item.revision}/${encodeURIComponent(base)}_icon.png`; + const iconName = item.classname.replace(/\*/g, "_"); + return `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`; } export function getFurniIconUrl(classname: string, revision: number) { @@ -23,10 +21,11 @@ export function handleImgError( const iconName = item.classname.replace(/\*/g, "_"); if (!img.dataset.fallback) { img.dataset.fallback = "1"; - img.src = getFurniIconUrl(item.classname, item.revision); + img.src = `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`; } else if (img.dataset.fallback === "1") { img.dataset.fallback = "2"; - img.src = `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`; + img.src = + 'data:image/svg+xml,'; } else { img.src = 'data:image/svg+xml,';