feat: vollständiges DB-Admin-Tool mit Spalten-Toggle, Sortierung und FK-Auflösung
- Audios-Tabelle in TABLES-Config ergänzt - Spalten dynamisch aus geladenen Daten abgeleitet; Sichtbarkeit per localStorage persistiert - Spalten-Toggle-Dropdown mit Checkboxen und Reset - Sortierung per Klick auf Spaltenheader (▲/▼) - FK-Felder zeigen aufgelöste Labels statt rohe UUIDs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -273,6 +273,24 @@ export const TABLES = {
|
||||
},
|
||||
fetchRelated: [],
|
||||
},
|
||||
|
||||
audios: {
|
||||
label: 'Audios',
|
||||
icon: '🔊',
|
||||
endpoint: '/audios',
|
||||
statusField: 'status',
|
||||
primaryLabel: 'text',
|
||||
columns: ['text', 'voice_id', 'status', 'audio_link', 'created_at'],
|
||||
linkedFields: {},
|
||||
editableFields: {
|
||||
text: { type: 'textarea' },
|
||||
status: { type: 'select', options: ['generated', 'published', 'blocked'] },
|
||||
voice_id: { type: 'text' },
|
||||
model_id: { type: 'text' },
|
||||
audio_link: { type: 'text' },
|
||||
},
|
||||
fetchRelated: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const STATUS_COLORS = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import RecordModal from '../components/RecordModal';
|
||||
@@ -12,25 +12,29 @@ function truncate(str, n = 60) {
|
||||
return s.length > n ? s.slice(0, n) + '…' : s;
|
||||
}
|
||||
|
||||
function CellValue({ col, value, linkedFields, navigate }) {
|
||||
function CellValue({ col, value, linkedFields, fkLookup, navigate }) {
|
||||
if (value == null || value === '') return <span className="text-slate-300">—</span>;
|
||||
|
||||
// Array of IDs (e.g. picture_ids, word_ids)
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return <span className="text-slate-300">[]</span>;
|
||||
const targetTable = linkedFields[col];
|
||||
const targetTableKey = linkedFields[col];
|
||||
const lookup = targetTableKey && fkLookup[targetTableKey];
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{value.slice(0, 3).map(id => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={e => { e.stopPropagation(); targetTable && navigate(`/db/${targetTable}?id=${id}`); }}
|
||||
className="text-xs bg-indigo-50 text-indigo-600 rounded px-1.5 py-0.5 hover:bg-indigo-100 font-mono"
|
||||
title={String(id)}
|
||||
>
|
||||
{String(id).slice(0, 8)}
|
||||
</button>
|
||||
))}
|
||||
{value.slice(0, 3).map(id => {
|
||||
const label = lookup?.[id];
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={e => { e.stopPropagation(); targetTableKey && navigate(`/db/${targetTableKey}?id=${id}`); }}
|
||||
className="text-xs bg-indigo-50 text-indigo-600 rounded px-1.5 py-0.5 hover:bg-indigo-100 font-mono"
|
||||
title={String(id)}
|
||||
>
|
||||
{label ? truncate(label, 20) : String(id).slice(0, 8)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{value.length > 3 && (
|
||||
<span className="text-xs text-slate-400">+{value.length - 3}</span>
|
||||
)}
|
||||
@@ -40,14 +44,20 @@ function CellValue({ col, value, linkedFields, navigate }) {
|
||||
|
||||
// Single UUID FK (e.g. question_id)
|
||||
if (typeof value === 'string' && linkedFields[col]) {
|
||||
const targetTable = linkedFields[col];
|
||||
const targetTableKey = linkedFields[col];
|
||||
const lookup = fkLookup[targetTableKey];
|
||||
const label = lookup?.[value];
|
||||
return (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); navigate(`/db/${targetTable}?id=${value}`); }}
|
||||
className="text-xs bg-indigo-50 text-indigo-600 rounded px-1.5 py-0.5 hover:bg-indigo-100 font-mono"
|
||||
onClick={e => { e.stopPropagation(); navigate(`/db/${targetTableKey}?id=${value}`); }}
|
||||
className="text-xs bg-indigo-50 text-indigo-600 rounded px-1.5 py-0.5 hover:bg-indigo-100 max-w-[200px] text-left"
|
||||
title={value}
|
||||
>
|
||||
{value.slice(0, 8)}…
|
||||
{label ? (
|
||||
<span>{truncate(label, 24)} <span className="opacity-50 font-mono">{value.slice(0, 6)}</span></span>
|
||||
) : (
|
||||
<span className="font-mono">{value.slice(0, 8)}…</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -63,18 +73,68 @@ function CellValue({ col, value, linkedFields, navigate }) {
|
||||
return <span className="text-xs text-slate-500">{new Date(value).toLocaleDateString('de-DE')}</span>;
|
||||
}
|
||||
|
||||
// Picture link — show as thumbnail + link
|
||||
if (col === 'picture_link') {
|
||||
// Picture/audio link
|
||||
if (col === 'picture_link' || col === 'audio_link') {
|
||||
return (
|
||||
<a href={value} target="_blank" rel="noreferrer" className="text-xs text-indigo-600 underline">
|
||||
Bild öffnen
|
||||
{col === 'audio_link' ? 'Audio öffnen' : 'Bild öffnen'}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// Boolean
|
||||
if (value === true || value === false) {
|
||||
return <span className={`text-xs font-mono ${value ? 'text-emerald-600' : 'text-slate-400'}`}>{String(value)}</span>;
|
||||
}
|
||||
|
||||
return <span className="text-sm text-slate-700">{truncate(value)}</span>;
|
||||
}
|
||||
|
||||
function ColPanel({ allColumns, visibleSet, onToggle, onReset, anchorRef, onClose }) {
|
||||
const panelRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e) {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target) &&
|
||||
anchorRef.current && !anchorRef.current.contains(e.target)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [onClose, anchorRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute right-0 top-full mt-1 z-30 bg-white border border-slate-200 rounded-xl shadow-lg w-56 max-h-80 overflow-y-auto"
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-slate-100 flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wide">Spalten</span>
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="text-xs text-indigo-500 hover:text-indigo-700 font-medium"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
{allColumns.map(col => (
|
||||
<label key={col} className="flex items-center gap-2.5 px-3 py-1.5 hover:bg-slate-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visibleSet.has(col)}
|
||||
onChange={() => onToggle(col)}
|
||||
className="accent-indigo-600"
|
||||
/>
|
||||
<span className="text-sm text-slate-700 font-mono">{col}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TableView() {
|
||||
const { tableKey } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -90,6 +150,23 @@ export default function TableView() {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [showColPanel, setShowColPanel] = useState(false);
|
||||
const [sortCol, setSortCol] = useState(null);
|
||||
const [sortDir, setSortDir] = useState('asc');
|
||||
const [fkLookup, setFkLookup] = useState({});
|
||||
const colBtnRef = useRef(null);
|
||||
|
||||
const COLS_KEY = `cols_${tableKey}`;
|
||||
|
||||
const [visibleCols, setVisibleCols] = useState(null);
|
||||
|
||||
// Load column prefs from localStorage whenever tableKey changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(`cols_${tableKey}`);
|
||||
setVisibleCols(saved ? JSON.parse(saved) : null);
|
||||
} catch { setVisibleCols(null); }
|
||||
}, [tableKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -100,12 +177,69 @@ export default function TableView() {
|
||||
useEffect(() => {
|
||||
if (!meta) return;
|
||||
setLoading(true);
|
||||
setRows([]);
|
||||
setSortCol(null);
|
||||
setFkLookup({});
|
||||
fetchAll(meta.endpoint)
|
||||
.then(data => setRows(Array.isArray(data) ? data : []))
|
||||
.catch(err => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [meta]);
|
||||
|
||||
// Derive all available columns from first loaded row
|
||||
const allColumns = useMemo(() => {
|
||||
if (rows.length === 0) return meta?.columns ?? [];
|
||||
return Object.keys(rows[0]);
|
||||
}, [rows, meta]);
|
||||
|
||||
// Effective visible columns
|
||||
const visibleSet = useMemo(() => {
|
||||
return new Set(visibleCols ?? meta?.columns ?? []);
|
||||
}, [visibleCols, meta]);
|
||||
|
||||
const effectiveCols = useMemo(() => {
|
||||
return allColumns.filter(c => visibleSet.has(c));
|
||||
}, [allColumns, visibleSet]);
|
||||
|
||||
function toggleCol(col) {
|
||||
const current = visibleCols ?? meta.columns;
|
||||
const next = current.includes(col)
|
||||
? current.filter(c => c !== col)
|
||||
: [...current, col];
|
||||
setVisibleCols(next);
|
||||
localStorage.setItem(COLS_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
function resetCols() {
|
||||
setVisibleCols(null);
|
||||
localStorage.removeItem(COLS_KEY);
|
||||
setShowColPanel(false);
|
||||
}
|
||||
|
||||
// Load FK lookup tables when rows are available
|
||||
useEffect(() => {
|
||||
if (!meta || rows.length === 0) return;
|
||||
const targets = new Set(
|
||||
Object.values(meta.linkedFields)
|
||||
.map(v => typeof v === 'string' ? v : null)
|
||||
.filter(Boolean)
|
||||
);
|
||||
targets.forEach(async targetKey => {
|
||||
if (fkLookup[targetKey]) return;
|
||||
const targetMeta = TABLES[targetKey];
|
||||
if (!targetMeta) return;
|
||||
try {
|
||||
const data = await fetchAll(targetMeta.endpoint);
|
||||
setFkLookup(prev => ({
|
||||
...prev,
|
||||
[targetKey]: Object.fromEntries(
|
||||
data.map(r => [r.id, r[targetMeta.primaryLabel] || r.id])
|
||||
),
|
||||
}));
|
||||
} catch {}
|
||||
});
|
||||
}, [rows, meta]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const statuses = useMemo(() => {
|
||||
const set = new Set(rows.map(r => r.status).filter(Boolean));
|
||||
return [...set].sort();
|
||||
@@ -124,6 +258,23 @@ export default function TableView() {
|
||||
});
|
||||
}, [rows, statusFilter, textFilter]);
|
||||
|
||||
const sortedFiltered = useMemo(() => {
|
||||
if (!sortCol) return filtered;
|
||||
return [...filtered].sort((a, b) => {
|
||||
const av = a[sortCol], bv = b[sortCol];
|
||||
if (av == null && bv == null) return 0;
|
||||
if (av == null) return 1;
|
||||
if (bv == null) return -1;
|
||||
const cmp = String(av).localeCompare(String(bv), 'de', { numeric: true });
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}, [filtered, sortCol, sortDir]);
|
||||
|
||||
function handleSort(col) {
|
||||
if (sortCol === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
else { setSortCol(col); setSortDir('asc'); }
|
||||
}
|
||||
|
||||
function handleRecordSaved(updated) {
|
||||
setRows(prev => prev.map(r => (r.id === updated.id ? { ...r, ...updated } : r)));
|
||||
}
|
||||
@@ -151,21 +302,44 @@ export default function TableView() {
|
||||
|
||||
return (
|
||||
<Layout back="/db">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap">
|
||||
<h2 className="text-xl font-semibold text-slate-700">
|
||||
{meta.icon} {meta.label}
|
||||
<span className="ml-2 text-base font-normal text-slate-400">
|
||||
({filtered.length}{rows.length === 500 ? '+' : ''} von {rows.length}{rows.length === 500 ? '+' : ''})
|
||||
({sortedFiltered.length}{rows.length === 500 ? '+' : ''} von {rows.length}{rows.length === 500 ? '+' : ''})
|
||||
</span>
|
||||
</h2>
|
||||
{hasCreateForm(tableKey) && (
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<span className="text-lg leading-none">+</span> Neu
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Column toggle */}
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={colBtnRef}
|
||||
onClick={() => setShowColPanel(p => !p)}
|
||||
className="flex items-center gap-1.5 border border-slate-300 hover:border-slate-400 text-slate-600 hover:text-slate-800 text-sm px-3 py-1.5 rounded-lg transition-colors bg-white"
|
||||
>
|
||||
Spalten {showColPanel ? '▲' : '▼'}
|
||||
</button>
|
||||
{showColPanel && (
|
||||
<ColPanel
|
||||
allColumns={allColumns}
|
||||
visibleSet={visibleSet}
|
||||
onToggle={toggleCol}
|
||||
onReset={resetCols}
|
||||
anchorRef={colBtnRef}
|
||||
onClose={() => setShowColPanel(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasCreateForm(tableKey) && (
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<span className="text-lg leading-none">+</span> Neu
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
@@ -210,35 +384,47 @@ export default function TableView() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-slate-200">
|
||||
{meta.columns.map(col => (
|
||||
<th key={col} className="text-left px-4 py-2.5 text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-nowrap">
|
||||
{col}
|
||||
{effectiveCols.map(col => (
|
||||
<th
|
||||
key={col}
|
||||
onClick={() => handleSort(col)}
|
||||
className="text-left px-4 py-2.5 text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-nowrap cursor-pointer hover:bg-slate-100 select-none transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{col}
|
||||
{sortCol === col ? (
|
||||
<span className="text-indigo-500">{sortDir === 'asc' ? '▲' : '▼'}</span>
|
||||
) : (
|
||||
<span className="text-slate-300 opacity-0 group-hover:opacity-100">▲</span>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-2.5 w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 && (
|
||||
{sortedFiltered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={meta.columns.length + 1} className="text-center py-10 text-slate-400">
|
||||
<td colSpan={effectiveCols.length + 1} className="text-center py-10 text-slate-400">
|
||||
Keine Einträge gefunden
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{filtered.map((row, i) => (
|
||||
{sortedFiltered.map((row, i) => (
|
||||
<tr
|
||||
key={row.id || i}
|
||||
onClick={() => setModalRecord(row)}
|
||||
className={`group border-b border-slate-100 hover:bg-slate-50 transition-colors cursor-pointer
|
||||
${highlightId && row.id === highlightId ? 'bg-indigo-50 ring-1 ring-indigo-300' : ''}`}
|
||||
>
|
||||
{meta.columns.map(col => (
|
||||
{effectiveCols.map(col => (
|
||||
<td key={col} className="px-4 py-2.5 align-top max-w-[280px]">
|
||||
<CellValue
|
||||
col={col}
|
||||
value={row[col]}
|
||||
linkedFields={meta.linkedFields}
|
||||
fkLookup={fkLookup}
|
||||
navigate={navigate}
|
||||
/>
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user