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
+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>;
|
||||
Reference in new issue
Block a user