Add: Feedback-System, GitHub Actions Deploy, Assets-Demo User-Suche
This commit is contained in:
293
frontend/src/pages/FeedbackPage.jsx
Normal file
293
frontend/src/pages/FeedbackPage.jsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────────────── */
|
||||
const API = process.env.REACT_APP_API_URL || '';
|
||||
|
||||
function authFetch(url, options = {}) {
|
||||
const token = localStorage.getItem('token');
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(str) {
|
||||
if (!str) return '';
|
||||
return new Date(str).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
const LABEL_META = {
|
||||
bug: { label: 'Bug', color: '#ef4444', bg: 'rgba(239,68,68,0.12)' },
|
||||
enhancement: { label: 'Feature', color: '#3b82f6', bg: 'rgba(59,130,246,0.12)' },
|
||||
question: { label: 'Idee', color: '#a855f7', bg: 'rgba(168,85,247,0.12)' },
|
||||
feedback: { label: 'Feedback', color: '#6b7280', bg: 'rgba(107,114,128,0.12)' },
|
||||
};
|
||||
|
||||
function getLabelMeta(labels = []) {
|
||||
for (const l of labels) {
|
||||
const m = LABEL_META[l.name];
|
||||
if (m && l.name !== 'feedback') return m;
|
||||
}
|
||||
return LABEL_META.feedback;
|
||||
}
|
||||
|
||||
/* ── IssueCard ───────────────────────────────────────────────────── */
|
||||
function IssueCard({ issue, isAdmin, onToggleState }) {
|
||||
const meta = getLabelMeta(issue.labels || []);
|
||||
const body = issue.body || '';
|
||||
// Zeige nur den Inhalt nach dem "---" Trennstrich
|
||||
const bodyDisplay = body.includes('---\n\n')
|
||||
? body.split('---\n\n')[1]?.slice(0, 200)
|
||||
: body.slice(0, 200);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--card-bg, #1e1e2e)',
|
||||
border: '1px solid var(--border-color, rgba(255,255,255,0.08))',
|
||||
borderRadius: 10,
|
||||
padding: '16px 18px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
||||
<span style={{
|
||||
padding: '2px 9px',
|
||||
borderRadius: 20,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: meta.color,
|
||||
background: meta.bg,
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
marginTop: 2
|
||||
}}>{meta.label}</span>
|
||||
<span style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary, #f1f5f9)', lineHeight: 1.4 }}>
|
||||
{issue.title}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--text-muted, #64748b)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
#{issue.number}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{bodyDisplay && (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary, #94a3b8)', margin: 0, lineHeight: 1.6 }}>
|
||||
{bodyDisplay}{body.length > 200 ? '…' : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted, #64748b)' }}>
|
||||
{formatDate(issue.created_at)}
|
||||
</span>
|
||||
{issue.user && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted, #64748b)' }}>
|
||||
· {issue.user.login}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
|
||||
<a
|
||||
href={issue.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: '4px 12px',
|
||||
borderRadius: 6,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
color: 'var(--text-secondary, #94a3b8)',
|
||||
textDecoration: 'none',
|
||||
border: '1px solid var(--border-color, rgba(255,255,255,0.08))'
|
||||
}}
|
||||
>
|
||||
GitHub ↗
|
||||
</a>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => onToggleState(issue.number, issue.state)}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: '4px 12px',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid',
|
||||
borderColor: issue.state === 'open' ? '#ef4444' : '#22c55e',
|
||||
background: issue.state === 'open' ? 'rgba(239,68,68,0.1)' : 'rgba(34,197,94,0.1)',
|
||||
color: issue.state === 'open' ? '#ef4444' : '#22c55e'
|
||||
}}
|
||||
>
|
||||
{issue.state === 'open' ? 'Schließen' : 'Wiedereröffnen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
FeedbackPage
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
const TABS = [
|
||||
{ key: 'open', label: 'Offen' },
|
||||
{ key: 'closed', label: 'Geschlossen' },
|
||||
{ key: 'all', label: 'Alle' },
|
||||
];
|
||||
|
||||
const FILTER_OPTIONS = [
|
||||
{ value: '', label: 'Alle Kategorien' },
|
||||
{ value: 'bug', label: 'Bug' },
|
||||
{ value: 'enhancement', label: 'Feature' },
|
||||
{ value: 'question', label: 'Idee' },
|
||||
];
|
||||
|
||||
export default function FeedbackPage() {
|
||||
const { isAdmin } = useAuth();
|
||||
const admin = isAdmin();
|
||||
|
||||
const [tab, setTab] = useState('open');
|
||||
const [issues, setIssues] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [filterLabel, setFilterLabel] = useState('');
|
||||
|
||||
const load = useCallback(async (state) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const stateParam = state === 'all' ? 'all' : state;
|
||||
const res = await authFetch(`${API}/api/feedback?state=${stateParam}`);
|
||||
const json = await res.json();
|
||||
if (json.status === 'success') setIssues(json.data || []);
|
||||
else setError('Fehler beim Laden der Issues.');
|
||||
} catch {
|
||||
setError('GitHub API nicht erreichbar.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(tab); }, [tab, load]);
|
||||
|
||||
const handleToggleState = async (issueNumber, currentState) => {
|
||||
const newState = currentState === 'open' ? 'closed' : 'open';
|
||||
try {
|
||||
await authFetch(`${API}/api/feedback/${issueNumber}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ state: newState })
|
||||
});
|
||||
load(tab);
|
||||
} catch {
|
||||
alert('Fehler beim Aktualisieren des Issues.');
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = filterLabel
|
||||
? issues.filter(i => (i.labels || []).some(l => l.name === filterLabel))
|
||||
: issues;
|
||||
|
||||
// Für Tab "open" nur tatsächlich offene anzeigen (API liefert bei "all" auch geschlossene)
|
||||
const displayed = tab === 'open'
|
||||
? filtered.filter(i => i.state === 'open')
|
||||
: tab === 'closed'
|
||||
? filtered.filter(i => i.state === 'closed')
|
||||
: filtered;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '28px 32px', maxWidth: 900 }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, color: 'var(--text-primary, #f1f5f9)', margin: 0 }}>
|
||||
Feedback & Issues
|
||||
</h1>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-secondary, #94a3b8)', marginTop: 6 }}>
|
||||
GitHub Issues aus dem IT-Nexus Repository
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs + Filter */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 20, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 4, background: 'var(--card-bg, #1e1e2e)', border: '1px solid var(--border-color, rgba(255,255,255,0.08))', borderRadius: 8, padding: 4 }}>
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
borderRadius: 6,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
fontWeight: tab === t.key ? 600 : 400,
|
||||
background: tab === t.key ? 'var(--accent, #6366f1)' : 'transparent',
|
||||
color: tab === t.key ? '#fff' : 'var(--text-secondary, #94a3b8)',
|
||||
transition: 'all .15s'
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={filterLabel}
|
||||
onChange={e => setFilterLabel(e.target.value)}
|
||||
style={{
|
||||
padding: '7px 12px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--border-color, rgba(255,255,255,0.08))',
|
||||
background: 'var(--card-bg, #1e1e2e)',
|
||||
color: 'var(--text-primary, #f1f5f9)',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{FILTER_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<span style={{ marginLeft: 'auto', fontSize: 13, color: 'var(--text-muted, #64748b)' }}>
|
||||
{displayed.length} Issue{displayed.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: 48, color: 'var(--text-muted, #64748b)' }}>
|
||||
Laden…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div style={{ padding: 16, borderRadius: 8, background: 'rgba(239,68,68,0.1)', color: '#ef4444', fontSize: 14 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && displayed.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: 48, color: 'var(--text-muted, #64748b)', fontSize: 14 }}>
|
||||
Keine Issues gefunden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{displayed.map(issue => (
|
||||
<IssueCard
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
isAdmin={admin}
|
||||
onToggleState={handleToggleState}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user