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>
138 lines
6.2 KiB
TypeScript
138 lines
6.2 KiB
TypeScript
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, 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">
|
|
<div className="grid gap-5 xl:grid-cols-[1fr_420px]">
|
|
<div>
|
|
<h3 className="mb-3 text-base font-semibold">Bestehende Sets ({reactionRoles.length})</h3>
|
|
<div className="space-y-3">
|
|
{reactionRoles.length ? reactionRoles.map((set, i) => (
|
|
<Card key={set.id || i}>
|
|
<CardContent className="flex items-center gap-3 p-4">
|
|
<div className="flex size-10 items-center justify-center rounded-xl bg-accent-soft text-accent-soft-foreground">
|
|
<Tag size={18} />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="font-semibold text-sm truncate">{set.title || 'Reaction Role'}</div>
|
|
<div className="text-xs text-muted truncate">Channel: {set.channelId || '-'}</div>
|
|
</div>
|
|
<Chip size="sm" variant="soft">{(set.entries?.length || 0)} Einträge</Chip>
|
|
</CardContent>
|
|
</Card>
|
|
)) : (
|
|
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
|
|
<Tag size={24} />
|
|
Keine Sets
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader className="px-5 pt-5 pb-0">
|
|
<h3 className="text-base font-semibold">Neues Set</h3>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-4 p-5">
|
|
<TextField>
|
|
<Label>Titel</Label>
|
|
<Input
|
|
placeholder="Rollenauswahl"
|
|
value={reactionDraft.title}
|
|
onChange={(e) => setReactionDraft((s) => ({ ...s, title: e.target.value }))}
|
|
/>
|
|
</TextField>
|
|
|
|
<TextField>
|
|
<Label>Channel</Label>
|
|
<ChannelSelect
|
|
options={channels}
|
|
value={reactionDraft.channelId}
|
|
onChange={(id) => setReactionDraft((s) => ({ ...s, channelId: id }))}
|
|
placeholder="Channel für die Nachricht wählen"
|
|
/>
|
|
</TextField>
|
|
|
|
<div>
|
|
<Label>Einträge</Label>
|
|
<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>
|
|
|
|
<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>
|
|
</Card>
|
|
</div>
|
|
</SectionCard>
|
|
);
|
|
}
|