Update: admin panel fixes, badge icons, studio improvements, gitea runner v5
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Failing after 21s
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / deploy (push) Skipped

This commit is contained in:
openhands committed 2026-10-11 18:44:02 +02:00
1 parent afc8909d3f
commit a06db72c65
7 files changed
+185 -47

No files matched your search

+6 -6
View File
@@ -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 }}
+46 -30
View File
@@ -10,38 +10,54 @@ import { rcon } from "@/lib/services/rcon";
export async function giveBadge(formData: FormData): Promise<void> {
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");
}
;
}
+8
View File
@@ -116,6 +116,7 @@ export default async function AdminBadges({
<th>{t("colCode")}</th>
<th>{t("colName")}</th>
<th>{t("colDescription")}</th>
<th>{t("colIcon")}</th>
<th />
</tr>
</thead>
@@ -129,6 +130,13 @@ export default async function AdminBadges({
<td className="text-sm theme-text-muted dark:theme-text-muted">
{b.badgeDescription}
</td>
<td>
<img
src={`/assets/images/badges/${b.badgeKey}.gif`}
alt={b.badgeName}
className="h-6 w-6 object-contain"
/>
</td>
<td>
<form
action={giveBadge}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { LayoutDashboard, RefreshCw } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { reportBrowserError } from "@/lib/browser-errors";
/**
* Studio error boundary. Renders inside the admin Studio layout shell.
* Catches runtime errors in furniture rendering, icon loading, etc.
*/
export default function StudioErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => 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 (
<div
className="rounded-xl border p-8 max-w-lg mx-auto mt-8 text-center"
style={{
borderColor: "var(--admin-border, rgba(0,0,0,0.08))",
background: "var(--admin-surface, #fff)",
}}
role="alert"
>
<p
className="text-sm font-bold uppercase tracking-wider mb-2"
style={{ color: "var(--admin-muted, #6b7280)" }}
>
{t("code")}
</p>
<h1 className="text-xl font-extrabold mb-2">{t("title")}</h1>
<p
className="text-sm mb-1"
style={{ color: "var(--admin-muted, #6b7280)" }}
>
{t("subtitle")}
</p>
<p
className="text-sm mb-6"
style={{ color: "var(--admin-muted, #6b7280)" }}
>
{t("body")}
</p>
<div className="flex flex-wrap gap-3 justify-center">
<Button type="button" variant="default" onClick={() => reset()}>
<RefreshCw className="size-4" aria-hidden />
{t("tryAgain")}
</Button>
<Button variant="outline" asChild>
<Link href="/admin/studio/furni">
<LayoutDashboard className="size-4" aria-hidden />
{tNav("dashboard")}
</Link>
</Button>
</div>
{reference ? (
<p
className="mt-6 text-xs font-mono"
style={{ color: "var(--admin-muted, #9ca3af)" }}
>
{t("reference", { digest: reference })}
</p>
) : null}
</div>
);
}
@@ -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",
+27 -5
View File
@@ -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<string>("");
const [activeSourceName, setActiveSourceName] = useState("");
// Filters
const [typeFilter, setTypeFilter] = useState<string>("all");
const [categoryFilter, setCategoryFilter] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
// Filter persistence in localStorage
const storedFilters = JSON.parse(
localStorage.getItem(LS_FILTERS) ||
'{"typeFilter":"all","categoryFilter":"all","statusFilter":"all"}',
);
const [typeFilter, setTypeFilter] = useState<string>(
storedFilters.typeFilter || "all",
);
const [categoryFilter, setCategoryFilter] = useState<string>(
storedFilters.categoryFilter || "all",
);
const [statusFilter, setStatusFilter] = useState<string>(
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<string>(() =>
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 = () => {
@@ -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,<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="%2399a0aa" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2.5"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>';
} else {
img.src =
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="%2399a0aa" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2.5"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>';