Feature: Plugin system, site search, notifications, webhooks, caching & loading states
Local Build and Deploy / deploy (push) Successful in 1m2s
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:
1 parent
09f1bc2bd6
commit
98d2b92218
85 files changed
+1889
-7
No files matched your search
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function StaffApplyLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function TeamApplyLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function BadgesLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSpinner } from "@/components/public/ui";
|
||||
|
||||
export default function BannedLoading() {
|
||||
return <PageSpinner />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSpinner } from "@/components/public/ui";
|
||||
|
||||
export default function ClientLoading() {
|
||||
return <PageSpinner />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function CommunityLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function DevelopersLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function DrawBadgeLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function ForgotLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function GuildDetailLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function HelpLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function HelpTicketsLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function LeaderboardLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function LoginLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function LogoLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSpinner } from "@/components/public/ui";
|
||||
|
||||
export default function MaintenanceLoading() {
|
||||
return <PageSpinner />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function MarketplaceLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSpinner } from "@/components/public/ui";
|
||||
|
||||
export default function MeLoading() {
|
||||
return <PageSpinner />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function MessagesLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function ArticleLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function PhotosLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function RadioShoutsLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function RaresLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function RedeemLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function RegisterLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function ResetLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function RoomLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function ShopLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSpinner } from "@/components/public/ui";
|
||||
|
||||
export default function TopUpLoading() {
|
||||
return <PageSpinner />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSkeleton } from "@/components/public/ui";
|
||||
|
||||
export default function StaffLoading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSpinner } from "@/components/public/ui";
|
||||
|
||||
export default function UserProfileLoading() {
|
||||
return <PageSpinner />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageSpinner } from "@/components/public/ui";
|
||||
|
||||
export default function VerifyLoading() {
|
||||
return <PageSpinner />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,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
@@ -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 ?? [];
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export { pluginRegistry } from "./registry";
|
||||
export type {
|
||||
PluginDefinition,
|
||||
PluginHub,
|
||||
PluginHubTab,
|
||||
PluginNavGroup,
|
||||
PluginNavItem,
|
||||
PluginPermission,
|
||||
PluginMessage,
|
||||
PluginRoute,
|
||||
PluginWebhookEvent,
|
||||
} from "./types";
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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>;
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user