deploy #1
@@ -15,6 +15,7 @@
|
|||||||
"@nexumi/shared": "workspace:^",
|
"@nexumi/shared": "workspace:^",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@radix-ui/react-avatar": "^1.2.3",
|
"@radix-ui/react-avatar": "^1.2.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.20",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.21",
|
"@radix-ui/react-dropdown-menu": "^2.1.21",
|
||||||
"@radix-ui/react-label": "^2.1.12",
|
"@radix-ui/react-label": "^2.1.12",
|
||||||
"@radix-ui/react-select": "^2.3.4",
|
"@radix-ui/react-select": "^2.3.4",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"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",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
"lucide-react": "^1.25.0",
|
"lucide-react": "^1.25.0",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
import { CommandsManager } from '@/components/modules/commands-manager';
|
import { CommandsManager } from '@/components/modules/commands-manager';
|
||||||
import { getLocale, t } from '@/lib/i18n';
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
import { listCommandOverridesDashboard } from '@/lib/module-configs/commands';
|
import { listCommandOverridesDashboard } from '@/lib/module-configs/commands';
|
||||||
@@ -16,7 +17,9 @@ export default async function CommandsPage({ params }: CommandsPageProps) {
|
|||||||
<h1 className="text-2xl font-semibold">{t(locale, 'modulePages.commands.title')}</h1>
|
<h1 className="text-2xl font-semibold">{t(locale, 'modulePages.commands.title')}</h1>
|
||||||
<p className="text-sm text-muted-foreground">{t(locale, 'modulePages.commands.description')}</p>
|
<p className="text-sm text-muted-foreground">{t(locale, 'modulePages.commands.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Suspense fallback={null}>
|
||||||
<CommandsManager guildId={guildId} initialCommands={commands} />
|
<CommandsManager guildId={guildId} initialCommands={commands} />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { DASHBOARD_MODULES } from '@nexumi/shared';
|
||||||
|
import { Activity, MessageSquare, Mic, Users } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { getActivitySummary } from '@/lib/activity';
|
import { getActivitySummary } from '@/lib/activity';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { listRecentDashboardAudit } from '@/lib/audit';
|
import { listRecentDashboardAudit } from '@/lib/audit';
|
||||||
import { getLocale, t } from '@/lib/i18n';
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
import { getModuleStatuses } from '@/lib/modules';
|
import { getModuleStatuses } from '@/lib/modules';
|
||||||
@@ -17,6 +19,10 @@ function formatDate(iso: string, locale: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moduleHref(moduleId: string): string {
|
||||||
|
return DASHBOARD_MODULES.find((module) => module.id === moduleId)?.href ?? moduleId;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function GuildOverviewPage({ params }: OverviewPageProps) {
|
export default async function GuildOverviewPage({ params }: OverviewPageProps) {
|
||||||
const { guildId } = await params;
|
const { guildId } = await params;
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
@@ -26,55 +32,108 @@ export default async function GuildOverviewPage({ params }: OverviewPageProps) {
|
|||||||
getActivitySummary(guildId)
|
getActivitySummary(guildId)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const enabledCount = modules.filter((module) => module.enabled).length;
|
||||||
|
const numberLocale = locale === 'de' ? 'de-DE' : 'en-US';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-10">
|
||||||
<h1 className="text-2xl font-semibold">{t(locale, 'dashboard.overview.title')}</h1>
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight">{t(locale, 'dashboard.overview.title')}</h1>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
{t(locale, 'dashboard.overview.activityHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Activity className="size-4 text-primary" />
|
||||||
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.activityTitle')}</h2>
|
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.activityTitle')}</h2>
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
</div>
|
||||||
<Card>
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<CardContent className="p-4">
|
<Card className="border-border/80 bg-card/80">
|
||||||
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityMessages')}</p>
|
<CardContent className="flex items-start gap-3 p-5">
|
||||||
<p className="text-2xl font-semibold">{activitySummary.messageCount.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</p>
|
<div className="rounded-md bg-primary/10 p-2 text-primary">
|
||||||
|
<MessageSquare className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t(locale, 'dashboard.overview.activityMessages')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">
|
||||||
|
{activitySummary.messageCount.toLocaleString(numberLocale)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card className="border-border/80 bg-card/80">
|
||||||
<CardContent className="p-4">
|
<CardContent className="flex items-start gap-3 p-5">
|
||||||
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityVoiceMinutes')}</p>
|
<div className="rounded-md bg-primary/10 p-2 text-primary">
|
||||||
<p className="text-2xl font-semibold">{activitySummary.voiceMinutes.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</p>
|
<Mic className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t(locale, 'dashboard.overview.activityVoiceMinutes')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">
|
||||||
|
{activitySummary.voiceMinutes.toLocaleString(numberLocale)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card className="border-border/80 bg-card/80">
|
||||||
<CardContent className="p-4">
|
<CardContent className="flex items-start gap-3 p-5">
|
||||||
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityActiveUsers')}</p>
|
<div className="rounded-md bg-primary/10 p-2 text-primary">
|
||||||
<p className="text-2xl font-semibold">{activitySummary.activeUserCount.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</p>
|
<Users className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t(locale, 'dashboard.overview.activityActiveUsers')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">
|
||||||
|
{activitySummary.activeUserCount.toLocaleString(numberLocale)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">{t(locale, 'dashboard.overview.activityHint')}</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.moduleStatusTitle')}</h2>
|
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.moduleStatusTitle')}</h2>
|
||||||
<Link href={`/dashboard/${guildId}/modules`} className="text-sm text-primary hover:underline">
|
{modules.length > 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{enabledCount}/{modules.length} {t(locale, 'common.enabled').toLowerCase()}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/${guildId}/modules`}
|
||||||
|
className="text-sm font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
{t(locale, 'dashboard.overview.viewAllModules')}
|
{t(locale, 'dashboard.overview.viewAllModules')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{modules.length === 0 ? (
|
{modules.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">{t(locale, 'dashboard.overview.moduleStatusEmpty')}</p>
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="p-8 text-center text-sm text-muted-foreground">
|
||||||
|
{t(locale, 'dashboard.overview.moduleStatusEmpty')}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{modules.map((module) => (
|
{modules.map((module) => (
|
||||||
<Card key={module.id}>
|
<Link key={module.id} href={`/dashboard/${guildId}/${moduleHref(module.id)}`}>
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<Card className="h-full border-border/80 transition-colors hover:border-primary/40 hover:bg-accent/30">
|
||||||
|
<CardContent className="flex items-center justify-between gap-3 p-4">
|
||||||
<span className="font-medium">{t(locale, `modules.${module.id}.label`)}</span>
|
<span className="font-medium">{t(locale, `modules.${module.id}.label`)}</span>
|
||||||
<Badge variant={module.enabled ? 'success' : 'secondary'}>
|
<Badge variant={module.enabled ? 'success' : 'secondary'}>
|
||||||
{t(locale, module.enabled ? 'common.enabled' : 'common.disabled')}
|
{t(locale, module.enabled ? 'common.enabled' : 'common.disabled')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -82,22 +141,23 @@ export default async function GuildOverviewPage({ params }: OverviewPageProps) {
|
|||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.recentAuditTitle')}</h2>
|
<h2 className="text-lg font-medium">{t(locale, 'dashboard.overview.recentAuditTitle')}</h2>
|
||||||
<Card>
|
<Card className="border-border/80">
|
||||||
<CardHeader>
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm text-muted-foreground">
|
<CardTitle className="text-base">{t(locale, 'dashboard.overview.recentAuditTitle')}</CardTitle>
|
||||||
{t(locale, 'dashboard.overview.recentAuditTitle')}
|
<CardDescription>{t(locale, 'dashboard.overview.auditPath')}</CardDescription>
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent>
|
||||||
{auditEntries.length === 0 ? (
|
{auditEntries.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">{t(locale, 'dashboard.overview.auditEmpty')}</p>
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t(locale, 'dashboard.overview.auditEmpty')}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border">
|
<ul className="divide-y divide-border">
|
||||||
{auditEntries.map((entry) => (
|
{auditEntries.map((entry) => (
|
||||||
<li key={entry.id} className="flex items-center justify-between gap-4 py-2 text-sm">
|
<li key={entry.id} className="flex items-center justify-between gap-4 py-3 text-sm">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-medium">{entry.action}</p>
|
<p className="truncate font-medium">{entry.action}</p>
|
||||||
{entry.path && <p className="truncate text-xs text-muted-foreground">{entry.path}</p>}
|
{entry.path ? <p className="truncate text-xs text-muted-foreground">{entry.path}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
{formatDate(entry.createdAt, locale)}
|
{formatDate(entry.createdAt, locale)}
|
||||||
|
|||||||
@@ -81,4 +81,11 @@ body {
|
|||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
|
font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle page depth without full-bleed gradients (SPEC). */
|
||||||
|
.dark body {
|
||||||
|
background-image: radial-gradient(ellipse 80% 50% at 50% -20%, rgba(99, 102, 241, 0.08), transparent);
|
||||||
|
background-attachment: fixed;
|
||||||
}
|
}
|
||||||
|
|||||||
176
apps/webui/src/components/layout/dashboard-command-palette.tsx
Normal file
176
apps/webui/src/components/layout/dashboard-command-palette.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Search, Terminal, Settings2, LayoutGrid } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
|
import {
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList
|
||||||
|
} from '@/components/ui/command';
|
||||||
|
import {
|
||||||
|
OWNER_NAV_ENTRIES,
|
||||||
|
buildGuildSearchHref,
|
||||||
|
buildGuildSearchIndex,
|
||||||
|
type SearchIndexEntry,
|
||||||
|
type SearchItemKind
|
||||||
|
} from '@/lib/dashboard-search-index';
|
||||||
|
|
||||||
|
function kindIcon(kind: SearchItemKind) {
|
||||||
|
switch (kind) {
|
||||||
|
case 'command':
|
||||||
|
return Terminal;
|
||||||
|
case 'setting':
|
||||||
|
return Settings2;
|
||||||
|
default:
|
||||||
|
return LayoutGrid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardCommandPaletteProps {
|
||||||
|
guildId?: string;
|
||||||
|
mode?: 'guild' | 'owner';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardCommandPalette({ guildId, mode = 'guild' }: DashboardCommandPaletteProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const entries = useMemo(() => {
|
||||||
|
if (mode === 'owner') {
|
||||||
|
return OWNER_NAV_ENTRIES;
|
||||||
|
}
|
||||||
|
return buildGuildSearchIndex();
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
||||||
|
event.preventDefault();
|
||||||
|
setOpen((prev) => !prev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolveLabel = useCallback(
|
||||||
|
(entry: SearchIndexEntry) => {
|
||||||
|
if (entry.labelOverride) {
|
||||||
|
return entry.labelOverride;
|
||||||
|
}
|
||||||
|
if (entry.labelKey) {
|
||||||
|
return t(entry.labelKey);
|
||||||
|
}
|
||||||
|
return entry.id;
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
function navigateTo(entry: SearchIndexEntry) {
|
||||||
|
setOpen(false);
|
||||||
|
if (mode === 'owner') {
|
||||||
|
router.push(entry.href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!guildId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const href = buildGuildSearchHref(guildId, entry);
|
||||||
|
router.push(href);
|
||||||
|
if (entry.hash) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
document.getElementById(entry.hash!)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = entries.filter((e) => e.kind === 'page');
|
||||||
|
const commands = entries.filter((e) => e.kind === 'command');
|
||||||
|
const settings = entries.filter((e) => e.kind === 'setting');
|
||||||
|
|
||||||
|
const isMac =
|
||||||
|
typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="inline-flex h-9 w-full max-w-xs items-center gap-2 rounded-md border border-input bg-background/60 px-3 text-sm text-muted-foreground shadow-sm transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Search className="size-4 shrink-0" />
|
||||||
|
<span className="flex-1 truncate text-left">{t('search.placeholder')}</span>
|
||||||
|
<kbd className="pointer-events-none hidden h-5 select-none items-center gap-1 rounded border border-border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground sm:inline-flex">
|
||||||
|
{isMac ? '⌘' : 'Ctrl'}K
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<CommandDialog open={open} onOpenChange={setOpen} title={t('search.title')}>
|
||||||
|
<CommandInput placeholder={t('search.placeholder')} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{t('search.empty')}</CommandEmpty>
|
||||||
|
{pages.length > 0 ? (
|
||||||
|
<CommandGroup heading={t('search.groups.pages')}>
|
||||||
|
{pages.map((entry) => {
|
||||||
|
const Icon = kindIcon(entry.kind);
|
||||||
|
const label = resolveLabel(entry);
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={entry.id}
|
||||||
|
value={`${label} ${entry.keywords?.join(' ') ?? ''} ${entry.labelKey ?? ''}`}
|
||||||
|
onSelect={() => navigateTo(entry)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4 text-muted-foreground" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
) : null}
|
||||||
|
{commands.length > 0 ? (
|
||||||
|
<CommandGroup heading={t('search.groups.commands')}>
|
||||||
|
{commands.map((entry) => {
|
||||||
|
const Icon = kindIcon(entry.kind);
|
||||||
|
const label = resolveLabel(entry);
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={entry.id}
|
||||||
|
value={`${label} ${entry.keywords?.join(' ') ?? ''}`}
|
||||||
|
onSelect={() => navigateTo(entry)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4 text-muted-foreground" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
) : null}
|
||||||
|
{settings.length > 0 ? (
|
||||||
|
<CommandGroup heading={t('search.groups.settings')}>
|
||||||
|
{settings.map((entry) => {
|
||||||
|
const Icon = kindIcon(entry.kind);
|
||||||
|
const label = resolveLabel(entry);
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={entry.id}
|
||||||
|
value={`${label} ${entry.keywords?.join(' ') ?? ''} ${entry.labelKey ?? ''}`}
|
||||||
|
onSelect={() => navigateTo(entry)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4 text-muted-foreground" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
) : null}
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import type { DashboardGuild, SessionUser } from '@nexumi/shared';
|
import type { DashboardGuild, SessionUser } from '@nexumi/shared';
|
||||||
import type { ReactNode } from 'react';
|
import { Menu } from 'lucide-react';
|
||||||
import { ServerSwitcher } from './server-switcher';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { Sidebar } from './sidebar';
|
import { DashboardCommandPalette } from '@/components/layout/dashboard-command-palette';
|
||||||
import { UserMenu } from './user-menu';
|
import { HashScroll } from '@/components/layout/field-anchor';
|
||||||
|
import { ServerSwitcher } from '@/components/layout/server-switcher';
|
||||||
|
import { Sidebar, SidebarNav } from '@/components/layout/sidebar';
|
||||||
|
import { UserMenu } from '@/components/layout/user-menu';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||||
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
|
|
||||||
interface DashboardShellProps {
|
interface DashboardShellProps {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
@@ -21,15 +29,36 @@ export function DashboardShell({
|
|||||||
showOwnerLink = false,
|
showOwnerLink = false,
|
||||||
children
|
children
|
||||||
}: DashboardShellProps) {
|
}: DashboardShellProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen bg-background">
|
||||||
|
<HashScroll />
|
||||||
<Sidebar guildId={guildId} />
|
<Sidebar guildId={guildId} />
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<header className="flex h-14 items-center justify-between border-b border-border px-6">
|
<header className="sticky top-0 z-40 flex h-14 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur-md supports-[backdrop-filter]:bg-background/70 sm:px-6">
|
||||||
|
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="lg:hidden" aria-label={t('search.openNav')}>
|
||||||
|
<Menu className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="left" className="p-0">
|
||||||
|
<SheetTitle className="sr-only">{t('nav.dashboard')}</SheetTitle>
|
||||||
|
<SidebarNav guildId={guildId} onNavigate={() => setMobileOpen(false)} />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
<ServerSwitcher currentGuild={currentGuild} otherGuilds={otherGuilds} />
|
<ServerSwitcher currentGuild={currentGuild} otherGuilds={otherGuilds} />
|
||||||
|
<div className="ml-auto flex min-w-0 items-center gap-3">
|
||||||
|
<div className="min-w-0 flex-1 sm:flex-initial">
|
||||||
|
<DashboardCommandPalette guildId={guildId} />
|
||||||
|
</div>
|
||||||
<UserMenu user={user} showOwnerLink={showOwnerLink} />
|
<UserMenu user={user} showOwnerLink={showOwnerLink} />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 px-6 py-8">
|
<main className="flex-1 px-4 py-6 sm:px-6 sm:py-8">
|
||||||
<div className="mx-auto w-full max-w-[1200px]">{children}</div>
|
<div className="mx-auto w-full max-w-[1200px]">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
47
apps/webui/src/components/layout/field-anchor.tsx
Normal file
47
apps/webui/src/components/layout/field-anchor.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, type ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/** Stable scroll target for command-palette deep links (`#field-…`). */
|
||||||
|
export function FieldAnchor({
|
||||||
|
id,
|
||||||
|
children,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div id={id} className={cn('scroll-mt-24', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scrolls to `location.hash` after navigation from the command palette. */
|
||||||
|
export function HashScroll() {
|
||||||
|
useEffect(() => {
|
||||||
|
function scrollToHash() {
|
||||||
|
const hash = window.location.hash.replace(/^#/, '');
|
||||||
|
if (!hash) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = document.getElementById(hash);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToHash();
|
||||||
|
window.addEventListener('hashchange', scrollToHash);
|
||||||
|
const timer = window.setTimeout(scrollToHash, 120);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('hashchange', scrollToHash);
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,7 +1,35 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared';
|
import { DASHBOARD_MODULES, type DashboardModuleGroup, type DashboardModuleId } from '@nexumi/shared';
|
||||||
import { LayoutGrid, Settings, ShieldCheck, SlidersHorizontal, Terminal } from 'lucide-react';
|
import {
|
||||||
|
Archive,
|
||||||
|
BarChart3,
|
||||||
|
Cake,
|
||||||
|
CalendarClock,
|
||||||
|
Coins,
|
||||||
|
Gamepad2,
|
||||||
|
Gift,
|
||||||
|
Hash,
|
||||||
|
LayoutGrid,
|
||||||
|
MessageSquareQuote,
|
||||||
|
MessagesSquare,
|
||||||
|
PartyPopper,
|
||||||
|
Radio,
|
||||||
|
Rss,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
ShieldAlert,
|
||||||
|
ShieldCheck,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Sparkles,
|
||||||
|
Star,
|
||||||
|
Terminal,
|
||||||
|
Ticket,
|
||||||
|
UserCheck,
|
||||||
|
Volume2,
|
||||||
|
type LucideIcon
|
||||||
|
} from 'lucide-react';
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
@@ -15,19 +43,55 @@ const MODULE_GROUP_ORDER: DashboardModuleGroup[] = [
|
|||||||
'integrations'
|
'integrations'
|
||||||
];
|
];
|
||||||
|
|
||||||
interface SidebarProps {
|
const MODULE_ICONS: Record<DashboardModuleId, LucideIcon> = {
|
||||||
|
moderation: Shield,
|
||||||
|
automod: ShieldAlert,
|
||||||
|
logging: Hash,
|
||||||
|
welcome: PartyPopper,
|
||||||
|
verification: UserCheck,
|
||||||
|
leveling: Sparkles,
|
||||||
|
economy: Coins,
|
||||||
|
fun: Gamepad2,
|
||||||
|
giveaways: Gift,
|
||||||
|
tickets: Ticket,
|
||||||
|
selfroles: UserCheck,
|
||||||
|
tags: MessageSquareQuote,
|
||||||
|
starboard: Star,
|
||||||
|
suggestions: MessagesSquare,
|
||||||
|
birthdays: Cake,
|
||||||
|
tempvoice: Volume2,
|
||||||
|
stats: BarChart3,
|
||||||
|
feeds: Rss,
|
||||||
|
scheduler: CalendarClock,
|
||||||
|
guildbackup: Archive
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SidebarNavProps {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
|
onNavigate?: () => void;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavLink({ href, active, children }: { href: string; active: boolean; children: React.ReactNode }) {
|
function NavLink({
|
||||||
|
href,
|
||||||
|
active,
|
||||||
|
onNavigate,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
active: boolean;
|
||||||
|
onNavigate?: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
|
onClick={onNavigate}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
'flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-medium transition-colors',
|
||||||
active
|
active
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'border-l-2 border-primary bg-primary/10 pl-2 text-foreground'
|
||||||
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
|
: 'border-l-2 border-transparent text-muted-foreground hover:bg-accent/60 hover:text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -35,7 +99,7 @@ function NavLink({ href, active, children }: { href: string; active: boolean; ch
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ guildId }: SidebarProps) {
|
export function SidebarNav({ guildId, onNavigate, className }: SidebarNavProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const base = `/dashboard/${guildId}`;
|
const base = `/dashboard/${guildId}`;
|
||||||
@@ -46,39 +110,50 @@ export function Sidebar({ guildId }: SidebarProps) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex h-full w-60 flex-col gap-6 overflow-y-auto border-r border-border px-3 py-4">
|
<nav className={cn('flex h-full flex-col gap-5 overflow-y-auto px-3 py-4', className)}>
|
||||||
<div className="flex flex-col gap-1">
|
<Link
|
||||||
<NavLink href={base} active={pathname === base}>
|
href={base}
|
||||||
<LayoutGrid className="size-4" />
|
onClick={onNavigate}
|
||||||
|
className="mb-1 flex items-center gap-2.5 rounded-md px-2.5 py-1.5 transition-opacity hover:opacity-90"
|
||||||
|
>
|
||||||
|
<Image src="/nexumi-logo.svg" alt="" width={28} height={28} className="size-7" />
|
||||||
|
<span className="text-base font-semibold tracking-tight">Nexumi</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<NavLink href={base} active={pathname === base} onNavigate={onNavigate}>
|
||||||
|
<LayoutGrid className="size-4 shrink-0" />
|
||||||
{t('nav.overview')}
|
{t('nav.overview')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink href={`${base}/settings`} active={pathname === `${base}/settings`}>
|
<NavLink href={`${base}/settings`} active={pathname === `${base}/settings`} onNavigate={onNavigate}>
|
||||||
<Settings className="size-4" />
|
<Settings className="size-4 shrink-0" />
|
||||||
{t('nav.settings')}
|
{t('nav.settings')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink href={`${base}/modules`} active={pathname === `${base}/modules`}>
|
<NavLink href={`${base}/modules`} active={pathname === `${base}/modules`} onNavigate={onNavigate}>
|
||||||
<SlidersHorizontal className="size-4" />
|
<SlidersHorizontal className="size-4 shrink-0" />
|
||||||
{t('nav.modules')}
|
{t('nav.modules')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink href={`${base}/access`} active={pathname === `${base}/access`}>
|
<NavLink href={`${base}/access`} active={pathname === `${base}/access`} onNavigate={onNavigate}>
|
||||||
<ShieldCheck className="size-4" />
|
<ShieldCheck className="size-4 shrink-0" />
|
||||||
{t('nav.access')}
|
{t('nav.access')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink href={`${base}/commands`} active={pathname === `${base}/commands`}>
|
<NavLink href={`${base}/commands`} active={pathname === `${base}/commands`} onNavigate={onNavigate}>
|
||||||
<Terminal className="size-4" />
|
<Terminal className="size-4 shrink-0" />
|
||||||
{t('nav.commands')}
|
{t('nav.commands')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{groups.map(({ group, modules }) => (
|
{groups.map(({ group, modules }) => (
|
||||||
<div key={group} className="flex flex-col gap-1">
|
<div key={group} className="flex flex-col gap-0.5">
|
||||||
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
<p className="mb-1 px-2.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/80">
|
||||||
{t(`moduleGroups.${group}`)}
|
{t(`moduleGroups.${group}`)}
|
||||||
</p>
|
</p>
|
||||||
{modules.map((module) => {
|
{modules.map((module) => {
|
||||||
const href = `${base}/${module.href}`;
|
const href = `${base}/${module.href}`;
|
||||||
|
const Icon = MODULE_ICONS[module.id] ?? Radio;
|
||||||
return (
|
return (
|
||||||
<NavLink key={module.id} href={href} active={pathname === href}>
|
<NavLink key={module.id} href={href} active={pathname === href} onNavigate={onNavigate}>
|
||||||
|
<Icon className="size-4 shrink-0" />
|
||||||
{t(`modules.${module.id}.label`)}
|
{t(`modules.${module.id}.label`)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
@@ -88,3 +163,12 @@ export function Sidebar({ guildId }: SidebarProps) {
|
|||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Desktop sidebar chrome. */
|
||||||
|
export function Sidebar({ guildId }: { guildId: string }) {
|
||||||
|
return (
|
||||||
|
<aside className="hidden h-screen w-60 shrink-0 border-r border-border bg-card/40 lg:sticky lg:top-0 lg:block">
|
||||||
|
<SidebarNav guildId={guildId} />
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { AutomodConfigDashboard } from '@nexumi/shared';
|
import type { AutomodConfigDashboard } from '@nexumi/shared';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -44,6 +45,7 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
<CardDescription>{t('modulePages.automod.description')}</CardDescription>
|
<CardDescription>{t('modulePages.automod.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
<FieldAnchor id="field-automod-enabled">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label>{t('modulePages.automod.enabledLabel')}</Label>
|
<Label>{t('modulePages.automod.enabledLabel')}</Label>
|
||||||
@@ -54,9 +56,11 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-anti-raid">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.automod.antiRaidTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.automod.antiRaidTitle')}</CardTitle>
|
||||||
@@ -97,7 +101,9 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
<p className="text-sm text-muted-foreground">{t('modulePages.automod.antiRaidActionHint')}</p>
|
<p className="text-sm text-muted-foreground">{t('modulePages.automod.antiRaidActionHint')}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-anti-nuke">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.automod.antiNukeTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.automod.antiNukeTitle')}</CardTitle>
|
||||||
@@ -146,6 +152,7 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FieldAnchor id="field-lockdown">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label>{t('modulePages.automod.lockdownActive')}</Label>
|
<Label>{t('modulePages.automod.lockdownActive')}</Label>
|
||||||
@@ -156,8 +163,10 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, lockdownActive: checked }))}
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, lockdownActive: checked }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SettingsForm>
|
</SettingsForm>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { BirthdayConfigDashboard } from '@nexumi/shared';
|
import type { BirthdayConfigDashboard } from '@nexumi/shared';
|
||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -48,28 +49,38 @@ export function BirthdaysForm({ guildId, initialValue }: BirthdaysFormProps) {
|
|||||||
<CardDescription>{t('modulePages.birthdays.description')}</CardDescription>
|
<CardDescription>{t('modulePages.birthdays.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<FieldAnchor id="field-birthdays-enabled">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.birthdays.enabledLabel')}</Label>
|
<Label>{t('modulePages.birthdays.enabledLabel')}</Label>
|
||||||
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
|
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<FieldAnchor id="field-birthdays-channel">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={channelId}>{t('modulePages.birthdays.channelId')}</Label>
|
<Label htmlFor={channelId}>{t('modulePages.birthdays.channelId')}</Label>
|
||||||
<Input id={channelId} value={value.channelId} onChange={(event) => setValue((prev) => ({ ...prev, channelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
<Input id={channelId} value={value.channelId} onChange={(event) => setValue((prev) => ({ ...prev, channelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-birthdays-role">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={roleId}>{t('modulePages.birthdays.roleId')}</Label>
|
<Label htmlFor={roleId}>{t('modulePages.birthdays.roleId')}</Label>
|
||||||
<Input id={roleId} value={value.roleId} onChange={(event) => setValue((prev) => ({ ...prev, roleId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
<Input id={roleId} value={value.roleId} onChange={(event) => setValue((prev) => ({ ...prev, roleId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-birthdays-timezone">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={timezoneId}>{t('modulePages.birthdays.timezone')}</Label>
|
<Label htmlFor={timezoneId}>{t('modulePages.birthdays.timezone')}</Label>
|
||||||
<Input id={timezoneId} value={value.timezone} onChange={(event) => setValue((prev) => ({ ...prev, timezone: event.target.value }))} placeholder="Europe/Berlin" />
|
<Input id={timezoneId} value={value.timezone} onChange={(event) => setValue((prev) => ({ ...prev, timezone: event.target.value }))} placeholder="Europe/Berlin" />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
|
<FieldAnchor id="field-birthdays-message">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.birthdays.message')}</Label>
|
<Label>{t('modulePages.birthdays.message')}</Label>
|
||||||
<Textarea value={value.message ?? ''} onChange={(event) => setValue((prev) => ({ ...prev, message: event.target.value || null }))} placeholder={t('modulePages.birthdays.messagePlaceholder')} />
|
<Textarea value={value.message ?? ''} onChange={(event) => setValue((prev) => ({ ...prev, message: event.target.value || null }))} placeholder={t('modulePages.birthdays.messagePlaceholder')} />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { CaseDashboard } from '@nexumi/shared';
|
import type { CaseDashboard } from '@nexumi/shared';
|
||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -48,6 +49,7 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) {
|
|||||||
}, [action, guildId, search, t]);
|
}, [action, guildId, search, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<FieldAnchor id="field-mod-cases">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.moderation.casesTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.moderation.casesTitle')}</CardTitle>
|
||||||
@@ -126,5 +128,6 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import type { CommandOverrideDashboard } from '@nexumi/shared';
|
import type { CommandOverrideDashboard } from '@nexumi/shared';
|
||||||
import { RotateCcw } from 'lucide-react';
|
import { RotateCcw } from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -32,9 +33,17 @@ interface CommandsManagerProps {
|
|||||||
|
|
||||||
export function CommandsManager({ guildId, initialCommands }: CommandsManagerProps) {
|
export function CommandsManager({ guildId, initialCommands }: CommandsManagerProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [commands, setCommands] = useState<LocalOverride[]>(initialCommands);
|
const [commands, setCommands] = useState<LocalOverride[]>(initialCommands);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const q = searchParams.get('q');
|
||||||
|
if (q) {
|
||||||
|
setSearch(q);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const query = search.trim().toLowerCase();
|
const query = search.trim().toLowerCase();
|
||||||
if (!query) {
|
if (!query) {
|
||||||
@@ -64,7 +73,9 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
|
|||||||
cooldownSeconds: entry.cooldownSeconds
|
cooldownSeconds: entry.cooldownSeconds
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const body = (await response.json().catch(() => null)) as (CommandOverrideDashboard & { error?: string }) | null;
|
const body = (await response.json().catch(() => null)) as
|
||||||
|
| (CommandOverrideDashboard & { error?: string })
|
||||||
|
| null;
|
||||||
if (!response.ok || !body) {
|
if (!response.ok || !body) {
|
||||||
toast.error(body?.error ?? t('common.saveError'));
|
toast.error(body?.error ?? t('common.saveError'));
|
||||||
return;
|
return;
|
||||||
@@ -81,7 +92,9 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
|
|||||||
async function handleReset(entry: LocalOverride) {
|
async function handleReset(entry: LocalOverride) {
|
||||||
patchLocal(entry.commandName, { saving: true });
|
patchLocal(entry.commandName, { saving: true });
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/guilds/${guildId}/commands/${entry.commandName}`, { method: 'DELETE' });
|
const response = await fetch(`/api/guilds/${guildId}/commands/${entry.commandName}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
const body = (await response.json().catch(() => null)) as CommandOverrideDashboard | null;
|
const body = (await response.json().catch(() => null)) as CommandOverrideDashboard | null;
|
||||||
if (!response.ok || !body) {
|
if (!response.ok || !body) {
|
||||||
toast.error(t('common.saveError'));
|
toast.error(t('common.saveError'));
|
||||||
@@ -147,7 +160,9 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
|
|||||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedRoleIds')}</p>
|
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedRoleIds')}</p>
|
||||||
<Input
|
<Input
|
||||||
value={toCsv(entry.allowedRoleIds)}
|
value={toCsv(entry.allowedRoleIds)}
|
||||||
onChange={(event) => patchLocal(entry.commandName, { allowedRoleIds: fromCsv(event.target.value) })}
|
onChange={(event) =>
|
||||||
|
patchLocal(entry.commandName, { allowedRoleIds: fromCsv(event.target.value) })
|
||||||
|
}
|
||||||
placeholder={t('modulePages.commands.idsPlaceholder')}
|
placeholder={t('modulePages.commands.idsPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,7 +170,9 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
|
|||||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedRoleIds')}</p>
|
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedRoleIds')}</p>
|
||||||
<Input
|
<Input
|
||||||
value={toCsv(entry.deniedRoleIds)}
|
value={toCsv(entry.deniedRoleIds)}
|
||||||
onChange={(event) => patchLocal(entry.commandName, { deniedRoleIds: fromCsv(event.target.value) })}
|
onChange={(event) =>
|
||||||
|
patchLocal(entry.commandName, { deniedRoleIds: fromCsv(event.target.value) })
|
||||||
|
}
|
||||||
placeholder={t('modulePages.commands.idsPlaceholder')}
|
placeholder={t('modulePages.commands.idsPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,7 +181,9 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
|
|||||||
<Input
|
<Input
|
||||||
value={toCsv(entry.allowedChannelIds)}
|
value={toCsv(entry.allowedChannelIds)}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
patchLocal(entry.commandName, { allowedChannelIds: fromCsv(event.target.value) })
|
patchLocal(entry.commandName, {
|
||||||
|
allowedChannelIds: fromCsv(event.target.value)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
placeholder={t('modulePages.commands.idsPlaceholder')}
|
placeholder={t('modulePages.commands.idsPlaceholder')}
|
||||||
/>
|
/>
|
||||||
@@ -174,18 +193,32 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
|
|||||||
<Input
|
<Input
|
||||||
value={toCsv(entry.deniedChannelIds)}
|
value={toCsv(entry.deniedChannelIds)}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
patchLocal(entry.commandName, { deniedChannelIds: fromCsv(event.target.value) })
|
patchLocal(entry.commandName, {
|
||||||
|
deniedChannelIds: fromCsv(event.target.value)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
placeholder={t('modulePages.commands.idsPlaceholder')}
|
placeholder={t('modulePages.commands.idsPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button type="button" variant="ghost" size="sm" disabled={entry.saving} onClick={() => handleReset(entry)}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={entry.saving}
|
||||||
|
onClick={() => handleReset(entry)}
|
||||||
|
>
|
||||||
<RotateCcw className="size-4" />
|
<RotateCcw className="size-4" />
|
||||||
{t('modulePages.commands.reset')}
|
{t('modulePages.commands.reset')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="outline" size="sm" disabled={entry.saving} onClick={() => handleSave(entry)}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={entry.saving}
|
||||||
|
onClick={() => handleSave(entry)}
|
||||||
|
>
|
||||||
{entry.saving ? t('common.saving') : t('common.save')}
|
{entry.saving ? t('common.saving') : t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { EconomyConfigDashboard } from '@nexumi/shared';
|
import type { EconomyConfigDashboard } from '@nexumi/shared';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -44,6 +45,7 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
<CardDescription>{t('modulePages.economy.description')}</CardDescription>
|
<CardDescription>{t('modulePages.economy.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<FieldAnchor id="field-economy-enabled">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.economy.enabledLabel')}</Label>
|
<Label>{t('modulePages.economy.enabledLabel')}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -51,7 +53,9 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FieldAnchor id="field-economy-currency-name">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.economy.currencyName')}</Label>
|
<Label>{t('modulePages.economy.currencyName')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -59,6 +63,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, currencyName: event.target.value }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, currencyName: event.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-economy-currency-symbol">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.economy.currencySymbol')}</Label>
|
<Label>{t('modulePages.economy.currencySymbol')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -66,6 +72,7 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, currencySymbol: event.target.value }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, currencySymbol: event.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -75,6 +82,7 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
<CardTitle>{t('modulePages.economy.amountsTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.economy.amountsTitle')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FieldAnchor id="field-economy-daily">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.economy.dailyAmount')}</Label>
|
<Label>{t('modulePages.economy.dailyAmount')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -84,6 +92,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, dailyAmount: Number(event.target.value) || 0 }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, dailyAmount: Number(event.target.value) || 0 }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-economy-weekly">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.economy.weeklyAmount')}</Label>
|
<Label>{t('modulePages.economy.weeklyAmount')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -93,6 +103,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, weeklyAmount: Number(event.target.value) || 0 }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, weeklyAmount: Number(event.target.value) || 0 }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-economy-work-min">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.economy.workMin')}</Label>
|
<Label>{t('modulePages.economy.workMin')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -102,6 +114,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, workMin: Number(event.target.value) || 0 }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, workMin: Number(event.target.value) || 0 }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-economy-work-max">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.economy.workMax')}</Label>
|
<Label>{t('modulePages.economy.workMax')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -111,6 +125,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, workMax: Number(event.target.value) || 0 }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, workMax: Number(event.target.value) || 0 }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-economy-work-cooldown">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.economy.workCooldownSeconds')}</Label>
|
<Label>{t('modulePages.economy.workCooldownSeconds')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -122,6 +138,8 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-economy-starting">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.economy.startingBalance')}</Label>
|
<Label>{t('modulePages.economy.startingBalance')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -133,6 +151,7 @@ export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { SocialFeedDashboard } from '@nexumi/shared';
|
|||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -152,6 +153,7 @@ export function FeedsManager({ guildId, initialFeeds }: FeedsManagerProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<FieldAnchor id="field-feeds-add">
|
||||||
<div className="space-y-3 rounded-md border border-dashed border-border p-4">
|
<div className="space-y-3 rounded-md border border-dashed border-border p-4">
|
||||||
<p className="text-sm font-medium">{t('modulePages.feeds.addFeed')}</p>
|
<p className="text-sm font-medium">{t('modulePages.feeds.addFeed')}</p>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
@@ -208,6 +210,7 @@ export function FeedsManager({ guildId, initialFeeds }: FeedsManagerProps) {
|
|||||||
{t('modulePages.feeds.addFeed')}
|
{t('modulePages.feeds.addFeed')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { FunConfigDashboard } from '@nexumi/shared';
|
import type { FunConfigDashboard } from '@nexumi/shared';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -42,6 +43,7 @@ export function FunForm({ guildId, initialValue }: FunFormProps) {
|
|||||||
<CardDescription>{t('modulePages.fun.description')}</CardDescription>
|
<CardDescription>{t('modulePages.fun.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<FieldAnchor id="field-fun-enabled">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label>{t('modulePages.fun.enabledLabel')}</Label>
|
<Label>{t('modulePages.fun.enabledLabel')}</Label>
|
||||||
@@ -52,6 +54,8 @@ export function FunForm({ guildId, initialValue }: FunFormProps) {
|
|||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-fun-media">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label>{t('modulePages.fun.mediaEnabledLabel')}</Label>
|
<Label>{t('modulePages.fun.mediaEnabledLabel')}</Label>
|
||||||
@@ -62,6 +66,7 @@ export function FunForm({ guildId, initialValue }: FunFormProps) {
|
|||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, mediaEnabled: checked }))}
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, mediaEnabled: checked }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { GiveawayDashboard } from '@nexumi/shared';
|
|||||||
import { Pause, Play, Plus, Trash2 } from 'lucide-react';
|
import { Pause, Play, Plus, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -116,6 +117,7 @@ export function GiveawaysManager({ guildId, initialGiveaways }: GiveawaysManager
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<FieldAnchor id="field-giveaways-create">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.giveaways.createTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.giveaways.createTitle')}</CardTitle>
|
||||||
@@ -187,6 +189,9 @@ export function GiveawaysManager({ guildId, initialGiveaways }: GiveawaysManager
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-giveaways-list">
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -281,6 +286,7 @@ export function GiveawaysManager({ guildId, initialGiveaways }: GiveawaysManager
|
|||||||
})}
|
})}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { GuildBackupDashboard } from '@nexumi/shared';
|
|||||||
import { Download, Info, Plus, RotateCcw, Trash2 } from 'lucide-react';
|
import { Download, Info, Plus, RotateCcw, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -130,6 +131,7 @@ export function GuildBackupManager({ guildId, initialBackups, isOwner }: GuildBa
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<FieldAnchor id="field-backup">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -211,5 +213,6 @@ export function GuildBackupManager({ guildId, initialBackups, isOwner }: GuildBa
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { LevelingConfigDashboard } from '@nexumi/shared';
|
import type { LevelingConfigDashboard } from '@nexumi/shared';
|
||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -126,6 +127,7 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
|
|||||||
<CardDescription>{t('modulePages.leveling.description')}</CardDescription>
|
<CardDescription>{t('modulePages.leveling.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<FieldAnchor id="field-leveling-enabled">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.leveling.enabledLabel')}</Label>
|
<Label>{t('modulePages.leveling.enabledLabel')}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -133,8 +135,10 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
|
|||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<FieldAnchor id="field-leveling-text-min">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.leveling.textXpMin')}</Label>
|
<Label>{t('modulePages.leveling.textXpMin')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -144,6 +148,8 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, textXpMin: Number(event.target.value) || 1 }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, textXpMin: Number(event.target.value) || 1 }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-leveling-text-max">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.leveling.textXpMax')}</Label>
|
<Label>{t('modulePages.leveling.textXpMax')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -153,6 +159,8 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, textXpMax: Number(event.target.value) || 1 }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, textXpMax: Number(event.target.value) || 1 }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-leveling-cooldown">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.leveling.textCooldownSeconds')}</Label>
|
<Label>{t('modulePages.leveling.textCooldownSeconds')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -164,8 +172,10 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-leveling-voice">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.leveling.voiceXpPerMinute')}</Label>
|
<Label>{t('modulePages.leveling.voiceXpPerMinute')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -178,7 +188,9 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-leveling-no-xp-channels">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={noXpChannelsId}>{t('modulePages.leveling.noXpChannels')}</Label>
|
<Label htmlFor={noXpChannelsId}>{t('modulePages.leveling.noXpChannels')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -188,6 +200,8 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
|
|||||||
placeholder={t('modulePages.logging.idsPlaceholder')}
|
placeholder={t('modulePages.logging.idsPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-leveling-no-xp-roles">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={noXpRolesId}>{t('modulePages.leveling.noXpRoles')}</Label>
|
<Label htmlFor={noXpRolesId}>{t('modulePages.leveling.noXpRoles')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -197,8 +211,10 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
|
|||||||
placeholder={t('modulePages.logging.idsPlaceholder')}
|
placeholder={t('modulePages.logging.idsPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FieldAnchor id="field-leveling-role-multipliers">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.leveling.roleMultipliers')}</Label>
|
<Label>{t('modulePages.leveling.roleMultipliers')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -210,6 +226,8 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
|
|||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-leveling-channel-multipliers">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.leveling.channelMultipliers')}</Label>
|
<Label>{t('modulePages.leveling.channelMultipliers')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -221,10 +239,12 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
|
|||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-leveling-levelup">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.leveling.levelUpTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.leveling.levelUpTitle')}</CardTitle>
|
||||||
@@ -266,6 +286,7 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
|
|||||||
placeholder="Congrats {user}, you reached level {level}!"
|
placeholder="Congrats {user}, you reached level {level}!"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<FieldAnchor id="field-leveling-stack">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.leveling.stackRewards')}</Label>
|
<Label>{t('modulePages.leveling.stackRewards')}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -273,9 +294,12 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
|
|||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, stackRewards: checked }))}
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, stackRewards: checked }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-leveling-rank-card">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.leveling.rankCardTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.leveling.rankCardTitle')}</CardTitle>
|
||||||
@@ -313,6 +337,7 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SettingsForm>
|
</SettingsForm>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { LogChannelMapping, LogEventTypeDashboard, LoggingConfigDashboard } from '@nexumi/shared';
|
import type { LogChannelMapping, LogEventTypeDashboard, LoggingConfigDashboard } from '@nexumi/shared';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -129,6 +130,7 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
|
|||||||
<CardDescription>{t('modulePages.logging.description')}</CardDescription>
|
<CardDescription>{t('modulePages.logging.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<FieldAnchor id="field-logging-enabled">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.logging.enabledLabel')}</Label>
|
<Label>{t('modulePages.logging.enabledLabel')}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -136,6 +138,8 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
|
|||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-logging-ignore-bots">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.logging.ignoreBotsLabel')}</Label>
|
<Label>{t('modulePages.logging.ignoreBotsLabel')}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -143,6 +147,8 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
|
|||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, ignoreBots: checked }))}
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, ignoreBots: checked }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-logging-ignored-channels">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={ignoredChannelsId}>{t('modulePages.logging.ignoredChannelsLabel')}</Label>
|
<Label htmlFor={ignoredChannelsId}>{t('modulePages.logging.ignoredChannelsLabel')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -153,6 +159,8 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
|
|||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">{t('modulePages.logging.idsHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('modulePages.logging.idsHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-logging-ignored-roles">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={ignoredRolesId}>{t('modulePages.logging.ignoredRolesLabel')}</Label>
|
<Label htmlFor={ignoredRolesId}>{t('modulePages.logging.ignoredRolesLabel')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -163,6 +171,8 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
|
|||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">{t('modulePages.logging.idsHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('modulePages.logging.idsHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-logging-retention">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="logging-retention">{t('modulePages.logging.retentionLabel')}</Label>
|
<Label htmlFor="logging-retention">{t('modulePages.logging.retentionLabel')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -178,9 +188,11 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
|
|||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">{t('modulePages.logging.retentionHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('modulePages.logging.retentionHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-logging-channels">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.logging.channelsTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.logging.channelsTitle')}</CardTitle>
|
||||||
@@ -268,6 +280,7 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SettingsForm>
|
</SettingsForm>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { EscalationAction, EscalationRuleDashboard, ModerationDashboard } from '@nexumi/shared';
|
import type { EscalationAction, EscalationRuleDashboard, ModerationDashboard } from '@nexumi/shared';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -160,6 +161,7 @@ export function ModerationForm({ guildId, initialValue }: ModerationFormProps) {
|
|||||||
<CardDescription>{t('modulePages.moderation.description')}</CardDescription>
|
<CardDescription>{t('modulePages.moderation.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
<FieldAnchor id="field-mod-enabled">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label>{t('modulePages.moderation.enabledLabel')}</Label>
|
<Label>{t('modulePages.moderation.enabledLabel')}</Label>
|
||||||
@@ -170,9 +172,11 @@ export function ModerationForm({ guildId, initialValue }: ModerationFormProps) {
|
|||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, moderationEnabled: checked }))}
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, moderationEnabled: checked }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-mod-escalations">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.moderation.escalationsTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.moderation.escalationsTitle')}</CardTitle>
|
||||||
@@ -225,6 +229,7 @@ export function ModerationForm({ guildId, initialValue }: ModerationFormProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SettingsForm>
|
</SettingsForm>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ScheduledMessageDashboard } from '@nexumi/shared';
|
|||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -97,6 +98,7 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<FieldAnchor id="field-scheduler-create">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.scheduler.createTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.scheduler.createTitle')}</CardTitle>
|
||||||
@@ -153,6 +155,7 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { SelfRoleBehavior, SelfRoleEntry, SelfRoleMode, SelfRolePanelDashbo
|
|||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -153,6 +154,7 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<FieldAnchor id="field-selfroles-create">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.selfroles.createTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.selfroles.createTitle')}</CardTitle>
|
||||||
@@ -219,6 +221,9 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-selfroles-list">
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -313,6 +318,7 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
|||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { StarboardConfigDashboard } from '@nexumi/shared';
|
import type { StarboardConfigDashboard } from '@nexumi/shared';
|
||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -83,33 +84,45 @@ export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
|
|||||||
<CardDescription>{t('modulePages.starboard.description')}</CardDescription>
|
<CardDescription>{t('modulePages.starboard.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<FieldAnchor id="field-starboard-enabled">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.starboard.enabledLabel')}</Label>
|
<Label>{t('modulePages.starboard.enabledLabel')}</Label>
|
||||||
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
|
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<FieldAnchor id="field-starboard-channel">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={channelId}>{t('modulePages.starboard.channelId')}</Label>
|
<Label htmlFor={channelId}>{t('modulePages.starboard.channelId')}</Label>
|
||||||
<Input id={channelId} value={value.channelId} onChange={(event) => setValue((prev) => ({ ...prev, channelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
<Input id={channelId} value={value.channelId} onChange={(event) => setValue((prev) => ({ ...prev, channelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-starboard-emoji">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={emojiId}>{t('modulePages.starboard.emoji')}</Label>
|
<Label htmlFor={emojiId}>{t('modulePages.starboard.emoji')}</Label>
|
||||||
<Input id={emojiId} value={value.emoji} onChange={(event) => setValue((prev) => ({ ...prev, emoji: event.target.value }))} />
|
<Input id={emojiId} value={value.emoji} onChange={(event) => setValue((prev) => ({ ...prev, emoji: event.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-starboard-threshold">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={thresholdId}>{t('modulePages.starboard.threshold')}</Label>
|
<Label htmlFor={thresholdId}>{t('modulePages.starboard.threshold')}</Label>
|
||||||
<Input id={thresholdId} type="number" min={1} value={value.threshold} onChange={(event) => setValue((prev) => ({ ...prev, threshold: Number(event.target.value) || 1 }))} />
|
<Input id={thresholdId} type="number" min={1} value={value.threshold} onChange={(event) => setValue((prev) => ({ ...prev, threshold: Number(event.target.value) || 1 }))} />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
|
<FieldAnchor id="field-starboard-self">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.starboard.allowSelfStar')}</Label>
|
<Label>{t('modulePages.starboard.allowSelfStar')}</Label>
|
||||||
<Switch checked={value.allowSelfStar} onCheckedChange={(allowSelfStar) => setValue((prev) => ({ ...prev, allowSelfStar }))} />
|
<Switch checked={value.allowSelfStar} onCheckedChange={(allowSelfStar) => setValue((prev) => ({ ...prev, allowSelfStar }))} />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-starboard-ignored">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={ignoredId}>{t('modulePages.starboard.ignoredChannelIds')}</Label>
|
<Label htmlFor={ignoredId}>{t('modulePages.starboard.ignoredChannelIds')}</Label>
|
||||||
<Textarea id={ignoredId} value={value.ignoredChannelIdsText} onChange={(event) => setValue((prev) => ({ ...prev, ignoredChannelIdsText: event.target.value }))} />
|
<Textarea id={ignoredId} value={value.ignoredChannelIdsText} onChange={(event) => setValue((prev) => ({ ...prev, ignoredChannelIdsText: event.target.value }))} />
|
||||||
<p className="text-xs text-muted-foreground">{t('modulePages.starboard.idsHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('modulePages.starboard.idsHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { StatsConfigDashboard } from '@nexumi/shared';
|
import type { StatsConfigDashboard } from '@nexumi/shared';
|
||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -50,31 +51,39 @@ export function StatsForm({ guildId, initialValue }: StatsFormProps) {
|
|||||||
<CardDescription>{t('modulePages.stats.description')}</CardDescription>
|
<CardDescription>{t('modulePages.stats.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<FieldAnchor id="field-stats-enabled">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.stats.enabledLabel')}</Label>
|
<Label>{t('modulePages.stats.enabledLabel')}</Label>
|
||||||
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
|
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FieldAnchor id="field-stats-members">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={membersId}>{t('modulePages.stats.membersChannelId')}</Label>
|
<Label htmlFor={membersId}>{t('modulePages.stats.membersChannelId')}</Label>
|
||||||
<Input id={membersId} value={value.membersChannelId} onChange={(event) => setValue((prev) => ({ ...prev, membersChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
<Input id={membersId} value={value.membersChannelId} onChange={(event) => setValue((prev) => ({ ...prev, membersChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={membersTemplateId}>{t('modulePages.stats.membersTemplate')}</Label>
|
<Label htmlFor={membersTemplateId}>{t('modulePages.stats.membersTemplate')}</Label>
|
||||||
<Input id={membersTemplateId} value={value.membersTemplate} onChange={(event) => setValue((prev) => ({ ...prev, membersTemplate: event.target.value }))} />
|
<Input id={membersTemplateId} value={value.membersTemplate} onChange={(event) => setValue((prev) => ({ ...prev, membersTemplate: event.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
|
<FieldAnchor id="field-stats-online">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={onlineId}>{t('modulePages.stats.onlineChannelId')}</Label>
|
<Label htmlFor={onlineId}>{t('modulePages.stats.onlineChannelId')}</Label>
|
||||||
<Input id={onlineId} value={value.onlineChannelId} onChange={(event) => setValue((prev) => ({ ...prev, onlineChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
<Input id={onlineId} value={value.onlineChannelId} onChange={(event) => setValue((prev) => ({ ...prev, onlineChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={onlineTemplateId}>{t('modulePages.stats.onlineTemplate')}</Label>
|
<Label htmlFor={onlineTemplateId}>{t('modulePages.stats.onlineTemplate')}</Label>
|
||||||
<Input id={onlineTemplateId} value={value.onlineTemplate} onChange={(event) => setValue((prev) => ({ ...prev, onlineTemplate: event.target.value }))} />
|
<Input id={onlineTemplateId} value={value.onlineTemplate} onChange={(event) => setValue((prev) => ({ ...prev, onlineTemplate: event.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
|
<FieldAnchor id="field-stats-boosts">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={boostsId}>{t('modulePages.stats.boostsChannelId')}</Label>
|
<Label htmlFor={boostsId}>{t('modulePages.stats.boostsChannelId')}</Label>
|
||||||
<Input id={boostsId} value={value.boostsChannelId} onChange={(event) => setValue((prev) => ({ ...prev, boostsChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
<Input id={boostsId} value={value.boostsChannelId} onChange={(event) => setValue((prev) => ({ ...prev, boostsChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={boostsTemplateId}>{t('modulePages.stats.boostsTemplate')}</Label>
|
<Label htmlFor={boostsTemplateId}>{t('modulePages.stats.boostsTemplate')}</Label>
|
||||||
<Input id={boostsTemplateId} value={value.boostsTemplate} onChange={(event) => setValue((prev) => ({ ...prev, boostsTemplate: event.target.value }))} />
|
<Input id={boostsTemplateId} value={value.boostsTemplate} onChange={(event) => setValue((prev) => ({ ...prev, boostsTemplate: event.target.value }))} />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { SuggestionConfigDashboard } from '@nexumi/shared';
|
import type { SuggestionConfigDashboard } from '@nexumi/shared';
|
||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -53,24 +54,33 @@ export function SuggestionsConfigForm({ guildId, initialValue }: SuggestionsConf
|
|||||||
<CardDescription>{t('modulePages.suggestions.configDescription')}</CardDescription>
|
<CardDescription>{t('modulePages.suggestions.configDescription')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<FieldAnchor id="field-suggestions-enabled">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.suggestions.enabledLabel')}</Label>
|
<Label>{t('modulePages.suggestions.enabledLabel')}</Label>
|
||||||
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
|
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<FieldAnchor id="field-suggestions-open">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={openId}>{t('modulePages.suggestions.openChannelId')}</Label>
|
<Label htmlFor={openId}>{t('modulePages.suggestions.openChannelId')}</Label>
|
||||||
<Input id={openId} value={value.openChannelId} onChange={(event) => setValue((prev) => ({ ...prev, openChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
<Input id={openId} value={value.openChannelId} onChange={(event) => setValue((prev) => ({ ...prev, openChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-suggestions-approved">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={approvedId}>{t('modulePages.suggestions.approvedChannelId')}</Label>
|
<Label htmlFor={approvedId}>{t('modulePages.suggestions.approvedChannelId')}</Label>
|
||||||
<Input id={approvedId} value={value.approvedChannelId} onChange={(event) => setValue((prev) => ({ ...prev, approvedChannelId: event.target.value.trim() }))} placeholder={t('common.optional')} />
|
<Input id={approvedId} value={value.approvedChannelId} onChange={(event) => setValue((prev) => ({ ...prev, approvedChannelId: event.target.value.trim() }))} placeholder={t('common.optional')} />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-suggestions-denied">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={deniedId}>{t('modulePages.suggestions.deniedChannelId')}</Label>
|
<Label htmlFor={deniedId}>{t('modulePages.suggestions.deniedChannelId')}</Label>
|
||||||
<Input id={deniedId} value={value.deniedChannelId} onChange={(event) => setValue((prev) => ({ ...prev, deniedChannelId: event.target.value.trim() }))} placeholder={t('common.optional')} />
|
<Input id={deniedId} value={value.deniedChannelId} onChange={(event) => setValue((prev) => ({ ...prev, deniedChannelId: event.target.value.trim() }))} placeholder={t('common.optional')} />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
|
<FieldAnchor id="field-suggestions-thread">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label>{t('modulePages.suggestions.createThread')}</Label>
|
<Label>{t('modulePages.suggestions.createThread')}</Label>
|
||||||
@@ -78,6 +88,7 @@ export function SuggestionsConfigForm({ guildId, initialValue }: SuggestionsConf
|
|||||||
</div>
|
</div>
|
||||||
<Switch checked={value.createThread} onCheckedChange={(createThread) => setValue((prev) => ({ ...prev, createThread }))} />
|
<Switch checked={value.createThread} onCheckedChange={(createThread) => setValue((prev) => ({ ...prev, createThread }))} />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { SuggestionAction, SuggestionDashboard } from '@nexumi/shared';
|
import type { SuggestionAction, SuggestionDashboard } from '@nexumi/shared';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Badge, type BadgeProps } from '@/components/ui/badge';
|
import { Badge, type BadgeProps } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -82,6 +83,7 @@ export function SuggestionsListManager({ guildId, initialSuggestions }: Suggesti
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<FieldAnchor id="field-suggestions-list">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -174,5 +176,6 @@ export function SuggestionsListManager({ guildId, initialSuggestions }: Suggesti
|
|||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { TagDashboard, TagResponseType } from '@nexumi/shared';
|
|||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -144,6 +145,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<FieldAnchor id="field-tags-create">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.tags.createTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.tags.createTitle')}</CardTitle>
|
||||||
@@ -195,6 +197,9 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-tags-list">
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -256,6 +261,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
|||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { TempVoiceConfigDashboard } from '@nexumi/shared';
|
import type { TempVoiceConfigDashboard } from '@nexumi/shared';
|
||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -48,27 +49,37 @@ export function TempVoiceForm({ guildId, initialValue }: TempVoiceFormProps) {
|
|||||||
<CardDescription>{t('modulePages.tempvoice.description')}</CardDescription>
|
<CardDescription>{t('modulePages.tempvoice.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<FieldAnchor id="field-tempvoice-enabled">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.tempvoice.enabledLabel')}</Label>
|
<Label>{t('modulePages.tempvoice.enabledLabel')}</Label>
|
||||||
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
|
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FieldAnchor id="field-tempvoice-hub">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={hubId}>{t('modulePages.tempvoice.hubChannelId')}</Label>
|
<Label htmlFor={hubId}>{t('modulePages.tempvoice.hubChannelId')}</Label>
|
||||||
<Input id={hubId} value={value.hubChannelId} onChange={(event) => setValue((prev) => ({ ...prev, hubChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
<Input id={hubId} value={value.hubChannelId} onChange={(event) => setValue((prev) => ({ ...prev, hubChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-tempvoice-category">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={categoryId}>{t('modulePages.tempvoice.categoryId')}</Label>
|
<Label htmlFor={categoryId}>{t('modulePages.tempvoice.categoryId')}</Label>
|
||||||
<Input id={categoryId} value={value.categoryId} onChange={(event) => setValue((prev) => ({ ...prev, categoryId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
<Input id={categoryId} value={value.categoryId} onChange={(event) => setValue((prev) => ({ ...prev, categoryId: event.target.value.trim() }))} placeholder="123456789012345678" />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-tempvoice-name">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={nameId}>{t('modulePages.tempvoice.defaultName')}</Label>
|
<Label htmlFor={nameId}>{t('modulePages.tempvoice.defaultName')}</Label>
|
||||||
<Input id={nameId} value={value.defaultName} onChange={(event) => setValue((prev) => ({ ...prev, defaultName: event.target.value }))} placeholder="{user}'s Channel" />
|
<Input id={nameId} value={value.defaultName} onChange={(event) => setValue((prev) => ({ ...prev, defaultName: event.target.value }))} placeholder="{user}'s Channel" />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-tempvoice-limit">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={limitId}>{t('modulePages.tempvoice.defaultLimit')}</Label>
|
<Label htmlFor={limitId}>{t('modulePages.tempvoice.defaultLimit')}</Label>
|
||||||
<Input id={limitId} type="number" min={0} max={99} value={value.defaultLimit} onChange={(event) => setValue((prev) => ({ ...prev, defaultLimit: Number(event.target.value) || 0 }))} />
|
<Input id={limitId} type="number" min={0} max={99} value={value.defaultLimit} onChange={(event) => setValue((prev) => ({ ...prev, defaultLimit: Number(event.target.value) || 0 }))} />
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">{t('modulePages.tempvoice.defaultLimitHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('modulePages.tempvoice.defaultLimitHint')}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { TicketCategoryDashboard } from '@nexumi/shared';
|
|||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -120,6 +121,7 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<FieldAnchor id="field-tickets-categories">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.tickets.categoriesTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.tickets.categoriesTitle')}</CardTitle>
|
||||||
@@ -235,5 +237,6 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { TicketConfigDashboard } from '@nexumi/shared';
|
import type { TicketConfigDashboard } from '@nexumi/shared';
|
||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -51,6 +52,7 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
|
|||||||
<CardDescription>{t('modulePages.tickets.description')}</CardDescription>
|
<CardDescription>{t('modulePages.tickets.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<FieldAnchor id="field-tickets-enabled">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.tickets.enabledLabel')}</Label>
|
<Label>{t('modulePages.tickets.enabledLabel')}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -58,8 +60,10 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
|
|||||||
onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))}
|
onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FieldAnchor id="field-tickets-mode">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.tickets.mode')}</Label>
|
<Label>{t('modulePages.tickets.mode')}</Label>
|
||||||
<Select value={value.mode} onValueChange={(mode) => setValue((prev) => ({ ...prev, mode: mode as TicketConfigDashboard['mode'] }))}>
|
<Select value={value.mode} onValueChange={(mode) => setValue((prev) => ({ ...prev, mode: mode as TicketConfigDashboard['mode'] }))}>
|
||||||
@@ -72,7 +76,9 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-tickets-category">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={categoryId}>{t('modulePages.tickets.categoryId')}</Label>
|
<Label htmlFor={categoryId}>{t('modulePages.tickets.categoryId')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -82,7 +88,9 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
|
|||||||
placeholder="123456789012345678"
|
placeholder="123456789012345678"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-tickets-log">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={logChannelId}>{t('modulePages.tickets.logChannelId')}</Label>
|
<Label htmlFor={logChannelId}>{t('modulePages.tickets.logChannelId')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -92,7 +100,9 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
|
|||||||
placeholder="123456789012345678"
|
placeholder="123456789012345678"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-tickets-inactivity">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={inactivityId}>{t('modulePages.tickets.inactivityHours')}</Label>
|
<Label htmlFor={inactivityId}>{t('modulePages.tickets.inactivityHours')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -105,8 +115,10 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-tickets-transcript">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label>{t('modulePages.tickets.transcriptDm')}</Label>
|
<Label>{t('modulePages.tickets.transcriptDm')}</Label>
|
||||||
@@ -117,7 +129,9 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
|
|||||||
onCheckedChange={(transcriptDm) => setValue((prev) => ({ ...prev, transcriptDm }))}
|
onCheckedChange={(transcriptDm) => setValue((prev) => ({ ...prev, transcriptDm }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-tickets-rating">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.tickets.ratingEnabled')}</Label>
|
<Label>{t('modulePages.tickets.ratingEnabled')}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -125,6 +139,7 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
|
|||||||
onCheckedChange={(ratingEnabled) => setValue((prev) => ({ ...prev, ratingEnabled }))}
|
onCheckedChange={(ratingEnabled) => setValue((prev) => ({ ...prev, ratingEnabled }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { VerificationConfigDashboard } from '@nexumi/shared';
|
import type { VerificationConfigDashboard } from '@nexumi/shared';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -47,6 +48,7 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp
|
|||||||
<CardDescription>{t('modulePages.verification.description')}</CardDescription>
|
<CardDescription>{t('modulePages.verification.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<FieldAnchor id="field-verify-enabled">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.verification.enabledLabel')}</Label>
|
<Label>{t('modulePages.verification.enabledLabel')}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -54,8 +56,10 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp
|
|||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FieldAnchor id="field-verify-mode">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.verification.mode')}</Label>
|
<Label>{t('modulePages.verification.mode')}</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -73,6 +77,8 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-verify-fail">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.verification.failAction')}</Label>
|
<Label>{t('modulePages.verification.failAction')}</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -91,9 +97,11 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<FieldAnchor id="field-verify-channel">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.verification.channelId')}</Label>
|
<Label>{t('modulePages.verification.channelId')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -102,6 +110,8 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp
|
|||||||
placeholder="123456789012345678"
|
placeholder="123456789012345678"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-verify-role">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.verification.verifiedRoleId')}</Label>
|
<Label>{t('modulePages.verification.verifiedRoleId')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -110,6 +120,8 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp
|
|||||||
placeholder="123456789012345678"
|
placeholder="123456789012345678"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-verify-unverified">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.verification.unverifiedRoleId')}</Label>
|
<Label>{t('modulePages.verification.unverifiedRoleId')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -118,8 +130,10 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp
|
|||||||
placeholder="123456789012345678"
|
placeholder="123456789012345678"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-verify-age">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.verification.minAccountAgeDays')}</Label>
|
<Label>{t('modulePages.verification.minAccountAgeDays')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -132,6 +146,7 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { WelcomeConfigDashboard } from '@nexumi/shared';
|
import type { WelcomeConfigDashboard } from '@nexumi/shared';
|
||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -116,6 +117,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
<CardDescription>{t('modulePages.welcome.welcomeDescription')}</CardDescription>
|
<CardDescription>{t('modulePages.welcome.welcomeDescription')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<FieldAnchor id="field-welcome-enabled">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.welcome.welcomeEnabledLabel')}</Label>
|
<Label>{t('modulePages.welcome.welcomeEnabledLabel')}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -123,7 +125,9 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, welcomeEnabled: checked }))}
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, welcomeEnabled: checked }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FieldAnchor id="field-welcome-channel">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.welcome.welcomeChannelId')}</Label>
|
<Label>{t('modulePages.welcome.welcomeChannelId')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -132,6 +136,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
placeholder="123456789012345678"
|
placeholder="123456789012345678"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-welcome-type">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.welcome.welcomeType')}</Label>
|
<Label>{t('modulePages.welcome.welcomeType')}</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -150,7 +156,9 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
|
<FieldAnchor id="field-welcome-content">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.welcome.welcomeContent')}</Label>
|
<Label>{t('modulePages.welcome.welcomeContent')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -160,6 +168,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">{t('modulePages.welcome.placeholdersHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('modulePages.welcome.placeholdersHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-welcome-embed">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.welcome.welcomeEmbed')}</Label>
|
<Label>{t('modulePages.welcome.welcomeEmbed')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -170,6 +180,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
placeholder='{ "title": "Welcome!", "color": 6591981 }'
|
placeholder='{ "title": "Welcome!", "color": 6591981 }'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-welcome-dm">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.welcome.welcomeDmEnabled')}</Label>
|
<Label>{t('modulePages.welcome.welcomeDmEnabled')}</Label>
|
||||||
@@ -184,6 +196,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
placeholder={t('modulePages.welcome.dmContentPlaceholder')}
|
placeholder={t('modulePages.welcome.dmContentPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-welcome-user-autoroles">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={userAutorolesId}>{t('modulePages.welcome.userAutoroles')}</Label>
|
<Label htmlFor={userAutorolesId}>{t('modulePages.welcome.userAutoroles')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -193,6 +207,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
placeholder={t('modulePages.logging.idsPlaceholder')}
|
placeholder={t('modulePages.logging.idsPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-welcome-bot-autoroles">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={botAutorolesId}>{t('modulePages.welcome.botAutoroles')}</Label>
|
<Label htmlFor={botAutorolesId}>{t('modulePages.welcome.botAutoroles')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -202,7 +218,9 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
placeholder={t('modulePages.logging.idsPlaceholder')}
|
placeholder={t('modulePages.logging.idsPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FieldAnchor id="field-welcome-image-title">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.welcome.imageTitle')}</Label>
|
<Label>{t('modulePages.welcome.imageTitle')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -210,6 +228,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, imageTitle: event.target.value || null }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, imageTitle: event.target.value || null }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-welcome-image-subtitle">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.welcome.imageSubtitle')}</Label>
|
<Label>{t('modulePages.welcome.imageSubtitle')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -217,6 +237,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, imageSubtitle: event.target.value || null }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, imageSubtitle: event.target.value || null }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -227,6 +248,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
<CardDescription>{t('modulePages.welcome.leaveDescription')}</CardDescription>
|
<CardDescription>{t('modulePages.welcome.leaveDescription')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<FieldAnchor id="field-leave-enabled">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.welcome.leaveEnabledLabel')}</Label>
|
<Label>{t('modulePages.welcome.leaveEnabledLabel')}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -234,6 +256,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, leaveEnabled: checked }))}
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, leaveEnabled: checked }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-leave-channel">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.welcome.leaveChannelId')}</Label>
|
<Label>{t('modulePages.welcome.leaveChannelId')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -242,6 +266,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
placeholder="123456789012345678"
|
placeholder="123456789012345678"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-leave-content">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.welcome.leaveContent')}</Label>
|
<Label>{t('modulePages.welcome.leaveContent')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -250,6 +276,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
placeholder={t('modulePages.welcome.contentPlaceholder')}
|
placeholder={t('modulePages.welcome.contentPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-leave-embed">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.welcome.leaveEmbed')}</Label>
|
<Label>{t('modulePages.welcome.leaveEmbed')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -260,6 +288,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
placeholder='{ "title": "Goodbye", "color": 15158332 }'
|
placeholder='{ "title": "Goodbye", "color": 15158332 }'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import type { SessionUser } from '@nexumi/shared';
|
import type { SessionUser } from '@nexumi/shared';
|
||||||
import type { ReactNode } from 'react';
|
import { Menu } from 'lucide-react';
|
||||||
import { OwnerSidebar } from '@/components/owner/owner-sidebar';
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { DashboardCommandPalette } from '@/components/layout/dashboard-command-palette';
|
||||||
import { UserMenu } from '@/components/layout/user-menu';
|
import { UserMenu } from '@/components/layout/user-menu';
|
||||||
|
import { OwnerSidebar, OwnerSidebarNav } from '@/components/owner/owner-sidebar';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||||
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
|
|
||||||
export function OwnerShell({
|
export function OwnerShell({
|
||||||
user,
|
user,
|
||||||
@@ -10,14 +17,33 @@ export function OwnerShell({
|
|||||||
user: SessionUser;
|
user: SessionUser;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen bg-background">
|
||||||
<OwnerSidebar />
|
<OwnerSidebar />
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<header className="flex h-14 items-center justify-end border-b border-border px-6">
|
<header className="sticky top-0 z-40 flex h-14 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur-md supports-[backdrop-filter]:bg-background/70 sm:px-6">
|
||||||
|
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="lg:hidden" aria-label={t('search.openNav')}>
|
||||||
|
<Menu className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="left" className="p-0">
|
||||||
|
<SheetTitle className="sr-only">{t('owner.nav.title')}</SheetTitle>
|
||||||
|
<OwnerSidebarNav onNavigate={() => setMobileOpen(false)} />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
<div className="ml-auto flex min-w-0 items-center gap-3">
|
||||||
|
<div className="min-w-0 flex-1 sm:flex-initial">
|
||||||
|
<DashboardCommandPalette mode="owner" />
|
||||||
|
</div>
|
||||||
<UserMenu user={user} showOwnerLink />
|
<UserMenu user={user} showOwnerLink />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 px-6 py-8">
|
<main className="flex-1 px-4 py-6 sm:px-6 sm:py-8">
|
||||||
<div className="mx-auto w-full max-w-[1200px]">{children}</div>
|
<div className="mx-auto w-full max-w-[1200px]">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
ArrowLeft
|
ArrowLeft
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const LINKS = [
|
export const OWNER_LINKS = [
|
||||||
{ href: '/owner', icon: LayoutDashboard, key: 'overview' },
|
{ href: '/owner', icon: LayoutDashboard, key: 'overview' },
|
||||||
{ href: '/owner/guilds', icon: Server, key: 'guilds' },
|
{ href: '/owner/guilds', icon: Server, key: 'guilds' },
|
||||||
{ href: '/owner/users', icon: Users, key: 'users' },
|
{ href: '/owner/users', icon: Users, key: 'users' },
|
||||||
@@ -31,47 +32,61 @@ const LINKS = [
|
|||||||
{ href: '/owner/audit', icon: ScrollText, key: 'audit' }
|
{ href: '/owner/audit', icon: ScrollText, key: 'audit' }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function OwnerSidebar() {
|
export function OwnerSidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex h-full w-60 flex-col gap-6 overflow-y-auto border-r border-border px-3 py-4">
|
<nav className="flex h-full flex-col gap-5 overflow-y-auto px-3 py-4">
|
||||||
|
<div className="flex items-center gap-2.5 px-2.5 py-1.5">
|
||||||
|
<Image src="/nexumi-logo.svg" alt="" width={28} height={28} className="size-7" />
|
||||||
|
<span className="text-base font-semibold tracking-tight">Nexumi</span>
|
||||||
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent/60 hover:text-foreground"
|
onClick={onNavigate}
|
||||||
|
className="flex items-center gap-2 rounded-md px-2.5 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
{t('owner.nav.backToDashboard')}
|
{t('owner.nav.backToDashboard')}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-0.5">
|
||||||
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
<p className="mb-1 px-2.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/80">
|
||||||
{t('owner.nav.title')}
|
{t('owner.nav.title')}
|
||||||
</p>
|
</p>
|
||||||
{LINKS.map((link) => {
|
{OWNER_LINKS.map((link) => {
|
||||||
const active = link.href === '/owner' ? pathname === '/owner' : pathname.startsWith(link.href);
|
const active = link.href === '/owner' ? pathname === '/owner' : pathname.startsWith(link.href);
|
||||||
const Icon = link.icon;
|
const Icon = link.icon;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
|
onClick={onNavigate}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
'flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-medium transition-colors',
|
||||||
active
|
active
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'border-l-2 border-primary bg-primary/10 pl-2 text-foreground'
|
||||||
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
|
: 'border-l-2 border-transparent text-muted-foreground hover:bg-accent/60 hover:text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="size-4" />
|
<Icon className="size-4 shrink-0" />
|
||||||
{t(`owner.nav.${link.key}`)}
|
{t(`owner.nav.${link.key}`)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto px-3 text-xs text-muted-foreground">
|
<div className="mt-auto flex items-center gap-2 px-2.5 text-xs text-muted-foreground">
|
||||||
<Activity className="mb-1 size-3.5" />
|
<Activity className="size-3.5" />
|
||||||
Nexumi Owner
|
Nexumi Owner
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function OwnerSidebar() {
|
||||||
|
return (
|
||||||
|
<aside className="hidden h-screen w-60 shrink-0 border-r border-border bg-card/40 lg:sticky lg:top-0 lg:block">
|
||||||
|
<OwnerSidebarNav />
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { DASHBOARD_MODULES, type DashboardAccessRule, type DashboardModuleId } from '@nexumi/shared';
|
import { DASHBOARD_MODULES, type DashboardAccessRule, type DashboardModuleId } from '@nexumi/shared';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -55,11 +56,13 @@ async function saveAccessRules(guildId: string, rules: LocalRule[]): Promise<Set
|
|||||||
function AccessRuleRow({
|
function AccessRuleRow({
|
||||||
rule,
|
rule,
|
||||||
onChange,
|
onChange,
|
||||||
onRemove
|
onRemove,
|
||||||
|
anchorIds
|
||||||
}: {
|
}: {
|
||||||
rule: LocalRule;
|
rule: LocalRule;
|
||||||
onChange: (rule: LocalRule) => void;
|
onChange: (rule: LocalRule) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
anchorIds?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const roleInputId = useId();
|
const roleInputId = useId();
|
||||||
@@ -71,8 +74,7 @@ function AccessRuleRow({
|
|||||||
onChange({ ...rule, selectedModules: next });
|
onChange({ ...rule, selectedModules: next });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const roleBlock = (
|
||||||
<div className="space-y-4 rounded-md border border-border p-4">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<Label htmlFor={roleInputId}>{t('access.roleIdLabel')}</Label>
|
<Label htmlFor={roleInputId}>{t('access.roleIdLabel')}</Label>
|
||||||
@@ -89,7 +91,9 @@ function AccessRuleRow({
|
|||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const modulesBlock = (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('access.modulesLabel')}</Label>
|
<Label>{t('access.modulesLabel')}</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -121,6 +125,12 @@ function AccessRuleRow({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 rounded-md border border-border p-4">
|
||||||
|
{anchorIds ? <FieldAnchor id="field-access-role">{roleBlock}</FieldAnchor> : roleBlock}
|
||||||
|
{anchorIds ? <FieldAnchor id="field-access-modules">{modulesBlock}</FieldAnchor> : modulesBlock}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -145,12 +155,22 @@ export function AccessRulesForm({ guildId, initialRules }: AccessRulesFormProps)
|
|||||||
<CardDescription>{t('access.description')}</CardDescription>
|
<CardDescription>{t('access.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{value.length === 0 && <p className="text-sm text-muted-foreground">{t('access.noRules')}</p>}
|
{value.length === 0 && (
|
||||||
|
<>
|
||||||
|
<FieldAnchor id="field-access-role">
|
||||||
|
<p className="text-sm text-muted-foreground">{t('access.noRules')}</p>
|
||||||
|
</FieldAnchor>
|
||||||
|
<FieldAnchor id="field-access-modules">
|
||||||
|
<span className="sr-only">{t('access.modulesLabel')}</span>
|
||||||
|
</FieldAnchor>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{value.map((rule) => (
|
{value.map((rule, index) => (
|
||||||
<AccessRuleRow
|
<AccessRuleRow
|
||||||
key={rule.clientKey}
|
key={rule.clientKey}
|
||||||
rule={rule}
|
rule={rule}
|
||||||
|
anchorIds={index === 0}
|
||||||
onChange={(updated) =>
|
onChange={(updated) =>
|
||||||
setValue((prev) => prev.map((entry) => (entry.clientKey === rule.clientKey ? updated : entry)))
|
setValue((prev) => prev.map((entry) => (entry.clientKey === rule.clientKey ? updated : entry)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { GuildGeneralSettings } from '@nexumi/shared';
|
import type { GuildGeneralSettings } from '@nexumi/shared';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -47,6 +48,7 @@ export function GeneralSettingsForm({ guildId, initialSettings }: GeneralSetting
|
|||||||
<CardDescription>{t('settings.general.description')}</CardDescription>
|
<CardDescription>{t('settings.general.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
|
<FieldAnchor id="field-locale">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="general-locale">{t('settings.general.localeLabel')}</Label>
|
<Label htmlFor="general-locale">{t('settings.general.localeLabel')}</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -63,7 +65,9 @@ export function GeneralSettingsForm({ guildId, initialSettings }: GeneralSetting
|
|||||||
</Select>
|
</Select>
|
||||||
<p className="text-sm text-muted-foreground">{t('settings.general.localeHint')}</p>
|
<p className="text-sm text-muted-foreground">{t('settings.general.localeHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-timezone">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="general-timezone">{t('settings.general.timezoneLabel')}</Label>
|
<Label htmlFor="general-timezone">{t('settings.general.timezoneLabel')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -75,7 +79,9 @@ export function GeneralSettingsForm({ guildId, initialSettings }: GeneralSetting
|
|||||||
/>
|
/>
|
||||||
<p className="text-sm text-muted-foreground">{t('settings.general.timezoneHint')}</p>
|
<p className="text-sm text-muted-foreground">{t('settings.general.timezoneHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-moderation-toggle">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="general-moderation">{t('settings.general.moderationLabel')}</Label>
|
<Label htmlFor="general-moderation">{t('settings.general.moderationLabel')}</Label>
|
||||||
@@ -87,7 +93,9 @@ export function GeneralSettingsForm({ guildId, initialSettings }: GeneralSetting
|
|||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, moderationEnabled: checked }))}
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, moderationEnabled: checked }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-snipe">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="general-snipe">{t('settings.general.snipeLabel')}</Label>
|
<Label htmlFor="general-snipe">{t('settings.general.snipeLabel')}</Label>
|
||||||
@@ -99,6 +107,7 @@ export function GeneralSettingsForm({ guildId, initialSettings }: GeneralSetting
|
|||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, snipeEnabled: checked }))}
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, snipeEnabled: checked }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
135
apps/webui/src/components/ui/command.tsx
Normal file
135
apps/webui/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Command as CommandPrimitive } from 'cmdk';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Command = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Command.displayName = CommandPrimitive.displayName;
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
open,
|
||||||
|
onOpenChange
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="overflow-hidden p-0 shadow-lg sm:max-w-xl [&>button]:hidden">
|
||||||
|
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:size-4">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandInput = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b border-border px-3" cmdk-input-wrapper="">
|
||||||
|
<Search className="mr-2 size-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||||
|
|
||||||
|
const CommandList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn('max-h-80 overflow-y-auto overflow-x-hidden', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const CommandEmpty = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
|
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
|
||||||
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||||
|
|
||||||
|
const CommandGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||||
|
|
||||||
|
const CommandSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Separator ref={ref} className={cn('-mx-1 h-px bg-border', className)} {...props} />
|
||||||
|
));
|
||||||
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const CommandItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
function CommandShortcut({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
|
||||||
|
return <span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator
|
||||||
|
};
|
||||||
90
apps/webui/src/components/ui/dialog.tsx
Normal file
90
apps/webui/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn('fixed inset-0 z-50 bg-black/50', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border bg-background p-6 shadow-lg sm:rounded-lg',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||||
|
<X className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription
|
||||||
|
};
|
||||||
59
apps/webui/src/components/ui/sheet.tsx
Normal file
59
apps/webui/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Sheet = DialogPrimitive.Root;
|
||||||
|
const SheetTrigger = DialogPrimitive.Trigger;
|
||||||
|
const SheetClose = DialogPrimitive.Close;
|
||||||
|
const SheetPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const SheetOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn('fixed inset-0 z-50 bg-black/50', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const SheetContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { side?: 'left' | 'right' }
|
||||||
|
>(({ side = 'left', className, children, ...props }, ref) => (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed z-50 flex h-full flex-col gap-4 border-border bg-background shadow-lg',
|
||||||
|
side === 'left' && 'inset-y-0 left-0 w-72 border-r',
|
||||||
|
side === 'right' && 'inset-y-0 right-0 w-72 border-l',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-3 top-3 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||||
|
<X className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
));
|
||||||
|
SheetContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold text-foreground', className)} {...props} />
|
||||||
|
));
|
||||||
|
SheetTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
export { Sheet, SheetPortal, SheetOverlay, SheetTrigger, SheetClose, SheetContent, SheetTitle };
|
||||||
53
apps/webui/src/lib/dashboard-search-index.test.ts
Normal file
53
apps/webui/src/lib/dashboard-search-index.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { DASHBOARD_MODULES, KNOWN_COMMAND_NAMES } from '@nexumi/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
SETTINGS_SEARCH_FIELDS,
|
||||||
|
buildGuildSearchHref,
|
||||||
|
buildGuildSearchIndex
|
||||||
|
} from './dashboard-search-index';
|
||||||
|
|
||||||
|
describe('dashboard-search-index', () => {
|
||||||
|
it('includes core pages, all modules, all commands, and settings fields', () => {
|
||||||
|
const index = buildGuildSearchIndex();
|
||||||
|
const pages = index.filter((entry) => entry.kind === 'page');
|
||||||
|
const commands = index.filter((entry) => entry.kind === 'command');
|
||||||
|
const settings = index.filter((entry) => entry.kind === 'setting');
|
||||||
|
|
||||||
|
expect(pages.length).toBe(5 + DASHBOARD_MODULES.length);
|
||||||
|
expect(commands.length).toBe(KNOWN_COMMAND_NAMES.length);
|
||||||
|
expect(settings.length).toBe(SETTINGS_SEARCH_FIELDS.length);
|
||||||
|
expect(settings.length).toBeGreaterThan(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds correct hrefs for pages, commands, and settings', () => {
|
||||||
|
const guildId = '123';
|
||||||
|
expect(buildGuildSearchHref(guildId, { id: 'p', kind: 'page', href: '' })).toBe('/dashboard/123');
|
||||||
|
expect(buildGuildSearchHref(guildId, { id: 'p', kind: 'page', href: 'moderation' })).toBe(
|
||||||
|
'/dashboard/123/moderation'
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
buildGuildSearchHref(guildId, {
|
||||||
|
id: 'c',
|
||||||
|
kind: 'command',
|
||||||
|
href: 'commands?q=ban'
|
||||||
|
})
|
||||||
|
).toBe('/dashboard/123/commands?q=ban');
|
||||||
|
expect(
|
||||||
|
buildGuildSearchHref(guildId, {
|
||||||
|
id: 's',
|
||||||
|
kind: 'setting',
|
||||||
|
href: 'logging',
|
||||||
|
hash: 'field-logging-retention'
|
||||||
|
})
|
||||||
|
).toBe('/dashboard/123/logging#field-logging-retention');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns unique ids and hashes for settings entries', () => {
|
||||||
|
const ids = new Set(SETTINGS_SEARCH_FIELDS.map((entry) => entry.id));
|
||||||
|
expect(ids.size).toBe(SETTINGS_SEARCH_FIELDS.length);
|
||||||
|
for (const entry of SETTINGS_SEARCH_FIELDS) {
|
||||||
|
expect(entry.hash).toMatch(/^field-/);
|
||||||
|
expect(entry.href.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
847
apps/webui/src/lib/dashboard-search-index.ts
Normal file
847
apps/webui/src/lib/dashboard-search-index.ts
Normal file
@@ -0,0 +1,847 @@
|
|||||||
|
import { DASHBOARD_MODULES, KNOWN_COMMAND_NAMES } from '@nexumi/shared';
|
||||||
|
|
||||||
|
export type SearchItemKind = 'page' | 'command' | 'setting';
|
||||||
|
|
||||||
|
export interface SearchIndexEntry {
|
||||||
|
id: string;
|
||||||
|
kind: SearchItemKind;
|
||||||
|
/** i18n key for display label; for commands use raw name via labelOverride */
|
||||||
|
labelKey?: string;
|
||||||
|
labelOverride?: string;
|
||||||
|
keywords?: string[];
|
||||||
|
/** Path relative to `/dashboard/{guildId}` (no leading slash), or absolute for owner */
|
||||||
|
href: string;
|
||||||
|
hash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORE_PAGES: SearchIndexEntry[] = [
|
||||||
|
{ id: 'page-overview', kind: 'page', labelKey: 'nav.overview', href: '' },
|
||||||
|
{ id: 'page-settings', kind: 'page', labelKey: 'nav.settings', href: 'settings' },
|
||||||
|
{ id: 'page-modules', kind: 'page', labelKey: 'nav.modules', href: 'modules' },
|
||||||
|
{ id: 'page-access', kind: 'page', labelKey: 'nav.access', href: 'access' },
|
||||||
|
{ id: 'page-commands', kind: 'page', labelKey: 'nav.commands', href: 'commands' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const MODULE_PAGES: SearchIndexEntry[] = DASHBOARD_MODULES.map((module) => ({
|
||||||
|
id: `page-${module.id}`,
|
||||||
|
kind: 'page' as const,
|
||||||
|
labelKey: `modules.${module.id}.label`,
|
||||||
|
keywords: [module.id, module.href],
|
||||||
|
href: module.href
|
||||||
|
}));
|
||||||
|
|
||||||
|
const COMMAND_ENTRIES: SearchIndexEntry[] = KNOWN_COMMAND_NAMES.map((name) => ({
|
||||||
|
id: `cmd-${name}`,
|
||||||
|
kind: 'command' as const,
|
||||||
|
labelOverride: `/${name}`,
|
||||||
|
keywords: [name],
|
||||||
|
href: `commands?q=${encodeURIComponent(name)}`
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** All searchable settings fields with deep-link hashes. */
|
||||||
|
export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
|
||||||
|
// General settings
|
||||||
|
{
|
||||||
|
id: 'setting-locale',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'settings.general.localeLabel',
|
||||||
|
href: 'settings',
|
||||||
|
hash: 'field-locale',
|
||||||
|
keywords: ['language', 'sprache']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-timezone',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'settings.general.timezoneLabel',
|
||||||
|
href: 'settings',
|
||||||
|
hash: 'field-timezone',
|
||||||
|
keywords: ['timezone', 'zeitzone']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-moderation-toggle',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'settings.general.moderationLabel',
|
||||||
|
href: 'settings',
|
||||||
|
hash: 'field-moderation-toggle',
|
||||||
|
keywords: ['moderation']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-snipe',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'settings.general.snipeLabel',
|
||||||
|
href: 'settings',
|
||||||
|
hash: 'field-snipe',
|
||||||
|
keywords: ['snipe', 'editsnipe']
|
||||||
|
},
|
||||||
|
// Access
|
||||||
|
{
|
||||||
|
id: 'setting-access-role',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'access.roleIdLabel',
|
||||||
|
href: 'access',
|
||||||
|
hash: 'field-access-role',
|
||||||
|
keywords: ['access', 'role']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-access-modules',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'access.modulesLabel',
|
||||||
|
href: 'access',
|
||||||
|
hash: 'field-access-modules',
|
||||||
|
keywords: ['access']
|
||||||
|
},
|
||||||
|
// Moderation
|
||||||
|
{
|
||||||
|
id: 'setting-mod-enabled',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.moderation.enabledLabel',
|
||||||
|
href: 'moderation',
|
||||||
|
hash: 'field-mod-enabled'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-mod-escalations',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.moderation.escalationsTitle',
|
||||||
|
href: 'moderation',
|
||||||
|
hash: 'field-mod-escalations',
|
||||||
|
keywords: ['escalation', 'warn']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-mod-cases',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.moderation.casesTitle',
|
||||||
|
href: 'moderation',
|
||||||
|
hash: 'field-mod-cases',
|
||||||
|
keywords: ['cases', 'fälle']
|
||||||
|
},
|
||||||
|
// Automod
|
||||||
|
{
|
||||||
|
id: 'setting-automod-enabled',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.automod.enabledLabel',
|
||||||
|
href: 'automod',
|
||||||
|
hash: 'field-automod-enabled'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-anti-raid',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.automod.antiRaidTitle',
|
||||||
|
href: 'automod',
|
||||||
|
hash: 'field-anti-raid',
|
||||||
|
keywords: ['raid']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-anti-nuke',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.automod.antiNukeTitle',
|
||||||
|
href: 'automod',
|
||||||
|
hash: 'field-anti-nuke',
|
||||||
|
keywords: ['nuke']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-lockdown',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.automod.lockdownActive',
|
||||||
|
href: 'automod',
|
||||||
|
hash: 'field-lockdown',
|
||||||
|
keywords: ['lockdown']
|
||||||
|
},
|
||||||
|
// Logging
|
||||||
|
{
|
||||||
|
id: 'setting-logging-enabled',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.logging.enabledLabel',
|
||||||
|
href: 'logging',
|
||||||
|
hash: 'field-logging-enabled'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-logging-ignore-bots',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.logging.ignoreBotsLabel',
|
||||||
|
href: 'logging',
|
||||||
|
hash: 'field-logging-ignore-bots'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-logging-ignored-channels',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.logging.ignoredChannelsLabel',
|
||||||
|
href: 'logging',
|
||||||
|
hash: 'field-logging-ignored-channels'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-logging-ignored-roles',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.logging.ignoredRolesLabel',
|
||||||
|
href: 'logging',
|
||||||
|
hash: 'field-logging-ignored-roles'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-logging-retention',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.logging.retentionLabel',
|
||||||
|
href: 'logging',
|
||||||
|
hash: 'field-logging-retention',
|
||||||
|
keywords: ['retention', 'aufbewahrung']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-logging-channels',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.logging.channelsTitle',
|
||||||
|
href: 'logging',
|
||||||
|
hash: 'field-logging-channels'
|
||||||
|
},
|
||||||
|
// Welcome
|
||||||
|
{
|
||||||
|
id: 'setting-welcome-enabled',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.welcome.welcomeEnabledLabel',
|
||||||
|
href: 'welcome',
|
||||||
|
hash: 'field-welcome-enabled'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-welcome-channel',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.welcome.welcomeChannelId',
|
||||||
|
href: 'welcome',
|
||||||
|
hash: 'field-welcome-channel'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-welcome-type',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.welcome.welcomeType',
|
||||||
|
href: 'welcome',
|
||||||
|
hash: 'field-welcome-type'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-welcome-content',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.welcome.welcomeContent',
|
||||||
|
href: 'welcome',
|
||||||
|
hash: 'field-welcome-content'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-welcome-embed',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.welcome.welcomeEmbed',
|
||||||
|
href: 'welcome',
|
||||||
|
hash: 'field-welcome-embed'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-welcome-dm',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.welcome.welcomeDmEnabled',
|
||||||
|
href: 'welcome',
|
||||||
|
hash: 'field-welcome-dm'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-welcome-user-autoroles',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.welcome.userAutoroles',
|
||||||
|
href: 'welcome',
|
||||||
|
hash: 'field-welcome-user-autoroles',
|
||||||
|
keywords: ['autorole']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-welcome-bot-autoroles',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.welcome.botAutoroles',
|
||||||
|
href: 'welcome',
|
||||||
|
hash: 'field-welcome-bot-autoroles'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-welcome-image-title',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.welcome.imageTitle',
|
||||||
|
href: 'welcome',
|
||||||
|
hash: 'field-welcome-image-title'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-welcome-image-subtitle',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.welcome.imageSubtitle',
|
||||||
|
href: 'welcome',
|
||||||
|
hash: 'field-welcome-image-subtitle'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-leave-enabled',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.welcome.leaveEnabledLabel',
|
||||||
|
href: 'welcome',
|
||||||
|
hash: 'field-leave-enabled'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-leave-channel',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.welcome.leaveChannelId',
|
||||||
|
href: 'welcome',
|
||||||
|
hash: 'field-leave-channel'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-leave-content',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.welcome.leaveContent',
|
||||||
|
href: 'welcome',
|
||||||
|
hash: 'field-leave-content'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-leave-embed',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.welcome.leaveEmbed',
|
||||||
|
href: 'welcome',
|
||||||
|
hash: 'field-leave-embed'
|
||||||
|
},
|
||||||
|
// Verification
|
||||||
|
{
|
||||||
|
id: 'setting-verify-enabled',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.verification.enabledLabel',
|
||||||
|
href: 'verification',
|
||||||
|
hash: 'field-verify-enabled'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-verify-mode',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.verification.mode',
|
||||||
|
href: 'verification',
|
||||||
|
hash: 'field-verify-mode'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-verify-fail',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.verification.failAction',
|
||||||
|
href: 'verification',
|
||||||
|
hash: 'field-verify-fail'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-verify-channel',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.verification.channelId',
|
||||||
|
href: 'verification',
|
||||||
|
hash: 'field-verify-channel'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-verify-role',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.verification.verifiedRoleId',
|
||||||
|
href: 'verification',
|
||||||
|
hash: 'field-verify-role'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-verify-unverified',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.verification.unverifiedRoleId',
|
||||||
|
href: 'verification',
|
||||||
|
hash: 'field-verify-unverified'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-verify-age',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.verification.minAccountAgeDays',
|
||||||
|
href: 'verification',
|
||||||
|
hash: 'field-verify-age'
|
||||||
|
},
|
||||||
|
// Leveling
|
||||||
|
{
|
||||||
|
id: 'setting-leveling-enabled',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.leveling.enabledLabel',
|
||||||
|
href: 'leveling',
|
||||||
|
hash: 'field-leveling-enabled'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-leveling-text-min',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.leveling.textXpMin',
|
||||||
|
href: 'leveling',
|
||||||
|
hash: 'field-leveling-text-min'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-leveling-text-max',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.leveling.textXpMax',
|
||||||
|
href: 'leveling',
|
||||||
|
hash: 'field-leveling-text-max'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-leveling-cooldown',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.leveling.textCooldownSeconds',
|
||||||
|
href: 'leveling',
|
||||||
|
hash: 'field-leveling-cooldown'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-leveling-voice',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.leveling.voiceXpPerMinute',
|
||||||
|
href: 'leveling',
|
||||||
|
hash: 'field-leveling-voice'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-leveling-no-xp-channels',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.leveling.noXpChannels',
|
||||||
|
href: 'leveling',
|
||||||
|
hash: 'field-leveling-no-xp-channels'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-leveling-no-xp-roles',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.leveling.noXpRoles',
|
||||||
|
href: 'leveling',
|
||||||
|
hash: 'field-leveling-no-xp-roles'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-leveling-role-multipliers',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.leveling.roleMultipliers',
|
||||||
|
href: 'leveling',
|
||||||
|
hash: 'field-leveling-role-multipliers'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-leveling-channel-multipliers',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.leveling.channelMultipliers',
|
||||||
|
href: 'leveling',
|
||||||
|
hash: 'field-leveling-channel-multipliers'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-leveling-levelup',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.leveling.levelUpTitle',
|
||||||
|
href: 'leveling',
|
||||||
|
hash: 'field-leveling-levelup'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-leveling-stack',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.leveling.stackRewards',
|
||||||
|
href: 'leveling',
|
||||||
|
hash: 'field-leveling-stack'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-leveling-rank-card',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.leveling.rankCardTitle',
|
||||||
|
href: 'leveling',
|
||||||
|
hash: 'field-leveling-rank-card'
|
||||||
|
},
|
||||||
|
// Economy
|
||||||
|
{
|
||||||
|
id: 'setting-economy-enabled',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.economy.enabledLabel',
|
||||||
|
href: 'economy',
|
||||||
|
hash: 'field-economy-enabled'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-economy-currency-name',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.economy.currencyName',
|
||||||
|
href: 'economy',
|
||||||
|
hash: 'field-economy-currency-name'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-economy-currency-symbol',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.economy.currencySymbol',
|
||||||
|
href: 'economy',
|
||||||
|
hash: 'field-economy-currency-symbol'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-economy-daily',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.economy.dailyAmount',
|
||||||
|
href: 'economy',
|
||||||
|
hash: 'field-economy-daily'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-economy-weekly',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.economy.weeklyAmount',
|
||||||
|
href: 'economy',
|
||||||
|
hash: 'field-economy-weekly'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-economy-work-min',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.economy.workMin',
|
||||||
|
href: 'economy',
|
||||||
|
hash: 'field-economy-work-min'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-economy-work-max',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.economy.workMax',
|
||||||
|
href: 'economy',
|
||||||
|
hash: 'field-economy-work-max'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-economy-work-cooldown',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.economy.workCooldownSeconds',
|
||||||
|
href: 'economy',
|
||||||
|
hash: 'field-economy-work-cooldown'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-economy-starting',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.economy.startingBalance',
|
||||||
|
href: 'economy',
|
||||||
|
hash: 'field-economy-starting'
|
||||||
|
},
|
||||||
|
// Fun
|
||||||
|
{
|
||||||
|
id: 'setting-fun-enabled',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.fun.enabledLabel',
|
||||||
|
href: 'fun',
|
||||||
|
hash: 'field-fun-enabled'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-fun-media',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.fun.mediaEnabledLabel',
|
||||||
|
href: 'fun',
|
||||||
|
hash: 'field-fun-media'
|
||||||
|
},
|
||||||
|
// Tickets
|
||||||
|
{
|
||||||
|
id: 'setting-tickets-enabled',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.tickets.enabledLabel',
|
||||||
|
href: 'tickets',
|
||||||
|
hash: 'field-tickets-enabled'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-tickets-mode',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.tickets.mode',
|
||||||
|
href: 'tickets',
|
||||||
|
hash: 'field-tickets-mode'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-tickets-category',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.tickets.categoryId',
|
||||||
|
href: 'tickets',
|
||||||
|
hash: 'field-tickets-category'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-tickets-log',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.tickets.logChannelId',
|
||||||
|
href: 'tickets',
|
||||||
|
hash: 'field-tickets-log'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-tickets-inactivity',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.tickets.inactivityHours',
|
||||||
|
href: 'tickets',
|
||||||
|
hash: 'field-tickets-inactivity'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-tickets-transcript',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.tickets.transcriptDm',
|
||||||
|
href: 'tickets',
|
||||||
|
hash: 'field-tickets-transcript'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-tickets-rating',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.tickets.ratingEnabled',
|
||||||
|
href: 'tickets',
|
||||||
|
hash: 'field-tickets-rating'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-tickets-categories',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.tickets.categoriesTitle',
|
||||||
|
href: 'tickets',
|
||||||
|
hash: 'field-tickets-categories'
|
||||||
|
},
|
||||||
|
// Giveaways
|
||||||
|
{
|
||||||
|
id: 'setting-giveaways-create',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.giveaways.createTitle',
|
||||||
|
href: 'giveaways',
|
||||||
|
hash: 'field-giveaways-create'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-giveaways-list',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.giveaways.listTitle',
|
||||||
|
href: 'giveaways',
|
||||||
|
hash: 'field-giveaways-list'
|
||||||
|
},
|
||||||
|
// Selfroles
|
||||||
|
{
|
||||||
|
id: 'setting-selfroles-create',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.selfroles.createTitle',
|
||||||
|
href: 'selfroles',
|
||||||
|
hash: 'field-selfroles-create'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-selfroles-list',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.selfroles.listTitle',
|
||||||
|
href: 'selfroles',
|
||||||
|
hash: 'field-selfroles-list'
|
||||||
|
},
|
||||||
|
// Tags
|
||||||
|
{
|
||||||
|
id: 'setting-tags-create',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.tags.createTitle',
|
||||||
|
href: 'tags',
|
||||||
|
hash: 'field-tags-create'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-tags-list',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.tags.listTitle',
|
||||||
|
href: 'tags',
|
||||||
|
hash: 'field-tags-list'
|
||||||
|
},
|
||||||
|
// Starboard
|
||||||
|
{
|
||||||
|
id: 'setting-starboard-enabled',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.starboard.enabledLabel',
|
||||||
|
href: 'starboard',
|
||||||
|
hash: 'field-starboard-enabled'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-starboard-channel',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.starboard.channelId',
|
||||||
|
href: 'starboard',
|
||||||
|
hash: 'field-starboard-channel'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-starboard-emoji',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.starboard.emoji',
|
||||||
|
href: 'starboard',
|
||||||
|
hash: 'field-starboard-emoji'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-starboard-threshold',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.starboard.threshold',
|
||||||
|
href: 'starboard',
|
||||||
|
hash: 'field-starboard-threshold'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-starboard-self',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.starboard.allowSelfStar',
|
||||||
|
href: 'starboard',
|
||||||
|
hash: 'field-starboard-self'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-starboard-ignored',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.starboard.ignoredChannelIds',
|
||||||
|
href: 'starboard',
|
||||||
|
hash: 'field-starboard-ignored'
|
||||||
|
},
|
||||||
|
// Suggestions
|
||||||
|
{
|
||||||
|
id: 'setting-suggestions-enabled',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.suggestions.enabledLabel',
|
||||||
|
href: 'suggestions',
|
||||||
|
hash: 'field-suggestions-enabled'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-suggestions-open',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.suggestions.openChannelId',
|
||||||
|
href: 'suggestions',
|
||||||
|
hash: 'field-suggestions-open'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-suggestions-approved',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.suggestions.approvedChannelId',
|
||||||
|
href: 'suggestions',
|
||||||
|
hash: 'field-suggestions-approved'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-suggestions-denied',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.suggestions.deniedChannelId',
|
||||||
|
href: 'suggestions',
|
||||||
|
hash: 'field-suggestions-denied'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-suggestions-thread',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.suggestions.createThread',
|
||||||
|
href: 'suggestions',
|
||||||
|
hash: 'field-suggestions-thread'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-suggestions-list',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.suggestions.listTitle',
|
||||||
|
href: 'suggestions',
|
||||||
|
hash: 'field-suggestions-list'
|
||||||
|
},
|
||||||
|
// Birthdays
|
||||||
|
{
|
||||||
|
id: 'setting-birthdays-enabled',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.birthdays.enabledLabel',
|
||||||
|
href: 'birthdays',
|
||||||
|
hash: 'field-birthdays-enabled'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-birthdays-channel',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.birthdays.channelId',
|
||||||
|
href: 'birthdays',
|
||||||
|
hash: 'field-birthdays-channel'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-birthdays-role',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.birthdays.roleId',
|
||||||
|
href: 'birthdays',
|
||||||
|
hash: 'field-birthdays-role'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-birthdays-timezone',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.birthdays.timezone',
|
||||||
|
href: 'birthdays',
|
||||||
|
hash: 'field-birthdays-timezone'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-birthdays-message',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.birthdays.message',
|
||||||
|
href: 'birthdays',
|
||||||
|
hash: 'field-birthdays-message'
|
||||||
|
},
|
||||||
|
// Tempvoice
|
||||||
|
{
|
||||||
|
id: 'setting-tempvoice-enabled',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.tempvoice.enabledLabel',
|
||||||
|
href: 'tempvoice',
|
||||||
|
hash: 'field-tempvoice-enabled'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-tempvoice-hub',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.tempvoice.hubChannelId',
|
||||||
|
href: 'tempvoice',
|
||||||
|
hash: 'field-tempvoice-hub'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-tempvoice-category',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.tempvoice.categoryId',
|
||||||
|
href: 'tempvoice',
|
||||||
|
hash: 'field-tempvoice-category'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-tempvoice-name',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.tempvoice.defaultName',
|
||||||
|
href: 'tempvoice',
|
||||||
|
hash: 'field-tempvoice-name'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-tempvoice-limit',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.tempvoice.defaultLimit',
|
||||||
|
href: 'tempvoice',
|
||||||
|
hash: 'field-tempvoice-limit'
|
||||||
|
},
|
||||||
|
// Stats
|
||||||
|
{
|
||||||
|
id: 'setting-stats-enabled',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.stats.enabledLabel',
|
||||||
|
href: 'stats',
|
||||||
|
hash: 'field-stats-enabled'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-stats-members',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.stats.membersChannelId',
|
||||||
|
href: 'stats',
|
||||||
|
hash: 'field-stats-members'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-stats-online',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.stats.onlineChannelId',
|
||||||
|
href: 'stats',
|
||||||
|
hash: 'field-stats-online'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-stats-boosts',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.stats.boostsChannelId',
|
||||||
|
href: 'stats',
|
||||||
|
hash: 'field-stats-boosts'
|
||||||
|
},
|
||||||
|
// Feeds
|
||||||
|
{
|
||||||
|
id: 'setting-feeds-add',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.feeds.addFeed',
|
||||||
|
href: 'feeds',
|
||||||
|
hash: 'field-feeds-add'
|
||||||
|
},
|
||||||
|
// Scheduler
|
||||||
|
{
|
||||||
|
id: 'setting-scheduler-create',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.scheduler.createTitle',
|
||||||
|
href: 'scheduler',
|
||||||
|
hash: 'field-scheduler-create'
|
||||||
|
},
|
||||||
|
// Backup
|
||||||
|
{
|
||||||
|
id: 'setting-backup',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modules.guildbackup.label',
|
||||||
|
href: 'backup',
|
||||||
|
hash: 'field-backup',
|
||||||
|
keywords: ['backup', 'restore']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const OWNER_NAV_ENTRIES: SearchIndexEntry[] = [
|
||||||
|
{ id: 'owner-overview', kind: 'page', labelKey: 'owner.nav.overview', href: '/owner' },
|
||||||
|
{ id: 'owner-guilds', kind: 'page', labelKey: 'owner.nav.guilds', href: '/owner/guilds' },
|
||||||
|
{ id: 'owner-users', kind: 'page', labelKey: 'owner.nav.users', href: '/owner/users' },
|
||||||
|
{ id: 'owner-flags', kind: 'page', labelKey: 'owner.nav.flags', href: '/owner/flags' },
|
||||||
|
{ id: 'owner-premium', kind: 'page', labelKey: 'owner.nav.premium', href: '/owner/premium' },
|
||||||
|
{ id: 'owner-presence', kind: 'page', labelKey: 'owner.nav.presence', href: '/owner/presence' },
|
||||||
|
{ id: 'owner-team', kind: 'page', labelKey: 'owner.nav.team', href: '/owner/team' },
|
||||||
|
{ id: 'owner-jobs', kind: 'page', labelKey: 'owner.nav.jobs', href: '/owner/jobs' },
|
||||||
|
{ id: 'owner-changelog', kind: 'page', labelKey: 'owner.nav.changelog', href: '/owner/changelog' },
|
||||||
|
{ id: 'owner-audit', kind: 'page', labelKey: 'owner.nav.audit', href: '/owner/audit' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function buildGuildSearchIndex(): SearchIndexEntry[] {
|
||||||
|
return [...CORE_PAGES, ...MODULE_PAGES, ...COMMAND_ENTRIES, ...SETTINGS_SEARCH_FIELDS];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commands use query string; settings use hash; pages are plain paths. */
|
||||||
|
export function buildGuildSearchHref(guildId: string, entry: SearchIndexEntry): string {
|
||||||
|
if (entry.kind === 'command') {
|
||||||
|
return `/dashboard/${guildId}/${entry.href}`;
|
||||||
|
}
|
||||||
|
const base = entry.href ? `/dashboard/${guildId}/${entry.href}` : `/dashboard/${guildId}`;
|
||||||
|
return entry.hash ? `${base}#${entry.hash}` : base;
|
||||||
|
}
|
||||||
@@ -770,5 +770,16 @@
|
|||||||
"updatesTitle": "Updates & Incidents",
|
"updatesTitle": "Updates & Incidents",
|
||||||
"updatesEmpty": "Noch keine Einträge.",
|
"updatesEmpty": "Noch keine Einträge.",
|
||||||
"incident": "Incident"
|
"incident": "Incident"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"title": "Suche",
|
||||||
|
"placeholder": "Seiten, Befehle oder Optionen suchen…",
|
||||||
|
"empty": "Keine Treffer",
|
||||||
|
"openNav": "Navigation öffnen",
|
||||||
|
"groups": {
|
||||||
|
"pages": "Seiten",
|
||||||
|
"commands": "Befehle",
|
||||||
|
"settings": "Optionen"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -770,5 +770,16 @@
|
|||||||
"updatesTitle": "Updates & incidents",
|
"updatesTitle": "Updates & incidents",
|
||||||
"updatesEmpty": "No entries yet.",
|
"updatesEmpty": "No entries yet.",
|
||||||
"incident": "Incident"
|
"incident": "Incident"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"title": "Search",
|
||||||
|
"placeholder": "Search pages, commands, or settings…",
|
||||||
|
"empty": "No results",
|
||||||
|
"openNav": "Open navigation",
|
||||||
|
"groups": {
|
||||||
|
"pages": "Pages",
|
||||||
|
"commands": "Commands",
|
||||||
|
"settings": "Settings"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,27 @@
|
|||||||
- Premium-Zahlungsanbindung
|
- Premium-Zahlungsanbindung
|
||||||
- Externe Marketing-Landing auf `nexumi.de`
|
- Externe Marketing-Landing auf `nexumi.de`
|
||||||
|
|
||||||
|
## Post-Phase – Dashboard-UI + Command-Palette + Landing-Polish (Status: implementiert)
|
||||||
|
|
||||||
|
### Abgeschlossen (Code)
|
||||||
|
|
||||||
|
- Guild-/Owner-Shell: Branding, Sticky-Header, mobile Sidebar-Sheet, Overview-Polish
|
||||||
|
- Globale Cmd/Ctrl+K-Suche: Seiten, Slash-Commands, Einstellungsfelder (Deep-Links)
|
||||||
|
- Settings-Feld-Registry + Form-Anchors; Commands-Seite liest `?q=`
|
||||||
|
- Plesk-Landing: Inter, Hero-Hierarchie, dezente Motion, Designsprache an WebUI
|
||||||
|
|
||||||
|
### Manuell testen
|
||||||
|
|
||||||
|
- [ ] Dashboard: Cmd/Ctrl+K → Seite, Command und Option navigieren (Hash-Scroll)
|
||||||
|
- [ ] Mobile: Sidebar-Sheet + Such-Trigger
|
||||||
|
- [ ] Overview und Owner-Panel Layout
|
||||||
|
- [ ] Landing Light/Dark (`prefers-color-scheme`) + CTAs
|
||||||
|
|
||||||
|
### Bewusst offen
|
||||||
|
|
||||||
|
- Musik- und KI-Modul (nur auf Zuruf)
|
||||||
|
- Premium-Zahlungsanbindung
|
||||||
|
|
||||||
## Nächster geplanter Schritt
|
## Nächster geplanter Schritt
|
||||||
|
|
||||||
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
--secondary: #f1f1f5;
|
--secondary: #f1f1f5;
|
||||||
--secondary-foreground: #111116;
|
--secondary-foreground: #111116;
|
||||||
--border: #e4e4ea;
|
--border: #e4e4ea;
|
||||||
|
--ring: #6366f1;
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
|
--hero-glow: rgba(99, 102, 241, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@@ -22,6 +24,7 @@
|
|||||||
--secondary: #1b1b22;
|
--secondary: #1b1b22;
|
||||||
--secondary-foreground: #e6e6ea;
|
--secondary-foreground: #e6e6ea;
|
||||||
--border: #23232b;
|
--border: #23232b;
|
||||||
|
--hero-glow: rgba(99, 102, 241, 0.18);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,10 +42,11 @@ body {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -72,6 +76,14 @@ a {
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 40;
|
||||||
|
background: color-mix(in srgb, var(--background) 80%, transparent);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
header .bar {
|
header .bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 3.5rem;
|
height: 3.5rem;
|
||||||
@@ -82,8 +94,9 @@ header .bar {
|
|||||||
.brand {
|
.brand {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.625rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand img {
|
.brand img {
|
||||||
@@ -110,12 +123,12 @@ nav {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-lg {
|
.btn-lg {
|
||||||
height: 2.75rem;
|
height: 2.75rem;
|
||||||
padding: 0 1.25rem;
|
padding: 0 1.35rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +139,7 @@ nav {
|
|||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
filter: brightness(0.95);
|
filter: brightness(0.95);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@@ -152,17 +166,35 @@ main {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -20% 20% auto;
|
||||||
|
height: 55%;
|
||||||
|
background: radial-gradient(ellipse at center, var(--hero-glow), transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2rem;
|
gap: 1.5rem;
|
||||||
padding-top: 4rem;
|
min-height: calc(100vh - 3.5rem);
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 3.5rem;
|
||||||
padding-bottom: 4rem;
|
padding-bottom: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.hero {
|
.hero {
|
||||||
padding-top: 5rem;
|
padding-top: 4rem;
|
||||||
padding-bottom: 5rem;
|
padding-bottom: 5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,45 +202,65 @@ main {
|
|||||||
.hero-brand {
|
.hero-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.875rem;
|
||||||
|
animation: rise 0.55s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-brand img {
|
.hero-brand img {
|
||||||
width: 48px;
|
width: 56px;
|
||||||
height: 48px;
|
height: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero h1 {
|
.hero-brand h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 2.25rem;
|
font-size: 2.75rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
letter-spacing: -0.025em;
|
letter-spacing: -0.04em;
|
||||||
|
line-height: 1.05;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.hero h1 {
|
.hero-brand h1 {
|
||||||
font-size: 3rem;
|
font-size: 4rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero p {
|
.hero-headline {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
max-width: 42rem;
|
max-width: 36rem;
|
||||||
font-size: 1.125rem;
|
font-size: 1.35rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.35;
|
||||||
|
animation: rise 0.55s ease 0.08s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.hero-headline {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 36rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
|
animation: rise 0.55s ease 0.16s both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
animation: rise 0.55s ease 0.24s both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats {
|
.stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding-top: 3rem;
|
padding-top: 3.5rem;
|
||||||
padding-bottom: 3rem;
|
padding-bottom: 3.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
@@ -217,63 +269,75 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.stat-card {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 1rem 1.25rem;
|
padding: 1.35rem 1.5rem;
|
||||||
|
transition: border-color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 45%, var(--border));
|
||||||
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-label {
|
.card-label {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-value {
|
.card-value {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.875rem;
|
font-size: 2rem;
|
||||||
font-weight: 600;
|
font-weight: 650;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.features {
|
.features {
|
||||||
padding-top: 3rem;
|
padding-top: 3.5rem;
|
||||||
padding-bottom: 3rem;
|
padding-bottom: 4rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2rem;
|
gap: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.features > .intro h2 {
|
.features > .intro h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.75rem;
|
||||||
font-weight: 600;
|
font-weight: 650;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.features > .intro p {
|
.features > .intro p {
|
||||||
margin: 0.5rem 0 0;
|
margin: 0.5rem 0 0;
|
||||||
|
max-width: 36rem;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.group {
|
.group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group h3 {
|
.group h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 650;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
@@ -288,16 +352,31 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-card {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.1rem 1.2rem;
|
||||||
|
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-card:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 40%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--primary) 6%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.module-title {
|
.module-title {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.4rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-desc {
|
.module-desc {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer .bar {
|
footer .bar {
|
||||||
@@ -404,3 +483,27 @@ footer a:hover {
|
|||||||
.legal-lines {
|
.legal-lines {
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hero-brand,
|
||||||
|
.hero-headline,
|
||||||
|
.hero-subtitle,
|
||||||
|
.actions,
|
||||||
|
.stat-card,
|
||||||
|
.module-card,
|
||||||
|
.btn {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ function render_header(array $config, array $messages, string $locale, string $t
|
|||||||
<title><?= h($title) ?></title>
|
<title><?= h($title) ?></title>
|
||||||
<meta name="description" content="<?= h($description) ?>">
|
<meta name="description" content="<?= h($description) ?>">
|
||||||
<link rel="icon" href="assets/nexumi-logo.svg" type="image/svg+xml">
|
<link rel="icon" href="assets/nexumi-logo.svg" type="image/svg+xml">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="assets/style.css">
|
<link rel="stylesheet" href="assets/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -59,13 +59,14 @@ render_header(
|
|||||||
t($messages, 'meta.description')
|
t($messages, 'meta.description')
|
||||||
);
|
);
|
||||||
?>
|
?>
|
||||||
<section class="border-b">
|
<section class="hero-section">
|
||||||
<div class="container hero">
|
<div class="container hero">
|
||||||
<div class="hero-brand">
|
<div class="hero-brand">
|
||||||
<img src="assets/nexumi-logo.svg" alt="" width="48" height="48">
|
<img src="assets/nexumi-logo.svg" alt="" width="56" height="56">
|
||||||
<h1>Nexumi</h1>
|
<h1>Nexumi</h1>
|
||||||
</div>
|
</div>
|
||||||
<p><?= h(t($messages, 'landing.subtitle')) ?></p>
|
<p class="hero-headline"><?= h(t($messages, 'landing.headline')) ?></p>
|
||||||
|
<p class="hero-subtitle"><?= h(t($messages, 'landing.subtitle')) ?></p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="btn btn-lg btn-primary" href="<?= h($inviteUrl) ?>" target="_blank" rel="noreferrer"><?= h(t($messages, 'landing.invite')) ?></a>
|
<a class="btn btn-lg btn-primary" href="<?= h($inviteUrl) ?>" target="_blank" rel="noreferrer"><?= h(t($messages, 'landing.invite')) ?></a>
|
||||||
<a class="btn btn-lg btn-secondary" href="<?= h($loginUrl) ?>"><?= h(t($messages, 'landing.login')) ?></a>
|
<a class="btn btn-lg btn-secondary" href="<?= h($loginUrl) ?>"><?= h(t($messages, 'landing.login')) ?></a>
|
||||||
@@ -78,11 +79,11 @@ render_header(
|
|||||||
|
|
||||||
<section class="border-b">
|
<section class="border-b">
|
||||||
<div class="container stats">
|
<div class="container stats">
|
||||||
<div class="card">
|
<div class="stat-card">
|
||||||
<p class="card-label"><?= h(t($messages, 'landing.stats_guilds')) ?></p>
|
<p class="card-label"><?= h(t($messages, 'landing.stats_guilds')) ?></p>
|
||||||
<p class="card-value"><?= h($formatNumber($guildCount)) ?></p>
|
<p class="card-value"><?= h($formatNumber($guildCount)) ?></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="stat-card">
|
||||||
<p class="card-label"><?= h(t($messages, 'landing.stats_users')) ?></p>
|
<p class="card-label"><?= h(t($messages, 'landing.stats_users')) ?></p>
|
||||||
<p class="card-value"><?= h($formatNumber($userCount)) ?></p>
|
<p class="card-value"><?= h($formatNumber($userCount)) ?></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +101,7 @@ render_header(
|
|||||||
<h3><?= h($group['label']) ?></h3>
|
<h3><?= h($group['label']) ?></h3>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<?php foreach ($group['modules'] as $module): ?>
|
<?php foreach ($group['modules'] as $module): ?>
|
||||||
<div class="card">
|
<div class="module-card">
|
||||||
<p class="module-title"><?= h($module['label']) ?></p>
|
<p class="module-title"><?= h($module['label']) ?></p>
|
||||||
<p class="module-desc"><?= h($module['description']) ?></p>
|
<p class="module-desc"><?= h($module['description']) ?></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ return [
|
|||||||
'dashboard' => 'Dashboard',
|
'dashboard' => 'Dashboard',
|
||||||
],
|
],
|
||||||
'landing' => [
|
'landing' => [
|
||||||
|
'headline' => 'Moderation, Community und Integrationen – in einem Bot.',
|
||||||
'subtitle' => 'Discord-Bot mit Moderations-, Community- und Integrationsmodulen sowie Web-Dashboard.',
|
'subtitle' => 'Discord-Bot mit Moderations-, Community- und Integrationsmodulen sowie Web-Dashboard.',
|
||||||
'invite' => 'Bot einladen',
|
'invite' => 'Bot einladen',
|
||||||
'login' => 'Zum Dashboard',
|
'login' => 'Zum Dashboard',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ return [
|
|||||||
'dashboard' => 'Dashboard',
|
'dashboard' => 'Dashboard',
|
||||||
],
|
],
|
||||||
'landing' => [
|
'landing' => [
|
||||||
|
'headline' => 'Moderation, community, and integrations — in one bot.',
|
||||||
'subtitle' => 'Discord bot with moderation, community, and integration modules plus a web dashboard.',
|
'subtitle' => 'Discord bot with moderation, community, and integration modules plus a web dashboard.',
|
||||||
'invite' => 'Invite bot',
|
'invite' => 'Invite bot',
|
||||||
'login' => 'Open dashboard',
|
'login' => 'Open dashboard',
|
||||||
|
|||||||
Reference in New Issue
Block a user