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:
@@ -17,6 +17,7 @@ import DevlogDetail from './pages/DevlogDetail.jsx';
|
||||
import Impressum from './pages/Impressum.jsx';
|
||||
import Datenschutz from './pages/Datenschutz.jsx';
|
||||
import Seite from './pages/Seite.jsx';
|
||||
import WunschDetail from './pages/WunschDetail.jsx';
|
||||
import { IconChevronDown, IconMenu, IconX, IconLock } from './icons.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/:id" element={<DevlogDetail />} />
|
||||
<Route path="/roadmap" element={<Roadmap />} />
|
||||
<Route path="/wunsch/:id" element={<WunschDetail />} />
|
||||
<Route path="/server" element={<Server />} />
|
||||
<Route path="/status" element={<Status />} />
|
||||
<Route path="/galerie" element={<Galerie />} />
|
||||
|
||||
@@ -127,6 +127,15 @@ export default {
|
||||
'roadmap.status': 'Stand',
|
||||
'roadmap.area': '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.offen': 'Wird geprüft',
|
||||
'wish.geplant': 'Geplant',
|
||||
|
||||
@@ -126,6 +126,15 @@ export default {
|
||||
'roadmap.status': 'Status',
|
||||
'roadmap.area': '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.offen': 'Under review',
|
||||
'wish.geplant': 'Planned',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { apiGet, apiPost } from '../api.js';
|
||||
import { Markdown } from '../markdown.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-idea">
|
||||
<Link className="wish-idea" to={`/wunsch/${w.id}`}>
|
||||
<span className="wish-schilder">
|
||||
{status !== 'offen' && (
|
||||
<span className={`wish-status ${status}`}>{t(WISH_STATUS[status])}</span>
|
||||
@@ -304,7 +305,7 @@ export default function Roadmap() {
|
||||
{w.status_grund && (
|
||||
<span className="wish-grund">{w.status_grund}</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
{/* Geredet wird im Thread unter dem Post — der
|
||||
Zähler führt direkt dorthin */}
|
||||
{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-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 ────────────────────────────────────────── */
|
||||
.dlg-backdrop {
|
||||
position: fixed; inset: 0; z-index: 1200;
|
||||
|
||||
Reference in New Issue
Block a user