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: [],
|
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 = {
|
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 { useParams, useNavigate } from 'react-router-dom';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import RecordModal from '../components/RecordModal';
|
import RecordModal from '../components/RecordModal';
|
||||||
@@ -12,25 +12,29 @@ function truncate(str, n = 60) {
|
|||||||
return s.length > n ? s.slice(0, n) + '…' : s;
|
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>;
|
if (value == null || value === '') return <span className="text-slate-300">—</span>;
|
||||||
|
|
||||||
// Array of IDs (e.g. picture_ids, word_ids)
|
// Array of IDs (e.g. picture_ids, word_ids)
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
if (value.length === 0) return <span className="text-slate-300">[]</span>;
|
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 (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{value.slice(0, 3).map(id => (
|
{value.slice(0, 3).map(id => {
|
||||||
|
const label = lookup?.[id];
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
onClick={e => { e.stopPropagation(); targetTable && navigate(`/db/${targetTable}?id=${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"
|
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)}
|
title={String(id)}
|
||||||
>
|
>
|
||||||
{String(id).slice(0, 8)}
|
{label ? truncate(label, 20) : String(id).slice(0, 8)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{value.length > 3 && (
|
{value.length > 3 && (
|
||||||
<span className="text-xs text-slate-400">+{value.length - 3}</span>
|
<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)
|
// Single UUID FK (e.g. question_id)
|
||||||
if (typeof value === 'string' && linkedFields[col]) {
|
if (typeof value === 'string' && linkedFields[col]) {
|
||||||
const targetTable = linkedFields[col];
|
const targetTableKey = linkedFields[col];
|
||||||
|
const lookup = fkLookup[targetTableKey];
|
||||||
|
const label = lookup?.[value];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); navigate(`/db/${targetTable}?id=${value}`); }}
|
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 font-mono"
|
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}
|
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>
|
</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>;
|
return <span className="text-xs text-slate-500">{new Date(value).toLocaleDateString('de-DE')}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Picture link — show as thumbnail + link
|
// Picture/audio link
|
||||||
if (col === 'picture_link') {
|
if (col === 'picture_link' || col === 'audio_link') {
|
||||||
return (
|
return (
|
||||||
<a href={value} target="_blank" rel="noreferrer" className="text-xs text-indigo-600 underline">
|
<a href={value} target="_blank" rel="noreferrer" className="text-xs text-indigo-600 underline">
|
||||||
Bild öffnen
|
{col === 'audio_link' ? 'Audio öffnen' : 'Bild öffnen'}
|
||||||
</a>
|
</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>;
|
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() {
|
export default function TableView() {
|
||||||
const { tableKey } = useParams();
|
const { tableKey } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -90,6 +150,23 @@ export default function TableView() {
|
|||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState(null);
|
const [confirmDeleteId, setConfirmDeleteId] = useState(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -100,12 +177,69 @@ export default function TableView() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setRows([]);
|
||||||
|
setSortCol(null);
|
||||||
|
setFkLookup({});
|
||||||
fetchAll(meta.endpoint)
|
fetchAll(meta.endpoint)
|
||||||
.then(data => setRows(Array.isArray(data) ? data : []))
|
.then(data => setRows(Array.isArray(data) ? data : []))
|
||||||
.catch(err => setError(err.message))
|
.catch(err => setError(err.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [meta]);
|
}, [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 statuses = useMemo(() => {
|
||||||
const set = new Set(rows.map(r => r.status).filter(Boolean));
|
const set = new Set(rows.map(r => r.status).filter(Boolean));
|
||||||
return [...set].sort();
|
return [...set].sort();
|
||||||
@@ -124,6 +258,23 @@ export default function TableView() {
|
|||||||
});
|
});
|
||||||
}, [rows, statusFilter, textFilter]);
|
}, [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) {
|
function handleRecordSaved(updated) {
|
||||||
setRows(prev => prev.map(r => (r.id === updated.id ? { ...r, ...updated } : r)));
|
setRows(prev => prev.map(r => (r.id === updated.id ? { ...r, ...updated } : r)));
|
||||||
}
|
}
|
||||||
@@ -151,13 +302,35 @@ export default function TableView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout back="/db">
|
<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">
|
<h2 className="text-xl font-semibold text-slate-700">
|
||||||
{meta.icon} {meta.label}
|
{meta.icon} {meta.label}
|
||||||
<span className="ml-2 text-base font-normal text-slate-400">
|
<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>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
<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) && (
|
{hasCreateForm(tableKey) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreate(true)}
|
onClick={() => setShowCreate(true)}
|
||||||
@@ -167,6 +340,7 @@ export default function TableView() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap gap-3 mb-4">
|
<div className="flex flex-wrap gap-3 mb-4">
|
||||||
@@ -210,35 +384,47 @@ export default function TableView() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-50 border-b border-slate-200">
|
<tr className="bg-slate-50 border-b border-slate-200">
|
||||||
{meta.columns.map(col => (
|
{effectiveCols.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">
|
<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}
|
{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>
|
||||||
))}
|
))}
|
||||||
<th className="px-3 py-2.5 w-10" />
|
<th className="px-3 py-2.5 w-10" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered.length === 0 && (
|
{sortedFiltered.length === 0 && (
|
||||||
<tr>
|
<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
|
Keine Einträge gefunden
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{filtered.map((row, i) => (
|
{sortedFiltered.map((row, i) => (
|
||||||
<tr
|
<tr
|
||||||
key={row.id || i}
|
key={row.id || i}
|
||||||
onClick={() => setModalRecord(row)}
|
onClick={() => setModalRecord(row)}
|
||||||
className={`group border-b border-slate-100 hover:bg-slate-50 transition-colors cursor-pointer
|
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' : ''}`}
|
${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]">
|
<td key={col} className="px-4 py-2.5 align-top max-w-[280px]">
|
||||||
<CellValue
|
<CellValue
|
||||||
col={col}
|
col={col}
|
||||||
value={row[col]}
|
value={row[col]}
|
||||||
linkedFields={meta.linkedFields}
|
linkedFields={meta.linkedFields}
|
||||||
|
fkLookup={fkLookup}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user