Feature: Plugin UI, event hooks, DB notifications, web push, webhook retry, games pages, rate limiting, tests
Local Build and Deploy / deploy (push) Successful in 1m5s
Local Build and Deploy / deploy (push) Successful in 1m5s
- Plugin admin UI: enable/disable per plugin with config forms - Event hooks system: HookManager with on/off/emit for plugin events - Database notifications: persistent via WebsiteNotification table + Redis pub/sub - Notification preferences: per-user settings with API endpoints - Web Push API: browser push notification subscription - Webhook retry: 3 attempts with exponential backoff (1s/3s/9s) + delivery logs - Webhook test button + paginated delivery log viewer - Games: rank/challenge/reward admin pages - Games: public frontend leaderboard - Rate limiting: search API (30/min), notification stream dedup - Unit tests: 28 new tests (registry, notifications, webhooks) - Prisma migration 0017: website_notifications, website_notification_preferences, website_webhook_logs
This commit is contained in:
1 parent
8bf48f46e7
commit
83f4585167
30 files changed
+2196
-51
No files matched your search
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { pluginRegistry } from "@/lib/plugins/registry";
|
||||
import type { PluginDefinition, PluginConfigField } from "@/lib/plugins/types";
|
||||
|
||||
interface PluginWithState extends PluginDefinition {
|
||||
enabled: boolean;
|
||||
config: Record<string, string | boolean>;
|
||||
}
|
||||
|
||||
export function PluginsClient() {
|
||||
const [plugins, setPlugins] = useState<PluginWithState[]>([]);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/plugins");
|
||||
const data = await res.json();
|
||||
setPlugins(data.plugins ?? []);
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const toggleEnabled = async (id: string, current: boolean) => {
|
||||
try {
|
||||
await fetch(`/api/admin/plugins`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, enabled: !current }),
|
||||
});
|
||||
load();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const saveConfig = async (id: string, config: Record<string, string | boolean>) => {
|
||||
try {
|
||||
await fetch(`/api/admin/plugins`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, config }),
|
||||
});
|
||||
setEditing(null);
|
||||
load();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{plugins.length === 0 && (
|
||||
<div className="text-center py-12 text-sm text-[var(--admin-text-muted)]">
|
||||
No plugins registered.
|
||||
</div>
|
||||
)}
|
||||
{plugins.map((plugin) => (
|
||||
<div
|
||||
key={plugin.id}
|
||||
className="admin-card p-4 flex items-start justify-between gap-4"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)]">
|
||||
{plugin.name}
|
||||
</h3>
|
||||
<span className="text-[0.55rem] font-bold px-1.5 py-0.5 rounded-full bg-[var(--admin-accent)]/12 text-[var(--admin-accent)]">
|
||||
v{plugin.version}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mt-1">
|
||||
{plugin.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-2 text-[0.6rem] text-[var(--admin-text-muted)]">
|
||||
<span>ID: {plugin.id}</span>
|
||||
{plugin.author && <span>Author: {plugin.author}</span>}
|
||||
</div>
|
||||
|
||||
{plugin.configFields && plugin.configFields.length > 0 && (
|
||||
<div className="mt-3">
|
||||
{editing === plugin.id ? (
|
||||
<ConfigForm
|
||||
fields={plugin.configFields}
|
||||
initial={plugin.config}
|
||||
onSave={(cfg) => saveConfig(plugin.id, cfg)}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(plugin.id)}
|
||||
className="text-[0.6rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/10 text-[var(--admin-accent)] hover:bg-[var(--admin-accent)]/20 transition-colors"
|
||||
>
|
||||
Configure
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-none">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={plugin.enabled}
|
||||
onChange={() => toggleEnabled(plugin.id, plugin.enabled)}
|
||||
/>
|
||||
<div className="w-9 h-5 bg-[var(--admin-text-muted)]/30 rounded-full peer peer-checked:bg-[var(--admin-accent)] after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigForm({
|
||||
fields,
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
fields: PluginConfigField[];
|
||||
initial: Record<string, string | boolean>;
|
||||
onSave: (config: Record<string, string | boolean>) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [values, setValues] = useState<Record<string, string | boolean>>(initial);
|
||||
|
||||
const update = (key: string, value: string | boolean) => {
|
||||
setValues((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 p-3 rounded-lg bg-[var(--admin-surface)]/50 border border-[var(--admin-border)]">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key}>
|
||||
<label className="text-[0.6rem] font-medium text-[var(--admin-text-muted)] block mb-1">
|
||||
{field.label}
|
||||
</label>
|
||||
{field.type === "text" && (
|
||||
<input
|
||||
type="text"
|
||||
value={(values[field.key] as string) ?? ""}
|
||||
onChange={(e) => update(field.key, e.target.value)}
|
||||
className="w-full px-2 py-1 text-xs rounded-md border border-[var(--admin-border)] bg-transparent text-[var(--admin-text)]"
|
||||
/>
|
||||
)}
|
||||
{field.type === "boolean" && (
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={(values[field.key] as boolean) ?? false}
|
||||
onChange={(e) => update(field.key, e.target.checked)}
|
||||
/>
|
||||
<div className="w-9 h-5 bg-[var(--admin-text-muted)]/30 rounded-full peer peer-checked:bg-[var(--admin-accent)] after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full" />
|
||||
</label>
|
||||
)}
|
||||
{field.type === "select" && field.options && (
|
||||
<select
|
||||
value={(values[field.key] as string) ?? ""}
|
||||
onChange={(e) => update(field.key, e.target.value)}
|
||||
className="w-full px-2 py-1 text-xs rounded-md border border-[var(--admin-border)] bg-transparent text-[var(--admin-text)]"
|
||||
>
|
||||
{field.options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave(values)}
|
||||
className="text-[0.6rem] font-bold px-2.5 py-1 rounded-full bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-[0.6rem] font-bold px-2.5 py-1 rounded-full border border-[var(--admin-border)] text-[var(--admin-text-muted)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user