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

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