Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import { type ReactNode } from 'react';
import { FORMAT_LOCALES, type AuditEntry, type Locale } from '@wellformed/shared';
import { useAudit } from '../api/hooks';
import { useI18n } from '../i18n';
import { useWorkspace } from '../state/workspace';
export function AuditPanel(): ReactNode {
const ws = useWorkspace();
const { t } = useI18n();
const query = useAudit({
table: ws.tableName,
rowId: ws.auditRowId ?? undefined,
});
const entries = query.data?.pages.flatMap((p) => p.entries) ?? [];
return (
<aside className="audit" aria-label={t('audit.title')}>
<h2>
{t('audit.title')}
<button onClick={() => ws.setShowAudit(false)} aria-label={t('audit.close')}>
×
</button>
</h2>
{ws.auditRowId && (
<div className="scope">
<span>{t('audit.row', ws.auditRowId)}</span>
<button
style={{ marginLeft: 'auto', color: 'var(--cobalt)' }}
onClick={() => ws.setAuditRowId(null)}
>
{t('audit.showAll')}
</button>
</div>
)}
<div className="list">
{query.isLoading && <div className="gridstate">{t('common.loading')}</div>}
{!query.isLoading && entries.length === 0 && (
<div className="gridstate">{t('audit.nothingYet')}</div>
)}
{entries.map((entry) => (
<AuditRow key={entry.id} entry={entry} />
))}
{query.hasNextPage && (
<button
className="loadmore"
onClick={() => void query.fetchNextPage()}
disabled={query.isFetchingNextPage}
>
{query.isFetchingNextPage ? t('common.loading') : t('audit.older')}
</button>
)}
</div>
</aside>
);
}
function AuditRow({ entry }: { entry: AuditEntry }): ReactNode {
const ws = useWorkspace();
const { t, locale } = useI18n();
// The trail stores identifiers, which outlive renames; the line above the
// change speaks labels while the column exists and falls back to the
// identifier once it does not.
const table = entry.table ? ws.tables.find((tb) => tb.name === entry.table) : undefined;
const column = entry.column
? (table?.columns.find((c) => c.name === entry.column)?.label ?? entry.column)
: null;
const location = [table?.label ?? entry.table, entry.rowLabel].filter(Boolean).join(' · ');
const where = column ? `${location} · ${column}` : location;
return (
<div className="ae">
<div className="meta">
<span className="who">{entry.who}</span>
{/* The account stays the author; this says the work came through an
assistant, and which one. Reading the trail is how you find that
out, so it has to be visible at a glance rather than on hover. */}
{entry.via && (
<span className="badge agent" title={t('audit.viaAgent', entry.via)}>
{entry.via}
</span>
)}
{entry.kind !== 'edit' && <span className={`badge ${entry.kind}`}>{entry.kind}</span>}
<span className="t" title={entry.ts}>
{formatTime(entry.ts, locale)}
</span>
</div>
{where && <div className="loc">{where}</div>}
<div className="delta">
{entry.old && (
<>
<span className="old">{entry.old}</span>{' → '}
</>
)}
<span className="new">{entry.nw}</span>
</div>
</div>
);
}
/** SQLite writes `YYYY-MM-DD HH:MM:SS` in UTC; show it in the reader's zone. */
function formatTime(ts: string, locale: Locale): string {
const parsed = new Date(ts.includes('T') ? ts : `${ts.replace(' ', 'T')}Z`);
if (Number.isNaN(parsed.getTime())) return ts;
const today = new Date();
const sameDay =
parsed.getFullYear() === today.getFullYear() &&
parsed.getMonth() === today.getMonth() &&
parsed.getDate() === today.getDate();
const time = new Intl.DateTimeFormat(FORMAT_LOCALES[locale], {
hour: '2-digit',
minute: '2-digit',
});
if (sameDay) return time.format(parsed);
const day = new Intl.DateTimeFormat(FORMAT_LOCALES[locale], { month: 'short', day: 'numeric' });
return `${day.format(parsed)} ${time.format(parsed)}`;
}