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>
390 lines
18 KiB
React
390 lines
18 KiB
React
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';
|
||
import { IconThumbsUp, IconMessage } from '../icons.jsx';
|
||
import { useT, useFmt } from '../i18n.jsx';
|
||
|
||
const ZIEL = { month: 'long', year: 'numeric' };
|
||
|
||
/** Milestone-Gruppe bestimmen: Fertig / In Arbeit / Geplant */
|
||
function groupOf(m) {
|
||
if (m.state === 'closed') return 'done';
|
||
return m.closed > 0 ? 'active' : 'planned';
|
||
}
|
||
|
||
const GROUPS = [
|
||
{ id: 'active', tag: 'roadmap.active', empty: 'roadmap.nothingActive' },
|
||
{ id: 'planned', tag: 'roadmap.planned', empty: null },
|
||
{ 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_SORT = [
|
||
{ id: 'top', key: 'roadmap.sortTop' },
|
||
{ id: 'bewegung', key: 'roadmap.sortTrend' },
|
||
{ id: 'neu', key: 'roadmap.sortNew' },
|
||
];
|
||
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();
|
||
const total = m.open + m.closed;
|
||
const pct = total > 0 ? Math.round((m.closed / total) * 100) : m.state === 'closed' ? 100 : 0;
|
||
return (
|
||
<article className="card milestone">
|
||
<div className="card-meta">
|
||
<span className="milestone-title">{m.title}</span>
|
||
{m.due_on && m.state !== 'closed' && (
|
||
<span className="card-rel">{t('roadmap.due', datum(m.due_on, ZIEL))}</span>
|
||
)}
|
||
</div>
|
||
{m.description && (
|
||
<div className="card-body">
|
||
<Markdown text={m.description} />
|
||
</div>
|
||
)}
|
||
<div className="progress">
|
||
<div className="progress-bar" style={{ width: `${pct}%` }} />
|
||
</div>
|
||
<div className="progress-info">
|
||
<span>{pct}%</span>
|
||
{total > 0 && <span>{t('roadmap.tasks', m.closed, total)}</span>}
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
/** GitHub-Style-Heatmap: 52 Wochen × 7 Tage */
|
||
function Heatmap({ days }) {
|
||
const { t } = useT();
|
||
const byDay = new Map(days.map((d) => [d.day, d.n]));
|
||
const max = Math.max(1, ...days.map((d) => d.n));
|
||
const today = new Date();
|
||
// Start: Montag vor ~52 Wochen
|
||
const start = new Date(today.getTime() - 364 * 86400000);
|
||
start.setDate(start.getDate() - ((start.getDay() + 6) % 7));
|
||
|
||
const weeks = [];
|
||
for (let w = 0; w < 53; w++) {
|
||
const col = [];
|
||
for (let d = 0; d < 7; d++) {
|
||
const date = new Date(start.getTime() + (w * 7 + d) * 86400000);
|
||
if (date > today) break;
|
||
const key = date.toISOString().slice(0, 10);
|
||
const n = byDay.get(key) ?? 0;
|
||
const level = n === 0 ? 0 : Math.min(4, Math.ceil((n / max) * 4));
|
||
col.push(
|
||
<span
|
||
key={d}
|
||
className={`hm-cell hm-${level}`}
|
||
title={n === 1 ? t('roadmap.commitOn', key) : t('roadmap.commitsOn', key, n)}
|
||
/>
|
||
);
|
||
}
|
||
weeks.push(<span className="hm-week" key={w}>{col}</span>);
|
||
}
|
||
const total = days.reduce((sum, d) => sum + d.n, 0);
|
||
return (
|
||
<div className="roadmap-group">
|
||
<h2 className="settings-title">{t('roadmap.activity', total)}</h2>
|
||
<div className="hm-wrap">{weeks}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function Roadmap() {
|
||
const { t } = useT();
|
||
const [data, setData] = useState(null);
|
||
const [wishes, setWishes] = useState([]);
|
||
const [canVote, setCanVote] = useState(false);
|
||
const [idea, setIdea] = useState('');
|
||
const [submitMsg, setSubmitMsg] = useState('');
|
||
const [heatmap, setHeatmap] = useState(null);
|
||
const [error, setError] = useState(false);
|
||
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([]);
|
||
|
||
useEffect(() => {
|
||
apiGet('/api/roadmap').then(setData).catch(() => setError(true));
|
||
apiGet('/api/heatmap').then((d) => setHeatmap(d.days)).catch(() => {});
|
||
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,
|
||
// 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 = wishes
|
||
.filter((w) => filter === 'alle' || (w.status ?? 'offen') === filter)
|
||
.filter((w) => bereich === 'alle' || w.category_id === bereich);
|
||
|
||
async function vote(id) {
|
||
try {
|
||
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'));
|
||
}
|
||
}
|
||
|
||
async function submitWish() {
|
||
const text = idea.trim();
|
||
if (text.length < 5) { setSubmitMsg(t('roadmap.tooShort')); return; }
|
||
try {
|
||
await apiPost('/api/wishes', {
|
||
idea: text,
|
||
category_id: neuerBereich ? Number(neuerBereich) : null,
|
||
});
|
||
setIdea('');
|
||
setSubmitMsg(t('roadmap.submitted'));
|
||
apiGet(`/api/wishes?sort=${sort}`).then((d) => setWishes(d.wishes)).catch(() => {});
|
||
} catch (e) {
|
||
setSubmitMsg(e.status === 403 ? t('roadmap.submitMembersOnly') : t('roadmap.submitFailed'));
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<section className="page-header">
|
||
<div className="page-bg-text">ROADMAP</div>
|
||
<div className="page-header-grid" aria-hidden="true" />
|
||
<div className="page-header-content">
|
||
<div className="page-tag">{t('roadmap.tag')}</div>
|
||
<h1 className="page-title">{t('nav.roadmap')}</h1>
|
||
<p className="page-subtitle">{t('roadmap.subtitle')}</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="content">
|
||
{error && <p className="notice">{t('roadmap.error')}</p>}
|
||
{!error && !data && <SkeletonCards n={3} />}
|
||
{data?.milestones.length === 0 && (
|
||
<p className="notice">{t('roadmap.noMilestones')}</p>
|
||
)}
|
||
|
||
{heatmap && heatmap.length > 0 && <Heatmap days={heatmap} />}
|
||
|
||
{(wishes.length > 0 || canVote) && (
|
||
<div className="roadmap-group">
|
||
<h2 className="settings-title">{t('roadmap.wishes')}</h2>
|
||
|
||
<div className="wish-bar">
|
||
{/* Sortierung: die Rangliste allein zementiert alte
|
||
Wünsche — „Bewegung" zeigt, wo diese Woche etwas
|
||
passiert, „Neu" das Frischeste. */}
|
||
<div className="wish-filter">
|
||
<span className="wish-filter-label">{t('roadmap.sort')}</span>
|
||
{WISH_SORT.map(({ id, key }) => (
|
||
<button
|
||
key={id}
|
||
className={`wish-chip${sort === id ? ' aktiv' : ''}`}
|
||
onClick={() => setSort(id)}
|
||
>
|
||
{t(key)}
|
||
</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>
|
||
|
||
{sichtbareWuensche.length === 0 && (
|
||
<p className="notice">{t('roadmap.noneInFilter')}</p>
|
||
)}
|
||
|
||
{sichtbareWuensche.map((w, i) => {
|
||
const status = w.status ?? 'offen';
|
||
return (
|
||
<div className={`wish-row st-${status}`} key={w.id ?? `${w.created_at}-${i}`}>
|
||
{canVote && w.id ? (
|
||
<button
|
||
className={`wish-vote with-icon${w.voted ? ' voted' : ''}`}
|
||
title={w.voted ? t('roadmap.unvote') : t('roadmap.vote')}
|
||
onClick={() => vote(w.id)}
|
||
>
|
||
<IconThumbsUp size={14} /> {w.score}
|
||
</button>
|
||
) : (
|
||
<span className="wish-score with-icon"><IconThumbsUp size={14} /> {w.score}</span>
|
||
)}
|
||
<Link className="wish-idea" to={`/wunsch/${w.id}`}>
|
||
<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.status_grund && (
|
||
<span className="wish-grund">{w.status_grund}</span>
|
||
)}
|
||
</Link>
|
||
{/* Geredet wird im Thread unter dem Post — der
|
||
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>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{canVote ? (
|
||
<div className="wish-form-wrap">
|
||
<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
|
||
type="text" maxLength={500} placeholder={t('roadmap.ideaPlaceholder')}
|
||
value={idea}
|
||
onChange={(e) => setIdea(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') submitWish(); }}
|
||
/>
|
||
<button className="btn btn-save" onClick={submitWish}>{t('roadmap.submit')}</button>
|
||
</div>
|
||
{/* Beim Tippen zeigen, was es schon gibt — ein Klick
|
||
auf einen Treffer ist besser als ein Doppler */}
|
||
{aehnliche.length > 0 && (
|
||
<div className="wish-hits">
|
||
<span className="wish-hits-label">{t('roadmap.alreadyThere')}</span>
|
||
{aehnliche.map((w) => (
|
||
<button
|
||
key={w.id}
|
||
className="wish-hit"
|
||
onClick={() => { setIdea(''); vote(w.id); }}
|
||
title={t('roadmap.vote')}
|
||
>
|
||
<IconThumbsUp size={12} /> {w.score} · {w.idea}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<p className="notice" style={{ padding: '.6rem 0 0' }}>
|
||
{t('roadmap.hint').split('%s')[0]}<code>/wunsch</code>{t('roadmap.hint').split('%s')[1]}
|
||
</p>
|
||
)}
|
||
{submitMsg && <p className="notice" style={{ padding: '.6rem 0 0' }}>{submitMsg}</p>}
|
||
</div>
|
||
)}
|
||
|
||
{GROUPS.map(({ id, tag, empty }) => {
|
||
const items = (data?.milestones ?? []).filter((m) => groupOf(m) === id);
|
||
if (items.length === 0 && !empty) return null;
|
||
return (
|
||
<div className="roadmap-group" key={id}>
|
||
<h2 className="settings-title">{t(tag)}</h2>
|
||
{items.length === 0 && <p className="notice">{t(empty)}</p>}
|
||
{items.map((m) => (
|
||
<Milestone m={m} key={m.title} />
|
||
))}
|
||
</div>
|
||
);
|
||
})}
|
||
</section>
|
||
</>
|
||
);
|
||
}
|