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
|
runs-on: self-hosted
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: ${{ gitea.repository }}
|
repository: ${{ gitea.repository }}
|
||||||
token: ${{ gitea.token }}
|
token: ${{ gitea.token }}
|
||||||
@@ -64,7 +64,7 @@ jobs:
|
|||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: ${{ gitea.repository }}
|
repository: ${{ gitea.repository }}
|
||||||
token: ${{ gitea.token }}
|
token: ${{ gitea.token }}
|
||||||
@@ -92,7 +92,7 @@ jobs:
|
|||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: ${{ gitea.repository }}
|
repository: ${{ gitea.repository }}
|
||||||
token: ${{ gitea.token }}
|
token: ${{ gitea.token }}
|
||||||
@@ -108,7 +108,7 @@ jobs:
|
|||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: ${{ gitea.repository }}
|
repository: ${{ gitea.repository }}
|
||||||
token: ${{ gitea.token }}
|
token: ${{ gitea.token }}
|
||||||
@@ -141,7 +141,7 @@ jobs:
|
|||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: ${{ gitea.repository }}
|
repository: ${{ gitea.repository }}
|
||||||
token: ${{ gitea.token }}
|
token: ${{ gitea.token }}
|
||||||
@@ -175,7 +175,7 @@ jobs:
|
|||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: ${{ gitea.repository }}
|
repository: ${{ gitea.repository }}
|
||||||
token: ${{ gitea.token }}
|
token: ${{ gitea.token }}
|
||||||
|
|||||||
+46
-30
@@ -10,38 +10,54 @@ import { rcon } from "@/lib/services/rcon";
|
|||||||
export async function giveBadge(formData: FormData): Promise<void> {
|
export async function giveBadge(formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
|
|
||||||
const userId = Number(formData.get("userId"));
|
// Support comma-separated userIds and/or codes for bulk operations
|
||||||
const code = String(formData.get("code") ?? "")
|
const userIdInput = String(formData.get("userId") ?? "").trim();
|
||||||
.normalize("NFC")
|
const codeInput = String(formData.get("code") ?? "").trim();
|
||||||
.trim()
|
const userIds = userIdInput ? userIdInput.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||||
.slice(0, 32);
|
const codes = codeInput ? codeInput.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||||
if (!(userId > 0) || code.length === 0) return;
|
|
||||||
|
|
||||||
// Fire the emulator command so the badge appears live for online users.
|
if (userIds.length === 0 || codes.length === 0) {
|
||||||
await rcon.giveBadge(userId, code);
|
return;
|
||||||
|
|
||||||
// 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.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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");
|
revalidatePath("/admin/badges");
|
||||||
}
|
}
|
||||||
|
;
|
||||||
|
}
|
||||||
@@ -116,6 +116,7 @@ export default async function AdminBadges({
|
|||||||
<th>{t("colCode")}</th>
|
<th>{t("colCode")}</th>
|
||||||
<th>{t("colName")}</th>
|
<th>{t("colName")}</th>
|
||||||
<th>{t("colDescription")}</th>
|
<th>{t("colDescription")}</th>
|
||||||
|
<th>{t("colIcon")}</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -129,6 +130,13 @@ export default async function AdminBadges({
|
|||||||
<td className="text-sm theme-text-muted dark:theme-text-muted">
|
<td className="text-sm theme-text-muted dark:theme-text-muted">
|
||||||
{b.badgeDescription}
|
{b.badgeDescription}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<img
|
||||||
|
src={`/assets/images/badges/${b.badgeKey}.gif`}
|
||||||
|
alt={b.badgeName}
|
||||||
|
className="h-6 w-6 object-contain"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<form
|
<form
|
||||||
action={giveBadge}
|
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 { withAdmin } from "@/lib/api-handler";
|
||||||
|
import { purgeEdgeCache } from "@/lib/edge-cache";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { repairMissingIcons } from "@/lib/services/repair-icons";
|
import { repairMissingIcons } from "@/lib/services/repair-icons";
|
||||||
|
|
||||||
@@ -21,6 +22,18 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await repairMissingIcons(send);
|
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) {
|
} catch (err) {
|
||||||
send({
|
send({
|
||||||
type: "error",
|
type: "error",
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ const GRID_ROW_H = 176;
|
|||||||
// Persisted UI preferences (survive reloads).
|
// Persisted UI preferences (survive reloads).
|
||||||
const LS_VIEW_MODE = "studio:viewMode";
|
const LS_VIEW_MODE = "studio:viewMode";
|
||||||
const LS_SORT_BY = "studio:sortBy";
|
const LS_SORT_BY = "studio:sortBy";
|
||||||
|
const LS_FILTERS = "studio.filters";
|
||||||
const SORT_KEYS = [
|
const SORT_KEYS = [
|
||||||
"default",
|
"default",
|
||||||
"name",
|
"name",
|
||||||
@@ -228,10 +229,31 @@ export function StudioClient({
|
|||||||
const [activeSource, setActiveSource] = useState<string>("");
|
const [activeSource, setActiveSource] = useState<string>("");
|
||||||
const [activeSourceName, setActiveSourceName] = useState("");
|
const [activeSourceName, setActiveSourceName] = useState("");
|
||||||
|
|
||||||
// Filters
|
// Filter persistence in localStorage
|
||||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
const storedFilters = JSON.parse(
|
||||||
const [categoryFilter, setCategoryFilter] = useState<string>("all");
|
localStorage.getItem(LS_FILTERS) ||
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
'{"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>(() =>
|
const [sortBy, setSortBy] = useState<string>(() =>
|
||||||
readLs(LS_SORT_BY, "default", SORT_KEYS),
|
readLs(LS_SORT_BY, "default", SORT_KEYS),
|
||||||
);
|
);
|
||||||
@@ -378,7 +400,7 @@ export function StudioClient({
|
|||||||
|
|
||||||
// ── Grid virtualization ─────────────────────────────────────────
|
// ── Grid virtualization ─────────────────────────────────────────
|
||||||
const [gridCols, setGridCols] = useState(1);
|
const [gridCols, setGridCols] = useState(1);
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = listRef.current;
|
const el = listRef.current;
|
||||||
if (viewMode !== "grid" || !el) return;
|
if (viewMode !== "grid" || !el) return;
|
||||||
const update = () => {
|
const update = () => {
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import type { FurniItem } from "./studio-types";
|
|||||||
|
|
||||||
export function getFurniImageUrl(item: FurniItem) {
|
export function getFurniImageUrl(item: FurniItem) {
|
||||||
if (item.iconUrl) return item.iconUrl;
|
if (item.iconUrl) return item.iconUrl;
|
||||||
const base = item.classname.includes("*")
|
const iconName = item.classname.replace(/\*/g, "_");
|
||||||
? item.classname.substring(0, item.classname.indexOf("*"))
|
return `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`;
|
||||||
: item.classname;
|
|
||||||
return `https://images.habbo.com/dcr/hof_furni/${item.revision}/${encodeURIComponent(base)}_icon.png`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFurniIconUrl(classname: string, revision: number) {
|
export function getFurniIconUrl(classname: string, revision: number) {
|
||||||
@@ -23,10 +21,11 @@ export function handleImgError(
|
|||||||
const iconName = item.classname.replace(/\*/g, "_");
|
const iconName = item.classname.replace(/\*/g, "_");
|
||||||
if (!img.dataset.fallback) {
|
if (!img.dataset.fallback) {
|
||||||
img.dataset.fallback = "1";
|
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") {
|
} else if (img.dataset.fallback === "1") {
|
||||||
img.dataset.fallback = "2";
|
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 {
|
} else {
|
||||||
img.src =
|
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>';
|
'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