diff --git a/README.md b/README.md index 33296eb..3bc2f66 100644 --- a/README.md +++ b/README.md @@ -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` öffnet ein Eingabefenster (Idee + „warum wäre das gut?") → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap | +| 💡 **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 | | 🗳️ **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 | @@ -453,6 +453,7 @@ urllib.request.urlopen(req) | `GET /brand.css` · `/brand-nav.js` | — | Design-Tokens + Navigation für andere Apps (CORS offen) | | `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 | +| `PUT /api/wishes/:id/status` · `POST /api/wishes/:id/merge` | Team (community) | Stand setzen (+ DM an Stimmen-Geber) · Doppler zusammenführen | | `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) | | `POST/GET /api/v1/*` | API-Key (Bearer) | Externe Skripte — siehe „API v1" oben | diff --git a/frontend/src/locales/de.js b/frontend/src/locales/de.js index 78611cb..03b60fa 100644 --- a/frontend/src/locales/de.js +++ b/frontend/src/locales/de.js @@ -120,6 +120,14 @@ export default { 'roadmap.activity': '// Aktivität — %s Commits in 12 Monaten', 'roadmap.commitsOn': '%s: %s Commits', 'roadmap.commitOn': '%s: 1 Commit', + 'wish.alle': 'Alle', + 'wish.offen': 'Wird geprüft', + 'wish.geplant': 'Geplant', + 'wish.inArbeit': 'In Arbeit', + 'wish.umgesetzt': 'Umgesetzt', + 'wish.abgelehnt': 'Nicht geplant', + 'roadmap.noneInFilter': 'Hier steht gerade nichts.', + 'roadmap.alreadyThere': 'Gibt es schon — stimm lieber dafür:', 'roadmap.wishes': '// Community-Wünsche', 'roadmap.unvote': 'Vote zurücknehmen', 'roadmap.vote': 'Dafür stimmen', diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js index 702b5dd..c34fb58 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -119,6 +119,14 @@ export default { 'roadmap.activity': '// Activity — %s commits in 12 months', 'roadmap.commitsOn': '%s: %s commits', 'roadmap.commitOn': '%s: 1 commit', + 'wish.alle': 'All', + 'wish.offen': 'Under review', + 'wish.geplant': 'Planned', + 'wish.inArbeit': 'In progress', + 'wish.umgesetzt': 'Shipped', + 'wish.abgelehnt': 'Not planned', + 'roadmap.noneInFilter': 'Nothing here right now.', + 'roadmap.alreadyThere': 'Already suggested — vote for it instead:', 'roadmap.wishes': '// Community wishes', 'roadmap.unvote': 'Take back your vote', 'roadmap.vote': 'Vote for this', diff --git a/frontend/src/pages/Roadmap.jsx b/frontend/src/pages/Roadmap.jsx index 1918924..7073856 100644 --- a/frontend/src/pages/Roadmap.jsx +++ b/frontend/src/pages/Roadmap.jsx @@ -19,6 +19,23 @@ const GROUPS = [ { id: 'done', tag: 'roadmap.done', empty: null }, ]; +// Stand eines Wunsches. „offen" braucht kein Schild — das ist der Normalfall. +const WISH_STATUS = { + offen: 'wish.offen', + geplant: 'wish.geplant', + in_arbeit: 'wish.inArbeit', + umgesetzt: 'wish.umgesetzt', + abgelehnt: 'wish.abgelehnt', +}; +const WISH_FILTER = [ + { id: 'alle', key: 'wish.alle' }, + { id: 'offen', key: 'wish.offen' }, + { id: 'geplant', key: 'wish.geplant' }, + { id: 'in_arbeit', key: 'wish.inArbeit' }, + { id: 'umgesetzt', key: 'wish.umgesetzt' }, + { id: 'abgelehnt', key: 'wish.abgelehnt' }, +]; + function Milestone({ m }) { const { t } = useT(); const { datum } = useFmt(); @@ -95,6 +112,8 @@ export default function Roadmap() { const [submitMsg, setSubmitMsg] = useState(''); const [heatmap, setHeatmap] = useState(null); const [error, setError] = useState(false); + const [filter, setFilter] = useState('alle'); + const [aehnliche, setAehnliche] = useState([]); useEffect(() => { apiGet('/api/roadmap').then(setData).catch(() => setError(true)); @@ -103,12 +122,30 @@ export default function Roadmap() { window.scrollTo(0, 0); }, []); - async function vote(messageId) { + // Während des Tippens nachschlagen, ob es das schon gibt. Kurz warten, + // damit nicht jeder Tastendruck eine Anfrage auslöst. + useEffect(() => { + const text = idea.trim(); + if (text.length < 3) { setAehnliche([]); return undefined; } + const timer = setTimeout(() => { + apiGet(`/api/wishes/suche?q=${encodeURIComponent(text)}`) + .then((d) => setAehnliche(d.treffer ?? [])) + .catch(() => setAehnliche([])); + }, 350); + return () => clearTimeout(timer); + }, [idea]); + + const sichtbareWuensche = filter === 'alle' + ? wishes + : wishes.filter((w) => (w.status ?? 'offen') === filter); + + async function vote(id) { try { - const res = await apiPost(`/api/wishes/${messageId}/vote`); - setWishes((old) => old.map((w) => w.message_id !== messageId + const res = await apiPost(`/api/wishes/${id}/vote`); + setWishes((old) => old.map((w) => w.id !== id ? w : { ...w, voted: res.voted, score: w.score + (res.voted ? 1 : -1) })); + setAehnliche((old) => old.map((w) => (w.id === id ? { ...w, score: w.score + 1 } : w))); } catch (e) { if (e.status === 403) setSubmitMsg(t('roadmap.voteMembersOnly')); } @@ -151,32 +188,89 @@ export default function Roadmap() { {(wishes.length > 0 || canVote) && (

{t('roadmap.wishes')}

- {wishes.map((w, i) => ( -
- {canVote && w.message_id ? ( + + {/* Ein Wunsch ohne sichtbaren Ausgang ist ein Wunsch, den + niemand mehr einreicht — deshalb steht der Stand vorne + dran und lässt sich filtern. */} +
+ {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 ( - ) : ( - {w.score} - )} - {w.idea} - {w.author} -
- ))} + ); + })} +
+ + {sichtbareWuensche.length === 0 && ( +

{t('roadmap.noneInFilter')}

+ )} + + {sichtbareWuensche.map((w, i) => { + const status = w.status ?? 'offen'; + return ( +
+ {canVote && w.id ? ( + + ) : ( + {w.score} + )} + + {w.idea} + {w.status_grund && ( + {w.status_grund} + )} + + {status !== 'offen' && ( + {t(WISH_STATUS[status])} + )} + {w.author} +
+ ); + })} + {canVote ? ( -
- setIdea(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') submitWish(); }} - /> - +
+
+ setIdea(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') submitWish(); }} + /> + +
+ {/* Beim Tippen zeigen, was es schon gibt — ein Klick + auf einen Treffer ist besser als ein Doppler */} + {aehnliche.length > 0 && ( +
+ {t('roadmap.alreadyThere')} + {aehnliche.map((w) => ( + + ))} +
+ )}
) : (

diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index e58c6b2..9309f22 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -76,6 +76,16 @@ const TABS = [ find: 'mitarbeiter rechte bereiche protokoll audit zugriff' }, ]; +// Stand eines Wunsches. Reihenfolge = Weg durchs Verfahren, damit die Knöpfe +// in der Ansicht so nebeneinander liegen, wie man sie durchläuft. +const WUNSCH_STATUS = [ + ['offen', 'Wird geprüft'], + ['geplant', 'Geplant'], + ['in_arbeit', 'In Arbeit'], + ['umgesetzt', 'Umgesetzt'], + ['abgelehnt', 'Nicht geplant'], +]; + // Tabs, die nur aus Formular-Blöcken bestehen — die dürfen zweispaltig laufen. // Alles mit Editor, Vorschau oder Liste bleibt einspaltig. const DENSE_TABS = new Set(['feeds', 'community', 'support', 'system']); @@ -351,6 +361,10 @@ export default function Settings({ me }) { const [watchdog, setWatchdog] = useState(null); const [automod, setAutomod] = useState(null); const [anliegen, setAnliegen] = useState([]); + const [wuensche, setWuensche] = useState([]); + const [statusDialog, setStatusDialog] = useState(null); + const [statusGrund, setStatusGrund] = useState(''); + const [mergeVon, setMergeVon] = useState(null); 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 [owner, setOwner] = useState(null); @@ -481,6 +495,7 @@ export default function Settings({ me }) { apiGet('/api/watchdog').then(setWatchdog).catch(() => {}); apiGet('/api/automod').then(setAutomod).catch(() => {}); apiGet('/api/ticket-categories').then((d) => setAnliegen(d.kategorien ?? [])).catch(() => {}); + apiGet('/api/wishes').then((d) => setWuensche(d.wishes ?? [])).catch(() => {}); apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {}); apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {}); apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).catch(() => {}); @@ -1754,6 +1769,102 @@ export default function Settings({ me }) { const tabCommunity = ( <> +

+

// Wünsche

+

+ Ein Wunsch ohne sichtbaren Ausgang ist ein Wunsch, den beim nächsten Mal + niemand mehr einreicht. Der Stand steht auf der Roadmap und im Discord-Post; + beim Sprung auf umgesetzt bekommt jeder eine Nachricht, der + dafür gestimmt hat. +

+ + {wuensche.length === 0 ? ( +

Noch keine Wünsche eingegangen.

+ ) : ( +
+ {wuensche.map((w) => ( +
+
+ 👍 {w.score} + {w.idea} + {w.author} +
+
+ {WUNSCH_STATUS.map(([id, label]) => ( + + ))} + + +
+ {w.status_grund &&

{w.status_grund}

} +
+ ))} +
+ )} +
+ + {statusDialog && ( + setStatusDialog(null)} + footer={ + <> + + + + } + > +

„{statusDialog.wunsch.idea}"

+
+ +