deploy #1
@@ -5,9 +5,7 @@ const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
transpilePackages: ['@nexumi/shared'],
|
||||
outputFileTracingRoot: path.join(__dirname, '../..'),
|
||||
// Keep Node-only packages out of the Next server webpack bundle so they
|
||||
// cannot break React's RSC/client boundary during page-data collection.
|
||||
serverExternalPackages: ['@sentry/node', '@prisma/client', 'ioredis', 'bullmq']
|
||||
serverExternalPackages: ['@prisma/client', 'ioredis', 'bullmq']
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"@radix-ui/react-separator": "^1.1.12",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-switch": "^1.3.4",
|
||||
"@sentry/node": "^10.67.0",
|
||||
"bullmq": "^5.34.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
86
apps/webui/src/components/dashboard/dashboard-guild-grid.tsx
Normal file
86
apps/webui/src/components/dashboard/dashboard-guild-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export async function register(): Promise<void> {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
const { initWebuiSentry } = await import('./lib/sentry');
|
||||
await initWebuiSentry();
|
||||
}
|
||||
}
|
||||
@@ -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 ?? {});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user