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
@@ -211,3 +211,26 @@ export async function deleteCustomTheme(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
redirect("/admin/theme?deletedTheme=1");
|
redirect("/admin/theme?deletedTheme=1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function importThemeJson(formData: FormData): Promise<void> {
|
||||||
|
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
const raw = String(formData.get("theme_json") ?? "").trim();
|
||||||
|
if (!raw) redirect("/admin/theme?error=empty");
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const settings = parsed.settings;
|
||||||
|
if (!settings || typeof settings !== "object") {
|
||||||
|
redirect("/admin/theme?error=invalid");
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
await writeSetting(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await siteSettings.reload();
|
||||||
|
revalidatePath("/admin/theme");
|
||||||
|
} catch {
|
||||||
|
redirect("/admin/theme?error=parse");
|
||||||
|
}
|
||||||
|
redirect("/admin/theme?imported=1");
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function StaffApplyLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function TeamApplyLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function BadgesLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSpinner } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function BannedLoading() {
|
||||||
|
return <PageSpinner />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSpinner } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function ClientLoading() {
|
||||||
|
return <PageSpinner />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function CommunityLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function DevelopersLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function DrawBadgeLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function ForgotLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function FriendsLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function GuildForumLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function NewForumThreadLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function GuildDetailLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -133,7 +133,7 @@ export default async function GuildPage({
|
|||||||
|
|
||||||
<ContentCard>
|
<ContentCard>
|
||||||
<div
|
<div
|
||||||
style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }}
|
style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start", flexWrap: "wrap" }}
|
||||||
>
|
>
|
||||||
{guild.badge ? (
|
{guild.badge ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function GuildsLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function HelpCategoryLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function HelpLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function HelpTicketsLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function LeaderboardLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function LoginLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function LogoLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSpinner } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function MaintenanceLoading() {
|
||||||
|
return <PageSpinner />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function MarketplaceLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSpinner } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function MeLoading() {
|
||||||
|
return <PageSpinner />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function MessagesLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function ArticleLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function PhotosLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSpinner } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function RadioApplyLoading() {
|
||||||
|
return <PageSpinner />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function RadioContestDetailLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function RadioContestsLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function RadioGiveawayDetailLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function RadioGiveawaysLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function RadioLeaderboardLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function RadioLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function RadioRequestsLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function RadioScheduleLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function RadioShoutsLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function RankingsLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function RareCategoryLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function RaresLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function RedeemLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function RegisterLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function ResetLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function RoomLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSpinner } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function TwoFaLoading() {
|
||||||
|
return <PageSpinner />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSpinner } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function ConnectionsLoading() {
|
||||||
|
return <PageSpinner />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function SettingsLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSpinner } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function SessionsLoading() {
|
||||||
|
return <PageSpinner />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function ShopLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSpinner } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function TopUpLoading() {
|
||||||
|
return <PageSpinner />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSkeleton } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function StaffLoading() {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSpinner } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function UserProfileLoading() {
|
||||||
|
return <PageSpinner />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageSpinner } from "@/components/public/ui";
|
||||||
|
|
||||||
|
export default function VerifyLoading() {
|
||||||
|
return <PageSpinner />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Gamepad2, Swords, Trophy, Users } from "lucide-react";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function GamesOverviewPage() {
|
||||||
|
const t = await getTranslations("pages.admin.games");
|
||||||
|
|
||||||
|
let totalScores = 0;
|
||||||
|
let totalRooms = 0;
|
||||||
|
let totalRanks = 0;
|
||||||
|
let totalChallenges = 0;
|
||||||
|
try {
|
||||||
|
[totalScores, totalRooms, totalRanks, totalChallenges] =
|
||||||
|
await Promise.all([
|
||||||
|
prisma.gameScores.count(),
|
||||||
|
prisma.gameRooms.count(),
|
||||||
|
prisma.gameRanks.count(),
|
||||||
|
prisma.dailyChallenges.count(),
|
||||||
|
]);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
icon: Gamepad2,
|
||||||
|
label: t("totalScores"),
|
||||||
|
value: totalScores.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Swords,
|
||||||
|
label: t("activeRooms"),
|
||||||
|
value: totalRooms.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Trophy,
|
||||||
|
label: t("ranks"),
|
||||||
|
value: totalRanks.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
label: t("dailyChallenges"),
|
||||||
|
value: totalChallenges.toLocaleString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{stats.map((s) => {
|
||||||
|
const Icon = s.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.label}
|
||||||
|
className="admin-card p-5 flex items-center gap-4"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-[var(--admin-accent)]/10 grid place-items-center flex-none">
|
||||||
|
<Icon
|
||||||
|
size={22}
|
||||||
|
className="text-[var(--admin-accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-extrabold text-[var(--admin-text)]">
|
||||||
|
{s.value}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||||
|
{s.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { fetchAdminList } from "@/lib/admin-list";
|
||||||
|
|
||||||
|
export default async function GameRoomsPage({
|
||||||
|
params: paramsPromise,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
searchParams: Promise<Record<string, string>>;
|
||||||
|
}) {
|
||||||
|
const result = await fetchAdminList(
|
||||||
|
{
|
||||||
|
permission: "admin.games.view",
|
||||||
|
model: "gameRoom",
|
||||||
|
searchFields: ["game"],
|
||||||
|
defaultSort: { createdAt: "desc" },
|
||||||
|
mapRow: (r: {
|
||||||
|
id: number;
|
||||||
|
game: string;
|
||||||
|
status: string;
|
||||||
|
player1Id: number;
|
||||||
|
winnerId: number | null;
|
||||||
|
createdAt: Date | null;
|
||||||
|
}) => r,
|
||||||
|
},
|
||||||
|
paramsPromise,
|
||||||
|
searchParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="rounded-md border border-[var(--admin-border)]">
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Game</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Player 1</th>
|
||||||
|
<th>Winner</th>
|
||||||
|
<th>Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.rows.map((r) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td>{r.id}</td>
|
||||||
|
<td>{r.game}</td>
|
||||||
|
<td>
|
||||||
|
<span className="inline-block text-[0.6rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||||
|
{r.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>#{r.player1Id}</td>
|
||||||
|
<td>{r.winnerId ? `#${r.winnerId}` : "—"}</td>
|
||||||
|
<td className="text-[var(--admin-text-muted)] text-xs">
|
||||||
|
{r.createdAt?.toISOString().slice(0, 16).replace("T", " ") ?? "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{result.rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
className="text-center text-[var(--admin-text-muted)] py-8"
|
||||||
|
>
|
||||||
|
No game rooms found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-4 text-xs text-[var(--admin-text-muted)]">
|
||||||
|
<span>
|
||||||
|
Page {result.page} of {result.lastPage} ({result.total} total)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import { AdminTopbar } from "@/components/admin/admin-topbar";
|
|||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import { requireStaff } from "@/lib/admin/guard";
|
import { requireStaff } from "@/lib/admin/guard";
|
||||||
import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav";
|
import { allNavGroups } from "@/lib/admin-nav";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ async function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||||
{ADMIN_NAV_GROUPS.map((group, gi) => {
|
{allNavGroups().map((group, gi) => {
|
||||||
const GroupIcon = group.icon;
|
const GroupIcon = group.icon;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export default async function AdminDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={r.rank}
|
key={r.rank}
|
||||||
className="grid items-center gap-3"
|
className="grid items-center gap-3 max-sm:grid-cols-[4rem_1fr_2rem]"
|
||||||
style={{ gridTemplateColumns: "5rem 1fr 2.5rem" }}
|
style={{ gridTemplateColumns: "5rem 1fr 2.5rem" }}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
applyCustomTheme,
|
applyCustomTheme,
|
||||||
applyPreset,
|
applyPreset,
|
||||||
deleteCustomTheme,
|
deleteCustomTheme,
|
||||||
|
importThemeJson,
|
||||||
saveCustomTheme,
|
saveCustomTheme,
|
||||||
saveTheme,
|
saveTheme,
|
||||||
} from "@/actions/admin-theme";
|
} from "@/actions/admin-theme";
|
||||||
@@ -520,6 +521,30 @@ export default async function AdminTheme({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="admin-section-title">Import / Export</h2>
|
||||||
|
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||||
|
Export your current theme as JSON to share or back up. Import a
|
||||||
|
previously exported JSON file to restore a theme.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<a href="/api/admin/theme/export" download className="btn btn-outline inline-flex items-center">
|
||||||
|
⬇ Export theme JSON
|
||||||
|
</a>
|
||||||
|
<form action={importThemeJson} className="flex flex-wrap items-center gap-2">
|
||||||
|
<textarea
|
||||||
|
name="theme_json"
|
||||||
|
placeholder="Paste theme JSON here..."
|
||||||
|
className="px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-xs w-80 h-20 resize-none"
|
||||||
|
style={{ borderColor: "var(--admin-border)" }}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Import theme
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<form action={saveTheme}>
|
<form action={saveTheme}>
|
||||||
<h2 className="mt-6 text-lg font-extrabold">☀ Light mode</h2>
|
<h2 className="mt-6 text-lg font-extrabold">☀ Light mode</h2>
|
||||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3 mt-1">
|
<p className="text-xs text-[var(--admin-text-muted)] mb-3 mt-1">
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select } from "@/components/ui/select";
|
||||||
|
import type { WebhookConfig, WebhookEvent } from "@/lib/webhooks/types";
|
||||||
|
|
||||||
|
const ALL_EVENTS: WebhookEvent[] = [
|
||||||
|
"user.register",
|
||||||
|
"user.login",
|
||||||
|
"user.ban",
|
||||||
|
"user.unban",
|
||||||
|
"article.created",
|
||||||
|
"article.updated",
|
||||||
|
"shop.purchase",
|
||||||
|
"radio.request",
|
||||||
|
"alert.custom",
|
||||||
|
"staff.action",
|
||||||
|
];
|
||||||
|
|
||||||
|
const EVENT_LABELS: Record<WebhookEvent, string> = {
|
||||||
|
"user.register": "User Registration",
|
||||||
|
"user.login": "User Login",
|
||||||
|
"user.ban": "User Banned",
|
||||||
|
"user.unban": "User Unbanned",
|
||||||
|
"article.created": "Article Created",
|
||||||
|
"article.updated": "Article Updated",
|
||||||
|
"shop.purchase": "Shop Purchase",
|
||||||
|
"radio.request": "Radio Song Request",
|
||||||
|
"alert.custom": "Custom Alert",
|
||||||
|
"staff.action": "Staff Action",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WebhooksPage() {
|
||||||
|
const [configs, setConfigs] = useState<WebhookConfig[]>([]);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [secret, setSecret] = useState("");
|
||||||
|
const [events, setEvents] = useState<WebhookEvent[]>(["staff.action"]);
|
||||||
|
|
||||||
|
const loadConfigs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/webhooks");
|
||||||
|
const data = await res.json();
|
||||||
|
setConfigs(data.configs ?? []);
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfigs();
|
||||||
|
}, [loadConfigs]);
|
||||||
|
|
||||||
|
const addWebhook = async () => {
|
||||||
|
if (!url) return;
|
||||||
|
try {
|
||||||
|
await fetch("/api/admin/webhooks", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ url, secret, events }),
|
||||||
|
});
|
||||||
|
setUrl("");
|
||||||
|
setSecret("");
|
||||||
|
setEvents(["staff.action"]);
|
||||||
|
setShowForm(false);
|
||||||
|
loadConfigs();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteWebhook = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/admin/webhooks?id=${id}`, { method: "DELETE" });
|
||||||
|
loadConfigs();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEvent = (event: WebhookEvent) => {
|
||||||
|
setEvents((prev) =>
|
||||||
|
prev.includes(event)
|
||||||
|
? prev.filter((e) => e !== event)
|
||||||
|
: [...prev, event],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||||
|
Configure outgoing webhooks for external services.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowForm(!showForm)}>
|
||||||
|
<Plus size={14} className="mr-1" /> Add Webhook
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="admin-card p-5 space-y-4">
|
||||||
|
<Input
|
||||||
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Secret (optional, for HMAC signing)"
|
||||||
|
value={secret}
|
||||||
|
onChange={(e) => setSecret(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-[var(--admin-text-muted)] block mb-2">
|
||||||
|
Trigger Events
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ALL_EVENTS.map((event) => (
|
||||||
|
<button
|
||||||
|
key={event}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleEvent(event)}
|
||||||
|
className={`text-[0.6rem] font-bold px-2.5 py-1 rounded-full border transition-colors ${
|
||||||
|
events.includes(event)
|
||||||
|
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)] border-[var(--admin-accent)]"
|
||||||
|
: "bg-transparent text-[var(--admin-text-muted)] border-[var(--admin-border)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{EVENT_LABELS[event]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={addWebhook}>Save Webhook</Button>
|
||||||
|
<Button variant="outline" onClick={() => setShowForm(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{configs.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-sm text-[var(--admin-text-muted)]">
|
||||||
|
No webhooks configured yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{configs.map((config) => (
|
||||||
|
<div
|
||||||
|
key={config.id}
|
||||||
|
className="admin-card p-4 flex items-start justify-between gap-4"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-[var(--admin-text)] truncate">
|
||||||
|
{config.url}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
{config.events.map((e) => (
|
||||||
|
<span
|
||||||
|
key={e}
|
||||||
|
className="inline-block text-[0.55rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/12 text-[var(--admin-accent)]"
|
||||||
|
>
|
||||||
|
{EVENT_LABELS[e]}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteWebhook(config.id)}
|
||||||
|
className="flex-none p-2 rounded-lg hover:bg-red-500/10 text-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { getApiAdminContext } = await import("@/lib/permissions");
|
||||||
|
const ctx = await getApiAdminContext();
|
||||||
|
if (!ctx || !ctx.permissions.has(PERMS.SETTINGS_VIEW)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { snapshotCurrentTheme } = await import("@/lib/theme-custom-store");
|
||||||
|
const { siteSettings } = await import("@/lib/services/site-settings");
|
||||||
|
|
||||||
|
const snapshot = await snapshotCurrentTheme();
|
||||||
|
const themePreset = await siteSettings.get("theme_preset", "atom");
|
||||||
|
|
||||||
|
const data = JSON.stringify(
|
||||||
|
{
|
||||||
|
name: themePreset,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
settings: snapshot,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new NextResponse(data, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Disposition": `attachment; filename="theme-${themePreset}-${new Date().toISOString().slice(0, 10)}.json"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { webhookService } from "@/lib/webhooks/service";
|
||||||
|
import type { WebhookEvent } from "@/lib/webhooks/types";
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
const { getApiAdminContext } = await import("@/lib/permissions");
|
||||||
|
const ctx = await getApiAdminContext();
|
||||||
|
if (!ctx || !ctx.permissions.has(PERMS.SETTINGS_VIEW)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const ctx = await checkAuth();
|
||||||
|
if (!ctx) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const configs = await webhookService.getConfigs();
|
||||||
|
return NextResponse.json({ configs });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const ctx = await checkAuth();
|
||||||
|
if (!ctx) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
if (!body.url) {
|
||||||
|
return NextResponse.json({ error: "URL is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
id: randomUUID(),
|
||||||
|
url: body.url,
|
||||||
|
secret: body.secret || undefined,
|
||||||
|
events: (body.events ?? []) as WebhookEvent[],
|
||||||
|
active: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await webhookService.addConfig(config);
|
||||||
|
return NextResponse.json({ ok: true, config });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
const ctx = await checkAuth();
|
||||||
|
if (!ctx) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await webhookService.removeConfig(id);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { notificationService } from "@/lib/notifications/service";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = Number(session.user.id);
|
||||||
|
const notifications = await notificationService.getNotifications(userId);
|
||||||
|
const unreadCount = await notificationService.getUnreadCount(userId);
|
||||||
|
|
||||||
|
return NextResponse.json({ notifications, unreadCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = Number(session.user.id);
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
if (body.all === true) {
|
||||||
|
await notificationService.markAllRead(userId);
|
||||||
|
} else if (body.id) {
|
||||||
|
await notificationService.markRead(userId, body.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { redis } from "@/lib/redis";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = Number(session.user.id);
|
||||||
|
if (!userId) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let cleanup: (() => void) | null = null;
|
||||||
|
|
||||||
|
const sendEvent = (data: string) => {
|
||||||
|
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
|
||||||
|
};
|
||||||
|
|
||||||
|
sendEvent(JSON.stringify({ type: "connected" }));
|
||||||
|
|
||||||
|
if (redis) {
|
||||||
|
const subscriber = redis.duplicate();
|
||||||
|
subscriber.subscribe(`user:${userId}`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
sendEvent(JSON.stringify({ type: "error", message: "Subscription failed" }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
subscriber.on("message", (_channel, message) => {
|
||||||
|
sendEvent(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanup = () => {
|
||||||
|
subscriber.unsubscribe();
|
||||||
|
subscriber.quit();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepAlive = setInterval(() => {
|
||||||
|
sendEvent(JSON.stringify({ type: "ping" }));
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
request.signal.addEventListener("abort", () => {
|
||||||
|
clearInterval(keepAlive);
|
||||||
|
cleanup?.();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const q = searchParams.get("q")?.trim();
|
||||||
|
|
||||||
|
if (!q || q.length < 2) {
|
||||||
|
return NextResponse.json({ results: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `%${q}%`;
|
||||||
|
|
||||||
|
const [users, articles, rooms, guilds] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where: { username: { contains: q } },
|
||||||
|
select: { id: true, username: true, look: true },
|
||||||
|
take: 5,
|
||||||
|
}).catch(() => []),
|
||||||
|
prisma.websiteArticles.findMany({
|
||||||
|
where: { title: { contains: q } },
|
||||||
|
select: { id: true, title: true, slug: true },
|
||||||
|
take: 5,
|
||||||
|
}).catch(() => []),
|
||||||
|
prisma.rooms.findMany({
|
||||||
|
where: { name: { contains: q } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
take: 5,
|
||||||
|
}).catch(() => []),
|
||||||
|
prisma.guilds.findMany({
|
||||||
|
where: { name: { contains: q } },
|
||||||
|
select: { id: true, name: true, badge: true },
|
||||||
|
take: 5,
|
||||||
|
}).catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
results: {
|
||||||
|
users: users.map((u) => ({ ...u, type: "user" as const })),
|
||||||
|
articles: articles.map((a) => ({ ...a, type: "article" as const })),
|
||||||
|
rooms: rooms.map((r) => ({ ...r, type: "room" as const })),
|
||||||
|
guilds: guilds.map((g) => ({ ...g, type: "guild" as const })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { LanguageSwitcher } from "@/components/language-switcher";
|
|||||||
import { MobileNav } from "@/components/mobile-nav";
|
import { MobileNav } from "@/components/mobile-nav";
|
||||||
import { NavDropdown } from "@/components/nav-dropdown";
|
import { NavDropdown } from "@/components/nav-dropdown";
|
||||||
import { NavbarColorPicker } from "@/components/navbar-color-picker";
|
import { NavbarColorPicker } from "@/components/navbar-color-picker";
|
||||||
|
import { SearchCommand } from "@/components/search-command";
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
@@ -298,12 +299,13 @@ export async function Navigation() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-x-1 pt-2 mt-2 border-t"
|
className="flex flex-wrap items-center gap-x-1 pt-2 mt-2 border-t"
|
||||||
style={{
|
style={{
|
||||||
borderColor:
|
borderColor:
|
||||||
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<SearchCommand />
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<NavbarColorPicker />
|
<NavbarColorPicker />
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
@@ -333,8 +335,9 @@ export async function Navigation() {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Desktop: controls */}
|
{/* Desktop: search + controls */}
|
||||||
<div className="ml-auto hidden md:flex items-center gap-x-1">
|
<div className="ml-auto hidden md:flex items-center gap-x-1">
|
||||||
|
<SearchCommand />
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<NavbarColorPicker />
|
<NavbarColorPicker />
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Bell, BellDot } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { Notification } from "@/lib/notifications/types";
|
||||||
|
|
||||||
|
export function NotificationBell() {
|
||||||
|
const [unread, setUnread] = useState(0);
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const fetchNotifications = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/notifications");
|
||||||
|
const data = await res.json();
|
||||||
|
setNotifications(data.notifications ?? []);
|
||||||
|
setUnread(data.unreadCount ?? 0);
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotifications();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const es = new EventSource("/api/notifications/stream");
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type !== "ping" && data.type !== "connected") {
|
||||||
|
fetchNotifications();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
eventSourceRef.current = es;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSourceRef.current?.close();
|
||||||
|
};
|
||||||
|
}, [fetchNotifications]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markAllRead = async () => {
|
||||||
|
try {
|
||||||
|
await fetch("/api/notifications", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ all: true }),
|
||||||
|
});
|
||||||
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||||
|
setUnread(0);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxVisible = 5;
|
||||||
|
const visibleNotifs = notifications.slice(0, maxVisible);
|
||||||
|
const remaining = notifications.length - maxVisible;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={dropdownRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="relative flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs font-medium text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-card)]/50 transition-all duration-150"
|
||||||
|
aria-label="Notifications"
|
||||||
|
>
|
||||||
|
{unread > 0 ? <BellDot size={16} /> : <Bell size={16} />}
|
||||||
|
{unread > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[0.55rem] font-bold text-white">
|
||||||
|
{unread > 9 ? "9+" : unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-2 w-80 rounded-xl shadow-xl border overflow-hidden z-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--color-card)",
|
||||||
|
borderColor: "var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3 border-b"
|
||||||
|
style={{ borderColor: "var(--color-border)" }}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-bold" style={{ color: "var(--color-text)" }}>
|
||||||
|
Notifications
|
||||||
|
</span>
|
||||||
|
{unread > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={markAllRead}
|
||||||
|
className="text-[0.65rem] font-medium text-[var(--color-primary)] hover:underline"
|
||||||
|
>
|
||||||
|
Mark all read
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-80 overflow-y-auto">
|
||||||
|
{visibleNotifs.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="px-4 py-8 text-center text-xs"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
No notifications yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
visibleNotifs.map((n) => (
|
||||||
|
<Link
|
||||||
|
key={n.id}
|
||||||
|
href={n.link ?? "#"}
|
||||||
|
className={`flex items-start gap-3 px-4 py-3 text-sm transition-colors duration-150 no-underline border-b last:border-0 ${
|
||||||
|
!n.read ? "bg-[var(--color-primary)]/5" : ""
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--color-border)",
|
||||||
|
color: "var(--color-text)",
|
||||||
|
}}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<span className="text-base flex-none mt-0.5">
|
||||||
|
{n.icon ?? "🔔"}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-semibold text-xs truncate">
|
||||||
|
{n.title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-[0.7rem] mt-0.5 line-clamp-2"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{n.body}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notifications.length > 0 && (
|
||||||
|
<Link
|
||||||
|
href="/notifications"
|
||||||
|
className="block px-4 py-2.5 text-center text-[0.65rem] font-medium border-t no-underline hover:bg-[var(--color-card)]/50"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--color-border)",
|
||||||
|
color: "var(--color-primary)",
|
||||||
|
}}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
{remaining > 0
|
||||||
|
? `View all ${notifications.length} notifications`
|
||||||
|
: "View all notifications"}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -143,6 +143,29 @@ export function Skeleton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PageSpinner() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}>
|
||||||
|
<div
|
||||||
|
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--color-primary)",
|
||||||
|
borderTopColor: "transparent",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageSkeleton() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "grid", gap: "1.5rem" }}>
|
||||||
|
<SkeletonCard lines={2} />
|
||||||
|
<SkeletonCard lines={6} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SkeletonCard({ lines = 3 }: { lines?: number }) {
|
export function SkeletonCard({ lines = 3 }: { lines?: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="content-card" style={{ padding: "1rem" }}>
|
<div className="content-card" style={{ padding: "1rem" }}>
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
|
||||||
|
interface SearchResults {
|
||||||
|
users: Array<{ id: number; username: string; look: string; type: "user" }>;
|
||||||
|
articles: Array<{ id: number; title: string; slug: string; type: "article" }>;
|
||||||
|
rooms: Array<{ id: number; name: string; type: "room" }>;
|
||||||
|
guilds: Array<{ id: number; name: string; badge: string; type: "guild" }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchCommand() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<SearchResults | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((o) => !o);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
setResults(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
setResults(data.results);
|
||||||
|
} catch {
|
||||||
|
setResults(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(type: string, id: number | string, slug?: string) => {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
switch (type) {
|
||||||
|
case "user":
|
||||||
|
router.push(`/u/${id}`);
|
||||||
|
break;
|
||||||
|
case "article":
|
||||||
|
router.push(`/news/${slug}`);
|
||||||
|
break;
|
||||||
|
case "room":
|
||||||
|
router.push(`/room/${id}`);
|
||||||
|
break;
|
||||||
|
case "guild":
|
||||||
|
router.push(`/guilds/${id}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-card)]/50 transition-all duration-150 border border-transparent hover:border-[var(--color-border)]"
|
||||||
|
aria-label="Search site"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
</svg>
|
||||||
|
<span className="hidden sm:inline">Search</span>
|
||||||
|
<kbd className="hidden lg:inline-flex items-center gap-0.5 rounded border px-1.5 py-0.5 text-[0.6rem] font-medium opacity-60">
|
||||||
|
<span>⌘</span>K
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<CommandDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
title="Search"
|
||||||
|
description="Search users, articles, rooms, and guilds"
|
||||||
|
>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search users, articles, rooms, guilds..."
|
||||||
|
value={query}
|
||||||
|
onValueChange={setQuery}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="animate-spin w-5 h-5 border-2 rounded-full border-[var(--color-primary)] border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && query.length >= 2 && !results && (
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
)}
|
||||||
|
{results && (
|
||||||
|
<>
|
||||||
|
{results.users.length > 0 && (
|
||||||
|
<CommandGroup heading="Users">
|
||||||
|
{results.users.map((u) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`user-${u.id}`}
|
||||||
|
onSelect={() => handleSelect("user", u.username)}
|
||||||
|
>
|
||||||
|
<span className="mr-2">👤</span>
|
||||||
|
<span>{u.username}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
{results.articles.length > 0 && (
|
||||||
|
<CommandGroup heading="Articles">
|
||||||
|
{results.articles.map((a) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`article-${a.id}`}
|
||||||
|
onSelect={() =>
|
||||||
|
handleSelect("article", a.id, a.slug)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="mr-2">📰</span>
|
||||||
|
<span>{a.title}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
{results.rooms.length > 0 && (
|
||||||
|
<CommandGroup heading="Rooms">
|
||||||
|
{results.rooms.map((r) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`room-${r.id}`}
|
||||||
|
onSelect={() => handleSelect("room", r.id)}
|
||||||
|
>
|
||||||
|
<span className="mr-2">🚪</span>
|
||||||
|
<span>{r.name}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
{results.guilds.length > 0 && (
|
||||||
|
<CommandGroup heading="Guilds">
|
||||||
|
{results.guilds.map((g) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`guild-${g.id}`}
|
||||||
|
onSelect={() => handleSelect("guild", g.id)}
|
||||||
|
>
|
||||||
|
<span className="mr-2">🏰</span>
|
||||||
|
<span>{g.name}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
{!results.users.length &&
|
||||||
|
!results.articles.length &&
|
||||||
|
!results.rooms.length &&
|
||||||
|
!results.guilds.length && (
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { auth, signOut } from "@/lib/auth";
|
|||||||
import { avatarImageUrl } from "@/lib/format";
|
import { avatarImageUrl } from "@/lib/format";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import { NotificationBell } from "@/components/notification-bell";
|
||||||
|
|
||||||
function Currency({
|
function Currency({
|
||||||
icon,
|
icon,
|
||||||
@@ -88,6 +89,7 @@ export async function TopHeader() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-x-1 sm:gap-x-3">
|
<div className="flex items-center gap-x-1 sm:gap-x-3">
|
||||||
|
<NotificationBell />
|
||||||
{isStaff ? (
|
{isStaff ? (
|
||||||
<details className="relative">
|
<details className="relative">
|
||||||
<summary
|
<summary
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { getRequestConfig } from "next-intl/server";
|
import { getRequestConfig } from "next-intl/server";
|
||||||
|
import { pluginRegistry } from "@/lib/plugins";
|
||||||
|
|
||||||
// i18n WITHOUT routing: the locale is chosen by a `NEXT_LOCALE` cookie, then
|
// i18n WITHOUT routing: the locale is chosen by a `NEXT_LOCALE` cookie, then
|
||||||
// the Accept-Language header, and finally English as fallback.
|
// the Accept-Language header, and finally English as fallback.
|
||||||
|
|||||||
+45
-1
@@ -8,6 +8,7 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Monitor,
|
Monitor,
|
||||||
Newspaper,
|
Newspaper,
|
||||||
|
Puzzle,
|
||||||
Radio,
|
Radio,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { pluginRegistry } from "@/lib/plugins";
|
||||||
|
|
||||||
export interface AdminHubTab {
|
export interface AdminHubTab {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -267,6 +269,7 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
|
|||||||
"/admin/emulator",
|
"/admin/emulator",
|
||||||
"/admin/email-templates",
|
"/admin/email-templates",
|
||||||
"/admin/housekeeping",
|
"/admin/housekeeping",
|
||||||
|
"/admin/webhooks",
|
||||||
],
|
],
|
||||||
tabs: [
|
tabs: [
|
||||||
{ href: "/admin/settings", labelKey: "cms" },
|
{ href: "/admin/settings", labelKey: "cms" },
|
||||||
@@ -276,6 +279,7 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
|
|||||||
{ href: "/admin/emulator", labelKey: "emulator" },
|
{ href: "/admin/emulator", labelKey: "emulator" },
|
||||||
{ href: "/admin/email-templates", labelKey: "email" },
|
{ href: "/admin/email-templates", labelKey: "email" },
|
||||||
{ href: "/admin/housekeeping", labelKey: "housekeeping" },
|
{ 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 {
|
export function findAdminHub(pathname: string): AdminHubDefinition | null {
|
||||||
|
const hubs = allHubs();
|
||||||
return (
|
return (
|
||||||
ADMIN_HUBS.find((hub) =>
|
hubs.find((hub) =>
|
||||||
hub.prefixes.some((p) => pathname === p || pathname.startsWith(`${p}/`)),
|
hub.prefixes.some((p) => pathname === p || pathname.startsWith(`${p}/`)),
|
||||||
) ?? null
|
) ?? 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[] {
|
function hubPrefixes(id: string): string[] {
|
||||||
return ADMIN_HUBS.find((h) => h.id === id)?.prefixes ?? [];
|
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";
|
export { PERMS } from "./permission-slugs";
|
||||||
|
|
||||||
import { PERMS } from "./permission-slugs";
|
import { PERMS } from "./permission-slugs";
|
||||||
|
import { pluginRegistry } from "./plugins";
|
||||||
|
|
||||||
// ── Permission Set ──────────────────────────────────────────────────
|
// ── Permission Set ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -279,3 +280,8 @@ export async function hasAdminAccess(
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return checkPermission(userId, rank, PERMS.ADMIN_DASHBOARD);
|
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>;
|
||||||
@@ -740,6 +740,7 @@
|
|||||||
"radioPoints": "Points",
|
"radioPoints": "Points",
|
||||||
"radioRanks": "Ranks",
|
"radioRanks": "Ranks",
|
||||||
"radioAutodj": "Auto DJ",
|
"radioAutodj": "Auto DJ",
|
||||||
|
"plugins": "Plugins",
|
||||||
"system": "System",
|
"system": "System",
|
||||||
"hubTools": "Tools",
|
"hubTools": "Tools",
|
||||||
"hubObservability": "Observability",
|
"hubObservability": "Observability",
|
||||||
@@ -1992,6 +1993,7 @@
|
|||||||
"emulator": "Emulator",
|
"emulator": "Emulator",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"housekeeping": "Housekeeping",
|
"housekeeping": "Housekeeping",
|
||||||
|
"webhooks": "Webhooks",
|
||||||
"commando": "Commando",
|
"commando": "Commando",
|
||||||
"rooms": "Rooms",
|
"rooms": "Rooms",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Gamepad2, Swords } from "lucide-react";
|
||||||
|
import type { PluginDefinition } from "@/lib/plugins";
|
||||||
|
|
||||||
|
const gamesPlugin: PluginDefinition = {
|
||||||
|
id: "games",
|
||||||
|
name: "Games",
|
||||||
|
description: "In-game mini-game management",
|
||||||
|
version: "1.0.0",
|
||||||
|
hubs: [
|
||||||
|
{
|
||||||
|
id: "games",
|
||||||
|
titleKey: "games",
|
||||||
|
subtitleKey: "gamesSubtitle",
|
||||||
|
icon: Gamepad2,
|
||||||
|
prefixes: ["/admin/games"],
|
||||||
|
tabs: [
|
||||||
|
{ href: "/admin/games", labelKey: "gamesOverview" },
|
||||||
|
{ href: "/admin/games/rooms", labelKey: "gameRooms" },
|
||||||
|
{ href: "/admin/games/ranks", labelKey: "gameRanks" },
|
||||||
|
{ href: "/admin/games/rewards", labelKey: "gameRewards" },
|
||||||
|
{ href: "/admin/games/challenges", labelKey: "dailyChallenges" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
navGroups: [
|
||||||
|
{
|
||||||
|
labelKey: "gamesGroup",
|
||||||
|
icon: Swords,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
href: "/admin/games",
|
||||||
|
labelKey: "navGames",
|
||||||
|
icon: Gamepad2,
|
||||||
|
matchPrefixes: ["/admin/games"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
permissions: [
|
||||||
|
{ key: "GAMES_VIEW", slug: "admin.games.view" },
|
||||||
|
{ key: "GAMES_EDIT", slug: "admin.games.edit" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default gamesPlugin;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"pages": {
|
||||||
|
"admin": {
|
||||||
|
"nav": {
|
||||||
|
"gamesGroup": "Games",
|
||||||
|
"navGames": "Games"
|
||||||
|
},
|
||||||
|
"hubs": {
|
||||||
|
"games": "Games",
|
||||||
|
"gamesSubtitle": "Manage in-game mini-games",
|
||||||
|
"tabs": {
|
||||||
|
"gamesOverview": "Overview",
|
||||||
|
"gameRooms": "Rooms",
|
||||||
|
"gameRanks": "Ranks",
|
||||||
|
"gameRewards": "Level Rewards",
|
||||||
|
"dailyChallenges": "Challenges"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"games": {
|
||||||
|
"title": "Games",
|
||||||
|
"subtitle": "Manage in-game mini-games",
|
||||||
|
"totalScores": "Total Scores",
|
||||||
|
"activeRooms": "Active Rooms",
|
||||||
|
"ranks": "Ranks",
|
||||||
|
"dailyChallenges": "Daily Challenges"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user