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

+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>;