Jede Idee bekommt ihre eigene Seite mit der Unterhaltung aus dem Thread
Vorbild windrose.support: Klick auf eine Idee, und man sieht sie ganz — Stand, Begruendung, wer sie eingereicht hat, und darunter, was dazu geschrieben wurde. Der Unterschied bleibt, wo geschrieben wird. Die Beitraege sind aus dem Discord-Thread gespiegelt, nicht hier getippt. Deshalb fuehrt der einzige Knopf am Ende in den Thread und nicht in ein Formular. Ein zweiter Ort fuer dieselbe Unterhaltung waere genau das, was ich beim Konzept als das Falsche bezeichnet habe. Bisher wurde nur mitgezaehlt. Jetzt landen die Beitraege in einer eigenen Tabelle — mit Bearbeiten und Loeschen, sonst stuende auf der Webseite fuer immer, was in Discord laengst zurueckgenommen wurde. Reine Bild-Posts ohne Text bleiben draussen, die haetten hier nichts zu sagen. Der Kommentar-Zaehler wird jetzt aus der Tabelle gezaehlt statt in einer Spalte mitgefuehrt. Eine Wahrheit statt zwei, die auseinanderlaufen koennen — dieselbe Ueberlegung wie beim Zuspruch. Nebenbei geprueft, weil es haette schiefgehen koennen: /api/wishes/suche und /api/wishes/:id liegen auf derselben Ebene. Der Router bevorzugt die feste Route vor der mit Platzhalter — nachgestellt mit find-my-way, nicht aus dem Gedaechtnis behauptet. Geprueft: im Browser die Seite mit drei Beitraegen und die ohne (kein Thread, keine Begruendung, Hinweis statt Liste), der Weg von der Roadmap dorthin ohne Neuladen, Migration erneut durchgespielt. 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-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 |
|
| 💡 **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. Ein Klick öffnet die **eigene Seite der Idee** mit der Unterhaltung aus dem Discord-Thread — gespiegelt, geschrieben wird weiter dort. 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 |
|
| 🗳️ **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 |
|
||||||
@@ -144,6 +144,7 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen.
|
|||||||
| `/events` | öffentlich | Discord-Events (Playtests, Streams …) |
|
| `/events` | öffentlich | Discord-Events (Playtests, Streams …) |
|
||||||
| `/changelog` | öffentlich | Alle Releases mit Notes, Tag- und Pre-Release-Chips |
|
| `/changelog` | öffentlich | Alle Releases mit Notes, Tag- und Pre-Release-Chips |
|
||||||
| `/feed.xml` | öffentlich | RSS-Feed der Devlogs |
|
| `/feed.xml` | öffentlich | RSS-Feed der Devlogs |
|
||||||
|
| `/wunsch/:id` | öffentlich | Eine Idee mit Stand, Begründung und der Unterhaltung aus dem Thread |
|
||||||
| `/<kürzel>` | öffentlich | Frei angelegte Seiten aus dem Seiten-Editor |
|
| `/<kürzel>` | öffentlich | Frei angelegte Seiten aus dem Seiten-Editor |
|
||||||
| `/impressum` · `/datenschutz` | öffentlich | Rechtstexte — Angaben aus der Config (Owner-only) |
|
| `/impressum` · `/datenschutz` | öffentlich | Rechtstexte — Angaben aus der Config (Owner-only) |
|
||||||
| `/profil` | Member | Eigenes Profil: Rang, XP, Rollen (togglebar), Alpha-Key, DSGVO-Löschung |
|
| `/profil` | Member | Eigenes Profil: Rang, XP, Rollen (togglebar), Alpha-Key, DSGVO-Löschung |
|
||||||
@@ -454,6 +455,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 /api/wishes/:id` · `/api/wishes/suche?q=` | — | Eine Idee mit Beiträgen aus dem Thread · Suche vor dem Einreichen |
|
||||||
| `GET/POST/PUT/DELETE /api/wish-categories` | Team (community) | Bereiche für Wünsche pflegen |
|
| `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) |
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import DevlogDetail from './pages/DevlogDetail.jsx';
|
|||||||
import Impressum from './pages/Impressum.jsx';
|
import Impressum from './pages/Impressum.jsx';
|
||||||
import Datenschutz from './pages/Datenschutz.jsx';
|
import Datenschutz from './pages/Datenschutz.jsx';
|
||||||
import Seite from './pages/Seite.jsx';
|
import Seite from './pages/Seite.jsx';
|
||||||
|
import WunschDetail from './pages/WunschDetail.jsx';
|
||||||
import { IconChevronDown, IconMenu, IconX, IconLock } from './icons.jsx';
|
import { IconChevronDown, IconMenu, IconX, IconLock } from './icons.jsx';
|
||||||
import { useT, LangSwitch } from './i18n.jsx';
|
import { useT, LangSwitch } from './i18n.jsx';
|
||||||
|
|
||||||
@@ -216,6 +217,7 @@ export default function App() {
|
|||||||
<Route path="/devlogs" element={<Devlogs me={me} />} />
|
<Route path="/devlogs" element={<Devlogs me={me} />} />
|
||||||
<Route path="/devlogs/:id" element={<DevlogDetail />} />
|
<Route path="/devlogs/:id" element={<DevlogDetail />} />
|
||||||
<Route path="/roadmap" element={<Roadmap />} />
|
<Route path="/roadmap" element={<Roadmap />} />
|
||||||
|
<Route path="/wunsch/:id" element={<WunschDetail />} />
|
||||||
<Route path="/server" element={<Server />} />
|
<Route path="/server" element={<Server />} />
|
||||||
<Route path="/status" element={<Status />} />
|
<Route path="/status" element={<Status />} />
|
||||||
<Route path="/galerie" element={<Galerie />} />
|
<Route path="/galerie" element={<Galerie />} />
|
||||||
|
|||||||
@@ -127,6 +127,15 @@ export default {
|
|||||||
'roadmap.status': 'Stand',
|
'roadmap.status': 'Stand',
|
||||||
'roadmap.area': 'Bereich',
|
'roadmap.area': 'Bereich',
|
||||||
'roadmap.areaPick': '— Bereich —',
|
'roadmap.areaPick': '— Bereich —',
|
||||||
|
'wunsch.tag': '// Idee',
|
||||||
|
'wunsch.back': 'Zurück zur Roadmap',
|
||||||
|
'wunsch.notFound': 'Diese Idee gibt es nicht (mehr).',
|
||||||
|
'wunsch.by': 'von %s',
|
||||||
|
'wunsch.teamSays': 'Vom Team',
|
||||||
|
'wunsch.comments': '// %s Beiträge',
|
||||||
|
'wunsch.oneComment': '// 1 Beitrag',
|
||||||
|
'wunsch.noComments': 'Noch nichts dazu geschrieben — mach den Anfang im Thread.',
|
||||||
|
'wunsch.joinThread': 'Im Discord mitreden',
|
||||||
'wish.alle': 'Alle',
|
'wish.alle': 'Alle',
|
||||||
'wish.offen': 'Wird geprüft',
|
'wish.offen': 'Wird geprüft',
|
||||||
'wish.geplant': 'Geplant',
|
'wish.geplant': 'Geplant',
|
||||||
|
|||||||
@@ -126,6 +126,15 @@ export default {
|
|||||||
'roadmap.status': 'Status',
|
'roadmap.status': 'Status',
|
||||||
'roadmap.area': 'Area',
|
'roadmap.area': 'Area',
|
||||||
'roadmap.areaPick': '— Area —',
|
'roadmap.areaPick': '— Area —',
|
||||||
|
'wunsch.tag': '// Idea',
|
||||||
|
'wunsch.back': 'Back to the roadmap',
|
||||||
|
'wunsch.notFound': 'This idea does not exist (any more).',
|
||||||
|
'wunsch.by': 'by %s',
|
||||||
|
'wunsch.teamSays': 'From the team',
|
||||||
|
'wunsch.comments': '// %s comments',
|
||||||
|
'wunsch.oneComment': '// 1 comment',
|
||||||
|
'wunsch.noComments': 'Nothing said about this yet — start it off in the thread.',
|
||||||
|
'wunsch.joinThread': 'Join the thread on Discord',
|
||||||
'wish.alle': 'All',
|
'wish.alle': 'All',
|
||||||
'wish.offen': 'Under review',
|
'wish.offen': 'Under review',
|
||||||
'wish.geplant': 'Planned',
|
'wish.geplant': 'Planned',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
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';
|
||||||
@@ -289,7 +290,7 @@ 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">
|
<Link className="wish-idea" to={`/wunsch/${w.id}`}>
|
||||||
<span className="wish-schilder">
|
<span className="wish-schilder">
|
||||||
{status !== 'offen' && (
|
{status !== 'offen' && (
|
||||||
<span className={`wish-status ${status}`}>{t(WISH_STATUS[status])}</span>
|
<span className={`wish-status ${status}`}>{t(WISH_STATUS[status])}</span>
|
||||||
@@ -304,7 +305,7 @@ export default function Roadmap() {
|
|||||||
{w.status_grund && (
|
{w.status_grund && (
|
||||||
<span className="wish-grund">{w.status_grund}</span>
|
<span className="wish-grund">{w.status_grund}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</Link>
|
||||||
{/* Geredet wird im Thread unter dem Post — der
|
{/* Geredet wird im Thread unter dem Post — der
|
||||||
Zähler führt direkt dorthin */}
|
Zähler führt direkt dorthin */}
|
||||||
{w.thread_url && (
|
{w.thread_url && (
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// Ein Wunsch für sich: Idee, Stand, Begründung — und darunter, was im
|
||||||
|
// Discord-Thread dazu geschrieben wurde.
|
||||||
|
//
|
||||||
|
// Die Beiträge sind gespiegelt, nicht abgetippt: geschrieben wird weiter in
|
||||||
|
// Discord, hier steht nur mit, was dort steht. Deshalb führt jeder Weg zum
|
||||||
|
// Mitreden in den Thread und nicht in ein Formular.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { apiGet, apiPost } from '../api.js';
|
||||||
|
import { SkeletonCards } from '../components/Skeleton.jsx';
|
||||||
|
import { IconThumbsUp, IconMessage, IconArrowLeft } from '../icons.jsx';
|
||||||
|
import { useT, useFmt } from '../i18n.jsx';
|
||||||
|
|
||||||
|
const WISH_STATUS = {
|
||||||
|
offen: 'wish.offen',
|
||||||
|
geplant: 'wish.geplant',
|
||||||
|
in_arbeit: 'wish.inArbeit',
|
||||||
|
umgesetzt: 'wish.umgesetzt',
|
||||||
|
abgelehnt: 'wish.abgelehnt',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WunschDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { t } = useT();
|
||||||
|
const { datum } = useFmt();
|
||||||
|
const [daten, setDaten] = useState(null);
|
||||||
|
const [fehler, setFehler] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDaten(null);
|
||||||
|
setFehler(false);
|
||||||
|
apiGet(`/api/wishes/${id}`).then(setDaten).catch(() => setFehler(true));
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
async function vote() {
|
||||||
|
try {
|
||||||
|
const res = await apiPost(`/api/wishes/${id}/vote`);
|
||||||
|
setDaten((d) => ({
|
||||||
|
...d,
|
||||||
|
wunsch: {
|
||||||
|
...d.wunsch,
|
||||||
|
voted: res.voted,
|
||||||
|
score: d.wunsch.score + (res.voted ? 1 : -1),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch { /* nicht angemeldet oder kein Member — der Knopf bleibt einfach */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = daten?.wunsch;
|
||||||
|
const status = w?.status ?? 'offen';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="page-header">
|
||||||
|
<div className="page-bg-text">IDEE</div>
|
||||||
|
<div className="page-header-grid" aria-hidden="true" />
|
||||||
|
<div className="page-header-content">
|
||||||
|
<div className="page-tag">{t('wunsch.tag')}</div>
|
||||||
|
<h1 className="page-title">{w?.idea ?? '…'}</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="content">
|
||||||
|
<Link className="btn btn-ghost wd-zurueck" to="/roadmap">
|
||||||
|
<IconArrowLeft size={14} /> {t('wunsch.back')}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{fehler && <p className="notice">{t('wunsch.notFound')}</p>}
|
||||||
|
{!fehler && !daten && <SkeletonCards n={2} />}
|
||||||
|
|
||||||
|
{w && (
|
||||||
|
<>
|
||||||
|
<div className={`card wd-kopf st-${status}`}>
|
||||||
|
<div className="wd-zeile">
|
||||||
|
{daten.canVote ? (
|
||||||
|
<button
|
||||||
|
className={`wish-vote with-icon${w.voted ? ' voted' : ''}`}
|
||||||
|
onClick={vote}
|
||||||
|
title={w.voted ? t('roadmap.unvote') : t('roadmap.vote')}
|
||||||
|
>
|
||||||
|
<IconThumbsUp size={15} /> {w.score}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="wish-score with-icon"><IconThumbsUp size={15} /> {w.score}</span>
|
||||||
|
)}
|
||||||
|
<div className="wd-schilder">
|
||||||
|
<span className={`wish-status ${status}`}>{t(WISH_STATUS[status])}</span>
|
||||||
|
{w.kategorie && (
|
||||||
|
<span className="wish-area">{w.kategorie_emoji} {w.kategorie}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="wd-idee">{w.idea}</p>
|
||||||
|
<div className="wd-meta">
|
||||||
|
<span>{t('wunsch.by', w.author ?? '—')}</span>
|
||||||
|
<span>{datum(w.created_at, { day: '2-digit', month: '2-digit', year: 'numeric' })}</span>
|
||||||
|
</div>
|
||||||
|
{w.status_grund && (
|
||||||
|
<div className="wd-antwort">
|
||||||
|
<span className="wd-antwort-kopf">{t('wunsch.teamSays')}</span>
|
||||||
|
{w.status_grund}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="roadmap-group">
|
||||||
|
<h2 className="settings-title">
|
||||||
|
{daten.kommentare.length === 1
|
||||||
|
? t('wunsch.oneComment')
|
||||||
|
: t('wunsch.comments', daten.kommentare.length)}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{daten.kommentare.length === 0 && (
|
||||||
|
<p className="notice">{t('wunsch.noComments')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{daten.kommentare.map((k) => (
|
||||||
|
<div className="wd-komm" key={k.message_id}>
|
||||||
|
{k.avatar
|
||||||
|
? <img className="wd-avatar" src={k.avatar} alt="" loading="lazy" />
|
||||||
|
: <span className="wd-avatar leer" />}
|
||||||
|
<div className="wd-komm-text">
|
||||||
|
<div className="wd-komm-kopf">
|
||||||
|
<span className="wd-komm-name">{k.author}</span>
|
||||||
|
<span className="wd-komm-zeit">
|
||||||
|
{datum(k.created_at, {
|
||||||
|
day: '2-digit', month: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="wd-komm-inhalt">{k.content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Geschrieben wird in Discord — hier steht nur mit, was dort steht */}
|
||||||
|
{w.thread_url && (
|
||||||
|
<a className="btn wd-mitreden" href={w.thread_url} target="_blank" rel="noreferrer">
|
||||||
|
<IconMessage size={14} /> {t('wunsch.joinThread')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2014,6 +2014,50 @@ label.toggle {
|
|||||||
.ep-zelle.aktiv { border-color: var(--neon); background: color-mix(in srgb, var(--neon) 12%, transparent); }
|
.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; }
|
.ep-nix { color: var(--muted); font-size: .84rem; margin: .6rem 0; }
|
||||||
|
|
||||||
|
/* ── EINE IDEE FÜR SICH ────────────────────────────── */
|
||||||
|
.wd-zurueck { margin-bottom: 1.2rem; display: inline-flex; align-items: center; gap: .4rem; }
|
||||||
|
.wd-kopf { padding: 1.3rem 1.5rem; margin-bottom: 2rem; }
|
||||||
|
.wd-zeile { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: .9rem; }
|
||||||
|
.wd-schilder { display: flex; gap: .35rem; flex-wrap: wrap; }
|
||||||
|
.wd-idee { margin: 0 0 .8rem; font-size: 1.05rem; font-weight: 300; line-height: 1.6; }
|
||||||
|
.wd-meta {
|
||||||
|
display: flex; gap: 1rem; flex-wrap: wrap;
|
||||||
|
font-family: var(--mono); font-size: .64rem; color: var(--muted2);
|
||||||
|
}
|
||||||
|
.wd-antwort {
|
||||||
|
margin-top: 1.1rem; padding: .8rem 1rem;
|
||||||
|
background: color-mix(in srgb, var(--neon) 6%, transparent);
|
||||||
|
border-left: 2px solid var(--neon);
|
||||||
|
font-size: .92rem; color: var(--text);
|
||||||
|
}
|
||||||
|
.wd-antwort-kopf {
|
||||||
|
display: block; margin-bottom: .3rem;
|
||||||
|
font-family: var(--mono); font-size: .58rem; letter-spacing: .12em;
|
||||||
|
text-transform: uppercase; color: var(--neon);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Beiträge aus dem Thread — gespiegelt, nicht geschrieben */
|
||||||
|
.wd-komm {
|
||||||
|
display: flex; gap: .8rem; padding: .8rem 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, .05);
|
||||||
|
}
|
||||||
|
.wd-komm:last-of-type { border-bottom: none; }
|
||||||
|
.wd-avatar { width: 34px; height: 34px; border-radius: 50%; flex: 0 0 auto; }
|
||||||
|
.wd-avatar.leer { background: var(--bg3); }
|
||||||
|
.wd-komm-text { min-width: 0; flex: 1; }
|
||||||
|
.wd-komm-kopf { display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; }
|
||||||
|
.wd-komm-name { font-weight: 600; font-size: .88rem; }
|
||||||
|
.wd-komm-zeit { font-family: var(--mono); font-size: .6rem; color: var(--muted2); }
|
||||||
|
.wd-komm-inhalt {
|
||||||
|
margin: .25rem 0 0; font-weight: 300; line-height: 1.55;
|
||||||
|
white-space: pre-wrap; overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.wd-mitreden { margin-top: 1.2rem; display: inline-flex; align-items: center; gap: .45rem; }
|
||||||
|
|
||||||
|
/* Die Idee in der Liste führt jetzt zur eigenen Seite */
|
||||||
|
a.wish-idea { color: inherit; text-decoration: none; }
|
||||||
|
a.wish-idea:hover { color: var(--neon); }
|
||||||
|
|
||||||
/* ── DIALOG ────────────────────────────────────────── */
|
/* ── DIALOG ────────────────────────────────────────── */
|
||||||
.dlg-backdrop {
|
.dlg-backdrop {
|
||||||
position: fixed; inset: 0; z-index: 1200;
|
position: fixed; inset: 0; z-index: 1200;
|
||||||
|
|||||||
+42
-4
@@ -14,7 +14,8 @@ import {
|
|||||||
ModalBuilder, MessageFlags, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle,
|
ModalBuilder, MessageFlags, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import {
|
import {
|
||||||
saveWish, setWishThread, bumpWishKommentare, getWishCategory, listWishCategories,
|
saveWish, setWishThread, wishByThread, getWishCategory, listWishCategories,
|
||||||
|
saveWishComment, updateWishComment, deleteWishComment,
|
||||||
} from '../db.js';
|
} from '../db.js';
|
||||||
import { votingChannelId, publicUrl, brandColor, brandFooter } from '../runtime-settings.js';
|
import { votingChannelId, publicUrl, brandColor, brandFooter } from '../runtime-settings.js';
|
||||||
import { renderTemplate } from '../templates.js';
|
import { renderTemplate } from '../templates.js';
|
||||||
@@ -223,14 +224,51 @@ export async function handleWunschModal(interaction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Beiträge in Wunsch-Threads mitzählen — das ist der Kommentar-Zähler */
|
/**
|
||||||
|
* Beiträge in Wunsch-Threads mitschreiben.
|
||||||
|
*
|
||||||
|
* Gespiegelt statt gezählt, damit die Detailseite die Unterhaltung zeigen
|
||||||
|
* kann. Bearbeiten und Löschen ziehen mit — sonst stünde auf der Webseite für
|
||||||
|
* immer, was in Discord längst zurückgenommen wurde.
|
||||||
|
*/
|
||||||
export function registerWishThreads(client) {
|
export function registerWishThreads(client) {
|
||||||
client.on(Events.MessageCreate, (message) => {
|
client.on(Events.MessageCreate, (message) => {
|
||||||
try {
|
try {
|
||||||
if (message.author?.bot || !message.channel?.isThread?.()) return;
|
if (message.author?.bot || !message.channel?.isThread?.()) return;
|
||||||
bumpWishKommentare(message.channelId);
|
const wunsch = wishByThread(message.channelId);
|
||||||
|
if (!wunsch) return;
|
||||||
|
const text = message.content?.trim();
|
||||||
|
// Reine Bild-Posts haben keinen Text — die zeigt die Seite nicht
|
||||||
|
if (!text) return;
|
||||||
|
saveWishComment({
|
||||||
|
message_id: message.id,
|
||||||
|
wish_id: wunsch.id,
|
||||||
|
author: message.member?.displayName ?? message.author.username,
|
||||||
|
author_id: message.author.id,
|
||||||
|
avatar: message.author.displayAvatarURL?.({ size: 64 }) ?? null,
|
||||||
|
content: text.slice(0, 2000),
|
||||||
|
created_at: new Date(message.createdTimestamp).toISOString().slice(0, 19).replace('T', ' '),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[voting] Kommentar-Zähler:', error);
|
console.error('[voting] Kommentar nicht gespeichert:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on(Events.MessageUpdate, (alt, neu) => {
|
||||||
|
try {
|
||||||
|
if (!neu?.channel?.isThread?.()) return;
|
||||||
|
const text = neu.content?.trim();
|
||||||
|
if (text) updateWishComment(neu.id, text.slice(0, 2000));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[voting] Kommentar nicht geändert:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on(Events.MessageDelete, (message) => {
|
||||||
|
try {
|
||||||
|
deleteWishComment(message.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[voting] Kommentar nicht entfernt:', error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,6 +315,37 @@ db.exec(`
|
|||||||
db.exec('ALTER TABLE wishes ADD COLUMN kommentare INTEGER NOT NULL DEFAULT 0');
|
db.exec('ALTER TABLE wishes ADD COLUMN kommentare INTEGER NOT NULL DEFAULT 0');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Was im Thread unter einem Wunsch geschrieben wird. Gespiegelt, nicht
|
||||||
|
// gezaehlt: die Detailseite soll die Unterhaltung zeigen koennen, und 50
|
||||||
|
// Threads je Seitenaufruf bei Discord abzufragen waere nicht zu bezahlen.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS wish_comments (
|
||||||
|
message_id TEXT PRIMARY KEY,
|
||||||
|
wish_id INTEGER NOT NULL,
|
||||||
|
author TEXT,
|
||||||
|
author_id TEXT,
|
||||||
|
avatar TEXT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wish_comments ON wish_comments (wish_id, created_at);
|
||||||
|
`);
|
||||||
|
const insertCommentStmt = db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO wish_comments
|
||||||
|
(message_id, wish_id, author, author_id, avatar, content, created_at)
|
||||||
|
VALUES (@message_id, @wish_id, @author, @author_id, @avatar, @content, @created_at)
|
||||||
|
`);
|
||||||
|
const updateCommentStmt = db.prepare('UPDATE wish_comments SET content = ? WHERE message_id = ?');
|
||||||
|
const deleteCommentStmt = db.prepare('DELETE FROM wish_comments WHERE message_id = ?');
|
||||||
|
const listCommentsStmt = db.prepare(
|
||||||
|
'SELECT * FROM wish_comments WHERE wish_id = ? ORDER BY created_at LIMIT ?'
|
||||||
|
);
|
||||||
|
const dropCommentsStmt = db.prepare('DELETE FROM wish_comments WHERE wish_id = ?');
|
||||||
|
export const saveWishComment = (c) => insertCommentStmt.run(c);
|
||||||
|
export const updateWishComment = (messageId, text) => updateCommentStmt.run(text, messageId).changes > 0;
|
||||||
|
export const deleteWishComment = (messageId) => deleteCommentStmt.run(messageId).changes > 0;
|
||||||
|
export const wishComments = (wishId, limit = 200) => listCommentsStmt.all(wishId, limit);
|
||||||
|
|
||||||
const listWishCatsStmt = db.prepare('SELECT * FROM wish_categories ORDER BY sort, id');
|
const listWishCatsStmt = db.prepare('SELECT * FROM wish_categories ORDER BY sort, id');
|
||||||
const getWishCatStmt = db.prepare('SELECT * FROM wish_categories WHERE id = ?');
|
const getWishCatStmt = db.prepare('SELECT * FROM wish_categories WHERE id = ?');
|
||||||
const insertWishCatStmt = db.prepare(
|
const insertWishCatStmt = db.prepare(
|
||||||
@@ -338,7 +369,8 @@ export const deleteWishCategory = db.transaction((id) => {
|
|||||||
/** 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.thread_id, w.idea, w.author, w.author_id, w.status,
|
w.id, w.message_id, w.thread_id, w.idea, w.author, w.author_id, w.status,
|
||||||
w.status_grund, w.status_at, w.created_at, w.kommentare, w.category_id,
|
w.status_grund, w.status_at, w.created_at, w.category_id,
|
||||||
|
(SELECT COUNT(*) FROM wish_comments c WHERE c.wish_id = w.id) AS kommentare,
|
||||||
(SELECT k.name FROM wish_categories k WHERE k.id = w.category_id) AS kategorie,
|
(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,
|
(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,
|
w.bonus + (SELECT COUNT(*) FROM wish_votes v WHERE v.wish_id = w.id) AS score,
|
||||||
@@ -351,9 +383,7 @@ const insertWish = db.prepare(
|
|||||||
'INSERT INTO wishes (message_id, author, author_id, idea, category_id) 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 setWishThreadStmt = db.prepare('UPDATE wishes SET thread_id = ? WHERE id = ?');
|
||||||
const bumpKommentareStmt = db.prepare(
|
const wishByThreadStmt = db.prepare('SELECT * FROM wishes WHERE thread_id = ?');
|
||||||
'UPDATE wishes SET kommentare = kommentare + 1 WHERE thread_id = ?'
|
|
||||||
);
|
|
||||||
const setWishCategoryStmt = db.prepare('UPDATE wishes SET category_id = ? WHERE 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 = ?');
|
||||||
@@ -387,18 +417,18 @@ export const saveWish = (messageId, author, idea, authorId = null, categoryId =
|
|||||||
insertWish.run(messageId, author, authorId, idea, categoryId ?? null).lastInsertRowid;
|
insertWish.run(messageId, author, authorId, idea, categoryId ?? null).lastInsertRowid;
|
||||||
export const setWishThread = (id, threadId) => setWishThreadStmt.run(threadId, id);
|
export const setWishThread = (id, threadId) => setWishThreadStmt.run(threadId, id);
|
||||||
export const setWishCategory = (id, categoryId) => setWishCategoryStmt.run(categoryId ?? null, id);
|
export const setWishCategory = (id, categoryId) => setWishCategoryStmt.run(categoryId ?? null, id);
|
||||||
/** @returns {boolean} true, wenn der Thread zu einem Wunsch gehoerte */
|
export const wishByThread = (threadId) => wishByThreadStmt.get(threadId) ?? null;
|
||||||
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, sortierung = 'top') =>
|
export const topWishes = (limit = 20, sortierung = 'top') =>
|
||||||
(topWishesStmts[sortierung] ?? topWishesStmts.top).all(limit);
|
(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 = db.transaction((id) => {
|
||||||
dropVotesStmt.run(id);
|
dropVotesStmt.run(id);
|
||||||
|
dropCommentsStmt.run(id);
|
||||||
return deleteWishStmt.run(id).changes > 0;
|
return deleteWishStmt.run(id).changes > 0;
|
||||||
};
|
});
|
||||||
export const setWishStatus = (id, status, grund) =>
|
export const setWishStatus = (id, status, grund) =>
|
||||||
setWishStatusStmt.run(status, grund || null, id).changes > 0;
|
setWishStatusStmt.run(status, grund || null, id).changes > 0;
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
saveTemplate, listTemplates, deleteTemplate,
|
saveTemplate, listTemplates, deleteTemplate,
|
||||||
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
||||||
getWish, searchWishes, setWishStatus, mergeWish, deleteWish, wishVoters, setWishCategory,
|
getWish, searchWishes, setWishStatus, mergeWish, deleteWish, wishVoters, setWishCategory,
|
||||||
|
wishComments,
|
||||||
listWishCategories, createWishCategory, updateWishCategory, deleteWishCategory,
|
listWishCategories, createWishCategory, updateWishCategory, deleteWishCategory,
|
||||||
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
|
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
|
||||||
heartbeatBuckets, heartbeatFirst,
|
heartbeatBuckets, heartbeatFirst,
|
||||||
@@ -1984,6 +1985,27 @@ ${rssItems}
|
|||||||
return { voted: toggleWishVote(id, user.id) };
|
return { voted: toggleWishVote(id, user.id) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ein einzelner Wunsch mit allem, was im Thread darunter steht.
|
||||||
|
// Muss vor /api/wishes/:id/... stehen? Nein — andere Tiefe, kein Konflikt.
|
||||||
|
app.get('/api/wishes/:id', async (request, reply) => {
|
||||||
|
const id = Number(request.params.id);
|
||||||
|
const wunsch = getWish(id);
|
||||||
|
if (!wunsch) return reply.code(404).send({ error: 'Wunsch nicht gefunden' });
|
||||||
|
const user = getSessionUser(request);
|
||||||
|
const gid = discordGuildId();
|
||||||
|
return {
|
||||||
|
wunsch: {
|
||||||
|
...wunsch,
|
||||||
|
voted: user ? myWishVotes(user.id).includes(id) : false,
|
||||||
|
thread_url: gid && wunsch.thread_id
|
||||||
|
? `https://discord.com/channels/${gid}/${wunsch.thread_id}`
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
kommentare: wishComments(id),
|
||||||
|
canVote: Boolean(user),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Suche vor dem Einreichen — Doppler gar nicht erst entstehen lassen
|
// Suche vor dem Einreichen — Doppler gar nicht erst entstehen lassen
|
||||||
app.get('/api/wishes/suche', async (request) => {
|
app.get('/api/wishes/suche', async (request) => {
|
||||||
const q = String(request.query?.q ?? '').trim();
|
const q = String(request.query?.q ?? '').trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user