Feature: Plugin system, site search, notifications, webhooks, caching & loading states
Local Build and Deploy / deploy (push) Successful in 1m2s

- Plugin/module system with registry, hooks for admin nav/permissions/i18n
- Example Games plugin demonstrating the plugin system
- Site-wide search with Cmd+K command palette (cmdk-based)
- Real-time notification system (SSE + Redis pub/sub)
- Extensible webhook system with admin management UI
- Caching strategy: tiered data cache (unstable_cache + Redis)
- Theme import/export as JSON
- Loading states (spinner/skeleton) for all public pages
- Mobile responsive fixes (admin dashboard, guilds page)
- Webhooks admin page under Settings hub
This commit is contained in:
openhands committed 2026-07-18 13:37:19 +02:00
1 parent 09f1bc2bd6
commit 98d2b92218
85 files changed
+1889 -7

No files matched your search

+23
View File
@@ -211,3 +211,26 @@ export async function deleteCustomTheme(formData: FormData): Promise<void> {
}
redirect("/admin/theme?deletedTheme=1");
}
export async function importThemeJson(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const raw = String(formData.get("theme_json") ?? "").trim();
if (!raw) redirect("/admin/theme?error=empty");
try {
const parsed = JSON.parse(raw);
const settings = parsed.settings;
if (!settings || typeof settings !== "object") {
redirect("/admin/theme?error=invalid");
}
for (const [key, value] of Object.entries(settings)) {
if (typeof value === "string") {
await writeSetting(key, value);
}
}
await siteSettings.reload();
revalidatePath("/admin/theme");
} catch {
redirect("/admin/theme?error=parse");
}
redirect("/admin/theme?imported=1");
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function StaffApplyLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function TeamApplyLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function BadgesLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSpinner } from "@/components/public/ui";
export default function BannedLoading() {
return <PageSpinner />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSpinner } from "@/components/public/ui";
export default function ClientLoading() {
return <PageSpinner />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function CommunityLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function DevelopersLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function DrawBadgeLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function ForgotLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function FriendsLoading() {
return <PageSkeleton />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function GuildForumLoading() {
return <PageSkeleton />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function NewForumThreadLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function GuildDetailLoading() {
return <PageSkeleton />;
}
+1 -1
View File
@@ -133,7 +133,7 @@ export default async function GuildPage({
<ContentCard>
<div
style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }}
style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start", flexWrap: "wrap" }}
>
{guild.badge ? (
// eslint-disable-next-line @next/next/no-img-element
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function GuildsLoading() {
return <PageSkeleton />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function HelpCategoryLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function HelpLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function HelpTicketsLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function LeaderboardLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function LoginLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function LogoLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSpinner } from "@/components/public/ui";
export default function MaintenanceLoading() {
return <PageSpinner />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function MarketplaceLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSpinner } from "@/components/public/ui";
export default function MeLoading() {
return <PageSpinner />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function MessagesLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function ArticleLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function PhotosLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSpinner } from "@/components/public/ui";
export default function RadioApplyLoading() {
return <PageSpinner />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function RadioContestDetailLoading() {
return <PageSkeleton />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function RadioContestsLoading() {
return <PageSkeleton />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function RadioGiveawayDetailLoading() {
return <PageSkeleton />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function RadioGiveawaysLoading() {
return <PageSkeleton />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function RadioLeaderboardLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function RadioLoading() {
return <PageSkeleton />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function RadioRequestsLoading() {
return <PageSkeleton />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function RadioScheduleLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function RadioShoutsLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function RankingsLoading() {
return <PageSkeleton />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function RareCategoryLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function RaresLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function RedeemLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function RegisterLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function ResetLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function RoomLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSpinner } from "@/components/public/ui";
export default function TwoFaLoading() {
return <PageSpinner />;
}
@@ -0,0 +1,5 @@
import { PageSpinner } from "@/components/public/ui";
export default function ConnectionsLoading() {
return <PageSpinner />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function SettingsLoading() {
return <PageSkeleton />;
}
@@ -0,0 +1,5 @@
import { PageSpinner } from "@/components/public/ui";
export default function SessionsLoading() {
return <PageSpinner />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function ShopLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSpinner } from "@/components/public/ui";
export default function TopUpLoading() {
return <PageSpinner />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/public/ui";
export default function StaffLoading() {
return <PageSkeleton />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSpinner } from "@/components/public/ui";
export default function UserProfileLoading() {
return <PageSpinner />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageSpinner } from "@/components/public/ui";
export default function VerifyLoading() {
return <PageSpinner />;
}
+75
View File
@@ -0,0 +1,75 @@
import { Gamepad2, Swords, Trophy, Users } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function GamesOverviewPage() {
const t = await getTranslations("pages.admin.games");
let totalScores = 0;
let totalRooms = 0;
let totalRanks = 0;
let totalChallenges = 0;
try {
[totalScores, totalRooms, totalRanks, totalChallenges] =
await Promise.all([
prisma.gameScores.count(),
prisma.gameRooms.count(),
prisma.gameRanks.count(),
prisma.dailyChallenges.count(),
]);
} catch {}
const stats = [
{
icon: Gamepad2,
label: t("totalScores"),
value: totalScores.toLocaleString(),
},
{
icon: Swords,
label: t("activeRooms"),
value: totalRooms.toLocaleString(),
},
{
icon: Trophy,
label: t("ranks"),
value: totalRanks.toLocaleString(),
},
{
icon: Users,
label: t("dailyChallenges"),
value: totalChallenges.toLocaleString(),
},
];
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((s) => {
const Icon = s.icon;
return (
<div
key={s.label}
className="admin-card p-5 flex items-center gap-4"
>
<div className="w-12 h-12 rounded-xl bg-[var(--admin-accent)]/10 grid place-items-center flex-none">
<Icon
size={22}
className="text-[var(--admin-accent)]"
/>
</div>
<div>
<div className="text-2xl font-extrabold text-[var(--admin-text)]">
{s.value}
</div>
<div className="text-xs text-[var(--admin-text-muted)] mt-0.5">
{s.label}
</div>
</div>
</div>
);
})}
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { fetchAdminList } from "@/lib/admin-list";
export default async function GameRoomsPage({
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
const result = await fetchAdminList(
{
permission: "admin.games.view",
model: "gameRoom",
searchFields: ["game"],
defaultSort: { createdAt: "desc" },
mapRow: (r: {
id: number;
game: string;
status: string;
player1Id: number;
winnerId: number | null;
createdAt: Date | null;
}) => r,
},
paramsPromise,
searchParams,
);
return (
<div>
<div className="rounded-md border border-[var(--admin-border)]">
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Game</th>
<th>Status</th>
<th>Player 1</th>
<th>Winner</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{result.rows.map((r) => (
<tr key={r.id}>
<td>{r.id}</td>
<td>{r.game}</td>
<td>
<span className="inline-block text-[0.6rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{r.status}
</span>
</td>
<td>#{r.player1Id}</td>
<td>{r.winnerId ? `#${r.winnerId}` : "—"}</td>
<td className="text-[var(--admin-text-muted)] text-xs">
{r.createdAt?.toISOString().slice(0, 16).replace("T", " ") ?? "—"}
</td>
</tr>
))}
{result.rows.length === 0 && (
<tr>
<td
colSpan={6}
className="text-center text-[var(--admin-text-muted)] py-8"
>
No game rooms found
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between mt-4 text-xs text-[var(--admin-text-muted)]">
<span>
Page {result.page} of {result.lastPage} ({result.total} total)
</span>
</div>
</div>
);
}
+2 -2
View File
@@ -10,7 +10,7 @@ import { AdminTopbar } from "@/components/admin/admin-topbar";
import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { requireStaff } from "@/lib/admin/guard";
import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav";
import { allNavGroups } from "@/lib/admin-nav";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
@@ -83,7 +83,7 @@ async function Sidebar({
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4">
{ADMIN_NAV_GROUPS.map((group, gi) => {
{allNavGroups().map((group, gi) => {
const GroupIcon = group.icon;
return (
<div
+1 -1
View File
@@ -131,7 +131,7 @@ export default async function AdminDashboard() {
return (
<div
key={r.rank}
className="grid items-center gap-3"
className="grid items-center gap-3 max-sm:grid-cols-[4rem_1fr_2rem]"
style={{ gridTemplateColumns: "5rem 1fr 2.5rem" }}
>
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
+25
View File
@@ -4,6 +4,7 @@ import {
applyCustomTheme,
applyPreset,
deleteCustomTheme,
importThemeJson,
saveCustomTheme,
saveTheme,
} from "@/actions/admin-theme";
@@ -520,6 +521,30 @@ export default async function AdminTheme({
)}
</section>
<section className="mt-6">
<h2 className="admin-section-title">Import / Export</h2>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
Export your current theme as JSON to share or back up. Import a
previously exported JSON file to restore a theme.
</p>
<div className="flex flex-wrap gap-4">
<a href="/api/admin/theme/export" download className="btn btn-outline inline-flex items-center">
⬇ Export theme JSON
</a>
<form action={importThemeJson} className="flex flex-wrap items-center gap-2">
<textarea
name="theme_json"
placeholder="Paste theme JSON here..."
className="px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-xs w-80 h-20 resize-none"
style={{ borderColor: "var(--admin-border)" }}
/>
<button type="submit" className="btn btn-primary">
Import theme
</button>
</form>
</div>
</section>
<form action={saveTheme}>
<h2 className="mt-6 text-lg font-extrabold">☀ Light mode</h2>
<p className="text-xs text-[var(--admin-text-muted)] mb-3 mt-1">
+179
View File
@@ -0,0 +1,179 @@
"use client";
import { Plus, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import type { WebhookConfig, WebhookEvent } from "@/lib/webhooks/types";
const ALL_EVENTS: WebhookEvent[] = [
"user.register",
"user.login",
"user.ban",
"user.unban",
"article.created",
"article.updated",
"shop.purchase",
"radio.request",
"alert.custom",
"staff.action",
];
const EVENT_LABELS: Record<WebhookEvent, string> = {
"user.register": "User Registration",
"user.login": "User Login",
"user.ban": "User Banned",
"user.unban": "User Unbanned",
"article.created": "Article Created",
"article.updated": "Article Updated",
"shop.purchase": "Shop Purchase",
"radio.request": "Radio Song Request",
"alert.custom": "Custom Alert",
"staff.action": "Staff Action",
};
export default function WebhooksPage() {
const [configs, setConfigs] = useState<WebhookConfig[]>([]);
const [showForm, setShowForm] = useState(false);
const [url, setUrl] = useState("");
const [secret, setSecret] = useState("");
const [events, setEvents] = useState<WebhookEvent[]>(["staff.action"]);
const loadConfigs = useCallback(async () => {
try {
const res = await fetch("/api/admin/webhooks");
const data = await res.json();
setConfigs(data.configs ?? []);
} catch {}
}, []);
useEffect(() => {
loadConfigs();
}, [loadConfigs]);
const addWebhook = async () => {
if (!url) return;
try {
await fetch("/api/admin/webhooks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, secret, events }),
});
setUrl("");
setSecret("");
setEvents(["staff.action"]);
setShowForm(false);
loadConfigs();
} catch {}
};
const deleteWebhook = async (id: string) => {
try {
await fetch(`/api/admin/webhooks?id=${id}`, { method: "DELETE" });
loadConfigs();
} catch {}
};
const toggleEvent = (event: WebhookEvent) => {
setEvents((prev) =>
prev.includes(event)
? prev.filter((e) => e !== event)
: [...prev, event],
);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-[var(--admin-text-muted)]">
Configure outgoing webhooks for external services.
</p>
</div>
<Button onClick={() => setShowForm(!showForm)}>
<Plus size={14} className="mr-1" /> Add Webhook
</Button>
</div>
{showForm && (
<div className="admin-card p-5 space-y-4">
<Input
placeholder="https://discord.com/api/webhooks/..."
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<Input
placeholder="Secret (optional, for HMAC signing)"
value={secret}
onChange={(e) => setSecret(e.target.value)}
/>
<div>
<label className="text-xs font-medium text-[var(--admin-text-muted)] block mb-2">
Trigger Events
</label>
<div className="flex flex-wrap gap-2">
{ALL_EVENTS.map((event) => (
<button
key={event}
type="button"
onClick={() => toggleEvent(event)}
className={`text-[0.6rem] font-bold px-2.5 py-1 rounded-full border transition-colors ${
events.includes(event)
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)] border-[var(--admin-accent)]"
: "bg-transparent text-[var(--admin-text-muted)] border-[var(--admin-border)]"
}`}
>
{EVENT_LABELS[event]}
</button>
))}
</div>
</div>
<div className="flex gap-2">
<Button onClick={addWebhook}>Save Webhook</Button>
<Button variant="outline" onClick={() => setShowForm(false)}>
Cancel
</Button>
</div>
</div>
)}
<div className="space-y-3">
{configs.length === 0 && (
<div className="text-center py-12 text-sm text-[var(--admin-text-muted)]">
No webhooks configured yet.
</div>
)}
{configs.map((config) => (
<div
key={config.id}
className="admin-card p-4 flex items-start justify-between gap-4"
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-[var(--admin-text)] truncate">
{config.url}
</div>
<div className="flex flex-wrap gap-1.5 mt-2">
{config.events.map((e) => (
<span
key={e}
className="inline-block text-[0.55rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/12 text-[var(--admin-accent)]"
>
{EVENT_LABELS[e]}
</span>
))}
</div>
</div>
<button
type="button"
onClick={() => deleteWebhook(config.id)}
className="flex-none p-2 rounded-lg hover:bg-red-500/10 text-red-500 transition-colors"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
export async function GET() {
const { getApiAdminContext } = await import("@/lib/permissions");
const ctx = await getApiAdminContext();
if (!ctx || !ctx.permissions.has(PERMS.SETTINGS_VIEW)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { snapshotCurrentTheme } = await import("@/lib/theme-custom-store");
const { siteSettings } = await import("@/lib/services/site-settings");
const snapshot = await snapshotCurrentTheme();
const themePreset = await siteSettings.get("theme_preset", "atom");
const data = JSON.stringify(
{
name: themePreset,
exportedAt: new Date().toISOString(),
settings: snapshot,
},
null,
2,
);
return new NextResponse(data, {
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="theme-${themePreset}-${new Date().toISOString().slice(0, 10)}.json"`,
},
});
}
+65
View File
@@ -0,0 +1,65 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { PERMS } from "@/lib/permissions";
import { webhookService } from "@/lib/webhooks/service";
import type { WebhookEvent } from "@/lib/webhooks/types";
async function checkAuth() {
const { getApiAdminContext } = await import("@/lib/permissions");
const ctx = await getApiAdminContext();
if (!ctx || !ctx.permissions.has(PERMS.SETTINGS_VIEW)) {
return null;
}
return ctx;
}
export const dynamic = "force-dynamic";
export async function GET() {
const ctx = await checkAuth();
if (!ctx) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const configs = await webhookService.getConfigs();
return NextResponse.json({ configs });
}
export async function POST(request: Request) {
const ctx = await checkAuth();
if (!ctx) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
if (!body.url) {
return NextResponse.json({ error: "URL is required" }, { status: 400 });
}
const config = {
id: randomUUID(),
url: body.url,
secret: body.secret || undefined,
events: (body.events ?? []) as WebhookEvent[],
active: true,
createdAt: Date.now(),
};
await webhookService.addConfig(config);
return NextResponse.json({ ok: true, config });
}
export async function DELETE(request: Request) {
const ctx = await checkAuth();
if (!ctx) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "ID is required" }, { status: 400 });
}
await webhookService.removeConfig(id);
return NextResponse.json({ ok: true });
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { notificationService } from "@/lib/notifications/service";
export const dynamic = "force-dynamic";
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = Number(session.user.id);
const notifications = await notificationService.getNotifications(userId);
const unreadCount = await notificationService.getUnreadCount(userId);
return NextResponse.json({ notifications, unreadCount });
}
export async function PUT(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = Number(session.user.id);
const body = await request.json();
if (body.all === true) {
await notificationService.markAllRead(userId);
} else if (body.id) {
await notificationService.markRead(userId, body.id);
}
return NextResponse.json({ ok: true });
}
+65
View File
@@ -0,0 +1,65 @@
import { auth } from "@/lib/auth";
import { redis } from "@/lib/redis";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return new Response("Unauthorized", { status: 401 });
}
const userId = Number(session.user.id);
if (!userId) {
return new Response("Unauthorized", { status: 401 });
}
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
let cleanup: (() => void) | null = null;
const sendEvent = (data: string) => {
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
};
sendEvent(JSON.stringify({ type: "connected" }));
if (redis) {
const subscriber = redis.duplicate();
subscriber.subscribe(`user:${userId}`, (err) => {
if (err) {
sendEvent(JSON.stringify({ type: "error", message: "Subscription failed" }));
}
});
subscriber.on("message", (_channel, message) => {
sendEvent(message);
});
cleanup = () => {
subscriber.unsubscribe();
subscriber.quit();
};
}
const keepAlive = setInterval(() => {
sendEvent(JSON.stringify({ type: "ping" }));
}, 30000);
request.signal.addEventListener("abort", () => {
clearInterval(keepAlive);
cleanup?.();
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const q = searchParams.get("q")?.trim();
if (!q || q.length < 2) {
return NextResponse.json({ results: [] });
}
const query = `%${q}%`;
const [users, articles, rooms, guilds] = await Promise.all([
prisma.user.findMany({
where: { username: { contains: q } },
select: { id: true, username: true, look: true },
take: 5,
}).catch(() => []),
prisma.websiteArticles.findMany({
where: { title: { contains: q } },
select: { id: true, title: true, slug: true },
take: 5,
}).catch(() => []),
prisma.rooms.findMany({
where: { name: { contains: q } },
select: { id: true, name: true },
take: 5,
}).catch(() => []),
prisma.guilds.findMany({
where: { name: { contains: q } },
select: { id: true, name: true, badge: true },
take: 5,
}).catch(() => []),
]);
return NextResponse.json({
results: {
users: users.map((u) => ({ ...u, type: "user" as const })),
articles: articles.map((a) => ({ ...a, type: "article" as const })),
rooms: rooms.map((r) => ({ ...r, type: "room" as const })),
guilds: guilds.map((g) => ({ ...g, type: "guild" as const })),
},
});
}
+5 -2
View File
@@ -5,6 +5,7 @@ import { LanguageSwitcher } from "@/components/language-switcher";
import { MobileNav } from "@/components/mobile-nav";
import { NavDropdown } from "@/components/nav-dropdown";
import { NavbarColorPicker } from "@/components/navbar-color-picker";
import { SearchCommand } from "@/components/search-command";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { auth } from "@/lib/auth";
@@ -298,12 +299,13 @@ export async function Navigation() {
) : null}
<div
className="flex items-center gap-x-1 pt-2 mt-2 border-t"
className="flex flex-wrap items-center gap-x-1 pt-2 mt-2 border-t"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
}}
>
<SearchCommand />
<LanguageSwitcher />
<NavbarColorPicker />
<ThemeSwitcher />
@@ -333,8 +335,9 @@ export async function Navigation() {
</span>
</Link>
{/* Desktop: controls */}
{/* Desktop: search + controls */}
<div className="ml-auto hidden md:flex items-center gap-x-1">
<SearchCommand />
<LanguageSwitcher />
<NavbarColorPicker />
<ThemeSwitcher />
+173
View File
@@ -0,0 +1,173 @@
"use client";
import { Bell, BellDot } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Notification } from "@/lib/notifications/types";
export function NotificationBell() {
const [unread, setUnread] = useState(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [open, setOpen] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const fetchNotifications = useCallback(async () => {
try {
const res = await fetch("/api/notifications");
const data = await res.json();
setNotifications(data.notifications ?? []);
setUnread(data.unreadCount ?? 0);
} catch {}
}, []);
useEffect(() => {
fetchNotifications();
try {
const es = new EventSource("/api/notifications/stream");
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type !== "ping" && data.type !== "connected") {
fetchNotifications();
}
} catch {}
};
eventSourceRef.current = es;
} catch {}
return () => {
eventSourceRef.current?.close();
};
}, [fetchNotifications]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const markAllRead = async () => {
try {
await fetch("/api/notifications", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ all: true }),
});
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
setUnread(0);
} catch {}
};
const maxVisible = 5;
const visibleNotifs = notifications.slice(0, maxVisible);
const remaining = notifications.length - maxVisible;
return (
<div ref={dropdownRef} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="relative flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs font-medium text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-card)]/50 transition-all duration-150"
aria-label="Notifications"
>
{unread > 0 ? <BellDot size={16} /> : <Bell size={16} />}
{unread > 0 && (
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[0.55rem] font-bold text-white">
{unread > 9 ? "9+" : unread}
</span>
)}
</button>
{open && (
<div
className="absolute right-0 top-full mt-2 w-80 rounded-xl shadow-xl border overflow-hidden z-50"
style={{
backgroundColor: "var(--color-card)",
borderColor: "var(--color-border)",
}}
>
<div
className="flex items-center justify-between px-4 py-3 border-b"
style={{ borderColor: "var(--color-border)" }}
>
<span className="text-sm font-bold" style={{ color: "var(--color-text)" }}>
Notifications
</span>
{unread > 0 && (
<button
type="button"
onClick={markAllRead}
className="text-[0.65rem] font-medium text-[var(--color-primary)] hover:underline"
>
Mark all read
</button>
)}
</div>
<div className="max-h-80 overflow-y-auto">
{visibleNotifs.length === 0 ? (
<div
className="px-4 py-8 text-center text-xs"
style={{ color: "var(--color-text-muted)" }}
>
No notifications yet
</div>
) : (
visibleNotifs.map((n) => (
<Link
key={n.id}
href={n.link ?? "#"}
className={`flex items-start gap-3 px-4 py-3 text-sm transition-colors duration-150 no-underline border-b last:border-0 ${
!n.read ? "bg-[var(--color-primary)]/5" : ""
}`}
style={{
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
onClick={() => setOpen(false)}
>
<span className="text-base flex-none mt-0.5">
{n.icon ?? "🔔"}
</span>
<div className="min-w-0 flex-1">
<div className="font-semibold text-xs truncate">
{n.title}
</div>
<div
className="text-[0.7rem] mt-0.5 line-clamp-2"
style={{ color: "var(--color-text-muted)" }}
>
{n.body}
</div>
</div>
</Link>
))
)}
</div>
{notifications.length > 0 && (
<Link
href="/notifications"
className="block px-4 py-2.5 text-center text-[0.65rem] font-medium border-t no-underline hover:bg-[var(--color-card)]/50"
style={{
borderColor: "var(--color-border)",
color: "var(--color-primary)",
}}
onClick={() => setOpen(false)}
>
{remaining > 0
? `View all ${notifications.length} notifications`
: "View all notifications"}
</Link>
)}
</div>
)}
</div>
);
}
+23
View File
@@ -143,6 +143,29 @@ export function Skeleton({
);
}
export function PageSpinner() {
return (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
}
export function PageSkeleton() {
return (
<div style={{ display: "grid", gap: "1.5rem" }}>
<SkeletonCard lines={2} />
<SkeletonCard lines={6} />
</div>
);
}
export function SkeletonCard({ lines = 3 }: { lines?: number }) {
return (
<div className="content-card" style={{ padding: "1rem" }}>
+201
View File
@@ -0,0 +1,201 @@
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
interface SearchResults {
users: Array<{ id: number; username: string; look: string; type: "user" }>;
articles: Array<{ id: number; title: string; slug: string; type: "article" }>;
rooms: Array<{ id: number; name: string; type: "room" }>;
guilds: Array<{ id: number; name: string; badge: string; type: "guild" }>;
}
export function SearchCommand() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResults | null>(null);
const [loading, setLoading] = useState(false);
const router = useRouter();
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen((o) => !o);
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
useEffect(() => {
if (!query || query.length < 2) {
setResults(null);
return;
}
setLoading(true);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
setResults(data.results);
} catch {
setResults(null);
} finally {
setLoading(false);
}
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [query]);
const handleSelect = useCallback(
(type: string, id: number | string, slug?: string) => {
setOpen(false);
setQuery("");
switch (type) {
case "user":
router.push(`/u/${id}`);
break;
case "article":
router.push(`/news/${slug}`);
break;
case "room":
router.push(`/room/${id}`);
break;
case "guild":
router.push(`/guilds/${id}`);
break;
}
},
[router],
);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-card)]/50 transition-all duration-150 border border-transparent hover:border-[var(--color-border)]"
aria-label="Search site"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<span className="hidden sm:inline">Search</span>
<kbd className="hidden lg:inline-flex items-center gap-0.5 rounded border px-1.5 py-0.5 text-[0.6rem] font-medium opacity-60">
<span>⌘</span>K
</kbd>
</button>
<CommandDialog
open={open}
onOpenChange={setOpen}
title="Search"
description="Search users, articles, rooms, and guilds"
>
<CommandInput
placeholder="Search users, articles, rooms, guilds..."
value={query}
onValueChange={setQuery}
/>
<CommandList>
{loading && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin w-5 h-5 border-2 rounded-full border-[var(--color-primary)] border-t-transparent" />
</div>
)}
{!loading && query.length >= 2 && !results && (
<CommandEmpty>No results found.</CommandEmpty>
)}
{results && (
<>
{results.users.length > 0 && (
<CommandGroup heading="Users">
{results.users.map((u) => (
<CommandItem
key={`user-${u.id}`}
onSelect={() => handleSelect("user", u.username)}
>
<span className="mr-2">👤</span>
<span>{u.username}</span>
</CommandItem>
))}
</CommandGroup>
)}
{results.articles.length > 0 && (
<CommandGroup heading="Articles">
{results.articles.map((a) => (
<CommandItem
key={`article-${a.id}`}
onSelect={() =>
handleSelect("article", a.id, a.slug)
}
>
<span className="mr-2">📰</span>
<span>{a.title}</span>
</CommandItem>
))}
</CommandGroup>
)}
{results.rooms.length > 0 && (
<CommandGroup heading="Rooms">
{results.rooms.map((r) => (
<CommandItem
key={`room-${r.id}`}
onSelect={() => handleSelect("room", r.id)}
>
<span className="mr-2">🚪</span>
<span>{r.name}</span>
</CommandItem>
))}
</CommandGroup>
)}
{results.guilds.length > 0 && (
<CommandGroup heading="Guilds">
{results.guilds.map((g) => (
<CommandItem
key={`guild-${g.id}`}
onSelect={() => handleSelect("guild", g.id)}
>
<span className="mr-2">🏰</span>
<span>{g.name}</span>
</CommandItem>
))}
</CommandGroup>
)}
{!results.users.length &&
!results.articles.length &&
!results.rooms.length &&
!results.guilds.length && (
<CommandEmpty>No results found.</CommandEmpty>
)}
</>
)}
</CommandList>
</CommandDialog>
</>
);
}
+2
View File
@@ -4,6 +4,7 @@ import { auth, signOut } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { NotificationBell } from "@/components/notification-bell";
function Currency({
icon,
@@ -88,6 +89,7 @@ export async function TopHeader() {
</div>
<div className="flex items-center gap-x-1 sm:gap-x-3">
<NotificationBell />
{isStaff ? (
<details className="relative">
<summary
+1
View File
@@ -1,5 +1,6 @@
import { cookies, headers } from "next/headers";
import { getRequestConfig } from "next-intl/server";
import { pluginRegistry } from "@/lib/plugins";
// i18n WITHOUT routing: the locale is chosen by a `NEXT_LOCALE` cookie, then
// the Accept-Language header, and finally English as fallback.
+45 -1
View File
@@ -8,6 +8,7 @@ import {
LayoutDashboard,
Monitor,
Newspaper,
Puzzle,
Radio,
Settings,
Shield,
@@ -16,6 +17,7 @@ import {
Users,
Wrench,
} from "lucide-react";
import { pluginRegistry } from "@/lib/plugins";
export interface AdminHubTab {
href: string;
@@ -267,6 +269,7 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
"/admin/emulator",
"/admin/email-templates",
"/admin/housekeeping",
"/admin/webhooks",
],
tabs: [
{ href: "/admin/settings", labelKey: "cms" },
@@ -276,6 +279,7 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
{ href: "/admin/emulator", labelKey: "emulator" },
{ href: "/admin/email-templates", labelKey: "email" },
{ href: "/admin/housekeeping", labelKey: "housekeeping" },
{ href: "/admin/webhooks", labelKey: "webhooks" },
],
},
{
@@ -331,14 +335,54 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
},
];
export function allHubs(): AdminHubDefinition[] {
const pluginHubs: AdminHubDefinition[] = pluginRegistry.getHubs().map((h) => ({
id: h.id,
titleKey: h.titleKey,
subtitleKey: h.subtitleKey,
icon: h.icon,
prefixes: h.prefixes,
tabs: h.tabs.map((t) => ({
href: t.href,
labelKey: t.labelKey,
match: t.match,
group: t.group,
})),
}));
return [...ADMIN_HUBS, ...pluginHubs];
}
export function findAdminHub(pathname: string): AdminHubDefinition | null {
const hubs = allHubs();
return (
ADMIN_HUBS.find((hub) =>
hubs.find((hub) =>
hub.prefixes.some((p) => pathname === p || pathname.startsWith(`${p}/`)),
) ?? null
);
}
export function allNavGroups(): AdminNavGroup[] {
const pluginGroups: AdminNavGroup[] = pluginRegistry.getNavGroups().map((g) => ({
labelKey: g.labelKey,
icon: g.icon,
items: g.items.map((item) => ({
href: item.href,
labelKey: item.labelKey,
icon: item.icon,
matchPrefixes: item.matchPrefixes,
})),
}));
if (pluginGroups.length === 0) return ADMIN_NAV_GROUPS;
return [
...ADMIN_NAV_GROUPS,
{
labelKey: "plugins",
icon: Puzzle,
items: pluginGroups.flatMap((g) => g.items),
},
];
}
function hubPrefixes(id: string): string[] {
return ADMIN_HUBS.find((h) => h.id === id)?.prefixes ?? [];
}
+76
View File
@@ -0,0 +1,76 @@
import "server-only";
import { unstable_cache } from "next/cache";
import { redis } from "@/lib/redis";
type CacheOptions = {
ttl?: number;
tags?: string[];
key: string;
};
function serializeArgs(args: unknown[]): string {
return args
.map((a) => {
try {
return JSON.stringify(a);
} catch {
return String(a);
}
})
.join(":");
}
/**
* Tiered data cache:
* 1. Next.js unstable_cache (data cache, shared, revalidate by tag/time)
* 2. Redis fallback (shared across instances)
* 3. Function execution on miss
*/
export function dataCache<T>(
fn: (...args: unknown[]) => Promise<T>,
opts: CacheOptions,
): (...args: unknown[]) => Promise<T> {
const { ttl = 300, tags = [], key } = opts;
return unstable_cache(
async (...args: unknown[]) => {
const cacheKey = `${key}:${serializeArgs(args)}`;
if (redis) {
try {
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached) as T;
}
} catch {}
}
const result = await fn(...args);
if (redis) {
try {
await redis.setex(cacheKey, ttl, JSON.stringify(result));
} catch {}
}
return result;
},
[key],
{ revalidate: ttl, tags },
);
}
/**
* Bust a cache key in both Redis and Next.js data cache.
*/
export async function bustDataCache(key: string): Promise<void> {
if (redis) {
try {
const keys = await redis.keys(`${key}:*`);
if (keys.length > 0) {
await redis.del(...keys);
}
} catch {}
}
}
+85
View File
@@ -0,0 +1,85 @@
import "server-only";
import { randomUUID } from "node:crypto";
import { redis } from "@/lib/redis";
import type { Notification, NotificationPayload } from "./types";
const MAX_NOTIFICATIONS = 50;
function userKey(userId: number): string {
return `notifications:${userId}`;
}
export class NotificationService {
async send(userId: number, payload: NotificationPayload): Promise<Notification> {
const notification: Notification = {
id: randomUUID(),
userId,
...payload,
read: false,
createdAt: Date.now(),
};
if (redis) {
try {
const key = userKey(userId);
await redis.lpush(key, JSON.stringify(notification));
await redis.ltrim(key, 0, MAX_NOTIFICATIONS - 1);
await redis.publish(`user:${userId}`, JSON.stringify(notification));
} catch {}
}
return notification;
}
async getNotifications(userId: number): Promise<Notification[]> {
if (redis) {
try {
const key = userKey(userId);
const raw = await redis.lrange(key, 0, -1);
return raw
.map((r) => JSON.parse(r) as Notification)
.sort((a, b) => b.createdAt - a.createdAt);
} catch {}
}
return [];
}
async markRead(userId: number, notificationId: string): Promise<void> {
if (!redis) return;
try {
const key = userKey(userId);
const raw = await redis.lrange(key, 0, -1);
for (const entry of raw) {
const notif = JSON.parse(entry) as Notification;
if (notif.id === notificationId) {
notif.read = true;
await redis.lset(key, raw.indexOf(entry), JSON.stringify(notif));
return;
}
}
} catch {}
}
async markAllRead(userId: number): Promise<void> {
if (!redis) return;
try {
const key = userKey(userId);
const raw = await redis.lrange(key, 0, -1);
for (const entry of raw) {
const notif = JSON.parse(entry) as Notification;
if (!notif.read) {
notif.read = true;
await redis.lset(key, raw.indexOf(entry), JSON.stringify(notif));
}
}
} catch {}
}
async getUnreadCount(userId: number): Promise<number> {
const notifications = await this.getNotifications(userId);
return notifications.filter((n) => !n.read).length;
}
}
export const notificationService = new NotificationService();
+19
View File
@@ -0,0 +1,19 @@
export interface Notification {
id: string;
userId: number;
type: "friend_request" | "message" | "guild_invite" | "event" | "alert" | "achievement" | "radio";
title: string;
body: string;
icon?: string;
link?: string;
read: boolean;
createdAt: number;
}
export interface NotificationPayload {
type: Notification["type"];
title: string;
body: string;
icon?: string;
link?: string;
}
+6
View File
@@ -12,6 +12,7 @@ import { prisma } from "./prisma";
export { PERMS } from "./permission-slugs";
import { PERMS } from "./permission-slugs";
import { pluginRegistry } from "./plugins";
// ── Permission Set ──────────────────────────────────────────────────
@@ -279,3 +280,8 @@ export async function hasAdminAccess(
): Promise<boolean> {
return checkPermission(userId, rank, PERMS.ADMIN_DASHBOARD);
}
export function getAllPermissions(): Record<string, string> {
const pluginPerms = pluginRegistry.getPermissionSlugs();
return { ...PERMS, ...pluginPerms };
}
+12
View File
@@ -0,0 +1,12 @@
export { pluginRegistry } from "./registry";
export type {
PluginDefinition,
PluginHub,
PluginHubTab,
PluginNavGroup,
PluginNavItem,
PluginPermission,
PluginMessage,
PluginRoute,
PluginWebhookEvent,
} from "./types";
+8
View File
@@ -0,0 +1,8 @@
import { pluginRegistry } from "./registry";
import gamesPlugin from "@/plugins/games";
pluginRegistry.register(gamesPlugin);
export function ensurePluginsLoaded(): void {
// Plugins loaded at import time
}
+63
View File
@@ -0,0 +1,63 @@
import type {
PluginDefinition,
PluginHub,
PluginNavGroup,
PluginPermission,
} from "./types";
class PluginRegistry {
private plugins = new Map<string, PluginDefinition>();
register(plugin: PluginDefinition): void {
if (this.plugins.has(plugin.id)) {
console.warn(`Plugin "${plugin.id}" is already registered`);
return;
}
this.plugins.set(plugin.id, plugin);
plugin.onLoad?.();
}
unregister(id: string): void {
const plugin = this.plugins.get(id);
if (plugin) {
plugin.onUnload?.();
this.plugins.delete(id);
}
}
getAll(): PluginDefinition[] {
return Array.from(this.plugins.values());
}
get(id: string): PluginDefinition | undefined {
return this.plugins.get(id);
}
getHubs(): PluginHub[] {
return Array.from(this.plugins.values()).flatMap((p) => p.hubs ?? []);
}
getNavGroups(): PluginNavGroup[] {
return Array.from(this.plugins.values()).flatMap((p) => p.navGroups ?? []);
}
getPermissionSlugs(): Record<string, string> {
const slugs: Record<string, string> = {};
for (const plugin of Array.from(this.plugins.values())) {
for (const perm of plugin.permissions ?? []) {
slugs[perm.key] = perm.slug;
}
}
return slugs;
}
getWebhookEvents(): Array<{ event: string; handler: string }> {
return Array.from(this.plugins.values()).flatMap((p) => p.webhookEvents ?? []);
}
getMessages(): Array<{ locale: string; namespace: string; messages: Record<string, unknown> }> {
return Array.from(this.plugins.values()).flatMap((p) => p.messages ?? []);
}
}
export const pluginRegistry = new PluginRegistry();
+70
View File
@@ -0,0 +1,70 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
export interface PluginHubTab {
href: string;
labelKey: string;
match?: string[];
group?: string;
}
export interface PluginHub {
id: string;
titleKey: string;
subtitleKey: string;
icon: LucideIcon;
prefixes: string[];
tabs: PluginHubTab[];
}
export interface PluginNavItem {
href: string;
labelKey: string;
icon: LucideIcon;
matchPrefixes?: string[];
}
export interface PluginNavGroup {
labelKey: string;
icon: LucideIcon;
items: PluginNavItem[];
}
export interface PluginPermission {
key: string;
slug: string;
}
export interface PluginMessage {
locale: string;
namespace: string;
messages: Record<string, unknown>;
}
export interface PluginRoute {
path: string;
component: ReactNode;
layout?: ReactNode;
}
export interface PluginWebhookEvent {
event: string;
handler: string;
}
export interface PluginDefinition {
id: string;
name: string;
description: string;
version: string;
author?: string;
hubs?: PluginHub[];
navGroups?: PluginNavGroup[];
permissions?: PluginPermission[];
messages?: PluginMessage[];
routes?: PluginRoute[];
webhookEvents?: PluginWebhookEvent[];
onLoad?: () => void | Promise<void>;
onUnload?: () => void | Promise<void>;
}
+101
View File
@@ -0,0 +1,101 @@
import "server-only";
import { createHmac } from "node:crypto";
import { redis } from "@/lib/redis";
import { pluginRegistry } from "@/lib/plugins";
import type { WebhookConfig, WebhookEvent, WebhookPayload } from "./types";
const WEBHOOKS_KEY = "webhooks:configs";
class WebhookService {
private inProcessHandlers = new Map<WebhookEvent, Set<(data: Record<string, unknown>) => Promise<void>>>();
on(event: WebhookEvent, handler: (data: Record<string, unknown>) => Promise<void>): void {
if (!this.inProcessHandlers.has(event)) {
this.inProcessHandlers.set(event, new Set());
}
this.inProcessHandlers.get(event)!.add(handler);
}
async fire(event: WebhookEvent, data: Record<string, unknown>): Promise<void> {
const payload: WebhookPayload = {
event,
timestamp: Date.now(),
data,
};
// Run in-process handlers (registered by plugins)
const handlers = this.inProcessHandlers.get(event);
if (handlers) {
await Promise.allSettled(
Array.from(handlers).map((h) => h(data)),
);
}
// Fire registered webhook URLs
const configs = await this.getConfigs();
const matched = configs.filter(
(c) => c.active && c.events.includes(event),
);
await Promise.allSettled(
matched.map((config) => this.deliver(config, payload)),
);
}
private async deliver(config: WebhookConfig, payload: WebhookPayload): Promise<void> {
try {
const body = JSON.stringify(payload);
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Webhook-Event": payload.event,
};
if (config.secret) {
const sig = createHmac("sha256", config.secret)
.update(body)
.digest("hex");
headers["X-Webhook-Signature"] = sig;
}
await fetch(config.url, {
method: "POST",
headers,
body,
signal: AbortSignal.timeout(10_000),
});
} catch {
// Silent fail — webhook delivery errors are non-critical
}
}
async getConfigs(): Promise<WebhookConfig[]> {
if (!redis) return [];
try {
const raw = await redis.get(WEBHOOKS_KEY);
return raw ? (JSON.parse(raw) as WebhookConfig[]) : [];
} catch {
return [];
}
}
async saveConfigs(configs: WebhookConfig[]): Promise<void> {
if (!redis) return;
try {
await redis.set(WEBHOOKS_KEY, JSON.stringify(configs));
} catch {}
}
async addConfig(config: WebhookConfig): Promise<void> {
const configs = await this.getConfigs();
configs.push(config);
await this.saveConfigs(configs);
}
async removeConfig(id: string): Promise<void> {
const configs = await this.getConfigs();
await this.saveConfigs(configs.filter((c) => c.id !== id));
}
}
export const webhookService = new WebhookService();
+31
View File
@@ -0,0 +1,31 @@
export type WebhookEvent =
| "user.register"
| "user.login"
| "user.ban"
| "user.unban"
| "article.created"
| "article.updated"
| "shop.purchase"
| "radio.request"
| "alert.custom"
| "staff.action";
export interface WebhookConfig {
id: string;
url: string;
secret?: string;
events: WebhookEvent[];
active: boolean;
createdAt: number;
}
export interface WebhookPayload {
event: WebhookEvent;
timestamp: number;
data: Record<string, unknown>;
}
export type WebhookHandler = (
event: WebhookEvent,
data: Record<string, unknown>,
) => Promise<void>;
+2
View File
@@ -740,6 +740,7 @@
"radioPoints": "Points",
"radioRanks": "Ranks",
"radioAutodj": "Auto DJ",
"plugins": "Plugins",
"system": "System",
"hubTools": "Tools",
"hubObservability": "Observability",
@@ -1992,6 +1993,7 @@
"emulator": "Emulator",
"email": "Email",
"housekeeping": "Housekeeping",
"webhooks": "Webhooks",
"commando": "Commando",
"rooms": "Rooms",
"import": "Import",
+45
View File
@@ -0,0 +1,45 @@
import { Gamepad2, Swords } from "lucide-react";
import type { PluginDefinition } from "@/lib/plugins";
const gamesPlugin: PluginDefinition = {
id: "games",
name: "Games",
description: "In-game mini-game management",
version: "1.0.0",
hubs: [
{
id: "games",
titleKey: "games",
subtitleKey: "gamesSubtitle",
icon: Gamepad2,
prefixes: ["/admin/games"],
tabs: [
{ href: "/admin/games", labelKey: "gamesOverview" },
{ href: "/admin/games/rooms", labelKey: "gameRooms" },
{ href: "/admin/games/ranks", labelKey: "gameRanks" },
{ href: "/admin/games/rewards", labelKey: "gameRewards" },
{ href: "/admin/games/challenges", labelKey: "dailyChallenges" },
],
},
],
navGroups: [
{
labelKey: "gamesGroup",
icon: Swords,
items: [
{
href: "/admin/games",
labelKey: "navGames",
icon: Gamepad2,
matchPrefixes: ["/admin/games"],
},
],
},
],
permissions: [
{ key: "GAMES_VIEW", slug: "admin.games.view" },
{ key: "GAMES_EDIT", slug: "admin.games.edit" },
],
};
export default gamesPlugin;
+29
View File
@@ -0,0 +1,29 @@
{
"pages": {
"admin": {
"nav": {
"gamesGroup": "Games",
"navGames": "Games"
},
"hubs": {
"games": "Games",
"gamesSubtitle": "Manage in-game mini-games",
"tabs": {
"gamesOverview": "Overview",
"gameRooms": "Rooms",
"gameRanks": "Ranks",
"gameRewards": "Level Rewards",
"dailyChallenges": "Challenges"
}
},
"games": {
"title": "Games",
"subtitle": "Manage in-game mini-games",
"totalScores": "Total Scores",
"activeRooms": "Active Rooms",
"ranks": "Ranks",
"dailyChallenges": "Daily Challenges"
}
}
}
}