Refactor Next.js configuration and remove Sentry integration

- Updated `serverExternalPackages` in the Next.js configuration to exclude `@sentry/node`, improving compatibility with React's RSC/client boundary.
- Removed the `instrumentation.ts` file and refactored Sentry-related code to a no-op, ensuring it does not interfere with the Next.js build process.
- Simplified the dashboard page by integrating `DashboardGuildGrid`, enhancing the user interface and reducing complexity in the component structure.
This commit is contained in:
smueller
2026-07-23 10:16:40 +02:00
parent c14c2ddaa2
commit 629e89f380
7 changed files with 117 additions and 107 deletions

View File

@@ -1,29 +1,9 @@
import type { DashboardGuild } from '@nexumi/shared';
import Link from 'next/link';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { DashboardGuildGrid } from '@/components/dashboard/dashboard-guild-grid';
import { requireAuthOrRedirect } from '@/lib/auth';
import { env } from '@/lib/env';
import { getManageableGuilds } from '@/lib/guilds';
import { getLocale, t } from '@/lib/i18n';
function guildIconUrl(guild: Pick<DashboardGuild, 'id' | 'icon'>): string | undefined {
return guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=64` : undefined;
}
function initials(name: string): string {
return (
name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join('') || '?'
);
}
function buildInviteUrl(guildId: string): string {
const url = new URL('https://discord.com/api/oauth2/authorize');
url.searchParams.set('client_id', env.BOT_CLIENT_ID);
@@ -38,6 +18,11 @@ export default async function DashboardGuildListPage() {
const guilds = await getManageableGuilds(session);
const locale = await getLocale();
const inviteUrls: Record<string, string> = {};
for (const guild of guilds) {
inviteUrls[guild.id] = buildInviteUrl(guild.id);
}
return (
<main className="flex min-h-screen justify-center px-6 py-10">
<div className="w-full max-w-[1200px] space-y-6">
@@ -46,47 +31,16 @@ export default async function DashboardGuildListPage() {
<p className="text-sm text-muted-foreground">{t(locale, 'dashboard.guildList.subtitle')}</p>
</div>
{guilds.length === 0 ? (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
{t(locale, 'dashboard.guildList.empty')}
</CardContent>
</Card>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{guilds.map((guild) => (
<Card key={guild.id}>
<CardContent className="flex flex-col gap-4 p-5">
<div className="flex items-center gap-3">
<Avatar className="size-10">
<AvatarImage src={guildIconUrl(guild)} alt={guild.name} />
<AvatarFallback>{initials(guild.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate font-medium">{guild.name}</p>
{!guild.botPresent && (
<Badge variant="outline" className="mt-1">
{t(locale, 'dashboard.guildList.botMissing')}
</Badge>
)}
</div>
</div>
{guild.botPresent ? (
<Button asChild>
<Link href={`/dashboard/${guild.id}`}>{t(locale, 'dashboard.guildList.manage')}</Link>
</Button>
) : (
<Button asChild variant="outline">
<a href={buildInviteUrl(guild.id)} target="_blank" rel="noreferrer">
{t(locale, 'dashboard.guildList.invite')}
</a>
</Button>
)}
</CardContent>
</Card>
))}
</div>
)}
<DashboardGuildGrid
guilds={guilds}
inviteUrls={inviteUrls}
labels={{
empty: t(locale, 'dashboard.guildList.empty'),
botMissing: t(locale, 'dashboard.guildList.botMissing'),
manage: t(locale, 'dashboard.guildList.manage'),
invite: t(locale, 'dashboard.guildList.invite')
}}
/>
</div>
</main>
);

View File

@@ -0,0 +1,86 @@
'use client';
import type { DashboardGuild } from '@nexumi/shared';
import Link from 'next/link';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
function guildIconUrl(guild: Pick<DashboardGuild, 'id' | 'icon'>): string | undefined {
return guild.icon
? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=64`
: undefined;
}
function initials(name: string): string {
return (
name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join('') || '?'
);
}
export function DashboardGuildGrid({
guilds,
inviteUrls,
labels
}: {
guilds: DashboardGuild[];
inviteUrls: Record<string, string>;
labels: {
empty: string;
botMissing: string;
manage: string;
invite: string;
};
}) {
if (guilds.length === 0) {
return (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
{labels.empty}
</CardContent>
</Card>
);
}
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{guilds.map((guild) => (
<Card key={guild.id}>
<CardContent className="flex flex-col gap-4 p-5">
<div className="flex items-center gap-3">
<Avatar className="size-10">
<AvatarImage src={guildIconUrl(guild)} alt={guild.name} />
<AvatarFallback>{initials(guild.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate font-medium">{guild.name}</p>
{!guild.botPresent && (
<Badge variant="outline" className="mt-1">
{labels.botMissing}
</Badge>
)}
</div>
</div>
{guild.botPresent ? (
<Button asChild>
<Link href={`/dashboard/${guild.id}`}>{labels.manage}</Link>
</Button>
) : (
<Button asChild variant="outline">
<a href={inviteUrls[guild.id]} target="_blank" rel="noreferrer">
{labels.invite}
</a>
</Button>
)}
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -1,3 +1,5 @@
'use client';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';

View File

@@ -1,6 +0,0 @@
export async function register(): Promise<void> {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initWebuiSentry } = await import('./lib/sentry');
await initWebuiSentry();
}
}

View File

@@ -1,43 +1,20 @@
let initialized = false;
/**
* WebUI error reporting stub.
*
* `@sentry/node` is intentionally not used in the Next.js app: importing it
* (even lazily via `instrumentation.ts`) pulled OpenTelemetry into the build
* graph and broke page-data collection with
* `TypeError: createContext is not a function`. Bot-side Sentry remains in
* `apps/bot`. Capture here stays a structured console fallback so call sites
* can keep using one API.
*/
export async function initWebuiSentry(): Promise<void> {
if (initialized) {
return;
}
const dsn = process.env.SENTRY_DSN?.trim();
if (!dsn) {
return;
}
// Import only when a DSN is configured. Loading `@sentry/node` at module
// scope patches Node builtins and can break Next.js page-data collection
// (`createContext is not a function`) during `next build`.
const Sentry = await import('@sentry/node');
Sentry.init({
dsn,
environment: process.env.NODE_ENV ?? 'production',
release: 'nexumi-webui@0.1.0',
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
initialScope: {
tags: { service: 'webui' }
}
});
initialized = true;
// no-op in WebUI
}
export async function captureWebuiException(
error: unknown,
context?: Record<string, unknown>
): Promise<void> {
const dsn = process.env.SENTRY_DSN?.trim();
if (!dsn) {
return;
}
const Sentry = await import('@sentry/node');
Sentry.withScope((scope) => {
if (context) {
scope.setExtras(context);
}
Sentry.captureException(error);
});
console.error('[webui]', error, context ?? {});
}