Emojis zum Anklicken, und Wuensche als Forums-Beitraege mit Tags
Ueberall, wo ein Emoji nach Discord geht — Rollen-Menues, Ticket-Anliegen,
Wunsch-Bereiche, Portal-Kacheln — stand ein leeres Textfeld, in das man mit
Win+. hineintippen musste. Jetzt sitzt daneben ein Knopf mit einer Tafel:
sieben Gruppen, deutsche Suchbegriffe ("auto", "geld", "warnung"), ein Klick
schreibt ins Feld. Das Feld bleibt daneben stehen, denn Server-Emojis
(<:name:123>) kennt nur Discord.
Bewusst eine eigene Liste statt einer Bibliothek: die vollstaendige
Unicode-Tabelle waere ein halbes Megabyte fuer eine Handvoll Symbole, und die
Suchbegriffe duerfen deutsch sein.
## Forum-Tags
Ist der Voting-Kanal ein Forum, wird jeder Wunsch jetzt ein Forums-Beitrag
statt Nachricht-plus-Thread — und bekommt Tags fuer Bereich und Stand. Damit
laesst sich auch in Discord filtern, wofuer Tags ja da sind. Beim Wechsel des
Stands zieht das Tag mit: der alte fliegt raus, der neue kommt rein, der
Bereich bleibt stehen.
Zugeordnet wird ueber den Namen. Wer im Forum ein Tag "Fahrzeuge" anlegt,
bekommt es automatisch gesetzt; wer keines anlegt, verliert nichts. Bewusst
keine ID-Zuordnung im Panel — das waere eine zweite Liste, die man pflegen
muss und die beim ersten Umbenennen auseinanderlaeuft.
Dabei aufgefallen und mitgefixt: im Forum liegt die Startnachricht im Beitrag
selbst und nicht im Kanal. Der Status-Aktualisierer hat sie vorher im Kanal
gesucht und nicht gefunden — die Begruendung waere im Forum also nie im Post
gelandet. Und der Titel wird jetzt nur um den Stand ergaenzt statt
ueberschrieben, damit "🚗 Fahrzeuge" stehen bleibt.
Geprueft: tagsFuer gegen 20 Faelle — kein Forum, Medien-Kanal, fehlende Tags,
Gross/Kleinschreibung, Deckel bei fuenf (mehr nimmt Discord nicht), und alle
fuenf Staende einzeln. Im Browser die Emoji-Tafel an allen vier Stellen:
Suche, Escape, Klick daneben, Leeren, Uebernehmen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -71,7 +71,7 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen.
|
||||
| 📈 **Level-System** | XP pro Nachricht (Cooldown, MEE6-Formel), `/rank`, Level-Up-Announce, Rollen-Belohnungen, öffentliche Bestenliste |
|
||||
| ⭐ **Starboard** | Nachrichten mit genug ⭐-Reaktionen landen im Best-of-Kanal |
|
||||
| 📸 **Screenshot-Galerie** | Bilder aus dem Screenshot-Kanal → öffentliche Galerie (lokal gespeichert, weil Discord-CDN-Links ablaufen) |
|
||||
| 💡 **Feature-Voting** | `/wunsch-setup` postet den Ideen-Aufruf: Bereich wählen, Formular ausfüllen → Voting-Post mit 👍 und **Thread zum Reden**. Wünsche haben einen **Stand** (wird geprüft · geplant · in Arbeit · umgesetzt · nicht geplant) samt Begründung — auf der Roadmap und im Discord-Post. Wer gestimmt hat, bekommt eine DM, sobald es umgesetzt ist. Die Roadmap filtert nach Stand und Bereich, sortiert nach Top / Bewegung / Neu und zeigt die Zahl der Beiträge im Thread. Eine Stimme je Person, egal ob 👍 oder Web-Knopf; Doppler lassen sich zusammenführen |
|
||||
| 💡 **Feature-Voting** | `/wunsch-setup` postet den Ideen-Aufruf: Bereich wählen, Formular ausfüllen → Voting-Post mit 👍 und **Thread zum Reden**. Wünsche haben einen **Stand** (wird geprüft · geplant · in Arbeit · umgesetzt · nicht geplant) samt Begründung — auf der Roadmap und im Discord-Post. Wer gestimmt hat, bekommt eine DM, sobald es umgesetzt ist. Die Roadmap filtert nach Stand und Bereich, sortiert nach Top / Bewegung / Neu und zeigt die Zahl der Beiträge im Thread. Ist der Voting-Kanal ein **Forum**, wird jeder Wunsch ein Forums-Beitrag mit **Tags** für Bereich und Stand — der Stand zieht beim Ändern automatisch mit. Eine Stimme je Person, egal ob 👍 oder Web-Knopf; Doppler lassen sich zusammenführen |
|
||||
| 🗳️ **Umfragen** | `/umfrage` erzeugt eine **native Discord-Poll** — keine Reaktions-Bastelei, Discord zählt selbst |
|
||||
| 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung; am Gewinner-Post 🔁 Neu auslosen + 👥 Teilnehmerliste |
|
||||
| 🎂 **Geburtstage** | `/geburtstag` zum Eintragen; morgens Gratulation im Kanal + Tages-Rolle |
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// Emoji-Auswahl fürs Panel.
|
||||
//
|
||||
// Überall, wo ein Emoji nach Discord geht — Rollen-Menüs, Ticket-Anliegen,
|
||||
// Wunsch-Bereiche, Portal-Kacheln — stand bisher ein Textfeld, in das man mit
|
||||
// Win+. hineintippen musste. Das hier ist derselbe Wert, nur klickbar.
|
||||
//
|
||||
// Bewusst eine eigene Liste statt einer Bibliothek: die vollständige
|
||||
// Unicode-Tabelle wäre ein halbes Megabyte für eine Handvoll Symbole, und die
|
||||
// Suchbegriffe dürfen deutsch sein.
|
||||
//
|
||||
// Server-Emojis (<:name:id>) tippt man weiter ins Feld — die kennt nur Discord.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { IconSearch, IconX } from '../icons.jsx';
|
||||
|
||||
const GRUPPEN = [
|
||||
['Häufig', '⭐', [
|
||||
['💡', 'idee gluehbirne wunsch'], ['🎫', 'ticket'], ['🐛', 'bug fehler kaefer'],
|
||||
['🎮', 'spiel gaming controller'], ['🎨', 'design farbe kunst'], ['🔧', 'technik werkzeug'],
|
||||
['📢', 'ankuendigung megafon'], ['💬', 'chat reden nachricht'], ['⭐', 'stern favorit'],
|
||||
['✅', 'haken fertig ja'], ['❌', 'kreuz nein fehler'], ['⚠️', 'warnung achtung'],
|
||||
['🔒', 'schloss privat gesperrt'], ['🚀', 'rakete release start'], ['🔥', 'feuer heiss'],
|
||||
['❤️', 'herz liebe'], ['👍', 'daumen gut ja'], ['🎉', 'party feier'],
|
||||
]],
|
||||
['Spiel', '🎮', [
|
||||
['🚗', 'auto fahrzeug'], ['🏎️', 'rennwagen'], ['🚓', 'polizei streifenwagen'],
|
||||
['🚑', 'krankenwagen rettung'], ['🚒', 'feuerwehr'], ['🏍️', 'motorrad'],
|
||||
['🚁', 'hubschrauber heli'], ['✈️', 'flugzeug'], ['🚤', 'boot schiff'],
|
||||
['⛵', 'segelboot'], ['🗺️', 'karte map'], ['🧭', 'kompass navigation'],
|
||||
['⚔️', 'kampf waffen pvp'], ['🛡️', 'schild verteidigung'], ['🏹', 'bogen jagd'],
|
||||
['💣', 'bombe'], ['🎯', 'ziel treffer'], ['🏆', 'pokal gewinner'],
|
||||
['🥇', 'gold erster'], ['🎲', 'wuerfel zufall'], ['🕹️', 'joystick arcade'],
|
||||
['🧪', 'test playtester labor'], ['⚙️', 'zahnrad einstellung'], ['🔫', 'waffe'],
|
||||
]],
|
||||
['Wirtschaft', '💰', [
|
||||
['💰', 'geld sack wirtschaft'], ['💵', 'schein dollar'], ['💳', 'karte bezahlen'],
|
||||
['🏦', 'bank'], ['📈', 'steigend gewinn'], ['📉', 'fallend verlust'],
|
||||
['🛒', 'einkauf shop'], ['🏪', 'laden geschaeft'], ['📦', 'paket lieferung'],
|
||||
['🏭', 'fabrik produktion'], ['⛏️', 'spitzhacke abbau'], ['🌾', 'landwirtschaft ernte'],
|
||||
['🔨', 'hammer bauen'], ['🧰', 'werkzeugkasten'], ['💎', 'diamant edelstein'],
|
||||
['🪙', 'muenze'],
|
||||
]],
|
||||
['Orte', '🏠', [
|
||||
['🏠', 'haus zuhause'], ['🏢', 'buero gebaeude'], ['🏥', 'krankenhaus'],
|
||||
['🏫', 'schule'], ['🏛️', 'amt behoerde'], ['⛽', 'tankstelle'],
|
||||
['🌳', 'baum natur'], ['🏝️', 'insel'], ['🏔️', 'berg'],
|
||||
['🌊', 'welle wasser meer'], ['🌆', 'stadt'], ['🛣️', 'strasse'],
|
||||
['🚧', 'baustelle'], ['🎪', 'zelt event'], ['🎬', 'film kino'],
|
||||
['🍔', 'essen restaurant'], ['☕', 'kaffee'], ['🍺', 'bier bar'],
|
||||
]],
|
||||
['Team', '👥', [
|
||||
['👥', 'team gruppe'], ['👤', 'person nutzer'], ['👋', 'winken willkommen'],
|
||||
['🙋', 'melden frage'], ['👮', 'polizist'], ['🧑⚕️', 'arzt sanitaeter'],
|
||||
['🧑🔧', 'mechaniker'], ['🧑🌾', 'bauer'], ['🧑💻', 'entwickler'],
|
||||
['🎭', 'rollen maske'], ['🤝', 'handschlag zusammen'], ['🗣️', 'sprechen stimme'],
|
||||
['📣', 'ruf laut'], ['🎤', 'mikrofon stream'], ['🎧', 'kopfhoerer voice'],
|
||||
]],
|
||||
['Technik', '🖥️', [
|
||||
['🖥️', 'rechner server'], ['💾', 'diskette speichern backup'], ['🗄️', 'archiv ablage'],
|
||||
['📡', 'antenne verbindung'], ['🔌', 'stecker strom'], ['🔋', 'akku batterie'],
|
||||
['📊', 'diagramm statistik'], ['📋', 'liste protokoll'], ['📝', 'notiz schreiben'],
|
||||
['📅', 'kalender termin'], ['⏰', 'wecker zeit'], ['⏳', 'sanduhr warten'],
|
||||
['🔍', 'lupe suche'], ['🔗', 'link verbindung'], ['🏷️', 'schild tag'],
|
||||
['🗂️', 'ordner dienst'], ['📁', 'ordner'], ['🧹', 'besen aufraeumen'],
|
||||
]],
|
||||
['Zeichen', '🔵', [
|
||||
['🟢', 'gruen online ok'], ['🟡', 'gelb warnung'], ['🔴', 'rot aus fehler'],
|
||||
['🔵', 'blau'], ['🟣', 'lila'], ['⚫', 'schwarz'],
|
||||
['⚪', 'weiss'], ['➕', 'plus hinzufuegen'], ['➖', 'minus entfernen'],
|
||||
['❓', 'frage hilfe'], ['❗', 'ausruf wichtig'], ['🔔', 'glocke benachrichtigung'],
|
||||
['🔕', 'stumm'], ['🚫', 'verboten nein'], ['♻️', 'recycling wiederholen'],
|
||||
['🆕', 'neu'], ['🆓', 'gratis frei'], ['🔝', 'oben top'],
|
||||
]],
|
||||
];
|
||||
|
||||
export default function EmojiPick({ value, onChange, placeholder = '🙂' }) {
|
||||
const [offen, setOffen] = useState(false);
|
||||
const [suche, setSuche] = useState('');
|
||||
const wrap = useRef(null);
|
||||
|
||||
// Klick daneben oder Escape schließt — ohne das bleibt das Feld offen
|
||||
// stehen, sobald man woanders weiterarbeitet
|
||||
useEffect(() => {
|
||||
if (!offen) return undefined;
|
||||
const aus = (e) => { if (!wrap.current?.contains(e.target)) setOffen(false); };
|
||||
const taste = (e) => { if (e.key === 'Escape') setOffen(false); };
|
||||
document.addEventListener('mousedown', aus);
|
||||
document.addEventListener('keydown', taste);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', aus);
|
||||
document.removeEventListener('keydown', taste);
|
||||
};
|
||||
}, [offen]);
|
||||
|
||||
const q = suche.trim().toLowerCase();
|
||||
const gefiltert = GRUPPEN
|
||||
.map(([name, symbol, liste]) => [
|
||||
name,
|
||||
symbol,
|
||||
q ? liste.filter(([e, worte]) => worte.includes(q) || e === q) : liste,
|
||||
])
|
||||
.filter(([, , liste]) => liste.length > 0);
|
||||
|
||||
return (
|
||||
<div className="ep-wrap" ref={wrap}>
|
||||
<div className="ep-feld">
|
||||
<button
|
||||
type="button"
|
||||
className="ep-knopf"
|
||||
onClick={() => setOffen((o) => !o)}
|
||||
title="Emoji wählen"
|
||||
>
|
||||
{value || <span className="ep-leer">{placeholder}</span>}
|
||||
</button>
|
||||
{/* Das Feld bleibt: Server-Emojis wie <:name:123> gibt es hier nicht */}
|
||||
<input
|
||||
type="text"
|
||||
className="ep-eingabe"
|
||||
maxLength={32}
|
||||
placeholder={placeholder}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
{value && (
|
||||
<button type="button" className="ep-weg" onClick={() => onChange('')} title="Leeren">
|
||||
<IconX size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{offen && (
|
||||
<div className="ep-tafel">
|
||||
<div className="ep-suche">
|
||||
<IconSearch size={13} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen — z. B. auto, geld, warnung"
|
||||
value={suche}
|
||||
onChange={(e) => setSuche(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="ep-liste">
|
||||
{gefiltert.length === 0 && <p className="ep-nix">Nichts gefunden.</p>}
|
||||
{gefiltert.map(([name, symbol, liste]) => (
|
||||
<div key={name}>
|
||||
<div className="ep-gruppe">{symbol} {name}</div>
|
||||
<div className="ep-raster">
|
||||
{liste.map(([e, worte]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={e}
|
||||
className={`ep-zelle${value === e ? ' aktiv' : ''}`}
|
||||
title={worte.split(' ')[0]}
|
||||
onClick={() => { onChange(e); setOffen(false); setSuche(''); }}
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { moduleIcon } from '../module-icons.jsx';
|
||||
import ModulePage from '../components/ModulePage.jsx';
|
||||
import UptimeTrack from '../components/UptimeTrack.jsx';
|
||||
import Dialog from '../components/Dialog.jsx';
|
||||
import EmojiPick from '../components/EmojiPick.jsx';
|
||||
import { findSettings, SETTING_INDEX } from '../setting-index.js';
|
||||
import { Markdown } from '../markdown.jsx';
|
||||
|
||||
@@ -1878,6 +1879,14 @@ export default function Settings({ me }) {
|
||||
der Aufruf-Post in Discord fragt beim Einreichen nach dem Bereich. Nach dem
|
||||
Ändern <strong>/wunsch-setup</strong> neu ausführen.
|
||||
</p>
|
||||
<p className="section-intro">
|
||||
Ist der Voting-Kanal ein <strong>Forum</strong>, wird jeder Wunsch ein
|
||||
Forums-Beitrag und bekommt Tags. Leg dort Tags mit denselben Namen an wie hier
|
||||
die Bereiche, dazu <em>Wird geprüft</em>, <em>Geplant</em>, <em>In Arbeit</em>,
|
||||
<em>Umgesetzt</em> und <em>Nicht geplant</em> — dann kann man auch in Discord
|
||||
danach filtern, und der Stand zieht automatisch mit. Fehlt ein Tag, bleibt es
|
||||
einfach ohne.
|
||||
</p>
|
||||
|
||||
{bereiche.length === 0 && <p className="notice">Noch keine Bereiche.</p>}
|
||||
|
||||
@@ -1906,10 +1915,10 @@ export default function Settings({ me }) {
|
||||
)}
|
||||
|
||||
<div className="field-row">
|
||||
<input
|
||||
type="text" maxLength={32} placeholder="🚗" style={{ maxWidth: '5rem' }}
|
||||
<EmojiPick
|
||||
value={bereichDraft.emoji}
|
||||
onChange={(e) => setBereichDraft({ ...bereichDraft, emoji: e.target.value })}
|
||||
onChange={(v) => setBereichDraft({ ...bereichDraft, emoji: v })}
|
||||
placeholder="🚗"
|
||||
/>
|
||||
<input
|
||||
type="text" maxLength={40} placeholder="Name, z. B. Fahrzeuge"
|
||||
@@ -2101,7 +2110,11 @@ export default function Settings({ me }) {
|
||||
<label>Rollen <span className="field-hint">Emoji optional (auch Custom als <:name:id>), Label = Button-Text</span></label>
|
||||
{menuDraft.entries.map((entry, i) => (
|
||||
<div className="rr-row" key={i}>
|
||||
<input type="text" className="rr-emoji" placeholder="🎮" value={entry.emoji} onChange={(e) => setMenuEntry(i, { emoji: e.target.value })} />
|
||||
<EmojiPick
|
||||
value={entry.emoji}
|
||||
onChange={(v) => setMenuEntry(i, { emoji: v })}
|
||||
placeholder="🎮"
|
||||
/>
|
||||
<input type="text" placeholder="Button-Label, z. B. FiveM" value={entry.label} onChange={(e) => setMenuEntry(i, { label: e.target.value })} />
|
||||
<PickSelect
|
||||
items={roleItems}
|
||||
@@ -2306,8 +2319,11 @@ export default function Settings({ me }) {
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Emoji <span className="field-hint">ein Zeichen oder ein Server-Emoji wie <:name:123> — unpassendes wird weggelassen</span></label>
|
||||
<input type="text" maxLength={32} placeholder="🐛" value={anliegenDraft.emoji}
|
||||
onChange={(e) => setAnliegenDraft({ ...anliegenDraft, emoji: e.target.value })} />
|
||||
<EmojiPick
|
||||
value={anliegenDraft.emoji}
|
||||
onChange={(v) => setAnliegenDraft({ ...anliegenDraft, emoji: v })}
|
||||
placeholder="🐛"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Kurzbeschreibung <span className="field-hint">graue Zeile unter dem Namen im Menü — höchstens 100 Zeichen</span></label>
|
||||
@@ -2896,7 +2912,11 @@ export default function Settings({ me }) {
|
||||
<div className="field">
|
||||
<label>{serviceDraft.id ? `Dienst #${serviceDraft.id} bearbeiten` : 'Neuer Dienst'}</label>
|
||||
<div className="field-row">
|
||||
<input type="text" style={{ flex: 'none', width: '4rem' }} placeholder="🗂️" value={serviceDraft.icon} onChange={(e) => setServiceDraft({ ...serviceDraft, icon: e.target.value })} />
|
||||
<EmojiPick
|
||||
value={serviceDraft.icon}
|
||||
onChange={(v) => setServiceDraft({ ...serviceDraft, icon: v })}
|
||||
placeholder="🗂️"
|
||||
/>
|
||||
<input type="text" style={{ flex: 'none', width: '10rem' }} placeholder="Kanban" value={serviceDraft.name} onChange={(e) => setServiceDraft({ ...serviceDraft, name: e.target.value })} />
|
||||
<input type="text" placeholder="https://kanban.d4rkst3r.de" value={serviceDraft.url} onChange={(e) => setServiceDraft({ ...serviceDraft, url: e.target.value })} />
|
||||
<input type="number" style={{ flex: 'none', width: '5rem' }} placeholder="Pos." value={serviceDraft.sort} onChange={(e) => setServiceDraft({ ...serviceDraft, sort: e.target.value })} />
|
||||
|
||||
@@ -1963,6 +1963,55 @@ label.toggle {
|
||||
border-left: 2px solid var(--border); padding-left: .6rem;
|
||||
}
|
||||
|
||||
/* ── EMOJI-AUSWAHL ─────────────────────────────────── */
|
||||
.ep-wrap { position: relative; display: inline-block; min-width: 0; }
|
||||
.ep-feld { display: flex; align-items: stretch; gap: .3rem; }
|
||||
.ep-knopf {
|
||||
flex: 0 0 auto; width: 2.4rem;
|
||||
background: var(--bg3); border: 1px solid var(--border); color: var(--text);
|
||||
font-size: 1.05rem; line-height: 1; cursor: pointer;
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
.ep-knopf:hover { border-color: var(--neon); }
|
||||
.ep-leer { opacity: .35; }
|
||||
.ep-eingabe { width: 5.5rem; }
|
||||
.ep-weg {
|
||||
flex: 0 0 auto; background: none; border: none; color: var(--muted2);
|
||||
cursor: pointer; padding: 0 .2rem;
|
||||
}
|
||||
.ep-weg:hover { color: #f23f43; }
|
||||
|
||||
.ep-tafel {
|
||||
position: absolute; z-index: 60; top: calc(100% + .35rem); left: 0;
|
||||
width: min(21rem, 78vw);
|
||||
background: var(--bg2); border: 1px solid var(--border); border-radius: 6px;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, .55);
|
||||
animation: riseIn .15s ease both;
|
||||
}
|
||||
.ep-suche {
|
||||
display: flex; align-items: center; gap: .45rem;
|
||||
padding: .5rem .7rem; border-bottom: 1px solid var(--border); color: var(--muted2);
|
||||
}
|
||||
.ep-suche input {
|
||||
flex: 1; min-width: 0; background: none; border: none; padding: 0;
|
||||
color: var(--text); font: inherit; font-size: .84rem;
|
||||
}
|
||||
.ep-suche input:focus { outline: none; }
|
||||
.ep-liste { max-height: 17rem; overflow-y: auto; padding: .5rem .7rem .7rem; }
|
||||
.ep-gruppe {
|
||||
font-family: var(--mono); font-size: .58rem; letter-spacing: .12em;
|
||||
text-transform: uppercase; color: var(--muted2); margin: .5rem 0 .3rem;
|
||||
}
|
||||
.ep-raster { display: grid; grid-template-columns: repeat(auto-fill, 2rem); gap: .15rem; }
|
||||
.ep-zelle {
|
||||
width: 2rem; height: 2rem; display: grid; place-items: center;
|
||||
background: none; border: 1px solid transparent; border-radius: 4px;
|
||||
font-size: 1.05rem; line-height: 1; cursor: pointer;
|
||||
}
|
||||
.ep-zelle:hover { background: var(--bg3); border-color: var(--neon); }
|
||||
.ep-zelle.aktiv { border-color: var(--neon); background: color-mix(in srgb, var(--neon) 12%, transparent); }
|
||||
.ep-nix { color: var(--muted); font-size: .84rem; margin: .6rem 0; }
|
||||
|
||||
/* ── DIALOG ────────────────────────────────────────── */
|
||||
.dlg-backdrop {
|
||||
position: fixed; inset: 0; z-index: 1200;
|
||||
|
||||
@@ -29,6 +29,16 @@ export async function execute(interaction) {
|
||||
.setFooter({ text: brandFooter('VOTING') });
|
||||
|
||||
const anzahl = listWishCategories().length;
|
||||
// In einem Forum-Kanal selbst kann man nichts posten — der Aufruf gehört
|
||||
// in einen normalen Kanal, die Wünsche landen trotzdem im Forum
|
||||
if (!interaction.channel?.send) {
|
||||
await interaction.reply({
|
||||
content: '❌ Hier lässt sich nichts posten. Führ den Befehl in einem normalen Textkanal aus — '
|
||||
+ 'die Ideen landen trotzdem im eingestellten Voting-Kanal.',
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await interaction.channel.send({ embeds: [embed], components: wunschPanelComponents() });
|
||||
await interaction.reply({
|
||||
content: anzahl > 0
|
||||
|
||||
+85
-3
@@ -10,7 +10,7 @@
|
||||
// Unterhaltung nur zerreißen. Die Roadmap zeigt die Zahl der Beiträge und
|
||||
// verlinkt hinein.
|
||||
import {
|
||||
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, Events,
|
||||
ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, EmbedBuilder, Events,
|
||||
ModalBuilder, MessageFlags, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle,
|
||||
} from 'discord.js';
|
||||
import {
|
||||
@@ -21,6 +21,22 @@ import { renderTemplate } from '../templates.js';
|
||||
import { tuning } from '../tuning.js';
|
||||
import { alsEmoji } from './tickets.js';
|
||||
|
||||
/**
|
||||
* Der Stand eines Wunsches, wie er nach außen heißt. Steht hier und nicht in
|
||||
* der API, weil ihn beides braucht: das Panel zum Anzeigen und der Bot für
|
||||
* Embed-Titel und Forum-Tags.
|
||||
*/
|
||||
export const STATUS_LABEL = {
|
||||
offen: 'Wird geprüft',
|
||||
geplant: 'Geplant',
|
||||
in_arbeit: 'In Arbeit',
|
||||
umgesetzt: 'Umgesetzt',
|
||||
abgelehnt: 'Nicht geplant',
|
||||
};
|
||||
export const STATUS_EMOJI = {
|
||||
offen: '💡', geplant: '📌', in_arbeit: '🔨', umgesetzt: '✅', abgelehnt: '🚫',
|
||||
};
|
||||
|
||||
/** Knopf oder Auswahlmenü für den Aufruf-Post — je nachdem, ob es Bereiche gibt */
|
||||
export function wunschPanelComponents() {
|
||||
const bereiche = listWishCategories();
|
||||
@@ -67,15 +83,41 @@ export function wunschModal(categoryId = null) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Ist der Voting-Kanal ein Forum? Dann wird jeder Wunsch ein Forums-Beitrag. */
|
||||
const istForum = (channel) =>
|
||||
channel?.type === ChannelType.GuildForum || channel?.type === ChannelType.GuildMedia;
|
||||
|
||||
/**
|
||||
* Passende Forum-Tags zu Bereich und Stand heraussuchen — über den Namen.
|
||||
* Wer im Forum ein Tag „Fahrzeuge" anlegt, bekommt es automatisch gesetzt;
|
||||
* wer keines anlegt, verliert nichts. Deshalb bewusst keine ID-Zuordnung im
|
||||
* Panel: das wäre eine zweite Liste, die man pflegen muss.
|
||||
*/
|
||||
export function tagsFuer(channel, { bereich, statusLabel }) {
|
||||
if (!istForum(channel)) return [];
|
||||
const gesucht = [bereich?.name, statusLabel].filter(Boolean).map((s) => s.toLowerCase());
|
||||
return (channel.availableTags ?? [])
|
||||
.filter((t) => gesucht.includes(t.name.toLowerCase()))
|
||||
.map((t) => t.id)
|
||||
.slice(0, 5); // Discord erlaubt höchstens fünf je Beitrag
|
||||
}
|
||||
|
||||
/**
|
||||
* Wunsch posten, speichern und den Thread aufmachen.
|
||||
*
|
||||
* Im Forum ist der Beitrag selbst schon der Thread — dort wird er mit Tags für
|
||||
* Bereich und Stand angelegt, damit man auch in Discord filtern kann. In einem
|
||||
* normalen Textkanal bleibt es bei Nachricht + Thread darunter.
|
||||
*
|
||||
* @returns {Promise<{id: number, url: string}>}
|
||||
*/
|
||||
export async function wunschAnlegen(client, { idee, warum, autor, autorId, avatar, categoryId }) {
|
||||
const channelId = votingChannelId();
|
||||
if (!channelId) throw new Error('Feature-Voting ist gerade nicht aktiviert.');
|
||||
const channel = await client.channels.fetch(channelId);
|
||||
if (!channel?.isTextBased()) throw new Error('Voting-Kanal nicht gefunden.');
|
||||
if (!channel?.isTextBased() && !istForum(channel)) {
|
||||
throw new Error('Voting-Kanal nicht gefunden.');
|
||||
}
|
||||
|
||||
const bereich = categoryId ? getWishCategory(categoryId) : null;
|
||||
const kopf = bereich ? `${bereich.emoji || '💡'} ${bereich.name}` : '💡 Feature-Wunsch';
|
||||
@@ -87,6 +129,22 @@ export async function wunschAnlegen(client, { idee, warum, autor, autorId, avata
|
||||
.setURL(`${publicUrl()}/roadmap`)
|
||||
.setFooter({ text: `${brandFooter('VOTING')} • 👍 = will ich!` });
|
||||
|
||||
const archiv = tuning('thread_archive_days') * 1440;
|
||||
|
||||
if (istForum(channel)) {
|
||||
const thread = await channel.threads.create({
|
||||
name: idee.slice(0, 100),
|
||||
autoArchiveDuration: archiv,
|
||||
appliedTags: tagsFuer(channel, { bereich, statusLabel: STATUS_LABEL.offen }),
|
||||
message: { embeds: [embed] },
|
||||
});
|
||||
const starter = await thread.fetchStarterMessage().catch(() => null);
|
||||
await starter?.react('👍').catch(() => {});
|
||||
const id = saveWish(starter?.id ?? thread.id, autor, idee, autorId ?? null, categoryId ?? null);
|
||||
setWishThread(id, thread.id);
|
||||
return { id, url: `${thread.url}` };
|
||||
}
|
||||
|
||||
const message = await channel.send({ embeds: [embed] });
|
||||
await message.react('👍').catch(() => {});
|
||||
const id = saveWish(message.id, autor, idee, autorId ?? null, categoryId ?? null);
|
||||
@@ -96,7 +154,7 @@ export async function wunschAnlegen(client, { idee, warum, autor, autorId, avata
|
||||
try {
|
||||
const thread = await message.startThread({
|
||||
name: `💬 ${idee}`.slice(0, 100),
|
||||
autoArchiveDuration: tuning('thread_archive_days') * 1440,
|
||||
autoArchiveDuration: archiv,
|
||||
});
|
||||
setWishThread(id, thread.id);
|
||||
} catch (error) {
|
||||
@@ -105,6 +163,30 @@ export async function wunschAnlegen(client, { idee, warum, autor, autorId, avata
|
||||
return { id, url: message.url };
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach einem Status-Wechsel die Tags am Forums-Beitrag nachziehen: der alte
|
||||
* Stand fliegt raus, der neue kommt rein, der Bereich bleibt.
|
||||
*/
|
||||
export async function wunschTagsAktualisieren(client, wunsch, statusLabel) {
|
||||
if (!wunsch.thread_id) return;
|
||||
const channelId = votingChannelId();
|
||||
if (!channelId) return;
|
||||
const channel = await client.channels.fetch(channelId).catch(() => null);
|
||||
if (!istForum(channel)) return;
|
||||
const thread = await client.channels.fetch(wunsch.thread_id).catch(() => null);
|
||||
if (!thread?.setAppliedTags) return;
|
||||
|
||||
const alleStaende = new Set(Object.values(STATUS_LABEL).map((s) => s.toLowerCase()));
|
||||
const behalten = (thread.appliedTags ?? []).filter((id) => {
|
||||
const tag = (channel.availableTags ?? []).find((t) => t.id === id);
|
||||
return tag && !alleStaende.has(tag.name.toLowerCase());
|
||||
});
|
||||
const neu = tagsFuer(channel, { bereich: null, statusLabel });
|
||||
await thread.setAppliedTags([...new Set([...behalten, ...neu])].slice(0, 5)).catch((e) =>
|
||||
console.error('[voting] Tags nicht gesetzt:', e.message)
|
||||
);
|
||||
}
|
||||
|
||||
/** Auswahl im Aufruf-Post → Eingabefenster für diesen Bereich */
|
||||
export async function handleWunschPick(interaction) {
|
||||
const gewaehlt = Number(interaction.values?.[0]);
|
||||
|
||||
+27
-15
@@ -34,7 +34,9 @@ import { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js';
|
||||
import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
|
||||
import { watchdogState, watchedServices } from '../bot/watchdog.js';
|
||||
import { listRules, setRuleEnabled } from '../bot/automod.js';
|
||||
import { wunschAnlegen } from '../bot/wishes.js';
|
||||
import {
|
||||
wunschAnlegen, wunschTagsAktualisieren, STATUS_LABEL, STATUS_EMOJI,
|
||||
} from '../bot/wishes.js';
|
||||
import { heartbeatSerie } from '../bot/heartbeat.js';
|
||||
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
|
||||
import { sanitizeEmbed } from './api-v1.js';
|
||||
@@ -2025,13 +2027,9 @@ ${rssItems}
|
||||
|
||||
// --- Wünsche verwalten (Team) ---
|
||||
|
||||
const STATUS = {
|
||||
offen: { label: 'Wird geprüft', emoji: '💡' },
|
||||
geplant: { label: 'Geplant', emoji: '📌' },
|
||||
in_arbeit: { label: 'In Arbeit', emoji: '🔨' },
|
||||
umgesetzt: { label: 'Umgesetzt', emoji: '✅' },
|
||||
abgelehnt: { label: 'Nicht geplant', emoji: '🚫' },
|
||||
};
|
||||
const STATUS = Object.fromEntries(Object.entries(STATUS_LABEL).map(([id, label]) => [
|
||||
id, { label, emoji: STATUS_EMOJI[id] },
|
||||
]));
|
||||
|
||||
/**
|
||||
* Den Wunsch-Post in Discord auf den neuen Stand bringen. Dort haben die
|
||||
@@ -2042,16 +2040,29 @@ ${rssItems}
|
||||
if (!wunsch.message_id) return;
|
||||
const channelId = votingChannelId();
|
||||
if (!channelId) return;
|
||||
const channel = await client.channels.fetch(channelId).catch(() => null);
|
||||
const message = await channel?.messages?.fetch(wunsch.message_id).catch(() => null);
|
||||
// Im Forum liegt die Startnachricht im Beitrag selbst, nicht im Kanal —
|
||||
// deshalb zuerst dort suchen, wo der Wunsch seinen Thread hat
|
||||
const wo = wunsch.thread_id
|
||||
? await client.channels.fetch(wunsch.thread_id).catch(() => null)
|
||||
: null;
|
||||
const channel = wo ?? await client.channels.fetch(channelId).catch(() => null);
|
||||
let message = await channel?.messages?.fetch(wunsch.message_id).catch(() => null);
|
||||
if (!message && wo) {
|
||||
// Textkanal mit Thread darunter: die Nachricht steht eine Ebene höher
|
||||
const eltern = await client.channels.fetch(channelId).catch(() => null);
|
||||
message = await eltern?.messages?.fetch(wunsch.message_id).catch(() => null);
|
||||
}
|
||||
if (!message?.embeds?.[0]) return;
|
||||
|
||||
const s = STATUS[wunsch.status] ?? STATUS.offen;
|
||||
const embed = EmbedBuilder.from(message.embeds[0])
|
||||
.setTitle(`${s.emoji} Feature-Wunsch — ${s.label}`);
|
||||
const felder = wunsch.status_grund
|
||||
const alt = message.embeds[0];
|
||||
// Den Kopf nur um den Stand ergänzen, nicht den Bereich überschreiben —
|
||||
// dort steht „🚗 Fahrzeuge", das soll bleiben
|
||||
const basis = String(alt.title ?? 'Feature-Wunsch').split(' — ')[0];
|
||||
const embed = EmbedBuilder.from(alt).setTitle(`${basis} — ${s.emoji} ${s.label}`);
|
||||
embed.setFields(wunsch.status_grund
|
||||
? [{ name: 'Vom Team', value: String(wunsch.status_grund).slice(0, 1000) }]
|
||||
: [];
|
||||
embed.setFields(felder);
|
||||
: []);
|
||||
await message.edit({ embeds: [embed] }).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -2066,6 +2077,7 @@ ${rssItems}
|
||||
|
||||
const wunsch = getWish(id);
|
||||
await wunschPostAktualisieren(wunsch);
|
||||
await wunschTagsAktualisieren(client, wunsch, STATUS[status].label);
|
||||
|
||||
// Kreis schliessen: wer dafür gestimmt hat, erfährt es — aber nur beim
|
||||
// Sprung auf umgesetzt, und nur einmal (vorher war ein anderer Status)
|
||||
|
||||
Reference in New Issue
Block a user