add permission scanner, info panels, image QR scan, invite tracker, ticket templates
Five new features: - /permissions scan: audits admin/ban roles, dangerous bot permissions, public channels, empty/duplicate/useless roles, too-many-admins warning. - /panel create: fixed info panels (rules/support/bewerbung/partner/ rollen/events/faq) with buttons wired into the existing ticket/ register/partner flows, FAQ panels get a question dropdown. - Image moderation: QR-code detection in message attachments via jimp+jsqr (pure JS, no native build deps) with a small scam-pattern check; opt-in toggle in dashboard settings since decoding costs per-message. - Invite tracker extension: now resolves the inviter (not just the code), tracks suspicious joins per invite, and the dashboard growth page gets an invite breakdown + recent-joins table. - Ticket categories: /ticketconfig lets each ticket topic get its own ping role and a modal question template shown before the channel is created; topics without config behave exactly as before. Also fixes two rough edges found along the way: the welcome embed had no way to attach an image despite the backend already supporting it (added URL/file-upload fields + live preview), and Reaction Roles required typing raw role IDs into a textarea (replaced with a proper role picker). Incidentally repairs node_modules/.bin symlinks that were committed as literal broken shell-script text instead of real symlinks. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,13 +6,14 @@ type Props = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
footer?: string;
|
||||
image?: string;
|
||||
accentColor?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const now = () => new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
export function DiscordPreview({ botName = 'Papo', title, description, footer, accentColor = '#5865f2', children }: Props) {
|
||||
export function DiscordPreview({ botName = 'Papo', title, description, footer, image, accentColor = '#5865f2', children }: Props) {
|
||||
return (
|
||||
<div className="rounded-lg bg-[#313338] p-4">
|
||||
<div className="flex gap-3">
|
||||
@@ -26,7 +27,7 @@ export function DiscordPreview({ botName = 'Papo', title, description, footer, a
|
||||
<span className="text-xs text-[#949ba4]">Heute um {now()}</span>
|
||||
</div>
|
||||
|
||||
{(title || description || footer) && (
|
||||
{(title || description || footer || image) && (
|
||||
<div
|
||||
className="mt-1 max-w-md rounded border-l-4 bg-[#2b2d31] p-3"
|
||||
style={{ borderLeftColor: accentColor }}
|
||||
@@ -36,6 +37,7 @@ export function DiscordPreview({ botName = 'Papo', title, description, footer, a
|
||||
<div className="mt-1 whitespace-pre-wrap text-sm leading-snug text-[#dbdee1]">{description}</div>
|
||||
)}
|
||||
{footer && <div className="mt-2.5 text-xs text-[#949ba4]">{footer}</div>}
|
||||
{image && <img src={image} alt="" className="mt-2.5 max-h-64 w-full max-w-full rounded object-cover" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import type {
|
||||
AppConfig, User, Guild, NavKey, TicketRecord, StatusService,
|
||||
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState,
|
||||
SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField,
|
||||
RegisterApplication, MusicSession, StaffTask, WatchlistEntry, GrowthStats
|
||||
RegisterApplication, MusicSession, StaffTask, WatchlistEntry, GrowthStats,
|
||||
InviteBreakdownEntry, RecentJoinEntry
|
||||
} from '../types';
|
||||
|
||||
const appConfig: AppConfig = (window as any).__PAPO__ || {};
|
||||
@@ -48,6 +49,8 @@ type AppState = {
|
||||
taskDraft: { title: string; description: string };
|
||||
watchlistEntries: WatchlistEntry[];
|
||||
growthStats: GrowthStats | null;
|
||||
inviteBreakdown: InviteBreakdownEntry[];
|
||||
recentJoins: RecentJoinEntry[];
|
||||
};
|
||||
|
||||
type AppContextType = AppState & {
|
||||
@@ -131,6 +134,7 @@ type AppContextType = AppState & {
|
||||
loadWatchlist: () => Promise<void>;
|
||||
removeFromWatchlist: (userId: string) => Promise<void>;
|
||||
loadGrowthStats: () => Promise<void>;
|
||||
loadInviteBreakdown: () => Promise<void>;
|
||||
};
|
||||
|
||||
const AppContext = createContext<AppContextType | null>(null);
|
||||
@@ -165,7 +169,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
const [eventDraft, setEventDraft] = useState({ title: '', description: '', channelId: '', startsAt: '' });
|
||||
const [statusDraft, setStatusDraft] = useState<any>(null);
|
||||
const [statsDraft, setStatsDraft] = useState<any>(null);
|
||||
const [reactionDraft, setReactionDraft] = useState({ title: '', channelId: '', entries: '' });
|
||||
const [reactionDraft, setReactionDraft] = useState<{ title: string; channelId: string; entries: { emoji: string; roleId: string; label: string; description: string }[] }>({ title: '', channelId: '', entries: [] });
|
||||
const [supportLogin, setSupportLogin] = useState<{ config: SupportLoginConfig; status: SupportLoginStatus; supportRoleId?: string } | null>(null);
|
||||
const [registerForms, setRegisterForms] = useState<RegisterForm[]>([]);
|
||||
const [registerApps, setRegisterApps] = useState<RegisterApplication[]>([]);
|
||||
@@ -190,6 +194,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
const [taskDraft, setTaskDraft] = useState({ title: '', description: '' });
|
||||
const [watchlistEntries, setWatchlistEntries] = useState<WatchlistEntry[]>([]);
|
||||
const [growthStats, setGrowthStats] = useState<GrowthStats | null>(null);
|
||||
const [inviteBreakdown, setInviteBreakdown] = useState<InviteBreakdownEntry[]>([]);
|
||||
const [recentJoins, setRecentJoins] = useState<RecentJoinEntry[]>([]);
|
||||
|
||||
const setSection = useCallback((key: NavKey) => {
|
||||
setSectionState(key);
|
||||
@@ -277,7 +283,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
setRegisterApps(registerAppsRes.applications || []);
|
||||
setTasks(tasksRes.tasks || []);
|
||||
setWatchlistEntries(watchlistRes.entries || []);
|
||||
setReactionDraft({ title: '', channelId: '', entries: '' });
|
||||
setReactionDraft({ title: '', channelId: '', entries: [] });
|
||||
await Promise.all([loadTicketData(guildId), loadAdminData()]);
|
||||
setStatusMessage('');
|
||||
} catch { setStatusMessage('Daten konnten nicht geladen werden'); }
|
||||
@@ -356,11 +362,10 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
async function saveReactionRole() {
|
||||
const entries = reactionDraft.entries.split('\n').map((l) => l.trim()).filter(Boolean)
|
||||
.map((line) => { const p = line.split('|').map((s) => s.trim()); return { emoji: p[0], roleId: p[1], label: p[2], description: p[3] }; })
|
||||
.filter((e) => e.emoji && e.roleId);
|
||||
const entries = (reactionDraft.entries as any[]).filter((e) => e.emoji && e.roleId);
|
||||
await apiFetch('/reactionroles', { method: 'POST', body: JSON.stringify({ guildId: currentGuildId, channelId: reactionDraft.channelId, title: reactionDraft.title, entries }) });
|
||||
await loadGuildData(currentGuildId);
|
||||
setReactionDraft({ title: '', channelId: '', entries: [] });
|
||||
setStatusMessage('Reaction Role gespeichert');
|
||||
}
|
||||
|
||||
@@ -606,6 +611,13 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
setGrowthStats(res.stats || null);
|
||||
}
|
||||
|
||||
async function loadInviteBreakdown() {
|
||||
if (!currentGuildId) return;
|
||||
const res = await apiFetch<any>(`/growth/invites?guildId=${encodeURIComponent(currentGuildId)}`);
|
||||
setInviteBreakdown(res.breakdown || []);
|
||||
setRecentJoins(res.recentJoins || []);
|
||||
}
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
|
||||
}, []);
|
||||
@@ -621,6 +633,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
ticketDetail, ticketMessages, kbEditDraft, automationEditDraft,
|
||||
automodStrikes, registerStatusFilter, registerFormFilter, selectedAppId,
|
||||
appNotes, appHistory, noteDraft, tasks, taskDraft, watchlistEntries, growthStats,
|
||||
inviteBreakdown, recentJoins,
|
||||
setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin,
|
||||
setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData,
|
||||
saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats,
|
||||
@@ -636,7 +649,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
loadAutomodStrikes, resetAutomodStrike, setRegisterStatusFilter, setRegisterFormFilter,
|
||||
loadRegisterApps, openAppDetail, setNoteDraft, addAppNote,
|
||||
setTaskDraft, createTask, updateTaskStatus, deleteTask,
|
||||
loadWatchlist, removeFromWatchlist, loadGrowthStats,
|
||||
loadWatchlist, removeFromWatchlist, loadGrowthStats, loadInviteBreakdown,
|
||||
}}>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader } from '@heroui/react';
|
||||
import { TrendingUp, TrendingDown, Gem, Link2, Handshake } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, Chip } from '@heroui/react';
|
||||
import { TrendingUp, TrendingDown, Gem, Link2, Handshake, Users } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
import { StatCard } from '../components/shared/StatCard';
|
||||
import { BarComparisonChart } from '../components/shared/BarComparisonChart';
|
||||
import { formatDate } from '../utils/formatters';
|
||||
|
||||
export function Growth() {
|
||||
const { currentGuildId, growthStats, loadGrowthStats } = useApp();
|
||||
const { currentGuildId, growthStats, loadGrowthStats, inviteBreakdown, recentJoins, loadInviteBreakdown } = useApp();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentGuildId) loadGrowthStats();
|
||||
if (currentGuildId) {
|
||||
loadGrowthStats();
|
||||
loadInviteBreakdown();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentGuildId]);
|
||||
|
||||
@@ -71,6 +75,64 @@ export function Growth() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-5 xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Invite-Übersicht (30 Tage)</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 p-5">
|
||||
{inviteBreakdown.length ? inviteBreakdown.map((inv) => (
|
||||
<div key={inv.code} className="bg-surface-tertiary flex items-center justify-between gap-2 rounded-xl px-4 py-3 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">discord.gg/{inv.code}</div>
|
||||
<div className="text-xs text-muted truncate">
|
||||
{inv.inviterId ? `von <@${inv.inviterId}>` : 'Unbekannter Ersteller'} · {inv.uses} Nutzungen gesamt
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Chip size="sm" variant="soft" color="accent">{inv.joins30} Joins</Chip>
|
||||
{inv.suspiciousJoins30 > 0 && (
|
||||
<Chip size="sm" variant="soft" color="danger">{inv.suspiciousJoins30} auffällig</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
|
||||
<Link2 size={20} />
|
||||
Keine Invite-Nutzung in den letzten 30 Tagen
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Letzte Beitritte</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex max-h-96 flex-col gap-2 overflow-y-auto p-5">
|
||||
{recentJoins.length ? recentJoins.map((j) => (
|
||||
<div key={j.id} className="bg-surface-tertiary flex items-center gap-3 rounded-xl px-4 py-3 text-sm">
|
||||
<Users size={14} className="text-muted shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate">
|
||||
User-ID {j.userId}{j.inviterId ? ` · eingeladen von ${j.inviterId}` : ''}
|
||||
</div>
|
||||
<div className="text-xs text-muted">
|
||||
{j.inviteCode ? `discord.gg/${j.inviteCode}` : 'Unbekannter Invite'} · {formatDate(j.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
{j.suspicious && <Chip size="sm" variant="soft" color="danger">Verdächtig</Chip>}
|
||||
</div>
|
||||
)) : (
|
||||
<div className="flex flex-col items-center gap-2 py-4 text-center text-xs text-muted">
|
||||
<Users size={20} />
|
||||
Noch keine Beitritte erfasst
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Separator, TextField, Label } from '@heroui/react';
|
||||
import { Tag, Save, Hash, List } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, Input, Button, Chip, TextField, Label } from '@heroui/react';
|
||||
import { Tag, Save, Plus, X } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
import { ChannelSelect } from '../components/shared/ChannelSelect';
|
||||
import { RoleSelect } from '../components/shared/RoleSelect';
|
||||
import { useGuildResources } from '../hooks/useGuildResources';
|
||||
|
||||
export function ReactionRoles() {
|
||||
const { reactionRoles, reactionDraft, setReactionDraft, saveReactionRole, currentGuildId } = useApp();
|
||||
const { channels } = useGuildResources(currentGuildId);
|
||||
const { channels, roles } = useGuildResources(currentGuildId);
|
||||
const [entryDraft, setEntryDraft] = useState({ emoji: '', roleId: '', label: '' });
|
||||
|
||||
const addEntry = () => {
|
||||
if (!entryDraft.emoji.trim() || !entryDraft.roleId) return;
|
||||
setReactionDraft((s) => ({
|
||||
...s,
|
||||
entries: [...s.entries, { emoji: entryDraft.emoji.trim(), roleId: entryDraft.roleId, label: entryDraft.label.trim(), description: '' }]
|
||||
}));
|
||||
setEntryDraft({ emoji: '', roleId: '', label: '' });
|
||||
};
|
||||
|
||||
const removeEntry = (index: number) => {
|
||||
setReactionDraft((s) => ({ ...s, entries: s.entries.filter((_: any, i: number) => i !== index) }));
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCard title="Reaction Roles" subtitle="Sets anzeigen und neue Zuordnungen anlegen">
|
||||
@@ -61,20 +77,56 @@ export function ReactionRoles() {
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
<TextField>
|
||||
<div>
|
||||
<Label>Einträge</Label>
|
||||
<TextArea
|
||||
placeholder="Emoji | Role ID | Label :emoji: | 123456789 | Rolle 1 :wave: | 987654321 | Rolle 2"
|
||||
rows={6}
|
||||
value={reactionDraft.entries}
|
||||
onChange={(e) => setReactionDraft((s) => ({ ...s, entries: e.target.value }))}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Pro Zeile: Emoji | Role ID | Label (optional) | Beschreibung (optional)
|
||||
</p>
|
||||
</TextField>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{reactionDraft.entries.map((entry, i) => {
|
||||
const role = roles.find((r) => r.id === entry.roleId);
|
||||
return (
|
||||
<div key={i} className="bg-default-soft flex items-center gap-2 rounded-xl py-1.5 pl-3 pr-1.5 text-sm">
|
||||
<span>{entry.emoji}</span>
|
||||
{role?.color && <span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: role.color }} />}
|
||||
<span className="min-w-0 flex-1 truncate">{entry.label || role?.name || entry.roleId}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-5 shrink-0 items-center justify-center rounded-full text-muted hover:bg-danger-soft hover:text-danger"
|
||||
onClick={() => removeEntry(i)}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!reactionDraft.entries.length && <p className="text-xs text-muted">Noch keine Einträge hinzugefügt.</p>}
|
||||
</div>
|
||||
|
||||
<Button variant="primary" onPress={saveReactionRole}>
|
||||
<div className="mt-3 flex flex-col gap-2 rounded-xl border border-border p-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="w-16 shrink-0"
|
||||
placeholder="😀"
|
||||
value={entryDraft.emoji}
|
||||
onChange={(e) => setEntryDraft((s) => ({ ...s, emoji: e.target.value }))}
|
||||
/>
|
||||
<RoleSelect
|
||||
options={roles}
|
||||
value={entryDraft.roleId}
|
||||
onChange={(id) => setEntryDraft((s) => ({ ...s, roleId: id }))}
|
||||
placeholder="Rolle wählen"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Label (optional, z.B. Gamer)"
|
||||
value={entryDraft.label}
|
||||
onChange={(e) => setEntryDraft((s) => ({ ...s, label: e.target.value }))}
|
||||
/>
|
||||
<Button size="sm" variant="tertiary" onPress={addEntry} isDisabled={!entryDraft.emoji.trim() || !entryDraft.roleId}>
|
||||
<Plus size={14} /> Eintrag hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="primary" onPress={saveReactionRole} isDisabled={!reactionDraft.entries.length || !reactionDraft.channelId}>
|
||||
<Save size={16} /> Reaction Role speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Card, CardContent, CardHeader, Button, Separator, TextField, Label } from '@heroui/react';
|
||||
import { Settings, Save, Logs, Bell, Shield, Edit3, Trash2 } from 'lucide-react';
|
||||
import { Settings, Save, Logs, Bell, Shield, Edit3, Trash2, ImageIcon, QrCode } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
import { ChannelSelect } from '../components/shared/ChannelSelect';
|
||||
import { RoleSelect } from '../components/shared/RoleSelect';
|
||||
import { AppSwitch } from '../components/shared/AppSwitch';
|
||||
import { ModuleActiveToggle } from '../components/shared/ModuleActiveToggle';
|
||||
import { useGuildResources } from '../hooks/useGuildResources';
|
||||
|
||||
export function SettingsPage() {
|
||||
@@ -99,6 +100,34 @@ export function SettingsPage() {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Bild-Moderation</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<ModuleActiveToggle
|
||||
icon={<ImageIcon size={16} />}
|
||||
title="Bild-Moderation aktiv"
|
||||
description="Scannt Bild-Anhänge auf QR-Codes und gleicht sie mit bekannten Scam-Mustern ab."
|
||||
isSelected={settings.imageModerationConfig?.enabled === true}
|
||||
onChange={(v) => setSettings((s) => ({ ...s, imageModerationConfig: { ...(s.imageModerationConfig || {}), enabled: v } }))}
|
||||
/>
|
||||
|
||||
<AppSwitch
|
||||
isSelected={settings.imageModerationConfig?.alertOnly === true}
|
||||
onChange={(v) => setSettings((s) => ({ ...s, imageModerationConfig: { ...(s.imageModerationConfig || {}), alertOnly: v } }))}
|
||||
label={<div className="flex items-center gap-2"><QrCode size={14} /> Nur bei erkanntem Scam-Verdacht alarmieren</div>}
|
||||
description="Wenn aus, wird bei jedem gefundenen QR-Code alarmiert, nicht nur bei verdächtigen."
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button variant="primary" onPress={() => saveSettingsPayload(settings, 'Settings gespeichert')}>
|
||||
<Save size={16} /> Speichern
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, TextArea, Button, Separator, TextField, Label } from '@heroui/react';
|
||||
import { Sparkles, Save } from 'lucide-react';
|
||||
import { Sparkles, Save, X } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
import { DiscordPreview } from '../components/shared/DiscordPreview';
|
||||
@@ -7,10 +7,28 @@ import { ChannelSelect } from '../components/shared/ChannelSelect';
|
||||
import { ModuleActiveToggle } from '../components/shared/ModuleActiveToggle';
|
||||
import { useGuildResources } from '../hooks/useGuildResources';
|
||||
|
||||
const MAX_IMAGE_BYTES = 3 * 1024 * 1024;
|
||||
|
||||
export function Welcome() {
|
||||
const { settings, setSettings, saveSettingsPayload, currentGuildId } = useApp();
|
||||
const { channels } = useGuildResources(currentGuildId);
|
||||
|
||||
const imagePreview = settings.welcomeConfig?.embedImageData || settings.welcomeConfig?.embedImage;
|
||||
|
||||
const handleImageUpload = (file: File) => {
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
alert('Bild ist zu groß (max. 3 MB).');
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedImageData: reader.result as string, embedImage: undefined } }));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const clearImage = () => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedImage: undefined, embedImageData: undefined } }));
|
||||
|
||||
return (
|
||||
<SectionCard title="Willkommen" subtitle="Welcome-Embeds und Join-Nachrichten">
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
@@ -67,6 +85,38 @@ export function Welcome() {
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
<TextField>
|
||||
<Label>Bild-URL</Label>
|
||||
<Input
|
||||
placeholder="https://..."
|
||||
value={settings.welcomeConfig?.embedImage || ''}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, welcomeConfig: { ...(s.welcomeConfig || {}), embedImage: e.target.value, embedImageData: undefined } }))}
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
<TextField>
|
||||
<Label>oder Bild hochladen</Label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
className="text-sm text-muted"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleImageUpload(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
{imagePreview && (
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={imagePreview} alt="Vorschau" className="h-12 w-20 rounded object-cover" />
|
||||
<Button size="sm" variant="danger-soft" onPress={clearImage}>
|
||||
<X size={14} /> Bild entfernen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button size="lg" variant="primary" onPress={() => saveSettingsPayload({ welcomeConfig: settings.welcomeConfig || {} }, 'Welcome gespeichert')}>
|
||||
@@ -87,6 +137,7 @@ export function Welcome() {
|
||||
title={settings.welcomeConfig?.embedTitle || 'Willkommen!'}
|
||||
description={settings.welcomeConfig?.embedDescription || 'Willkommen auf dem Server!'}
|
||||
footer={settings.welcomeConfig?.embedFooter}
|
||||
image={imagePreview}
|
||||
/>
|
||||
|
||||
<p className="text-sm text-muted">
|
||||
|
||||
@@ -179,6 +179,23 @@ export type GrowthStats = {
|
||||
dailyJoins: { day: string; count: number }[];
|
||||
};
|
||||
|
||||
export type InviteBreakdownEntry = {
|
||||
code: string;
|
||||
inviterId?: string;
|
||||
uses: number;
|
||||
joins30: number;
|
||||
suspiciousJoins30: number;
|
||||
};
|
||||
|
||||
export type RecentJoinEntry = {
|
||||
id: string;
|
||||
userId: string;
|
||||
inviterId?: string | null;
|
||||
inviteCode?: string | null;
|
||||
suspicious: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type MusicSession = {
|
||||
guildId: string;
|
||||
nowPlaying?: { title: string; url: string } | null;
|
||||
|
||||
Reference in New Issue
Block a user