Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

4.1 kB Ruw bestand
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)}`;
}