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
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:
1 parent
afc8909d3f
commit
a06db72c65
7 files changed
+185
-47
No files matched your search
@@ -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
@@ -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");
|
||||
}
|
||||
;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>';
|
||||
|
||||
Reference in new issue
Block a user