Ideen bekommen Bereiche und einen Thread zum Reden
Nach dem Vorbild von windrose.support: Bereiche zum Einsortieren, Sortierung nach Top / Bewegung / Neu, und eine Zahl daneben, wie viel darueber geredet wird. Der Unterschied zu so einem Dienst ist, wo geredet wird. Zu jedem Wunsch macht der Bot jetzt einen Thread unter dem Post auf. Der Kommentar-Zaehler auf der Roadmap zaehlt die Beitraege darin und verlinkt hinein — es gibt also einen Zaehler wie bei einem Ideen-Board, aber ohne ein zweites Kommentarsystem neben Discord zu stellen. Genau das hatte ich beim letzten Mal als das Falsche bezeichnet, und dabei bleibt es. Eingereicht wird jetzt auch ueber einen Aufruf-Post: /wunsch-setup postet ihn, und mit Bereichen wird daraus ein Auswahlmenue — erst wohin, dann das Formular. Ohne Bereiche bleibt es beim einen Knopf. Derselbe Aufbau wie bei den Ticket-Anliegen, bis hin zur Emoji-Pruefung, die ich mir dort schon geschrieben hatte. Das Anlegen selbst ist nach bot/wishes.js gewandert. /wunsch, der Knopf und die Webseite gehen jetzt denselben Weg — vorher hatte die Webseite ihr eigenes Embed zusammengebaut, das dem aus dem Befehl nur aehnlich sah und keinen Thread bekam. "Bewegung" sind die Stimmen der letzten sieben Tage. Die Rangliste allein zementiert alte Wuensche: was einmal oben steht, bleibt oben, egal ob noch jemand hinschaut. Sortiert wird auf dem Server, weil die Zeitstempel der Stimmen im Browser gar nicht ankommen. Bewusst nicht uebernommen: Gegenstimmen. Windrose hat sie, aber bei einer Community dieser Groesse laden sie zum Nachtreten ein und schrecken vom Einreichen ab — und eine Zahl, die aus zwei Richtungen kommt, sagt am Ende weniger als eine, die nur zaehlt, wer etwas will. Geprueft: Migration erneut durchgespielt (das Schema hat drei Spalten dazubekommen), Routen, SQL gegen das Schema, und im Browser alle drei Filterreihen einzeln und kombiniert, der Sortierwechsel, die Thread-Links und die Bereichs-Pflege im Panel. 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 |
|
| 📈 **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 |
|
| ⭐ **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) |
|
| 📸 **Screenshot-Galerie** | Bilder aus dem Screenshot-Kanal → öffentliche Galerie (lokal gespeichert, weil Discord-CDN-Links ablaufen) |
|
||||||
| 💡 **Feature-Voting** | `/wunsch` öffnet ein Eingabefenster → Voting-Post mit 👍; Wünsche mit **Stand** (wird geprüft · geplant · in Arbeit · umgesetzt · nicht geplant) samt Begründung auf der Roadmap und im Discord-Post. Wer für einen Wunsch gestimmt hat, bekommt eine DM, sobald er umgesetzt ist. 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. 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 |
|
| 🗳️ **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 |
|
| 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung; am Gewinner-Post 🔁 Neu auslosen + 👥 Teilnehmerliste |
|
||||||
| 🎂 **Geburtstage** | `/geburtstag` zum Eintragen; morgens Gratulation im Kanal + Tages-Rolle |
|
| 🎂 **Geburtstage** | `/geburtstag` zum Eintragen; morgens Gratulation im Kanal + Tages-Rolle |
|
||||||
@@ -454,6 +454,7 @@ urllib.request.urlopen(req)
|
|||||||
| `GET /api/legal` · `DELETE /api/profile` | — / Member | Impressums-Angaben · DSGVO-Löschung |
|
| `GET /api/legal` · `DELETE /api/profile` | — / Member | Impressums-Angaben · DSGVO-Löschung |
|
||||||
| `GET /api/profile` · `/api/myroles*` · `POST /api/wishes*` | Member | Profil · Rollen-Selfservice · Wunsch einreichen/voten |
|
| `GET /api/profile` · `/api/myroles*` · `POST /api/wishes*` | Member | Profil · Rollen-Selfservice · Wunsch einreichen/voten |
|
||||||
| `PUT /api/wishes/:id/status` · `POST /api/wishes/:id/merge` | Team (community) | Stand setzen (+ DM an Stimmen-Geber) · Doppler zusammenführen |
|
| `PUT /api/wishes/:id/status` · `POST /api/wishes/:id/merge` | Team (community) | Stand setzen (+ DM an Stimmen-Geber) · Doppler zusammenführen |
|
||||||
|
| `GET/POST/PUT/DELETE /api/wish-categories` | Team (community) | Bereiche für Wünsche pflegen |
|
||||||
| `GET /linked-roles` · `/linked-roles/callback` | Member | Discords Verknüpfungs-Ablauf für Linked Roles |
|
| `GET /linked-roles` · `/linked-roles/callback` | Member | Discords Verknüpfungs-Ablauf für Linked Roles |
|
||||||
| `GET /sso/authorize` · `POST /sso/verify` | App-Secret | **Single Sign-On** für andere Dienste — siehe [docs/sso.md](docs/sso.md) |
|
| `GET /sso/authorize` · `POST /sso/verify` | App-Secret | **Single Sign-On** für andere Dienste — siehe [docs/sso.md](docs/sso.md) |
|
||||||
| `POST/GET /api/v1/*` | API-Key (Bearer) | Externe Skripte — siehe „API v1" oben |
|
| `POST/GET /api/v1/*` | API-Key (Bearer) | Externe Skripte — siehe „API v1" oben |
|
||||||
|
|||||||
@@ -120,6 +120,13 @@ export default {
|
|||||||
'roadmap.activity': '// Aktivität — %s Commits in 12 Monaten',
|
'roadmap.activity': '// Aktivität — %s Commits in 12 Monaten',
|
||||||
'roadmap.commitsOn': '%s: %s Commits',
|
'roadmap.commitsOn': '%s: %s Commits',
|
||||||
'roadmap.commitOn': '%s: 1 Commit',
|
'roadmap.commitOn': '%s: 1 Commit',
|
||||||
|
'roadmap.sort': 'Sortierung',
|
||||||
|
'roadmap.sortTop': 'Top',
|
||||||
|
'roadmap.sortTrend': 'Bewegung',
|
||||||
|
'roadmap.sortNew': 'Neu',
|
||||||
|
'roadmap.status': 'Stand',
|
||||||
|
'roadmap.area': 'Bereich',
|
||||||
|
'roadmap.areaPick': '— Bereich —',
|
||||||
'wish.alle': 'Alle',
|
'wish.alle': 'Alle',
|
||||||
'wish.offen': 'Wird geprüft',
|
'wish.offen': 'Wird geprüft',
|
||||||
'wish.geplant': 'Geplant',
|
'wish.geplant': 'Geplant',
|
||||||
|
|||||||
@@ -119,6 +119,13 @@ export default {
|
|||||||
'roadmap.activity': '// Activity — %s commits in 12 months',
|
'roadmap.activity': '// Activity — %s commits in 12 months',
|
||||||
'roadmap.commitsOn': '%s: %s commits',
|
'roadmap.commitsOn': '%s: %s commits',
|
||||||
'roadmap.commitOn': '%s: 1 commit',
|
'roadmap.commitOn': '%s: 1 commit',
|
||||||
|
'roadmap.sort': 'Sort',
|
||||||
|
'roadmap.sortTop': 'Top',
|
||||||
|
'roadmap.sortTrend': 'Trending',
|
||||||
|
'roadmap.sortNew': 'New',
|
||||||
|
'roadmap.status': 'Status',
|
||||||
|
'roadmap.area': 'Area',
|
||||||
|
'roadmap.areaPick': '— Area —',
|
||||||
'wish.alle': 'All',
|
'wish.alle': 'All',
|
||||||
'wish.offen': 'Under review',
|
'wish.offen': 'Under review',
|
||||||
'wish.geplant': 'Planned',
|
'wish.geplant': 'Planned',
|
||||||
|
|||||||
+112
-24
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { apiGet, apiPost } from '../api.js';
|
import { apiGet, apiPost } from '../api.js';
|
||||||
import { Markdown } from '../markdown.jsx';
|
import { Markdown } from '../markdown.jsx';
|
||||||
import { SkeletonCards } from '../components/Skeleton.jsx';
|
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||||
import { IconThumbsUp } from '../icons.jsx';
|
import { IconThumbsUp, IconMessage } from '../icons.jsx';
|
||||||
import { useT, useFmt } from '../i18n.jsx';
|
import { useT, useFmt } from '../i18n.jsx';
|
||||||
|
|
||||||
const ZIEL = { month: 'long', year: 'numeric' };
|
const ZIEL = { month: 'long', year: 'numeric' };
|
||||||
@@ -27,6 +27,11 @@ const WISH_STATUS = {
|
|||||||
umgesetzt: 'wish.umgesetzt',
|
umgesetzt: 'wish.umgesetzt',
|
||||||
abgelehnt: 'wish.abgelehnt',
|
abgelehnt: 'wish.abgelehnt',
|
||||||
};
|
};
|
||||||
|
const WISH_SORT = [
|
||||||
|
{ id: 'top', key: 'roadmap.sortTop' },
|
||||||
|
{ id: 'bewegung', key: 'roadmap.sortTrend' },
|
||||||
|
{ id: 'neu', key: 'roadmap.sortNew' },
|
||||||
|
];
|
||||||
const WISH_FILTER = [
|
const WISH_FILTER = [
|
||||||
{ id: 'alle', key: 'wish.alle' },
|
{ id: 'alle', key: 'wish.alle' },
|
||||||
{ id: 'offen', key: 'wish.offen' },
|
{ id: 'offen', key: 'wish.offen' },
|
||||||
@@ -113,15 +118,30 @@ export default function Roadmap() {
|
|||||||
const [heatmap, setHeatmap] = useState(null);
|
const [heatmap, setHeatmap] = useState(null);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [filter, setFilter] = useState('alle');
|
const [filter, setFilter] = useState('alle');
|
||||||
|
const [bereich, setBereich] = useState('alle');
|
||||||
|
const [bereiche, setBereiche] = useState([]);
|
||||||
|
const [neuerBereich, setNeuerBereich] = useState('');
|
||||||
|
const [sort, setSort] = useState('top');
|
||||||
const [aehnliche, setAehnliche] = useState([]);
|
const [aehnliche, setAehnliche] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiGet('/api/roadmap').then(setData).catch(() => setError(true));
|
apiGet('/api/roadmap').then(setData).catch(() => setError(true));
|
||||||
apiGet('/api/wishes').then((d) => { setWishes(d.wishes); setCanVote(d.canVote); }).catch(() => {});
|
|
||||||
apiGet('/api/heatmap').then((d) => setHeatmap(d.days)).catch(() => {});
|
apiGet('/api/heatmap').then((d) => setHeatmap(d.days)).catch(() => {});
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Sortiert wird auf dem Server — „Bewegung" braucht die Zeitstempel der
|
||||||
|
// Stimmen, die hier gar nicht ankommen
|
||||||
|
useEffect(() => {
|
||||||
|
apiGet(`/api/wishes?sort=${sort}`)
|
||||||
|
.then((d) => {
|
||||||
|
setWishes(d.wishes);
|
||||||
|
setCanVote(d.canVote);
|
||||||
|
setBereiche(d.bereiche ?? []);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [sort]);
|
||||||
|
|
||||||
// Während des Tippens nachschlagen, ob es das schon gibt. Kurz warten,
|
// Während des Tippens nachschlagen, ob es das schon gibt. Kurz warten,
|
||||||
// damit nicht jeder Tastendruck eine Anfrage auslöst.
|
// damit nicht jeder Tastendruck eine Anfrage auslöst.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -135,9 +155,9 @@ export default function Roadmap() {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [idea]);
|
}, [idea]);
|
||||||
|
|
||||||
const sichtbareWuensche = filter === 'alle'
|
const sichtbareWuensche = wishes
|
||||||
? wishes
|
.filter((w) => filter === 'alle' || (w.status ?? 'offen') === filter)
|
||||||
: wishes.filter((w) => (w.status ?? 'offen') === filter);
|
.filter((w) => bereich === 'alle' || w.category_id === bereich);
|
||||||
|
|
||||||
async function vote(id) {
|
async function vote(id) {
|
||||||
try {
|
try {
|
||||||
@@ -155,10 +175,13 @@ export default function Roadmap() {
|
|||||||
const text = idea.trim();
|
const text = idea.trim();
|
||||||
if (text.length < 5) { setSubmitMsg(t('roadmap.tooShort')); return; }
|
if (text.length < 5) { setSubmitMsg(t('roadmap.tooShort')); return; }
|
||||||
try {
|
try {
|
||||||
await apiPost('/api/wishes', { idea: text });
|
await apiPost('/api/wishes', {
|
||||||
|
idea: text,
|
||||||
|
category_id: neuerBereich ? Number(neuerBereich) : null,
|
||||||
|
});
|
||||||
setIdea('');
|
setIdea('');
|
||||||
setSubmitMsg(t('roadmap.submitted'));
|
setSubmitMsg(t('roadmap.submitted'));
|
||||||
apiGet('/api/wishes').then((d) => setWishes(d.wishes)).catch(() => {});
|
apiGet(`/api/wishes?sort=${sort}`).then((d) => setWishes(d.wishes)).catch(() => {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSubmitMsg(e.status === 403 ? t('roadmap.submitMembersOnly') : t('roadmap.submitFailed'));
|
setSubmitMsg(e.status === 403 ? t('roadmap.submitMembersOnly') : t('roadmap.submitFailed'));
|
||||||
}
|
}
|
||||||
@@ -189,25 +212,62 @@ export default function Roadmap() {
|
|||||||
<div className="roadmap-group">
|
<div className="roadmap-group">
|
||||||
<h2 className="settings-title">{t('roadmap.wishes')}</h2>
|
<h2 className="settings-title">{t('roadmap.wishes')}</h2>
|
||||||
|
|
||||||
{/* Ein Wunsch ohne sichtbaren Ausgang ist ein Wunsch, den
|
<div className="wish-bar">
|
||||||
niemand mehr einreicht — deshalb steht der Stand vorne
|
{/* Sortierung: die Rangliste allein zementiert alte
|
||||||
dran und lässt sich filtern. */}
|
Wünsche — „Bewegung" zeigt, wo diese Woche etwas
|
||||||
<div className="wish-filter">
|
passiert, „Neu" das Frischeste. */}
|
||||||
{WISH_FILTER.map(({ id, key }) => {
|
<div className="wish-filter">
|
||||||
const n = id === 'alle'
|
<span className="wish-filter-label">{t('roadmap.sort')}</span>
|
||||||
? wishes.length
|
{WISH_SORT.map(({ id, key }) => (
|
||||||
: wishes.filter((w) => (w.status ?? 'offen') === id).length;
|
|
||||||
if (n === 0 && id !== 'alle') return null;
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
className={`wish-chip${filter === id ? ' aktiv' : ''}`}
|
className={`wish-chip${sort === id ? ' aktiv' : ''}`}
|
||||||
onClick={() => setFilter(id)}
|
onClick={() => setSort(id)}
|
||||||
>
|
>
|
||||||
{t(key)} <span className="wish-chip-n">{n}</span>
|
{t(key)}
|
||||||
</button>
|
</button>
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
|
|
||||||
|
<div className="wish-filter">
|
||||||
|
<span className="wish-filter-label">{t('roadmap.status')}</span>
|
||||||
|
{WISH_FILTER.map(({ id, key }) => {
|
||||||
|
const n = id === 'alle'
|
||||||
|
? wishes.length
|
||||||
|
: wishes.filter((w) => (w.status ?? 'offen') === id).length;
|
||||||
|
if (n === 0 && id !== 'alle') return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
className={`wish-chip${filter === id ? ' aktiv' : ''}`}
|
||||||
|
onClick={() => setFilter(id)}
|
||||||
|
>
|
||||||
|
{t(key)} <span className="wish-chip-n">{n}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bereiche.length > 0 && (
|
||||||
|
<div className="wish-filter">
|
||||||
|
<span className="wish-filter-label">{t('roadmap.area')}</span>
|
||||||
|
<button
|
||||||
|
className={`wish-chip${bereich === 'alle' ? ' aktiv' : ''}`}
|
||||||
|
onClick={() => setBereich('alle')}
|
||||||
|
>
|
||||||
|
{t('wish.alle')}
|
||||||
|
</button>
|
||||||
|
{bereiche.map((b) => (
|
||||||
|
<button
|
||||||
|
key={b.id}
|
||||||
|
className={`wish-chip${bereich === b.id ? ' aktiv' : ''}`}
|
||||||
|
onClick={() => setBereich(b.id)}
|
||||||
|
>
|
||||||
|
{b.emoji} {b.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sichtbareWuensche.length === 0 && (
|
{sichtbareWuensche.length === 0 && (
|
||||||
@@ -230,13 +290,27 @@ export default function Roadmap() {
|
|||||||
<span className="wish-score with-icon"><IconThumbsUp size={14} /> {w.score}</span>
|
<span className="wish-score with-icon"><IconThumbsUp size={14} /> {w.score}</span>
|
||||||
)}
|
)}
|
||||||
<span className="wish-idea">
|
<span className="wish-idea">
|
||||||
|
<span className="wish-schilder">
|
||||||
|
{status !== 'offen' && (
|
||||||
|
<span className={`wish-status ${status}`}>{t(WISH_STATUS[status])}</span>
|
||||||
|
)}
|
||||||
|
{w.kategorie && (
|
||||||
|
<span className="wish-area">
|
||||||
|
{w.kategorie_emoji} {w.kategorie}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
{w.idea}
|
{w.idea}
|
||||||
{w.status_grund && (
|
{w.status_grund && (
|
||||||
<span className="wish-grund">{w.status_grund}</span>
|
<span className="wish-grund">{w.status_grund}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{status !== 'offen' && (
|
{/* Geredet wird im Thread unter dem Post — der
|
||||||
<span className={`wish-status ${status}`}>{t(WISH_STATUS[status])}</span>
|
Zähler führt direkt dorthin */}
|
||||||
|
{w.thread_url && (
|
||||||
|
<a className="wish-komm" href={w.thread_url} target="_blank" rel="noreferrer">
|
||||||
|
<IconMessage size={13} /> {w.kommentare ?? 0}
|
||||||
|
</a>
|
||||||
)}
|
)}
|
||||||
<span className="wish-author">{w.author}</span>
|
<span className="wish-author">{w.author}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,6 +320,20 @@ export default function Roadmap() {
|
|||||||
{canVote ? (
|
{canVote ? (
|
||||||
<div className="wish-form-wrap">
|
<div className="wish-form-wrap">
|
||||||
<div className="wish-form">
|
<div className="wish-form">
|
||||||
|
{bereiche.length > 0 && (
|
||||||
|
<select
|
||||||
|
className="wish-area-select"
|
||||||
|
value={neuerBereich}
|
||||||
|
onChange={(e) => setNeuerBereich(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('roadmap.areaPick')}</option>
|
||||||
|
{bereiche.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{b.emoji} {b.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
type="text" maxLength={500} placeholder={t('roadmap.ideaPlaceholder')}
|
type="text" maxLength={500} placeholder={t('roadmap.ideaPlaceholder')}
|
||||||
value={idea}
|
value={idea}
|
||||||
|
|||||||
@@ -365,6 +365,8 @@ export default function Settings({ me }) {
|
|||||||
const [statusDialog, setStatusDialog] = useState(null);
|
const [statusDialog, setStatusDialog] = useState(null);
|
||||||
const [statusGrund, setStatusGrund] = useState('');
|
const [statusGrund, setStatusGrund] = useState('');
|
||||||
const [mergeVon, setMergeVon] = useState(null);
|
const [mergeVon, setMergeVon] = useState(null);
|
||||||
|
const [bereiche, setBereiche] = useState([]);
|
||||||
|
const [bereichDraft, setBereichDraft] = useState({ id: null, name: '', emoji: '' });
|
||||||
const [anliegenDraft, setAnliegenDraft] = useState({ id: null, name: '', emoji: '🎫', beschreibung: '', ping_role_id: '', intro: '' });
|
const [anliegenDraft, setAnliegenDraft] = useState({ id: null, name: '', emoji: '🎫', beschreibung: '', ping_role_id: '', intro: '' });
|
||||||
const [dienstDraft, setDienstDraft] = useState({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false });
|
const [dienstDraft, setDienstDraft] = useState({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false });
|
||||||
const [owner, setOwner] = useState(null);
|
const [owner, setOwner] = useState(null);
|
||||||
@@ -495,7 +497,10 @@ export default function Settings({ me }) {
|
|||||||
apiGet('/api/watchdog').then(setWatchdog).catch(() => {});
|
apiGet('/api/watchdog').then(setWatchdog).catch(() => {});
|
||||||
apiGet('/api/automod').then(setAutomod).catch(() => {});
|
apiGet('/api/automod').then(setAutomod).catch(() => {});
|
||||||
apiGet('/api/ticket-categories').then((d) => setAnliegen(d.kategorien ?? [])).catch(() => {});
|
apiGet('/api/ticket-categories').then((d) => setAnliegen(d.kategorien ?? [])).catch(() => {});
|
||||||
apiGet('/api/wishes').then((d) => setWuensche(d.wishes ?? [])).catch(() => {});
|
apiGet('/api/wishes').then((d) => {
|
||||||
|
setWuensche(d.wishes ?? []);
|
||||||
|
setBereiche(d.bereiche ?? []);
|
||||||
|
}).catch(() => {});
|
||||||
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
|
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
|
||||||
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
|
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
|
||||||
apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).catch(() => {});
|
apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).catch(() => {});
|
||||||
@@ -1865,6 +1870,64 @@ export default function Settings({ me }) {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Wunsch-Bereiche</h2>
|
||||||
|
<p className="section-intro">
|
||||||
|
Schubladen für Ideen — „Fahrzeuge", „Wirtschaft", „Technik". Ohne Bereiche
|
||||||
|
bleibt die Liste eine Liste; mit ihnen kann man auf der Roadmap filtern, und
|
||||||
|
der Aufruf-Post in Discord fragt beim Einreichen nach dem Bereich. Nach dem
|
||||||
|
Ändern <strong>/wunsch-setup</strong> neu ausführen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{bereiche.length === 0 && <p className="notice">Noch keine Bereiche.</p>}
|
||||||
|
|
||||||
|
{bereiche.length > 0 && (
|
||||||
|
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
|
||||||
|
{bereiche.map((b) => (
|
||||||
|
<div className="key-row" key={b.id}>
|
||||||
|
<span className="tk-emoji">{b.emoji}</span>
|
||||||
|
<span className="key-name">{b.name}</span>
|
||||||
|
<span className="key-used">
|
||||||
|
{(() => {
|
||||||
|
const n = wuensche.filter((w) => w.category_id === b.id).length;
|
||||||
|
return `${n} ${n === 1 ? 'Wunsch' : 'Wünsche'}`;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-mini"
|
||||||
|
onClick={() => setBereichDraft({ id: b.id, name: b.name, emoji: b.emoji ?? '' })}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button className="card-delete" title="Bereich entfernen" onClick={() => bereichLoeschen(b)}>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="field-row">
|
||||||
|
<input
|
||||||
|
type="text" maxLength={32} placeholder="🚗" style={{ maxWidth: '5rem' }}
|
||||||
|
value={bereichDraft.emoji}
|
||||||
|
onChange={(e) => setBereichDraft({ ...bereichDraft, emoji: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text" maxLength={40} placeholder="Name, z. B. Fahrzeuge"
|
||||||
|
value={bereichDraft.name}
|
||||||
|
onChange={(e) => setBereichDraft({ ...bereichDraft, name: e.target.value })}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') bereichSpeichern(); }}
|
||||||
|
/>
|
||||||
|
<button className="btn" onClick={bereichSpeichern}>
|
||||||
|
{bereichDraft.id ? 'Speichern' : 'Hinzufügen'}
|
||||||
|
</button>
|
||||||
|
{bereichDraft.id && (
|
||||||
|
<button className="btn btn-ghost" onClick={() => setBereichDraft({ id: null, name: '', emoji: '' })}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// Geburtstage</h2>
|
<h2 className="settings-title">// Geburtstage</h2>
|
||||||
<div className="field" data-setting="birthday_channel_id">
|
<div className="field" data-setting="birthday_channel_id">
|
||||||
@@ -2080,6 +2143,29 @@ export default function Settings({ me }) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function bereichSpeichern() {
|
||||||
|
try {
|
||||||
|
const res = bereichDraft.id
|
||||||
|
? await apiPut(`/api/wish-categories/${bereichDraft.id}`, bereichDraft)
|
||||||
|
: await apiPost('/api/wish-categories', bereichDraft);
|
||||||
|
setBereiche(res.bereiche ?? []);
|
||||||
|
setBereichDraft({ id: null, name: '', emoji: '' });
|
||||||
|
} catch (e) {
|
||||||
|
flash(`✗ ${e.body?.error ?? 'Fehlgeschlagen'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bereichLoeschen(b) {
|
||||||
|
if (!window.confirm(`Bereich „${b.name}" entfernen? Die Wünsche darin bleiben, stehen dann nur ohne Schild da.`)) return;
|
||||||
|
try {
|
||||||
|
const res = await apiDelete(`/api/wish-categories/${b.id}`);
|
||||||
|
setBereiche(res.bereiche ?? []);
|
||||||
|
if (res.wishes) setWuensche(res.wishes);
|
||||||
|
} catch {
|
||||||
|
flash('✗ Entfernen fehlgeschlagen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Status setzen — mit Begruendung, denn genau die fehlt sonst */
|
/** Status setzen — mit Begruendung, denn genau die fehlt sonst */
|
||||||
function wunschStatus(wunsch, status) {
|
function wunschStatus(wunsch, status) {
|
||||||
const label = Object.fromEntries(WUNSCH_STATUS)[status];
|
const label = Object.fromEntries(WUNSCH_STATUS)[status];
|
||||||
|
|||||||
+25
-1
@@ -488,7 +488,31 @@ body::after {
|
|||||||
/* Erledigtes tritt zurück, ohne zu verschwinden */
|
/* Erledigtes tritt zurück, ohne zu verschwinden */
|
||||||
.wish-row.st-umgesetzt, .wish-row.st-abgelehnt { opacity: .72; }
|
.wish-row.st-umgesetzt, .wish-row.st-abgelehnt { opacity: .72; }
|
||||||
|
|
||||||
.wish-filter { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: .9rem; }
|
/* Schilder stehen über der Idee, nicht dahinter — sonst rutschen sie bei
|
||||||
|
langen Texten aus dem Blick */
|
||||||
|
.wish-schilder { display: flex; gap: .35rem; flex-wrap: wrap; margin-bottom: .25rem; }
|
||||||
|
.wish-area {
|
||||||
|
font-family: var(--mono); font-size: .58rem; letter-spacing: .1em;
|
||||||
|
text-transform: uppercase; white-space: nowrap; color: var(--muted2);
|
||||||
|
padding: .18rem .5rem; border-radius: 999px; border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
/* Kommentare liegen im Discord-Thread — der Zähler führt direkt hinein */
|
||||||
|
.wish-komm {
|
||||||
|
display: inline-flex; align-items: center; gap: .3rem;
|
||||||
|
font-family: var(--mono); font-size: .68rem; color: var(--muted2);
|
||||||
|
text-decoration: none; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wish-komm:hover { color: var(--neon); }
|
||||||
|
|
||||||
|
.wish-bar { display: flex; flex-direction: column; gap: .3rem; margin-bottom: 1rem; }
|
||||||
|
.wish-filter-label {
|
||||||
|
font-family: var(--mono); font-size: .58rem; letter-spacing: .12em;
|
||||||
|
text-transform: uppercase; color: var(--muted2);
|
||||||
|
align-self: center; margin-right: .2rem; min-width: 5.2rem;
|
||||||
|
}
|
||||||
|
.wish-area-select { flex: 0 0 auto; max-width: 11rem; }
|
||||||
|
|
||||||
|
.wish-filter { display: flex; flex-wrap: wrap; gap: .4rem; align-items: center; }
|
||||||
.wish-chip {
|
.wish-chip {
|
||||||
background: var(--bg2); border: 1px solid var(--border); border-radius: 999px;
|
background: var(--bg2); border: 1px solid var(--border); border-radius: 999px;
|
||||||
color: var(--muted); font-family: var(--mono); font-size: .62rem;
|
color: var(--muted); font-family: var(--mono); font-size: .62rem;
|
||||||
|
|||||||
+22
-4
@@ -20,6 +20,9 @@ import {
|
|||||||
claimTicketButton, unclaimTicketButton,
|
claimTicketButton, unclaimTicketButton,
|
||||||
} from './tickets.js';
|
} from './tickets.js';
|
||||||
import { handleRoleMenuButton } from './role-menus.js';
|
import { handleRoleMenuButton } from './role-menus.js';
|
||||||
|
import {
|
||||||
|
handleWunschPick, handleWunschButton, handleWunschModal, registerWishThreads,
|
||||||
|
} from './wishes.js';
|
||||||
import { handleGiveawayAdminButton } from './giveaways.js';
|
import { handleGiveawayAdminButton } from './giveaways.js';
|
||||||
import {
|
import {
|
||||||
removePlaytester, playtesterForm, wishByMessage, addWishVote, removeWishVote,
|
removePlaytester, playtesterForm, wishByMessage, addWishVote, removeWishVote,
|
||||||
@@ -31,6 +34,7 @@ import * as bug from './commands/bug.js';
|
|||||||
import * as playtesterSetup from './commands/playtester-setup.js';
|
import * as playtesterSetup from './commands/playtester-setup.js';
|
||||||
import * as galerieBackfill from './commands/galerie-backfill.js';
|
import * as galerieBackfill from './commands/galerie-backfill.js';
|
||||||
import * as wunsch from './commands/wunsch.js';
|
import * as wunsch from './commands/wunsch.js';
|
||||||
|
import * as wunschSetup from './commands/wunsch-setup.js';
|
||||||
import * as giveaway from './commands/giveaway.js';
|
import * as giveaway from './commands/giveaway.js';
|
||||||
import * as ticketSetup from './commands/ticket-setup.js';
|
import * as ticketSetup from './commands/ticket-setup.js';
|
||||||
import * as warn from './commands/warn.js';
|
import * as warn from './commands/warn.js';
|
||||||
@@ -47,7 +51,7 @@ import * as umfrage from './commands/umfrage.js';
|
|||||||
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
|
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
|
||||||
const commandModules = [
|
const commandModules = [
|
||||||
ping, devlogBackfill, bug, playtesterSetup, galerieBackfill,
|
ping, devlogBackfill, bug, playtesterSetup, galerieBackfill,
|
||||||
wunsch, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag,
|
wunsch, wunschSetup, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag,
|
||||||
raid, umfrage,
|
raid, umfrage,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -196,9 +200,22 @@ export async function startBot() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eingabefenster von /bug und /wunsch
|
// Ideen einreichen: Knopf oder Auswahl im Aufruf-Post -> Eingabefenster
|
||||||
if (interaction.isModalSubmit() && (interaction.customId === 'bugmodal' || interaction.customId === 'wunschmodal')) {
|
if ((interaction.isButton() && interaction.customId === 'wunsch_neu')
|
||||||
const handler = interaction.customId === 'bugmodal' ? bug.handleModal : wunsch.handleModal;
|
|| (interaction.isStringSelectMenu() && interaction.customId === 'wunsch_pick')) {
|
||||||
|
try {
|
||||||
|
if (interaction.isButton()) await handleWunschButton(interaction);
|
||||||
|
else await handleWunschPick(interaction);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[voting] Eingabefenster fehlgeschlagen:', error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eingabefenster von /bug und /wunsch (der Bereich haengt hinter dem Doppelpunkt)
|
||||||
|
if (interaction.isModalSubmit()
|
||||||
|
&& (interaction.customId === 'bugmodal' || interaction.customId.startsWith('wunschmodal'))) {
|
||||||
|
const handler = interaction.customId === 'bugmodal' ? bug.handleModal : handleWunschModal;
|
||||||
try {
|
try {
|
||||||
await handler(interaction);
|
await handler(interaction);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -374,6 +391,7 @@ export async function startBot() {
|
|||||||
// Vor den anderen Beitritts-Handlern egal — die Erkennung zaehlt nur mit
|
// Vor den anderen Beitritts-Handlern egal — die Erkennung zaehlt nur mit
|
||||||
registerAntiRaid(client);
|
registerAntiRaid(client);
|
||||||
registerAutoMod(client);
|
registerAutoMod(client);
|
||||||
|
registerWishThreads(client);
|
||||||
registerLevels(client);
|
registerLevels(client);
|
||||||
registerPresence(client);
|
registerPresence(client);
|
||||||
registerExtras(client);
|
registerExtras(client);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// /wunsch-setup — postet den Ideen-Aufruf in den aktuellen Kanal (Admin).
|
||||||
|
//
|
||||||
|
// Gibt es Bereiche, wird daraus ein Auswahlmenü: erst wohin, dann das
|
||||||
|
// Eingabefenster. Ohne Bereiche bleibt es beim einen Knopf.
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js';
|
||||||
|
import { votingChannelId, brandColor, brandFooter } from '../../runtime-settings.js';
|
||||||
|
import { renderTemplate } from '../../templates.js';
|
||||||
|
import { listWishCategories } from '../../db.js';
|
||||||
|
import { wunschPanelComponents } from '../wishes.js';
|
||||||
|
|
||||||
|
export const data = new SlashCommandBuilder()
|
||||||
|
.setName('wunsch-setup')
|
||||||
|
.setDescription('Postet den Ideen-Aufruf mit Knopf in diesen Kanal')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator);
|
||||||
|
|
||||||
|
export async function execute(interaction) {
|
||||||
|
if (!votingChannelId()) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: '❌ Erst in der Config einen Voting-Kanal wählen.',
|
||||||
|
flags: MessageFlags.Ephemeral,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(brandColor())
|
||||||
|
.setTitle(renderTemplate('wish.panel_title'))
|
||||||
|
.setDescription(renderTemplate('wish.panel_text'))
|
||||||
|
.setFooter({ text: brandFooter('VOTING') });
|
||||||
|
|
||||||
|
const anzahl = listWishCategories().length;
|
||||||
|
await interaction.channel.send({ embeds: [embed], components: wunschPanelComponents() });
|
||||||
|
await interaction.reply({
|
||||||
|
content: anzahl > 0
|
||||||
|
? `✅ Aufruf gepostet — mit Auswahl aus ${anzahl} Bereichen.`
|
||||||
|
: '✅ Aufruf gepostet. Bereiche zum Einsortieren legst du in der Config unter Community an.',
|
||||||
|
flags: MessageFlags.Ephemeral,
|
||||||
|
});
|
||||||
|
}
|
||||||
+30
-64
@@ -1,21 +1,40 @@
|
|||||||
// /wunsch — Feature-Wunsch einreichen: Eingabefenster → Voting-Post mit 👍,
|
// /wunsch — Feature-Wunsch einreichen: Eingabefenster → Voting-Post mit 👍,
|
||||||
// Rangliste auf der Webseite.
|
// Thread zum Reden, Rangliste auf der Webseite.
|
||||||
//
|
//
|
||||||
// Das Fenster statt einer Slash-Option, weil eine Idee selten in eine Zeile
|
// Das Fenster statt einer Slash-Option, weil eine Idee selten in eine Zeile
|
||||||
// passt — und weil die Rückfrage „warum wäre das gut?" die Vorschläge
|
// passt — und weil die Rückfrage „warum wäre das gut?" die Vorschläge
|
||||||
// deutlich besser macht als ein Einzeiler.
|
// deutlich besser macht als ein Einzeiler.
|
||||||
import {
|
//
|
||||||
SlashCommandBuilder, MessageFlags, EmbedBuilder,
|
// Das Posten selbst steckt in bot/wishes.js, damit Befehl, Knopf unter dem
|
||||||
ModalBuilder, ActionRowBuilder, TextInputBuilder, TextInputStyle,
|
// Aufruf-Post und Webseite denselben Weg gehen und ein Wunsch überall gleich
|
||||||
} from 'discord.js';
|
// aussieht.
|
||||||
import { votingChannelId, publicUrl, brandColor, brandFooter } from '../../runtime-settings.js';
|
import { SlashCommandBuilder, MessageFlags } from 'discord.js';
|
||||||
import { saveWish } from '../../db.js';
|
import { votingChannelId } from '../../runtime-settings.js';
|
||||||
import { renderTemplate } from '../../templates.js';
|
import { listWishCategories } from '../../db.js';
|
||||||
|
import { wunschModal } from '../wishes.js';
|
||||||
|
|
||||||
export const data = new SlashCommandBuilder()
|
const builder = new SlashCommandBuilder()
|
||||||
.setName('wunsch')
|
.setName('wunsch')
|
||||||
.setDescription('Feature-Wunsch einreichen — die Community stimmt ab');
|
.setDescription('Feature-Wunsch einreichen — die Community stimmt ab');
|
||||||
|
|
||||||
|
// Discord will die Auswahlmöglichkeiten schon beim Registrieren kennen. Wer
|
||||||
|
// Bereiche nachträglich anlegt, bekommt sie hier beim nächsten Bot-Start —
|
||||||
|
// über den Aufruf-Post (/wunsch-setup) stehen sie sofort zur Verfügung.
|
||||||
|
{
|
||||||
|
const bereiche = listWishCategories();
|
||||||
|
if (bereiche.length > 0) {
|
||||||
|
builder.addStringOption((option) => option
|
||||||
|
.setName('bereich')
|
||||||
|
.setDescription('Worum geht es?')
|
||||||
|
.addChoices(...bereiche.slice(0, 25).map((b) => ({
|
||||||
|
name: `${b.emoji} ${b.name}`.trim().slice(0, 100),
|
||||||
|
value: String(b.id),
|
||||||
|
}))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const data = builder;
|
||||||
|
|
||||||
export async function execute(interaction) {
|
export async function execute(interaction) {
|
||||||
if (!votingChannelId()) {
|
if (!votingChannelId()) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
@@ -24,59 +43,6 @@ export async function execute(interaction) {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const gewaehlt = Number(interaction.options.getString('bereich'));
|
||||||
const modal = new ModalBuilder().setCustomId('wunschmodal').setTitle('Feature-Wunsch');
|
await interaction.showModal(wunschModal(Number.isFinite(gewaehlt) ? gewaehlt : null));
|
||||||
modal.addComponents(
|
|
||||||
new ActionRowBuilder().addComponents(
|
|
||||||
new TextInputBuilder()
|
|
||||||
.setCustomId('idee').setLabel('Deine Idee')
|
|
||||||
.setPlaceholder('Was soll dazukommen oder anders laufen?')
|
|
||||||
.setStyle(TextInputStyle.Paragraph).setMaxLength(500).setRequired(true)
|
|
||||||
),
|
|
||||||
new ActionRowBuilder().addComponents(
|
|
||||||
new TextInputBuilder()
|
|
||||||
.setCustomId('warum').setLabel('Warum wäre das gut?')
|
|
||||||
.setPlaceholder('Was wird dadurch besser oder einfacher?')
|
|
||||||
.setStyle(TextInputStyle.Paragraph).setMaxLength(400).setRequired(false)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
await interaction.showModal(modal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Formular abgeschickt → Voting-Post */
|
|
||||||
export async function handleModal(interaction) {
|
|
||||||
const channelId = votingChannelId();
|
|
||||||
if (!channelId) {
|
|
||||||
await interaction.reply({
|
|
||||||
content: '❌ Feature-Voting ist gerade nicht aktiviert.',
|
|
||||||
flags: MessageFlags.Ephemeral,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
|
||||||
|
|
||||||
const idee = interaction.fields.getTextInputValue('idee');
|
|
||||||
const warum = interaction.fields.getTextInputValue('warum')?.trim();
|
|
||||||
const channel = await interaction.client.channels.fetch(channelId);
|
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setColor(brandColor())
|
|
||||||
.setAuthor({
|
|
||||||
name: interaction.member?.displayName ?? interaction.user.username,
|
|
||||||
iconURL: interaction.user.displayAvatarURL({ size: 64 }),
|
|
||||||
})
|
|
||||||
.setTitle('💡 Feature-Wunsch')
|
|
||||||
.setDescription(warum ? `${idee}\n\n**Warum:** ${warum}` : idee)
|
|
||||||
.setURL(`${publicUrl()}/roadmap`)
|
|
||||||
.setFooter({ text: `${brandFooter('VOTING')} • 👍 = will ich!` });
|
|
||||||
|
|
||||||
const message = await channel.send({ embeds: [embed] });
|
|
||||||
await message.react('👍');
|
|
||||||
saveWish(message.id, interaction.user.username, idee);
|
|
||||||
|
|
||||||
await interaction.editReply(renderTemplate('wish.submitted', {
|
|
||||||
user: interaction.member?.displayName ?? interaction.user.username,
|
|
||||||
mention: `<@${interaction.user.id}>`,
|
|
||||||
url: message.url,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Feature-Wünsche: einreichen, posten, bereden.
|
||||||
|
//
|
||||||
|
// Ein Wunsch entsteht an drei Stellen — per /wunsch, über den Knopf unter dem
|
||||||
|
// Aufruf-Post und auf der Webseite. Alle drei landen hier, damit ein Wunsch
|
||||||
|
// überall gleich aussieht: gebrandetes Embed, 👍 zum Abstimmen, und ein Thread
|
||||||
|
// darunter fürs Reden.
|
||||||
|
//
|
||||||
|
// Der Thread ist bewusst der Diskussionsort und nicht ein Kommentarfeld auf der
|
||||||
|
// Webseite: die Leute sind ohnehin in Discord, und ein zweiter Ort würde die
|
||||||
|
// Unterhaltung nur zerreißen. Die Roadmap zeigt die Zahl der Beiträge und
|
||||||
|
// verlinkt hinein.
|
||||||
|
import {
|
||||||
|
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, Events,
|
||||||
|
ModalBuilder, MessageFlags, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle,
|
||||||
|
} from 'discord.js';
|
||||||
|
import {
|
||||||
|
saveWish, setWishThread, bumpWishKommentare, getWishCategory, listWishCategories,
|
||||||
|
} from '../db.js';
|
||||||
|
import { votingChannelId, publicUrl, brandColor, brandFooter } from '../runtime-settings.js';
|
||||||
|
import { renderTemplate } from '../templates.js';
|
||||||
|
import { tuning } from '../tuning.js';
|
||||||
|
import { alsEmoji } from './tickets.js';
|
||||||
|
|
||||||
|
/** Knopf oder Auswahlmenü für den Aufruf-Post — je nachdem, ob es Bereiche gibt */
|
||||||
|
export function wunschPanelComponents() {
|
||||||
|
const bereiche = listWishCategories();
|
||||||
|
if (bereiche.length === 0) {
|
||||||
|
return [new ActionRowBuilder().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('wunsch_neu')
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
.setLabel('Idee einreichen')
|
||||||
|
.setEmoji('💡')
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
const menu = new StringSelectMenuBuilder()
|
||||||
|
.setCustomId('wunsch_pick')
|
||||||
|
.setPlaceholder('Worum geht es?')
|
||||||
|
.addOptions(bereiche.slice(0, 25).map((b) => {
|
||||||
|
const option = { label: b.name.slice(0, 100), value: String(b.id) };
|
||||||
|
const emoji = alsEmoji(b.emoji);
|
||||||
|
if (emoji) option.emoji = emoji;
|
||||||
|
return option;
|
||||||
|
}));
|
||||||
|
return [new ActionRowBuilder().addComponents(menu)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Das Eingabefenster. Der Bereich reist in der Kennung mit. */
|
||||||
|
export function wunschModal(categoryId = null) {
|
||||||
|
const bereich = categoryId ? getWishCategory(categoryId) : null;
|
||||||
|
return new ModalBuilder()
|
||||||
|
.setCustomId(`wunschmodal:${categoryId ?? ''}`)
|
||||||
|
.setTitle(bereich ? `Idee — ${bereich.name}`.slice(0, 45) : 'Feature-Wunsch')
|
||||||
|
.addComponents(
|
||||||
|
new ActionRowBuilder().addComponents(
|
||||||
|
new TextInputBuilder()
|
||||||
|
.setCustomId('idee').setLabel('Deine Idee')
|
||||||
|
.setPlaceholder('Was soll dazukommen oder anders laufen?')
|
||||||
|
.setStyle(TextInputStyle.Paragraph).setMaxLength(500).setRequired(true)
|
||||||
|
),
|
||||||
|
new ActionRowBuilder().addComponents(
|
||||||
|
new TextInputBuilder()
|
||||||
|
.setCustomId('warum').setLabel('Warum wäre das gut?')
|
||||||
|
.setPlaceholder('Was wird dadurch besser oder einfacher?')
|
||||||
|
.setStyle(TextInputStyle.Paragraph).setMaxLength(400).setRequired(false)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wunsch posten, speichern und den Thread aufmachen.
|
||||||
|
* @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.');
|
||||||
|
|
||||||
|
const bereich = categoryId ? getWishCategory(categoryId) : null;
|
||||||
|
const kopf = bereich ? `${bereich.emoji || '💡'} ${bereich.name}` : '💡 Feature-Wunsch';
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(brandColor())
|
||||||
|
.setAuthor({ name: autor, iconURL: avatar || undefined })
|
||||||
|
.setTitle(kopf)
|
||||||
|
.setDescription(warum ? `${idee}\n\n**Warum:** ${warum}` : idee)
|
||||||
|
.setURL(`${publicUrl()}/roadmap`)
|
||||||
|
.setFooter({ text: `${brandFooter('VOTING')} • 👍 = will ich!` });
|
||||||
|
|
||||||
|
const message = await channel.send({ embeds: [embed] });
|
||||||
|
await message.react('👍').catch(() => {});
|
||||||
|
const id = saveWish(message.id, autor, idee, autorId ?? null, categoryId ?? null);
|
||||||
|
|
||||||
|
// Thread zum Reden. Scheitert er (fehlende Rechte, kein Thread-fähiger
|
||||||
|
// Kanal), bleibt der Wunsch trotzdem stehen — abstimmen geht auch ohne.
|
||||||
|
try {
|
||||||
|
const thread = await message.startThread({
|
||||||
|
name: `💬 ${idee}`.slice(0, 100),
|
||||||
|
autoArchiveDuration: tuning('thread_archive_days') * 1440,
|
||||||
|
});
|
||||||
|
setWishThread(id, thread.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[voting] Thread nicht angelegt:', error.message);
|
||||||
|
}
|
||||||
|
return { id, url: message.url };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auswahl im Aufruf-Post → Eingabefenster für diesen Bereich */
|
||||||
|
export async function handleWunschPick(interaction) {
|
||||||
|
const gewaehlt = Number(interaction.values?.[0]);
|
||||||
|
await interaction.showModal(wunschModal(Number.isFinite(gewaehlt) ? gewaehlt : null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Knopf ohne Bereiche → Eingabefenster ohne Bereich */
|
||||||
|
export async function handleWunschButton(interaction) {
|
||||||
|
await interaction.showModal(wunschModal(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formular abgeschickt → Wunsch anlegen */
|
||||||
|
export async function handleWunschModal(interaction) {
|
||||||
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
const rohBereich = interaction.customId.split(':')[1];
|
||||||
|
const categoryId = rohBereich ? Number(rohBereich) : null;
|
||||||
|
const name = interaction.member?.displayName ?? interaction.user.username;
|
||||||
|
try {
|
||||||
|
const { url } = await wunschAnlegen(interaction.client, {
|
||||||
|
idee: interaction.fields.getTextInputValue('idee'),
|
||||||
|
warum: interaction.fields.getTextInputValue('warum')?.trim(),
|
||||||
|
autor: name,
|
||||||
|
autorId: interaction.user.id,
|
||||||
|
avatar: interaction.user.displayAvatarURL({ size: 64 }),
|
||||||
|
categoryId,
|
||||||
|
});
|
||||||
|
await interaction.editReply(renderTemplate('wish.submitted', {
|
||||||
|
user: name,
|
||||||
|
mention: `<@${interaction.user.id}>`,
|
||||||
|
url,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
await interaction.editReply(`❌ ${error.message}`).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beiträge in Wunsch-Threads mitzählen — das ist der Kommentar-Zähler */
|
||||||
|
export function registerWishThreads(client) {
|
||||||
|
client.on(Events.MessageCreate, (message) => {
|
||||||
|
try {
|
||||||
|
if (message.author?.bot || !message.channel?.isThread?.()) return;
|
||||||
|
bumpWishKommentare(message.channelId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[voting] Kommentar-Zähler:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -294,24 +294,80 @@ db.exec(`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bereiche, in die ein Wunsch faellt — „Fahrzeuge", „Wirtschaft", „Technik".
|
||||||
|
// Ohne Eintrag bleibt alles wie bisher: eine Liste ohne Schubladen.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS wish_categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
emoji TEXT NOT NULL DEFAULT '',
|
||||||
|
sort INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
{
|
||||||
|
const spalten = db.prepare('PRAGMA table_info(wishes)').all().map((c) => c.name);
|
||||||
|
if (!spalten.includes('category_id')) db.exec('ALTER TABLE wishes ADD COLUMN category_id INTEGER');
|
||||||
|
// Der Thread unter dem Wunsch-Post ist der Diskussionsort. Die Zahl der
|
||||||
|
// Beitraege darin zaehlen wir mit, statt sie fuer jede Seitenansicht bei
|
||||||
|
// Discord zu erfragen — 50 Threads je Aufruf waere nicht zu bezahlen.
|
||||||
|
if (!spalten.includes('thread_id')) db.exec('ALTER TABLE wishes ADD COLUMN thread_id TEXT');
|
||||||
|
if (!spalten.includes('kommentare')) {
|
||||||
|
db.exec('ALTER TABLE wishes ADD COLUMN kommentare INTEGER NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const listWishCatsStmt = db.prepare('SELECT * FROM wish_categories ORDER BY sort, id');
|
||||||
|
const getWishCatStmt = db.prepare('SELECT * FROM wish_categories WHERE id = ?');
|
||||||
|
const insertWishCatStmt = db.prepare(
|
||||||
|
'INSERT INTO wish_categories (name, emoji, sort) VALUES (@name, @emoji, @sort)'
|
||||||
|
);
|
||||||
|
const updateWishCatStmt = db.prepare(
|
||||||
|
'UPDATE wish_categories SET name = @name, emoji = @emoji, sort = @sort WHERE id = @id'
|
||||||
|
);
|
||||||
|
const deleteWishCatStmt = db.prepare('DELETE FROM wish_categories WHERE id = ?');
|
||||||
|
// Wuensche im geloeschten Bereich bleiben, sie stehen dann nur ohne Schild da
|
||||||
|
const orphanWishesStmt = db.prepare('UPDATE wishes SET category_id = NULL WHERE category_id = ?');
|
||||||
|
export const listWishCategories = () => listWishCatsStmt.all();
|
||||||
|
export const getWishCategory = (id) => getWishCatStmt.get(id) ?? null;
|
||||||
|
export const createWishCategory = (c) => insertWishCatStmt.run(c).lastInsertRowid;
|
||||||
|
export const updateWishCategory = (c) => updateWishCatStmt.run(c);
|
||||||
|
export const deleteWishCategory = db.transaction((id) => {
|
||||||
|
orphanWishesStmt.run(id);
|
||||||
|
return deleteWishCatStmt.run(id).changes > 0;
|
||||||
|
});
|
||||||
|
|
||||||
/** Zuspruch = eigene Stimmen + nicht mehr zuordenbarer Sockel aus der Altzeit */
|
/** Zuspruch = eigene Stimmen + nicht mehr zuordenbarer Sockel aus der Altzeit */
|
||||||
const WUNSCH_FELDER = `
|
const WUNSCH_FELDER = `
|
||||||
w.id, w.message_id, w.idea, w.author, w.author_id, w.status, w.status_grund,
|
w.id, w.message_id, w.thread_id, w.idea, w.author, w.author_id, w.status,
|
||||||
w.status_at, w.created_at,
|
w.status_grund, w.status_at, w.created_at, w.kommentare, w.category_id,
|
||||||
w.bonus + (SELECT COUNT(*) FROM wish_votes v WHERE v.wish_id = w.id) AS score
|
(SELECT k.name FROM wish_categories k WHERE k.id = w.category_id) AS kategorie,
|
||||||
|
(SELECT k.emoji FROM wish_categories k WHERE k.id = w.category_id) AS kategorie_emoji,
|
||||||
|
w.bonus + (SELECT COUNT(*) FROM wish_votes v WHERE v.wish_id = w.id) AS score,
|
||||||
|
-- Bewegung: Stimmen der letzten sieben Tage. Ein alter Wunsch mit vielen
|
||||||
|
-- Stimmen steht sonst fuer immer oben, egal ob noch jemand hinschaut.
|
||||||
|
(SELECT COUNT(*) FROM wish_votes v
|
||||||
|
WHERE v.wish_id = w.id AND v.created_at >= datetime('now', '-7 days')) AS bewegung
|
||||||
`;
|
`;
|
||||||
const insertWish = db.prepare(
|
const insertWish = db.prepare(
|
||||||
'INSERT INTO wishes (message_id, author, author_id, idea) VALUES (?, ?, ?, ?)'
|
'INSERT INTO wishes (message_id, author, author_id, idea, category_id) VALUES (?, ?, ?, ?, ?)'
|
||||||
);
|
);
|
||||||
|
const setWishThreadStmt = db.prepare('UPDATE wishes SET thread_id = ? WHERE id = ?');
|
||||||
|
const bumpKommentareStmt = db.prepare(
|
||||||
|
'UPDATE wishes SET kommentare = kommentare + 1 WHERE thread_id = ?'
|
||||||
|
);
|
||||||
|
const setWishCategoryStmt = db.prepare('UPDATE wishes SET category_id = ? WHERE id = ?');
|
||||||
const setWishMessageStmt = db.prepare('UPDATE wishes SET message_id = ? WHERE id = ?');
|
const setWishMessageStmt = db.prepare('UPDATE wishes SET message_id = ? WHERE id = ?');
|
||||||
const wishByMessageStmt = db.prepare('SELECT * FROM wishes WHERE message_id = ?');
|
const wishByMessageStmt = db.prepare('SELECT * FROM wishes WHERE message_id = ?');
|
||||||
const wishByIdStmt = db.prepare(`SELECT ${WUNSCH_FELDER} FROM wishes w WHERE w.id = ?`);
|
const wishByIdStmt = db.prepare(`SELECT ${WUNSCH_FELDER} FROM wishes w WHERE w.id = ?`);
|
||||||
// Zusammengeführte Doppler tauchen nirgends mehr auf — ihre Stimmen stehen beim Original
|
// Zusammengeführte Doppler tauchen nirgends mehr auf — ihre Stimmen stehen beim Original
|
||||||
const topWishesStmt = db.prepare(`
|
const SORTIERUNG = {
|
||||||
SELECT ${WUNSCH_FELDER} FROM wishes w
|
top: 'score DESC, w.created_at DESC',
|
||||||
WHERE w.merged_into IS NULL
|
bewegung: 'bewegung DESC, score DESC',
|
||||||
ORDER BY score DESC, w.created_at DESC LIMIT ?
|
neu: 'w.created_at DESC',
|
||||||
`);
|
};
|
||||||
|
const topWishesStmts = Object.fromEntries(Object.entries(SORTIERUNG).map(([k, ord]) => [
|
||||||
|
k,
|
||||||
|
db.prepare(`SELECT ${WUNSCH_FELDER} FROM wishes w WHERE w.merged_into IS NULL ORDER BY ${ord} LIMIT ?`),
|
||||||
|
]));
|
||||||
const searchWishesStmt = db.prepare(`
|
const searchWishesStmt = db.prepare(`
|
||||||
SELECT ${WUNSCH_FELDER} FROM wishes w
|
SELECT ${WUNSCH_FELDER} FROM wishes w
|
||||||
WHERE w.merged_into IS NULL AND w.idea LIKE ?
|
WHERE w.merged_into IS NULL AND w.idea LIKE ?
|
||||||
@@ -327,12 +383,17 @@ const moveVotesStmt = db.prepare(
|
|||||||
);
|
);
|
||||||
const dropVotesStmt = db.prepare('DELETE FROM wish_votes WHERE wish_id = ?');
|
const dropVotesStmt = db.prepare('DELETE FROM wish_votes WHERE wish_id = ?');
|
||||||
|
|
||||||
export const saveWish = (messageId, author, idea, authorId = null) =>
|
export const saveWish = (messageId, author, idea, authorId = null, categoryId = null) =>
|
||||||
insertWish.run(messageId, author, authorId, idea).lastInsertRowid;
|
insertWish.run(messageId, author, authorId, idea, categoryId ?? null).lastInsertRowid;
|
||||||
|
export const setWishThread = (id, threadId) => setWishThreadStmt.run(threadId, id);
|
||||||
|
export const setWishCategory = (id, categoryId) => setWishCategoryStmt.run(categoryId ?? null, id);
|
||||||
|
/** @returns {boolean} true, wenn der Thread zu einem Wunsch gehoerte */
|
||||||
|
export const bumpWishKommentare = (threadId) => bumpKommentareStmt.run(threadId).changes > 0;
|
||||||
export const setWishMessage = (id, messageId) => setWishMessageStmt.run(messageId, id);
|
export const setWishMessage = (id, messageId) => setWishMessageStmt.run(messageId, id);
|
||||||
export const wishByMessage = (messageId) => wishByMessageStmt.get(messageId) ?? null;
|
export const wishByMessage = (messageId) => wishByMessageStmt.get(messageId) ?? null;
|
||||||
export const getWish = (id) => wishByIdStmt.get(id) ?? null;
|
export const getWish = (id) => wishByIdStmt.get(id) ?? null;
|
||||||
export const topWishes = (limit = 20) => topWishesStmt.all(limit);
|
export const topWishes = (limit = 20, sortierung = 'top') =>
|
||||||
|
(topWishesStmts[sortierung] ?? topWishesStmts.top).all(limit);
|
||||||
export const searchWishes = (text, limit = 8) => searchWishesStmt.all(`%${text}%`, limit);
|
export const searchWishes = (text, limit = 8) => searchWishesStmt.all(`%${text}%`, limit);
|
||||||
export const deleteWish = (id) => {
|
export const deleteWish = (id) => {
|
||||||
dropVotesStmt.run(id);
|
dropVotesStmt.run(id);
|
||||||
|
|||||||
@@ -190,6 +190,22 @@ export const TEMPLATES = [
|
|||||||
vars: [...WHO, { key: 'url', desc: 'Link zum Voting-Post' }],
|
vars: [...WHO, { key: 'url', desc: 'Link zum Voting-Post' }],
|
||||||
default: '✅ Wunsch eingereicht — [zur Abstimmung]({url})!',
|
default: '✅ Wunsch eingereicht — [zur Abstimmung]({url})!',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'wish.panel_title', group: 'sonstiges', module: 'voting',
|
||||||
|
name: 'Ideen-Aufruf — Überschrift',
|
||||||
|
vars: [],
|
||||||
|
default: '💡 Deine Idee für EcoGame',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wish.panel_text', group: 'sonstiges', module: 'voting',
|
||||||
|
name: 'Ideen-Aufruf — Text', long: true,
|
||||||
|
vars: [],
|
||||||
|
default:
|
||||||
|
'Dir fehlt etwas, oder du hättest gern, dass etwas anders läuft?\n\n' +
|
||||||
|
'Such dir unten den passenden Bereich, schreib deine Idee auf — sie landet als '
|
||||||
|
+ 'Post hier im Kanal. Mit 👍 stimmt die Community ab, und im Thread darunter '
|
||||||
|
+ 'wird darüber geredet. Auf der Roadmap siehst du, was daraus geworden ist.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'bug.submitted', group: 'sonstiges', module: 'bug_reports',
|
id: 'bug.submitted', group: 'sonstiges', module: 'bug_reports',
|
||||||
name: 'Fehlermeldung — Bestätigung', long: true,
|
name: 'Fehlermeldung — Bestätigung', long: true,
|
||||||
|
|||||||
+83
-21
@@ -15,7 +15,8 @@ import {
|
|||||||
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
|
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
|
||||||
saveTemplate, listTemplates, deleteTemplate,
|
saveTemplate, listTemplates, deleteTemplate,
|
||||||
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
||||||
getWish, searchWishes, setWishStatus, mergeWish, deleteWish, wishVoters,
|
getWish, searchWishes, setWishStatus, mergeWish, deleteWish, wishVoters, setWishCategory,
|
||||||
|
listWishCategories, createWishCategory, updateWishCategory, deleteWishCategory,
|
||||||
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
|
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
|
||||||
heartbeatBuckets, heartbeatFirst,
|
heartbeatBuckets, heartbeatFirst,
|
||||||
freeAlphaKeys, deleteFreeAlphaKey, removePlaytester,
|
freeAlphaKeys, deleteFreeAlphaKey, removePlaytester,
|
||||||
@@ -33,6 +34,7 @@ import { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js';
|
|||||||
import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
|
import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
|
||||||
import { watchdogState, watchedServices } from '../bot/watchdog.js';
|
import { watchdogState, watchedServices } from '../bot/watchdog.js';
|
||||||
import { listRules, setRuleEnabled } from '../bot/automod.js';
|
import { listRules, setRuleEnabled } from '../bot/automod.js';
|
||||||
|
import { wunschAnlegen } from '../bot/wishes.js';
|
||||||
import { heartbeatSerie } from '../bot/heartbeat.js';
|
import { heartbeatSerie } from '../bot/heartbeat.js';
|
||||||
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
|
import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js';
|
||||||
import { sanitizeEmbed } from './api-v1.js';
|
import { sanitizeEmbed } from './api-v1.js';
|
||||||
@@ -186,8 +188,19 @@ export function registerApiRoutes(app, client) {
|
|||||||
app.get('/api/wishes', async (request) => {
|
app.get('/api/wishes', async (request) => {
|
||||||
const user = getSessionUser(request);
|
const user = getSessionUser(request);
|
||||||
const mine = user ? new Set(myWishVotes(user.id)) : new Set();
|
const mine = user ? new Set(myWishVotes(user.id)) : new Set();
|
||||||
|
const sortierung = String(request.query?.sort ?? 'top');
|
||||||
|
// Den Link zum Thread bauen wir hier — die Guild-ID gehört nicht auf
|
||||||
|
// die öffentliche Seite, nur der fertige Verweis
|
||||||
|
const gid = discordGuildId();
|
||||||
return {
|
return {
|
||||||
wishes: topWishes(50).map((w) => ({ ...w, voted: mine.has(w.id) })),
|
wishes: topWishes(50, sortierung).map((w) => ({
|
||||||
|
...w,
|
||||||
|
voted: mine.has(w.id),
|
||||||
|
thread_url: gid && w.thread_id
|
||||||
|
? `https://discord.com/channels/${gid}/${w.thread_id}`
|
||||||
|
: null,
|
||||||
|
})),
|
||||||
|
bereiche: listWishCategories(),
|
||||||
canVote: Boolean(user),
|
canVote: Boolean(user),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -1925,31 +1938,26 @@ ${rssItems}
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wunsch einreichen (postet wie /wunsch in den Voting-Kanal)
|
// Wunsch einreichen — läuft durch denselben Weg wie /wunsch, damit ein
|
||||||
|
// Wunsch von hier genauso aussieht: Embed, 👍 und Thread zum Reden
|
||||||
app.post('/api/wishes', async (request, reply) => {
|
app.post('/api/wishes', async (request, reply) => {
|
||||||
if (await requireMember(request, reply)) return;
|
if (await requireMember(request, reply)) return;
|
||||||
const user = getSessionUser(request);
|
const user = getSessionUser(request);
|
||||||
const idea = String(request.body?.idea ?? '').trim().slice(0, 500);
|
const idea = String(request.body?.idea ?? '').trim().slice(0, 500);
|
||||||
if (idea.length < 5) return reply.code(400).send({ error: 'Idee zu kurz' });
|
if (idea.length < 5) return reply.code(400).send({ error: 'Idee zu kurz' });
|
||||||
const channelId = votingChannelId();
|
const categoryId = Number(request.body?.category_id) || null;
|
||||||
if (!channelId) return reply.code(400).send({ error: 'Feature-Voting ist nicht aktiviert' });
|
try {
|
||||||
const channel = await client.channels.fetch(channelId).catch(() => null);
|
const { id } = await wunschAnlegen(client, {
|
||||||
if (!channel?.isTextBased()) return reply.code(500).send({ error: 'Voting-Kanal nicht gefunden' });
|
idee: idea,
|
||||||
|
autor: user.username,
|
||||||
const embed = new EmbedBuilder()
|
autorId: user.id,
|
||||||
.setColor(brandColor())
|
avatar: user.avatar ?? null,
|
||||||
.setAuthor({ name: user.username, iconURL: user.avatar ?? undefined })
|
categoryId,
|
||||||
.setTitle('💡 Feature-Wunsch')
|
|
||||||
.setDescription(idea)
|
|
||||||
.setURL(`${publicUrl()}/roadmap`)
|
|
||||||
.setFooter({
|
|
||||||
text: `${brandFooter('VOTING')} • 👍 = will ich!`,
|
|
||||||
iconURL: client.user?.displayAvatarURL?.({ size: 64 }),
|
|
||||||
});
|
});
|
||||||
const message = await channel.send({ embeds: [embed] });
|
return { ok: true, id };
|
||||||
await message.react('👍').catch(() => {});
|
} catch (error) {
|
||||||
const id = saveWish(message.id, user.username, idea, user.id);
|
return reply.code(400).send({ error: error.message });
|
||||||
return { ok: true, id };
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Eine Stimme je Person — ob hier geklickt oder als 👍 in Discord, ist
|
// Eine Stimme je Person — ob hier geklickt oder als 👍 in Discord, ist
|
||||||
@@ -1969,6 +1977,52 @@ ${rssItems}
|
|||||||
return { treffer: q.length >= 3 ? searchWishes(q) : [] };
|
return { treffer: q.length >= 3 ? searchWishes(q) : [] };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bereiche, in die ein Wunsch faellt. Ohne Eintrag bleibt die Liste eine
|
||||||
|
// Liste — Schubladen lohnen sich erst ab einer gewissen Zahl.
|
||||||
|
app.get('/api/wish-categories', async (request, reply) => {
|
||||||
|
if (requireAnyScope(request, reply)) return;
|
||||||
|
return { bereiche: listWishCategories() };
|
||||||
|
});
|
||||||
|
|
||||||
|
function bereichBody(request, reply) {
|
||||||
|
const name = String(request.body?.name ?? '').trim();
|
||||||
|
if (!name) {
|
||||||
|
reply.code(400).send({ error: 'Name fehlt.' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: name.slice(0, 40),
|
||||||
|
emoji: String(request.body?.emoji ?? '').trim().slice(0, 32),
|
||||||
|
sort: Number(request.body?.sort) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/api/wish-categories', async (request, reply) => {
|
||||||
|
if (requireScope(request, reply, 'community')) return;
|
||||||
|
const b = bereichBody(request, reply);
|
||||||
|
if (!b) return;
|
||||||
|
createWishCategory(b);
|
||||||
|
logAudit(getSessionUser(request), 'wunsch-bereich angelegt', b.name);
|
||||||
|
return { ok: true, bereiche: listWishCategories() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/wish-categories/:id', async (request, reply) => {
|
||||||
|
if (requireScope(request, reply, 'community')) return;
|
||||||
|
const b = bereichBody(request, reply);
|
||||||
|
if (!b) return;
|
||||||
|
updateWishCategory({ ...b, id: Number(request.params.id) });
|
||||||
|
logAudit(getSessionUser(request), 'wunsch-bereich geaendert', b.name);
|
||||||
|
return { ok: true, bereiche: listWishCategories() };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wuensche im geloeschten Bereich bleiben stehen, nur ohne Schild
|
||||||
|
app.delete('/api/wish-categories/:id', async (request, reply) => {
|
||||||
|
if (requireScope(request, reply, 'community')) return;
|
||||||
|
const weg = deleteWishCategory(Number(request.params.id));
|
||||||
|
logAudit(getSessionUser(request), 'wunsch-bereich entfernt', String(request.params.id));
|
||||||
|
return { deleted: weg, bereiche: listWishCategories(), wishes: topWishes(50) };
|
||||||
|
});
|
||||||
|
|
||||||
// --- Wünsche verwalten (Team) ---
|
// --- Wünsche verwalten (Team) ---
|
||||||
|
|
||||||
const STATUS = {
|
const STATUS = {
|
||||||
@@ -2034,6 +2088,14 @@ ${rssItems}
|
|||||||
return { ok: true, benachrichtigt, wishes: topWishes(50) };
|
return { ok: true, benachrichtigt, wishes: topWishes(50) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.put('/api/wishes/:id/category', async (request, reply) => {
|
||||||
|
if (requireScope(request, reply, 'community')) return;
|
||||||
|
const id = Number(request.params.id);
|
||||||
|
if (!getWish(id)) return reply.code(404).send({ error: 'Wunsch nicht gefunden.' });
|
||||||
|
setWishCategory(id, Number(request.body?.category_id) || null);
|
||||||
|
return { ok: true, wishes: topWishes(50) };
|
||||||
|
});
|
||||||
|
|
||||||
// Doppler zusammenführen: Stimmen wandern zum Original, wer für beide
|
// Doppler zusammenführen: Stimmen wandern zum Original, wer für beide
|
||||||
// gestimmt hat, zählt dort weiterhin einmal
|
// gestimmt hat, zählt dort weiterhin einmal
|
||||||
app.post('/api/wishes/:id/merge', async (request, reply) => {
|
app.post('/api/wishes/:id/merge', async (request, reply) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user