deploy #1
@@ -5,9 +5,7 @@ const nextConfig: NextConfig = {
|
|||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
transpilePackages: ['@nexumi/shared'],
|
transpilePackages: ['@nexumi/shared'],
|
||||||
outputFileTracingRoot: path.join(__dirname, '../..'),
|
outputFileTracingRoot: path.join(__dirname, '../..'),
|
||||||
// Keep Node-only packages out of the Next server webpack bundle so they
|
serverExternalPackages: ['@prisma/client', 'ioredis', 'bullmq']
|
||||||
// cannot break React's RSC/client boundary during page-data collection.
|
|
||||||
serverExternalPackages: ['@sentry/node', '@prisma/client', 'ioredis', 'bullmq']
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.12",
|
"@radix-ui/react-separator": "^1.1.12",
|
||||||
"@radix-ui/react-slot": "^1.3.0",
|
"@radix-ui/react-slot": "^1.3.0",
|
||||||
"@radix-ui/react-switch": "^1.3.4",
|
"@radix-ui/react-switch": "^1.3.4",
|
||||||
"@sentry/node": "^10.67.0",
|
|
||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -1,29 +1,9 @@
|
|||||||
import type { DashboardGuild } from '@nexumi/shared';
|
import { DashboardGuildGrid } from '@/components/dashboard/dashboard-guild-grid';
|
||||||
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 { requireAuthOrRedirect } from '@/lib/auth';
|
import { requireAuthOrRedirect } from '@/lib/auth';
|
||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
import { getManageableGuilds } from '@/lib/guilds';
|
import { getManageableGuilds } from '@/lib/guilds';
|
||||||
import { getLocale, t } from '@/lib/i18n';
|
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 {
|
function buildInviteUrl(guildId: string): string {
|
||||||
const url = new URL('https://discord.com/api/oauth2/authorize');
|
const url = new URL('https://discord.com/api/oauth2/authorize');
|
||||||
url.searchParams.set('client_id', env.BOT_CLIENT_ID);
|
url.searchParams.set('client_id', env.BOT_CLIENT_ID);
|
||||||
@@ -38,6 +18,11 @@ export default async function DashboardGuildListPage() {
|
|||||||
const guilds = await getManageableGuilds(session);
|
const guilds = await getManageableGuilds(session);
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
|
|
||||||
|
const inviteUrls: Record<string, string> = {};
|
||||||
|
for (const guild of guilds) {
|
||||||
|
inviteUrls[guild.id] = buildInviteUrl(guild.id);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen justify-center px-6 py-10">
|
<main className="flex min-h-screen justify-center px-6 py-10">
|
||||||
<div className="w-full max-w-[1200px] space-y-6">
|
<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>
|
<p className="text-sm text-muted-foreground">{t(locale, 'dashboard.guildList.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{guilds.length === 0 ? (
|
<DashboardGuildGrid
|
||||||
<Card>
|
guilds={guilds}
|
||||||
<CardContent className="py-10 text-center text-sm text-muted-foreground">
|
inviteUrls={inviteUrls}
|
||||||
{t(locale, 'dashboard.guildList.empty')}
|
labels={{
|
||||||
</CardContent>
|
empty: t(locale, 'dashboard.guildList.empty'),
|
||||||
</Card>
|
botMissing: t(locale, 'dashboard.guildList.botMissing'),
|
||||||
) : (
|
manage: t(locale, 'dashboard.guildList.manage'),
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
invite: t(locale, 'dashboard.guildList.invite')
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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 { Slot } from '@radix-ui/react-slot';
|
||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
import * as React from 'react';
|
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> {
|
export async function initWebuiSentry(): Promise<void> {
|
||||||
if (initialized) {
|
// no-op in WebUI
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function captureWebuiException(
|
export async function captureWebuiException(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
context?: Record<string, unknown>
|
context?: Record<string, unknown>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const dsn = process.env.SENTRY_DSN?.trim();
|
console.error('[webui]', error, context ?? {});
|
||||||
if (!dsn) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const Sentry = await import('@sentry/node');
|
|
||||||
Sentry.withScope((scope) => {
|
|
||||||
if (context) {
|
|
||||||
scope.setExtras(context);
|
|
||||||
}
|
|
||||||
Sentry.captureException(error);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user