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