diff --git a/apps/webui/src/app/dashboard/[guildId]/commands/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/commands/loading.tsx new file mode 100644 index 0000000..aa95b3b --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/commands/loading.tsx @@ -0,0 +1,13 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function CommandsLoading() { + return ( +
+
+ + +
+ +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/commands/page.tsx b/apps/webui/src/app/dashboard/[guildId]/commands/page.tsx new file mode 100644 index 0000000..4362d01 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/commands/page.tsx @@ -0,0 +1,22 @@ +import { CommandsManager } from '@/components/modules/commands-manager'; +import { getLocale, t } from '@/lib/i18n'; +import { listCommandOverridesDashboard } from '@/lib/module-configs/commands'; + +interface CommandsPageProps { + params: Promise<{ guildId: string }>; +} + +export default async function CommandsPage({ params }: CommandsPageProps) { + const { guildId } = await params; + const [locale, commands] = await Promise.all([getLocale(), listCommandOverridesDashboard(guildId)]); + + return ( +
+
+

{t(locale, 'modulePages.commands.title')}

+

{t(locale, 'modulePages.commands.description')}

+
+ +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/page.tsx b/apps/webui/src/app/dashboard/[guildId]/page.tsx index 891b37b..ed8c6e3 100644 --- a/apps/webui/src/app/dashboard/[guildId]/page.tsx +++ b/apps/webui/src/app/dashboard/[guildId]/page.tsx @@ -1,4 +1,5 @@ import Link from 'next/link'; +import { getActivitySummary } from '@/lib/activity'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { listRecentDashboardAudit } from '@/lib/audit'; @@ -19,15 +20,41 @@ function formatDate(iso: string, locale: string): string { export default async function GuildOverviewPage({ params }: OverviewPageProps) { const { guildId } = await params; const locale = await getLocale(); - const [modules, auditEntries] = await Promise.all([ + const [modules, auditEntries, activitySummary] = await Promise.all([ getModuleStatuses(guildId), - listRecentDashboardAudit(guildId, 10) + listRecentDashboardAudit(guildId, 10), + getActivitySummary(guildId) ]); return (

{t(locale, 'dashboard.overview.title')}

+
+

{t(locale, 'dashboard.overview.activityTitle')}

+
+ + +

{t(locale, 'dashboard.overview.activityMessages')}

+

{activitySummary.messageCount.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}

+
+
+ + +

{t(locale, 'dashboard.overview.activityVoiceMinutes')}

+

{activitySummary.voiceMinutes.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}

+
+
+ + +

{t(locale, 'dashboard.overview.activityActiveUsers')}

+

{activitySummary.activeUserCount.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}

+
+
+
+

{t(locale, 'dashboard.overview.activityHint')}

+
+

{t(locale, 'dashboard.overview.moduleStatusTitle')}

diff --git a/apps/webui/src/components/layout/sidebar.tsx b/apps/webui/src/components/layout/sidebar.tsx index c306934..2b2a641 100644 --- a/apps/webui/src/components/layout/sidebar.tsx +++ b/apps/webui/src/components/layout/sidebar.tsx @@ -1,7 +1,7 @@ 'use client'; import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared'; -import { LayoutGrid, Settings, ShieldCheck, SlidersHorizontal } from 'lucide-react'; +import { LayoutGrid, Settings, ShieldCheck, SlidersHorizontal, Terminal } from 'lucide-react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { useTranslations } from '@/components/locale-provider'; @@ -64,6 +64,10 @@ export function Sidebar({ guildId }: SidebarProps) { {t('nav.access')} + + + {t('nav.commands')} +
{groups.map(({ group, modules }) => ( diff --git a/apps/webui/src/components/modules/commands-manager.tsx b/apps/webui/src/components/modules/commands-manager.tsx new file mode 100644 index 0000000..d021998 --- /dev/null +++ b/apps/webui/src/components/modules/commands-manager.tsx @@ -0,0 +1,198 @@ +'use client'; + +import type { CommandOverrideDashboard } from '@nexumi/shared'; +import { RotateCcw } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { useTranslations } from '@/components/locale-provider'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; + +function toCsv(ids: string[]): string { + return ids.join(', '); +} + +function fromCsv(text: string): string[] { + return text + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +interface LocalOverride extends CommandOverrideDashboard { + saving?: boolean; +} + +interface CommandsManagerProps { + guildId: string; + initialCommands: CommandOverrideDashboard[]; +} + +export function CommandsManager({ guildId, initialCommands }: CommandsManagerProps) { + const t = useTranslations(); + const [commands, setCommands] = useState(initialCommands); + const [search, setSearch] = useState(''); + + const filtered = useMemo(() => { + const query = search.trim().toLowerCase(); + if (!query) { + return commands; + } + return commands.filter((entry) => entry.commandName.toLowerCase().includes(query)); + }, [commands, search]); + + function patchLocal(commandName: string, patch: Partial) { + setCommands((prev) => + prev.map((entry) => (entry.commandName === commandName ? { ...entry, ...patch } : entry)) + ); + } + + async function handleSave(entry: LocalOverride) { + patchLocal(entry.commandName, { saving: true }); + try { + const response = await fetch(`/api/guilds/${guildId}/commands/${entry.commandName}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: entry.enabled, + allowedRoleIds: entry.allowedRoleIds, + deniedRoleIds: entry.deniedRoleIds, + allowedChannelIds: entry.allowedChannelIds, + deniedChannelIds: entry.deniedChannelIds, + cooldownSeconds: entry.cooldownSeconds + }) + }); + const body = (await response.json().catch(() => null)) as (CommandOverrideDashboard & { error?: string }) | null; + if (!response.ok || !body) { + toast.error(body?.error ?? t('common.saveError')); + return; + } + patchLocal(entry.commandName, body); + toast.success(t('common.saveSuccess')); + } catch { + toast.error(t('common.saveError')); + } finally { + patchLocal(entry.commandName, { saving: false }); + } + } + + async function handleReset(entry: LocalOverride) { + patchLocal(entry.commandName, { saving: true }); + try { + const response = await fetch(`/api/guilds/${guildId}/commands/${entry.commandName}`, { method: 'DELETE' }); + const body = (await response.json().catch(() => null)) as CommandOverrideDashboard | null; + if (!response.ok || !body) { + toast.error(t('common.saveError')); + return; + } + patchLocal(entry.commandName, body); + toast.success(t('common.saveSuccess')); + } catch { + toast.error(t('common.saveError')); + } finally { + patchLocal(entry.commandName, { saving: false }); + } + } + + return ( + + + {t('modulePages.commands.title')} + {t('modulePages.commands.description')} + + + setSearch(event.target.value)} + placeholder={t('modulePages.commands.searchPlaceholder')} + className="max-w-sm" + /> +
+ {filtered.length === 0 && ( +

{t('modulePages.commands.empty')}

+ )} + {filtered.map((entry) => ( +
+
+

/{entry.commandName}

+
+ patchLocal(entry.commandName, { enabled })} + /> + + {entry.enabled ? t('common.enabled') : t('common.disabled')} + +
+
+
+
+

{t('modulePages.commands.cooldownSeconds')}

+ + patchLocal(entry.commandName, { + cooldownSeconds: event.target.value === '' ? null : Number(event.target.value) + }) + } + placeholder={t('common.optional')} + /> +
+
+

{t('modulePages.commands.allowedRoleIds')}

+ patchLocal(entry.commandName, { allowedRoleIds: fromCsv(event.target.value) })} + placeholder={t('modulePages.commands.idsPlaceholder')} + /> +
+
+

{t('modulePages.commands.deniedRoleIds')}

+ patchLocal(entry.commandName, { deniedRoleIds: fromCsv(event.target.value) })} + placeholder={t('modulePages.commands.idsPlaceholder')} + /> +
+
+

{t('modulePages.commands.allowedChannelIds')}

+ + patchLocal(entry.commandName, { allowedChannelIds: fromCsv(event.target.value) }) + } + placeholder={t('modulePages.commands.idsPlaceholder')} + /> +
+
+

{t('modulePages.commands.deniedChannelIds')}

+ + patchLocal(entry.commandName, { deniedChannelIds: fromCsv(event.target.value) }) + } + placeholder={t('modulePages.commands.idsPlaceholder')} + /> +
+
+
+ + +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/webui/src/lib/activity.ts b/apps/webui/src/lib/activity.ts new file mode 100644 index 0000000..2024bbc --- /dev/null +++ b/apps/webui/src/lib/activity.ts @@ -0,0 +1,31 @@ +import type { ActivityStatSummary } from '@nexumi/shared'; +import { prisma } from './prisma'; + +const SUMMARY_WINDOW_DAYS = 7; + +/** + * Aggregates `ActivityStat` rows (written by the bot's leveling/stats + * modules) for the last {@link SUMMARY_WINDOW_DAYS} days into totals shown + * on the guild overview page. + */ +export async function getActivitySummary(guildId: string): Promise { + const since = new Date(Date.now() - SUMMARY_WINDOW_DAYS * 24 * 60 * 60 * 1000); + + const [aggregate, activeUsers] = await Promise.all([ + prisma.activityStat.aggregate({ + where: { guildId, day: { gte: since } }, + _sum: { messageCount: true, voiceMinutes: true } + }), + prisma.activityStat.findMany({ + where: { guildId, day: { gte: since } }, + distinct: ['userId'], + select: { userId: true } + }) + ]); + + return { + messageCount: aggregate._sum.messageCount ?? 0, + voiceMinutes: aggregate._sum.voiceMinutes ?? 0, + activeUserCount: activeUsers.length + }; +} diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json index defe8a1..0ff5dea 100644 --- a/apps/webui/src/messages/de.json +++ b/apps/webui/src/messages/de.json @@ -15,6 +15,7 @@ "unsavedChanges": "Du hast ungespeicherte Änderungen", "saveSuccess": "Änderungen gespeichert", "saveError": "Änderungen konnten nicht gespeichert werden", + "optional": "Optional", "back": "Zurück", "comingSoon": "Demnächst verfügbar", "close": "Schließen" @@ -24,7 +25,8 @@ "overview": "Übersicht", "settings": "Einstellungen", "modules": "Module", - "access": "Dashboard-Zugriff" + "access": "Dashboard-Zugriff", + "commands": "Commands" }, "moduleGroups": { "moderation": "Moderation", @@ -83,7 +85,12 @@ "recentAuditTitle": "Letzte Dashboard-Aktivität", "auditEmpty": "Noch keine Dashboard-Änderungen.", "auditPath": "Pfad", - "viewAllModules": "Module verwalten" + "viewAllModules": "Module verwalten", + "activityTitle": "Aktivität (letzte 7 Tage)", + "activityMessages": "Nachrichten", + "activityVoiceMinutes": "Voice-Minuten", + "activityActiveUsers": "Aktive Mitglieder", + "activityHint": "Aggregiert aus der Aktivitätserfassung der Leveling- und Stats-Module." } }, "settings": { @@ -270,6 +277,240 @@ "enabledHint": "Aktiviert Minispiele wie Trivia, Tic-Tac-Toe und 8-Ball.", "mediaEnabledLabel": "Medien-Commands aktiviert", "mediaEnabledHint": "Aktiviert API-basierte Commands wie /cat und /dog." + }, + "tickets": { + "title": "Tickets", + "description": "Lege fest, wie Support-Tickets erstellt und verwaltet werden.", + "enabledLabel": "Ticket-Modul aktiviert", + "mode": "Ticket-Modus", + "mode.CHANNEL": "Privater Kanal", + "mode.THREAD": "Privater Thread", + "categoryId": "Ticket-Kategorie-ID", + "logChannelId": "Log-Kanal-ID", + "inactivityHours": "Automatisch schließen nach Inaktivität (Stunden)", + "transcriptDm": "Transkript per DM an Ticket-Ersteller", + "transcriptDmHint": "Sendet dem Nutzer nach Schließung ein Transkript per DM.", + "ratingEnabled": "Nach Schließung um Bewertung bitten", + "categoriesTitle": "Ticket-Kategorien", + "categoriesDescription": "Kategorien, die Mitglieder beim Öffnen eines Tickets auswählen können.", + "categoriesEmpty": "Noch keine Ticket-Kategorien konfiguriert.", + "categoryName": "Name", + "categoryEmoji": "Emoji", + "categoryDescription": "Beschreibung", + "supportRoleIds": "Support-Rollen-IDs", + "idsPlaceholder": "Eine oder mehrere IDs, kommagetrennt", + "addCategory": "Kategorie hinzufügen", + "nameRequired": "Ein Name ist erforderlich." + }, + "giveaways": { + "formIncomplete": "Bitte Kanal, Preis und Enddatum ausfüllen.", + "created": "Giveaway erstellt.", + "confirmDelete": "Dieses Giveaway wirklich löschen?", + "createTitle": "Giveaway starten", + "createDescription": "Postet eine Giveaway-Nachricht in Discord und ermittelt Gewinner automatisch bei Ablauf.", + "channelId": "Kanal-ID", + "prize": "Preis", + "winnerCount": "Anzahl Gewinner", + "endsAt": "Endet am", + "requiredRoleId": "Benötigte Rollen-ID", + "requiredLevel": "Benötigtes Level", + "requiredMemberDays": "Benötigte Mitgliedschaftsdauer (Tage)", + "create": "Giveaway starten", + "listTitle": "Giveaways", + "listDescription": "Aktive und beendete Giveaways für diesen Server.", + "empty": "Noch keine Giveaways.", + "entrants": "Teilnehmer", + "statusEnded": "Beendet", + "statusPaused": "Pausiert", + "statusActive": "Aktiv", + "endedAt": "Beendet am", + "winners": "Gewinner", + "pause": "Pausieren", + "unpause": "Fortsetzen", + "end": "Jetzt beenden" + }, + "selfroles": { + "formIncomplete": "Bitte Kanal, Titel und mindestens eine Rolle ausfüllen.", + "confirmDelete": "Dieses Panel wirklich löschen?", + "createTitle": "Self-Role-Panel erstellen", + "createDescription": "Postet eine Nachricht in Discord, über die sich Mitglieder selbst Rollen zuweisen können.", + "channelId": "Kanal-ID", + "panelTitle": "Panel-Titel", + "mode": "Modus", + "modes": { "BUTTONS": "Buttons", "DROPDOWN": "Dropdown", "REACTIONS": "Reaktionen" }, + "behavior": "Verhalten", + "behaviors": { + "NORMAL": "Normal (frei umschaltbar)", + "UNIQUE": "Exklusiv (nur eine Rolle aus diesem Panel)", + "VERIFY": "Verifizierungsrolle", + "TEMPORARY": "Temporär (läuft nach einer Zeit ab)" + }, + "description": "Beschreibung", + "roles": "Rollen", + "rolesPlaceholder": "roleId | Label | Emoji | Beschreibung (eine pro Zeile)", + "rolesHint": "Eine Rolle pro Zeile: roleId | Label | Emoji (optional) | Beschreibung (optional).", + "create": "Panel erstellen", + "listTitle": "Self-Role-Panels", + "listDescription": "Vorhandene Panels für diesen Server.", + "empty": "Noch keine Self-Role-Panels." + }, + "tags": { + "nameRequired": "Ein Name ist erforderlich.", + "confirmDelete": "Diesen Tag wirklich löschen?", + "createTitle": "Tag erstellen", + "createDescription": "Custom Commands und Auto-Responder, ausgelöst per Name oder Stichwort.", + "name": "Name", + "responseType": "Antworttyp", + "responseTypes": { "TEXT": "Text", "EMBED": "Embed" }, + "triggerWord": "Auslöse-Wort (optional)", + "content": "Inhalt", + "allowedRoleIds": "Erlaubte Rollen-IDs", + "allowedChannelIds": "Erlaubte Kanal-IDs", + "idsPlaceholder": "Eine oder mehrere IDs, kommagetrennt", + "create": "Tag erstellen", + "listTitle": "Tags", + "listDescription": "Vorhandene Tags für diesen Server.", + "empty": "Noch keine Tags." + }, + "starboard": { + "title": "Starboard", + "description": "Beliebte Nachrichten in einem eigenen Kanal hervorheben.", + "enabledLabel": "Starboard aktiviert", + "channelId": "Starboard-Kanal-ID", + "emoji": "Auslöse-Emoji", + "threshold": "Stern-Schwellenwert", + "allowSelfStar": "Eigene Nachrichten dürfen selbst markiert werden", + "ignoredChannelIds": "Ignorierte Kanal-IDs", + "idsHint": "Kommagetrennte Liste von Kanal-IDs, die vom Starboard ausgeschlossen werden." + }, + "suggestions": { + "configTitle": "Vorschläge-Einstellungen", + "configDescription": "Lege fest, wo Vorschläge gepostet und nach der Prüfung verschoben werden.", + "enabledLabel": "Vorschläge aktiviert", + "openChannelId": "Kanal-ID für offene Vorschläge", + "approvedChannelId": "Kanal-ID für angenommene Vorschläge", + "deniedChannelId": "Kanal-ID für abgelehnte Vorschläge", + "createThread": "Diskussions-Thread erstellen", + "createThreadHint": "Öffnet automatisch einen Thread unter jedem neuen Vorschlag.", + "listTitle": "Vorschläge", + "listDescription": "Von Mitgliedern eingereichte Vorschläge prüfen und moderieren.", + "statusAll": "Alle Status", + "status": { + "OPEN": "Offen", + "APPROVED": "Angenommen", + "DENIED": "Abgelehnt", + "CONSIDERED": "In Erwägung", + "IMPLEMENTED": "Umgesetzt" + }, + "empty": "Keine Vorschläge gefunden.", + "staffReason": "Begründung des Teams", + "reasonPlaceholder": "Optionale Begründung, die dem Ersteller angezeigt wird", + "approve": "Annehmen", + "deny": "Ablehnen", + "consider": "In Erwägung ziehen", + "implement": "Umsetzen" + }, + "birthdays": { + "title": "Geburtstage", + "description": "Geburtstage von Mitgliedern ankündigen und optional eine Geburtstagsrolle vergeben.", + "enabledLabel": "Geburtstage aktiviert", + "channelId": "Ankündigungs-Kanal-ID", + "roleId": "Geburtstagsrolle-ID", + "timezone": "Zeitzone", + "message": "Ankündigungstext", + "messagePlaceholder": "Optionale eigene Nachricht, z. B. Alles Gute zum Geburtstag {user}!" + }, + "tempvoice": { + "title": "Temp-Voice", + "description": "Mitglieder, die dem Hub-Kanal beitreten, erhalten einen eigenen temporären Voice-Kanal.", + "enabledLabel": "Temp-Voice aktiviert", + "hubChannelId": "Hub-Kanal-ID", + "categoryId": "Kategorie-ID für erstellte Kanäle", + "defaultName": "Standard-Kanalname", + "defaultLimit": "Standard-Nutzerlimit", + "defaultLimitHint": "0 setzen für kein Limit." + }, + "stats": { + "title": "Server-Statistiken", + "description": "Automatisch aktualisierte Voice-Kanäle mit Live-Serverstatistiken.", + "enabledLabel": "Server-Statistiken aktiviert", + "membersChannelId": "Mitglieder-Kanal-ID", + "membersTemplate": "Mitglieder-Vorlage", + "onlineChannelId": "Online-Kanal-ID", + "onlineTemplate": "Online-Vorlage", + "boostsChannelId": "Boosts-Kanal-ID", + "boostsTemplate": "Boosts-Vorlage", + "templateHint": "Nutze {count} als Platzhalter für den Live-Wert." + }, + "feeds": { + "title": "Social Feeds", + "description": "Benachrichtigungen posten, wenn neuer Inhalt auf Twitch, YouTube, RSS oder Reddit erscheint.", + "empty": "Noch keine Feeds konfiguriert.", + "addFeed": "Feed hinzufügen", + "type": "Feed-Typ", + "sourceId": "Quell-ID", + "sourceIdPlaceholder": "Kanalname, Subreddit oder Feed-URL", + "channelId": "Discord-Kanal-ID", + "rolePingId": "Zu erwähnende Rolle", + "template": "Nachrichtenvorlage", + "templatePlaceholder": "Optionale eigene Nachrichtenvorlage", + "formIncomplete": "Bitte Quelle und Kanal-ID ausfüllen.", + "confirmDelete": "Diesen Feed wirklich löschen?" + }, + "scheduler": { + "createTitle": "Nachricht planen", + "createDescription": "Sende eine Nachricht einmalig zu einem bestimmten Zeitpunkt oder wiederholt per Cron-Zeitplan.", + "channelId": "Kanal-ID", + "rolePingId": "Zu erwähnende Rolle", + "cron": "Cron-Ausdruck", + "runAt": "Ausführen am", + "content": "Nachrichtentext", + "cronHint": "Cron-Ausdruck für wiederkehrende Nachrichten ODER ein einmaliges Datum/Uhrzeit angeben - nicht beides.", + "create": "Nachricht planen", + "created": "Nachricht geplant.", + "formIncomplete": "Bitte Kanal, Inhalt sowie Cron-Ausdruck oder Datum/Uhrzeit ausfüllen.", + "confirmDelete": "Diesen Zeitplan wirklich löschen?", + "listTitle": "Geplante Nachrichten", + "listDescription": "Anstehende und wiederkehrende geplante Nachrichten.", + "empty": "Noch keine geplanten Nachrichten.", + "noContent": "(nur Embed)" + }, + "guildbackup": { + "createTitle": "Backup erstellen", + "createDescription": "Erstellt einen Snapshot von Rollen, Kanälen und Servereinstellungen für spätere Wiederherstellung.", + "name": "Backup-Name", + "namePlaceholder": "z. B. Vor Season-3-Update", + "create": "Backup erstellen", + "created": "Backup erstellt.", + "nameRequired": "Ein Name ist erforderlich.", + "listTitle": "Backups", + "listDescription": "Vorhandene Backups für diesen Server.", + "empty": "Noch keine Backups.", + "info": "Info", + "infoUnavailable": "Backup-Details nicht verfügbar.", + "infoGuildName": "Servername", + "infoRoles": "Rollen", + "infoChannels": "Kanäle", + "download": "JSON herunterladen", + "restore": "Wiederherstellen", + "confirmRestoreStep1": "Dieses Backup wiederherstellen? Fehlende Rollen/Kanäle werden ergänzt und Overwrites best-effort angewendet. Bestehende Kanäle werden nicht gelöscht.", + "confirmRestoreStep2": "Letzte Bestätigung: Dieses Backup jetzt wiederherstellen? Diese Aktion kann nicht rückgängig gemacht werden.", + "restoreQueued": "Wiederherstellung eingereiht - läuft im Hintergrund.", + "confirmDelete": "Dieses Backup wirklich löschen?", + "ownerOnlyBadge": "Wiederherstellung nur durch Owner" + }, + "commands": { + "title": "Commands", + "description": "Einzelne Slash-Commands aktivieren/deaktivieren und per Rolle, Kanal oder Cooldown einschränken.", + "searchPlaceholder": "Commands durchsuchen…", + "empty": "Keine Commands entsprechen der Suche.", + "cooldownSeconds": "Cooldown (Sekunden)", + "allowedRoleIds": "Erlaubte Rollen-IDs", + "deniedRoleIds": "Verbotene Rollen-IDs", + "allowedChannelIds": "Erlaubte Kanal-IDs", + "deniedChannelIds": "Verbotene Kanal-IDs", + "idsPlaceholder": "Eine oder mehrere IDs, kommagetrennt", + "reset": "Auf Standard zurücksetzen" } }, "userMenu": { diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json index e86f1d6..acbdfc4 100644 --- a/apps/webui/src/messages/en.json +++ b/apps/webui/src/messages/en.json @@ -17,14 +17,16 @@ "saveError": "Could not save changes", "back": "Back", "comingSoon": "Coming soon", - "close": "Close" + "close": "Close", + "optional": "Optional" }, "nav": { "dashboard": "Dashboard", "overview": "Overview", "settings": "Settings", "modules": "Modules", - "access": "Dashboard access" + "access": "Dashboard access", + "commands": "Commands" }, "moduleGroups": { "moderation": "Moderation", @@ -83,7 +85,12 @@ "recentAuditTitle": "Recent dashboard activity", "auditEmpty": "No dashboard changes yet.", "auditPath": "Path", - "viewAllModules": "Manage modules" + "viewAllModules": "Manage modules", + "activityTitle": "Activity (last 7 days)", + "activityMessages": "Messages", + "activityVoiceMinutes": "Voice minutes", + "activityActiveUsers": "Active members", + "activityHint": "Aggregated from the leveling and stats modules' activity tracking." } }, "settings": { @@ -270,6 +277,240 @@ "enabledHint": "Enables mini games like trivia, tic-tac-toe and 8-ball.", "mediaEnabledLabel": "Media commands enabled", "mediaEnabledHint": "Enables API-based commands like /cat and /dog." + }, + "tickets": { + "title": "Tickets", + "description": "Configure how support tickets are created and managed.", + "enabledLabel": "Tickets module enabled", + "mode": "Ticket mode", + "mode.CHANNEL": "Private channel", + "mode.THREAD": "Private thread", + "categoryId": "Ticket category ID", + "logChannelId": "Log channel ID", + "inactivityHours": "Auto-close after inactivity (hours)", + "transcriptDm": "DM transcript to ticket creator", + "transcriptDmHint": "Sends a transcript to the user via DM once their ticket is closed.", + "ratingEnabled": "Ask for a rating after closing", + "categoriesTitle": "Ticket categories", + "categoriesDescription": "Categories members can choose from when opening a ticket.", + "categoriesEmpty": "No ticket categories configured yet.", + "categoryName": "Name", + "categoryEmoji": "Emoji", + "categoryDescription": "Description", + "supportRoleIds": "Support role IDs", + "idsPlaceholder": "One or more IDs, comma-separated", + "addCategory": "Add category", + "nameRequired": "A name is required." + }, + "giveaways": { + "formIncomplete": "Please fill in the channel, prize and end date.", + "created": "Giveaway created.", + "confirmDelete": "Really delete this giveaway?", + "createTitle": "Start a giveaway", + "createDescription": "Posts a giveaway message in Discord and picks winners automatically when it ends.", + "channelId": "Channel ID", + "prize": "Prize", + "winnerCount": "Winner count", + "endsAt": "Ends at", + "requiredRoleId": "Required role ID", + "requiredLevel": "Required level", + "requiredMemberDays": "Required membership (days)", + "create": "Start giveaway", + "listTitle": "Giveaways", + "listDescription": "Active and ended giveaways for this server.", + "empty": "No giveaways yet.", + "entrants": "Entrants", + "statusEnded": "Ended", + "statusPaused": "Paused", + "statusActive": "Active", + "endedAt": "Ended at", + "winners": "Winners", + "pause": "Pause", + "unpause": "Resume", + "end": "End now" + }, + "selfroles": { + "formIncomplete": "Please fill in the channel, title and at least one role.", + "confirmDelete": "Really delete this panel?", + "createTitle": "Create a self-role panel", + "createDescription": "Posts a message in Discord that members can use to assign themselves roles.", + "channelId": "Channel ID", + "panelTitle": "Panel title", + "mode": "Mode", + "modes": { "BUTTONS": "Buttons", "DROPDOWN": "Dropdown", "REACTIONS": "Reactions" }, + "behavior": "Behavior", + "behaviors": { + "NORMAL": "Normal (toggle freely)", + "UNIQUE": "Unique (only one role from this panel)", + "VERIFY": "Verification role", + "TEMPORARY": "Temporary (expires after a while)" + }, + "description": "Description", + "roles": "Roles", + "rolesPlaceholder": "roleId | label | emoji | description (one per line)", + "rolesHint": "One role per line: roleId | label | emoji (optional) | description (optional).", + "create": "Create panel", + "listTitle": "Self-role panels", + "listDescription": "Existing panels for this server.", + "empty": "No self-role panels yet." + }, + "tags": { + "nameRequired": "A name is required.", + "confirmDelete": "Really delete this tag?", + "createTitle": "Create a tag", + "createDescription": "Custom commands and auto-responders triggered by name or keyword.", + "name": "Name", + "responseType": "Response type", + "responseTypes": { "TEXT": "Text", "EMBED": "Embed" }, + "triggerWord": "Trigger word (optional)", + "content": "Content", + "allowedRoleIds": "Allowed role IDs", + "allowedChannelIds": "Allowed channel IDs", + "idsPlaceholder": "One or more IDs, comma-separated", + "create": "Create tag", + "listTitle": "Tags", + "listDescription": "Existing tags for this server.", + "empty": "No tags yet." + }, + "starboard": { + "title": "Starboard", + "description": "Highlight popular messages in a dedicated channel.", + "enabledLabel": "Starboard enabled", + "channelId": "Starboard channel ID", + "emoji": "Trigger emoji", + "threshold": "Star threshold", + "allowSelfStar": "Allow starring your own messages", + "ignoredChannelIds": "Ignored channel IDs", + "idsHint": "Comma-separated list of channel IDs to exclude from the starboard." + }, + "suggestions": { + "configTitle": "Suggestions settings", + "configDescription": "Configure where suggestions are posted and moved once reviewed.", + "enabledLabel": "Suggestions enabled", + "openChannelId": "Open channel ID", + "approvedChannelId": "Approved channel ID", + "deniedChannelId": "Denied channel ID", + "createThread": "Create a discussion thread", + "createThreadHint": "Automatically opens a thread under every new suggestion.", + "listTitle": "Suggestions", + "listDescription": "Review and moderate suggestions submitted by members.", + "statusAll": "All statuses", + "status": { + "OPEN": "Open", + "APPROVED": "Approved", + "DENIED": "Denied", + "CONSIDERED": "Considered", + "IMPLEMENTED": "Implemented" + }, + "empty": "No suggestions found.", + "staffReason": "Staff reason", + "reasonPlaceholder": "Optional reason shown to the author", + "approve": "Approve", + "deny": "Deny", + "consider": "Consider", + "implement": "Implement" + }, + "birthdays": { + "title": "Birthdays", + "description": "Announce member birthdays and optionally assign a birthday role.", + "enabledLabel": "Birthdays enabled", + "channelId": "Announcement channel ID", + "roleId": "Birthday role ID", + "timezone": "Timezone", + "message": "Announcement message", + "messagePlaceholder": "Optional custom message, e.g. Happy birthday {user}!" + }, + "tempvoice": { + "title": "Temp Voice", + "description": "Members joining the hub channel get their own temporary voice channel.", + "enabledLabel": "Temp voice enabled", + "hubChannelId": "Hub channel ID", + "categoryId": "Category ID for created channels", + "defaultName": "Default channel name", + "defaultLimit": "Default user limit", + "defaultLimitHint": "Set to 0 for no limit." + }, + "stats": { + "title": "Server Stats", + "description": "Auto-updating voice channels showing live server statistics.", + "enabledLabel": "Server stats enabled", + "membersChannelId": "Members channel ID", + "membersTemplate": "Members template", + "onlineChannelId": "Online channel ID", + "onlineTemplate": "Online template", + "boostsChannelId": "Boosts channel ID", + "boostsTemplate": "Boosts template", + "templateHint": "Use {count} as a placeholder for the live value." + }, + "feeds": { + "title": "Social Feeds", + "description": "Post notifications when new content is published on Twitch, YouTube, RSS or Reddit.", + "empty": "No feeds configured yet.", + "addFeed": "Add feed", + "type": "Feed type", + "sourceId": "Source ID", + "sourceIdPlaceholder": "Channel name, subreddit or feed URL", + "channelId": "Discord channel ID", + "rolePingId": "Role to ping", + "template": "Message template", + "templatePlaceholder": "Optional custom message template", + "formIncomplete": "Please fill in the source and channel ID.", + "confirmDelete": "Really delete this feed?" + }, + "scheduler": { + "createTitle": "Schedule a message", + "createDescription": "Send a message once at a specific time or repeatedly on a cron schedule.", + "channelId": "Channel ID", + "rolePingId": "Role to ping", + "cron": "Cron expression", + "runAt": "Run at", + "content": "Message content", + "cronHint": "Set a cron expression for recurring messages, or a one-time date/time - not both.", + "create": "Schedule message", + "created": "Message scheduled.", + "formIncomplete": "Please fill in the channel, content and either a cron expression or a date/time.", + "confirmDelete": "Really delete this schedule?", + "listTitle": "Scheduled messages", + "listDescription": "Upcoming and recurring scheduled messages.", + "empty": "No scheduled messages yet.", + "noContent": "(embed only)" + }, + "guildbackup": { + "createTitle": "Create a backup", + "createDescription": "Snapshots roles, channels and server settings for later restore.", + "name": "Backup name", + "namePlaceholder": "e.g. Before season 3 update", + "create": "Create backup", + "created": "Backup created.", + "nameRequired": "A name is required.", + "listTitle": "Backups", + "listDescription": "Existing backups for this server.", + "empty": "No backups yet.", + "info": "Info", + "infoUnavailable": "Backup details are unavailable.", + "infoGuildName": "Server name", + "infoRoles": "Roles", + "infoChannels": "Channels", + "download": "Download JSON", + "restore": "Restore", + "confirmRestoreStep1": "Restore this backup? Missing roles/channels will be added and overwrites applied best-effort. Existing channels will not be deleted.", + "confirmRestoreStep2": "Final confirmation: restore this backup now? This cannot be undone.", + "restoreQueued": "Restore queued - it will run in the background.", + "confirmDelete": "Really delete this backup?", + "ownerOnlyBadge": "Owner-only restore" + }, + "commands": { + "title": "Commands", + "description": "Enable/disable individual slash commands and restrict them by role, channel or cooldown.", + "searchPlaceholder": "Search commands…", + "empty": "No commands match your search.", + "cooldownSeconds": "Cooldown (seconds)", + "allowedRoleIds": "Allowed role IDs", + "deniedRoleIds": "Denied role IDs", + "allowedChannelIds": "Allowed channel IDs", + "deniedChannelIds": "Denied channel IDs", + "idsPlaceholder": "One or more IDs, comma-separated", + "reset": "Reset to default" } }, "userMenu": {