Datenexport
Export-Screens. Schneller Export mit Format und Zeilen, Spalten wählen und ordnen mit Vorschau, geplante Exporte, Exportverlauf und ein vollständiges Kontoarchiv.
Eine von 3 Components in Import, Export und Verlauf
Variants
Details
Was Datenexport mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Import, Export und Verlauf
- Bereich
- App
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/data-export.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 83 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle DataExportProps aus components/ui/data-export.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalDataExportVariantquick-exportcolumnsscheduledhistoryaccount-archive
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlePflichtstringsubtitleoptionalstringentityoptionalstringWhat gets exported, e.g. contacts
countsoptional{ all: number; filtered?: number; selected?: number }quick-export: rows in all data, in the current filter and selected
columnsoptionalExportColumn[]columns
schedulesoptionalScheduledExport[]scheduled
filesoptionalExportFile[]history
partsoptionalArchivePart[]account-archive
emailoptionalstringaccount-archive: where the link goes
nowoptionalstringISO date and time that counts as now
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on export, save and download. Without it the block simulates the work.
Eingebaute Texte
Diese Texte schreibt Datenexport selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 83 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| format | Englisch Format | Deutsch Format |
| csv | Englisch CSV | Deutsch CSV |
| xlsx | Englisch Excel | Deutsch Excel |
| json | Englisch JSON | Deutsch JSON |
| Englisch PDF | Deutsch PDF | |
| csvText | Englisch For any spreadsheet | Deutsch Für jede Tabelle |
| xlsxText | Englisch With column widths | Deutsch Mit Spaltenbreiten |
| jsonText | Englisch For developers | Deutsch Für Entwickler |
| pdfText | Englisch To print or send | Deutsch Zum Drucken und Senden |
| which | Englisch Which rows | Deutsch Welche Zeilen |
| all | Englisch All {entity} | Deutsch Alle {entity} |
| filtered | Englisch Current filter | Deutsch Aktueller Filter |
| selected | Englisch Selected only | Deutsch Nur ausgewählte |
| rowsN | Englisch {n} rows | Deutsch {n} Zeilen |
| options | Englisch Options | Deutsch Optionen |
| headers | Englisch Include column names | Deutsch Spaltennamen mitnehmen |
| dates | Englisch Dates as YYYY-MM-DD | Deutsch Datum als JJJJ-MM-TT |
| export | Englisch Export {n} rows | Deutsch {n} Zeilen exportieren |
| exporting | Englisch Preparing your file… | Deutsch Deine Datei wird vorbereitet… |
| ready | Englisch Your file is ready | Deutsch Deine Datei ist fertig |
| download | Englisch Download | Deutsch Herunterladen |
| another | Englisch Export again | Deutsch Erneut exportieren |
| emailToo | Englisch We also sent the link to your email. | Deutsch Den Link haben wir dir auch per E-Mail geschickt. |
| columns | Englisch Columns | Deutsch Spalten |
| columnsText | Englisch Pick the columns and their order. The preview shows the first rows. | Deutsch Wähle Spalten und Reihenfolge. Die Vorschau zeigt die ersten Zeilen. |
| picked | Englisch {n} of {total} columns | Deutsch {n} von {total} Spalten |
| pickAll | Englisch All | Deutsch Alle |
| pickNone | Englisch None | Deutsch Keine |
| moveUp | Englisch Move {name} up | Deutsch {name} nach oben |
| moveDown | Englisch Move {name} down | Deutsch {name} nach unten |
| preview | Englisch Preview | Deutsch Vorschau |
| noColumns | Englisch Pick at least one column. | Deutsch Wähle mindestens eine Spalte. |
| saveSet | Englisch Save as preset | Deutsch Als Vorlage speichern |
| saved | Englisch Preset saved | Deutsch Vorlage gespeichert |
| scheduled | Englisch Scheduled exports | Deutsch Geplante Exporte |
| scheduledText | Englisch Fresh files arrive on their own, no one has to remember. | Deutsch Frische Dateien kommen von selbst, niemand muss daran denken. |
| next | Englisch Next {when} | Deutsch Nächster {when} |
| paused | Englisch Paused | Deutsch Pausiert |
| toggle | Englisch Run {name} | Deutsch {name} ausführen |
| newSchedule | Englisch New scheduled export | Deutsch Neuer geplanter Export |
| name | Englisch Name | Deutsch Name |
| every | Englisch Repeat | Deutsch Wiederholen |
| daily | Englisch Every day | Deutsch Jeden Tag |
| weekly | Englisch Every Monday | Deutsch Jeden Montag |
| monthly | Englisch On the 1st | Deutsch Am 1. des Monats |
| at | Englisch At | Deutsch Um |
| sendTo | Englisch Send to | Deutsch Senden an |
| toEmail | Englisch Email | Deutsch E-Mail |
| toFolder | Englisch Shared folder | Deutsch Geteilter Ordner |
| address | Englisch Address | Deutsch Adresse |
| folder | Englisch Folder path | Deutsch Ordnerpfad |
| add | Englisch Add schedule | Deutsch Export planen |
| adding | Englisch Adding… | Deutsch Wird geplant… |
| added | Englisch {name} added. | Deutsch {name} hinzugefügt. |
| nameError | Englisch Give it a name. | Deutsch Gib ihm einen Namen. |
| toError | Englisch Fill in where the file goes. | Deutsch Trag ein, wohin die Datei geht. |
| history | Englisch Export history | Deutsch Exportverlauf |
| historyText | Englisch Links work for 7 days, then the files are deleted. | Deutsch Links funktionieren 7 Tage, danach werden die Dateien gelöscht. |
| statusReady | Englisch Ready | Deutsch Bereit |
| statusRunning | Englisch Running | Deutsch Läuft |
| statusExpired | Englisch Expired | Deutsch Abgelaufen |
| statusFailed | Englisch Failed | Deutsch Fehlgeschlagen |
| expiresIn | Englisch Link works {when} | Deutsch Link gilt {when} |
| rerun | Englisch Export again | Deutsch Erneut exportieren |
| retry | Englisch Try again | Deutsch Erneut versuchen |
| by | Englisch by {name} | Deutsch von {name} |
| allStatus | Englisch All | Deutsch Alle |
| archive | Englisch Download your data | Deutsch Deine Daten herunterladen |
| archiveText | Englisch A ZIP with everything you pick. We email you a link as soon as it is ready, usually within an hour. | Deutsch Ein ZIP mit allem, was du wählst. Wir schicken dir einen Link, sobald es fertig ist, meist innerhalb einer Stunde. |
| whatIn | Englisch What goes in | Deutsch Was hinein kommt |
| total | Englisch About {size} | Deutsch Etwa {size} |
| confirm | Englisch Confirm with your password | Deutsch Mit deinem Passwort bestätigen |
| password | Englisch Password | Deutsch Passwort |
| passwordError | Englisch Enter your password to continue. | Deutsch Gib dein Passwort ein, um weiterzumachen. |
| request | Englisch Request archive | Deutsch Archiv anfordern |
| requesting | Englisch Requesting… | Deutsch Wird angefordert… |
| steps | Englisch What happens next | Deutsch So geht es weiter |
| step1 | Englisch We collect your data | Deutsch Wir sammeln deine Daten |
| step2 | Englisch We pack it into a ZIP | Deutsch Wir packen sie in ein ZIP |
| step3 | Englisch You get an email with a link | Deutsch Du bekommst eine E-Mail mit Link |
| requested | Englisch We are on it | Deutsch Wir sind dran |
| requestedText | Englisch The link goes to {email}. It works for 7 days and needs your password again. | Deutsch Der Link geht an {email}. Er gilt 7 Tage und fragt noch einmal nach deinem Passwort. |
| eta | Englisch Ready in about {n} minutes | Deutsch Fertig in etwa {n} Minuten |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Datenexport reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-checked, aria-describedby, aria-invalid, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen radio, radiogroup und status.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 1.150 Zeilen
'use client';
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Switch } from '@/components/ui/switch';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
import { useScrollFade } from '@/components/ui/motion';
export type DataExportVariant = 'quick-export' | 'columns' | 'scheduled' | 'history' | 'account-archive';
export type ExportFormat = 'csv' | 'xlsx' | 'json' | 'pdf';
export interface ExportColumn {
id: string;
label: string;
/** Shown in the first rows of the preview */
samples?: string[];
on?: boolean;
}
export interface ScheduledExport {
id: string;
name: string;
format: ExportFormat;
/** e.g. Every Monday at 07:00 */
when: string;
/** Where the file goes, e.g. an email address or a folder */
to: string;
toKind: 'email' | 'folder';
enabled: boolean;
/** ISO date and time */
next?: string;
}
export interface ExportFile {
id: string;
name: string;
format: ExportFormat;
rows: number;
/** Bytes */
size: number;
/** ISO date and time */
created: string;
/** ISO date and time when the link stops working */
expires?: string;
by?: string;
status: 'ready' | 'running' | 'expired' | 'failed';
}
export interface ArchivePart {
id: string;
label: string;
description?: string;
/** Rough size in bytes */
size: number;
icon?: IconName;
}
export interface DataExportProps {
variant?: DataExportVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title: string;
subtitle?: string;
/** What gets exported, e.g. contacts */
entity?: string;
/** quick-export: rows in all data, in the current filter and selected */
counts?: { all: number; filtered?: number; selected?: number };
/** columns */
columns?: ExportColumn[];
/** scheduled */
schedules?: ScheduledExport[];
/** history */
files?: ExportFile[];
/** account-archive */
parts?: ArchivePart[];
/** account-archive: where the link goes */
email?: string;
/** ISO date and time that counts as now */
now?: string;
/** Called on export, save and download. Without it the block simulates the work. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
format: 'Format',
csv: 'CSV',
xlsx: 'Excel',
json: 'JSON',
pdf: 'PDF',
csvText: 'For any spreadsheet',
xlsxText: 'With column widths',
jsonText: 'For developers',
pdfText: 'To print or send',
which: 'Which rows',
all: 'All {entity}',
filtered: 'Current filter',
selected: 'Selected only',
rowsN: '{n} rows',
options: 'Options',
headers: 'Include column names',
dates: 'Dates as YYYY-MM-DD',
export: 'Export {n} rows',
exporting: 'Preparing your file…',
ready: 'Your file is ready',
download: 'Download',
another: 'Export again',
emailToo: 'We also sent the link to your email.',
columns: 'Columns',
columnsText: 'Pick the columns and their order. The preview shows the first rows.',
picked: '{n} of {total} columns',
pickAll: 'All',
pickNone: 'None',
moveUp: 'Move {name} up',
moveDown: 'Move {name} down',
preview: 'Preview',
noColumns: 'Pick at least one column.',
saveSet: 'Save as preset',
saved: 'Preset saved',
scheduled: 'Scheduled exports',
scheduledText: 'Fresh files arrive on their own, no one has to remember.',
next: 'Next {when}',
paused: 'Paused',
toggle: 'Run {name}',
newSchedule: 'New scheduled export',
name: 'Name',
every: 'Repeat',
daily: 'Every day',
weekly: 'Every Monday',
monthly: 'On the 1st',
at: 'At',
sendTo: 'Send to',
toEmail: 'Email',
toFolder: 'Shared folder',
address: 'Address',
folder: 'Folder path',
add: 'Add schedule',
adding: 'Adding…',
added: '{name} added.',
nameError: 'Give it a name.',
toError: 'Fill in where the file goes.',
history: 'Export history',
historyText: 'Links work for 7 days, then the files are deleted.',
statusReady: 'Ready',
statusRunning: 'Running',
statusExpired: 'Expired',
statusFailed: 'Failed',
expiresIn: 'Link works {when}',
rerun: 'Export again',
retry: 'Try again',
by: 'by {name}',
allStatus: 'All',
archive: 'Download your data',
archiveText: 'A ZIP with everything you pick. We email you a link as soon as it is ready, usually within an hour.',
whatIn: 'What goes in',
total: 'About {size}',
confirm: 'Confirm with your password',
password: 'Password',
passwordError: 'Enter your password to continue.',
request: 'Request archive',
requesting: 'Requesting…',
steps: 'What happens next',
step1: 'We collect your data',
step2: 'We pack it into a ZIP',
step3: 'You get an email with a link',
requested: 'We are on it',
requestedText: 'The link goes to {email}. It works for 7 days and needs your password again.',
eta: 'Ready in about {n} minutes',
},
de: {
format: 'Format',
csv: 'CSV',
xlsx: 'Excel',
json: 'JSON',
pdf: 'PDF',
csvText: 'Für jede Tabelle',
xlsxText: 'Mit Spaltenbreiten',
jsonText: 'Für Entwickler',
pdfText: 'Zum Drucken und Senden',
which: 'Welche Zeilen',
all: 'Alle {entity}',
filtered: 'Aktueller Filter',
selected: 'Nur ausgewählte',
rowsN: '{n} Zeilen',
options: 'Optionen',
headers: 'Spaltennamen mitnehmen',
dates: 'Datum als JJJJ-MM-TT',
export: '{n} Zeilen exportieren',
exporting: 'Deine Datei wird vorbereitet…',
ready: 'Deine Datei ist fertig',
download: 'Herunterladen',
another: 'Erneut exportieren',
emailToo: 'Den Link haben wir dir auch per E-Mail geschickt.',
columns: 'Spalten',
columnsText: 'Wähle Spalten und Reihenfolge. Die Vorschau zeigt die ersten Zeilen.',
picked: '{n} von {total} Spalten',
pickAll: 'Alle',
pickNone: 'Keine',
moveUp: '{name} nach oben',
moveDown: '{name} nach unten',
preview: 'Vorschau',
noColumns: 'Wähle mindestens eine Spalte.',
saveSet: 'Als Vorlage speichern',
saved: 'Vorlage gespeichert',
scheduled: 'Geplante Exporte',
scheduledText: 'Frische Dateien kommen von selbst, niemand muss daran denken.',
next: 'Nächster {when}',
paused: 'Pausiert',
toggle: '{name} ausführen',
newSchedule: 'Neuer geplanter Export',
name: 'Name',
every: 'Wiederholen',
daily: 'Jeden Tag',
weekly: 'Jeden Montag',
monthly: 'Am 1. des Monats',
at: 'Um',
sendTo: 'Senden an',
toEmail: 'E-Mail',
toFolder: 'Geteilter Ordner',
address: 'Adresse',
folder: 'Ordnerpfad',
add: 'Export planen',
adding: 'Wird geplant…',
added: '{name} hinzugefügt.',
nameError: 'Gib ihm einen Namen.',
toError: 'Trag ein, wohin die Datei geht.',
history: 'Exportverlauf',
historyText: 'Links funktionieren 7 Tage, danach werden die Dateien gelöscht.',
statusReady: 'Bereit',
statusRunning: 'Läuft',
statusExpired: 'Abgelaufen',
statusFailed: 'Fehlgeschlagen',
expiresIn: 'Link gilt {when}',
rerun: 'Erneut exportieren',
retry: 'Erneut versuchen',
by: 'von {name}',
allStatus: 'Alle',
archive: 'Deine Daten herunterladen',
archiveText: 'Ein ZIP mit allem, was du wählst. Wir schicken dir einen Link, sobald es fertig ist, meist innerhalb einer Stunde.',
whatIn: 'Was hinein kommt',
total: 'Etwa {size}',
confirm: 'Mit deinem Passwort bestätigen',
password: 'Passwort',
passwordError: 'Gib dein Passwort ein, um weiterzumachen.',
request: 'Archiv anfordern',
requesting: 'Wird angefordert…',
steps: 'So geht es weiter',
step1: 'Wir sammeln deine Daten',
step2: 'Wir packen sie in ein ZIP',
step3: 'Du bekommst eine E-Mail mit Link',
requested: 'Wir sind dran',
requestedText: 'Der Link geht an {email}. Er gilt 7 Tage und fragt noch einmal nach deinem Passwort.',
eta: 'Fertig in etwa {n} Minuten',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/* ------------------------------------------------------------------ */
/* Motion of the app screens */
/* ------------------------------------------------------------------ */
/*
* The same small set in every app block (auth, dashboard, files, inbox …),
* kept in the block so it ships on its own:
* useEnter entrance that waits until its area scrolls in. The area gets
* data-kitm-scope and data-kitm={state}, children take kitm-wipe
* (charts draw from the left), kitm-grow-x, kitm-grow-y (bars),
* kitm-up and kitm-pop, with --kitm-d as delay.
* useGlide a marker inside the active item ([data-glide]) glides over
* from where the previous one stood: tabs, segments, steps.
* kitm-in, kitm-fly, kitm-tick for things that appear after an action.
* The server renders the end state, without JavaScript nothing waits longer
* than 2.5 s, with reduced motion nothing moves. Only transform, opacity and
* clip-path change, so no layout shifts.
*/
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const KITM_EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const KITM_FX: [string, string, number][] = [
['wipe', 'clip-path:inset(0 100% 0 0)', 1100],
['grow-x', 'transform:scaleX(0)', 900],
['grow-y', 'transform:scaleY(0)', 800],
['up', 'opacity:0;transform:translateY(10px)', 600],
['pop', 'opacity:0;transform:scale(.92)', 450],
];
const KITM_CSS =
KITM_FX.map(([k, v]) => `@keyframes kitm-${k}{from{${v}}}@keyframes kitm-${k}-h{from{${v}}}`).join('') +
'@keyframes kitm-fly{from{opacity:0;transform:translateX(24px)}}@keyframes kitm-sheen{to{transform:translateX(130%)}}.kitm-sheen{transform:translateX(-130%)}@keyframes kitm-blink{50%{opacity:0}}@keyframes kitm-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}@keyframes kitm-ring{20%{transform:rotate(14deg)}40%{transform:rotate(-11deg)}60%{transform:rotate(7deg)}80%{transform:rotate(-3deg)}}@keyframes kitm-next{from{opacity:0;transform:translateX(18px)}}@keyframes kitm-prev{from{opacity:0;transform:translateX(-18px)}}.kitm-caret{display:inline-block;width:.45em;height:1.1em;margin-left:.15em;vertical-align:-.2em;border-radius:2px;background:color-mix(in oklab,var(--primary) 80%,var(--foreground))}@keyframes kitm-flash{from{background-color:color-mix(in oklab,var(--primary) 14%,transparent)}}' +
'.kitm-grow-x{transform-origin:left}.kitm-grow-y{transform-origin:bottom}' +
'@media (prefers-reduced-motion:no-preference){' +
KITM_FX.map(([k, , d]) => `[data-kitm=play] .kitm-${k}{animation:kitm-${k} var(--kitm-t,${d}ms) var(--ease-kit) var(--kitm-d,0ms) both}[data-kitm=wait] .kitm-${k}{animation:kitm-${k}-h 0s 1s both paused}`).join('') +
'[data-kitm=play] .kitm-sheen{animation:kitm-sheen 1.6s var(--ease-kit) var(--kitm-d,400ms) both}.kitm-in{animation:kitm-up var(--kitm-t,.5s) var(--ease-kit) var(--kitm-d,0ms) both}.kitm-fly{animation:kitm-fly .55s var(--ease-kit) var(--kitm-d,0ms) both}.kitm-tick{animation:kitm-wipe .36s var(--ease-kit) both}.kitm-caret{animation:kitm-blink 1.05s steps(1) infinite}.kitm-shake{animation:kitm-shake .4s ease-in-out}.kitm-ring{transform-origin:50% 8%;animation:kitm-ring .9s var(--ease-kit) both}.kitm-next{animation:kitm-next .45s var(--ease-kit) both}.kitm-prev{animation:kitm-prev .45s var(--ease-kit) both}.kitm-flash{animation:kitm-flash 1.6s ease-out both}' +
'}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
KITM_FX.map(([k]) => `[data-kitm-scope]:not([data-kitm]) .kitm-${k}{animation:kitm-${k}-h 0s 2.5s both}`).join('') +
'}';
function KitmStyle() {
// React hoists it once into the head, however many app blocks render
return (
<style href="kitm" precedence="default">
{KITM_CSS}
</style>
);
}
/** Entrance state of an area: undefined on the server, then wait (below the fold) or play */
function useEnter<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
useEffect(() => {
const el = ref.current;
if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
setState('wait');
const io = new IntersectionObserver(
([e]) => {
if (!e?.isIntersecting) return;
setState('play');
io.disconnect();
},
{ rootMargin: '0px 0px -8% 0px' },
);
io.observe(el);
return () => io.disconnect();
}, []);
return [ref, { 'data-kitm-scope': '', 'data-kitm': state }] as const;
}
type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
const r = el.getBoundingClientRect();
const b = root.getBoundingClientRect();
return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};
/**
* The marker ([data-glide]) is rendered inside the active item, so the server
* and a page without JavaScript show it in place. After a change it starts
* where the old one stood and glides into the new item.
*/
function useGlide<T extends HTMLElement>(key: unknown) {
const ref = useRef<T>(null);
const before = useRef<Box | null>(null);
// Read during render, while the DOM still shows the old marker
if (typeof window !== 'undefined' && ref.current) {
const m = ref.current.querySelector('[data-glide]');
before.current = m ? boxOf(m, ref.current) : null;
}
useIso(() => {
const root = ref.current;
const m = root?.querySelector<HTMLElement>('[data-glide]');
const a = before.current;
if (!root || !m || !a || prefersReducedMotion()) return;
const b = boxOf(m, root);
if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: KITM_EASE });
}, [key]);
return ref;
}
/* ------------------------------------------------------------------ */
/* Parts */
/* ------------------------------------------------------------------ */
type P = Omit<DataExportProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; locale: Locale; entity: string; now: string };
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: DataExportProps['onAction'], action: string, data: Record<string, unknown>, ms = 700) {
if (onAction) await onAction(action, data);
else await wait(prefersReducedMotion() ? 0 : ms);
}
const MIN = 60000;
const toMin = (iso: string) => Math.round(Date.parse(iso + (iso.length <= 10 ? 'T00:00:00Z' : iso.endsWith('Z') ? '' : 'Z')) / MIN);
const num = (l: Locale, n: number) => new Intl.NumberFormat(l === 'de' ? 'de-DE' : 'en-US').format(n);
function bytes(l: Locale, n: number) {
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let v = n;
while (v >= 1000 && i < units.length - 1) {
v /= 1000;
i++;
}
return `${new Intl.NumberFormat(l === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: v < 10 && i ? 1 : 0 }).format(v)} ${units[i]}`;
}
function rel(l: Locale, iso: string, now: string) {
const rtf = new Intl.RelativeTimeFormat(l === 'de' ? 'de-DE' : 'en-US', { numeric: 'auto' });
const d = toMin(iso) - toMin(now);
const a = Math.abs(d);
if (a < 60) return rtf.format(Math.round(d), 'minute');
if (a < 1440) return rtf.format(Math.round(d / 60), 'hour');
return rtf.format(Math.round(d / 1440), 'day');
}
/** Next run after now for a new schedule, as YYYY-MM-DDTHH:mm, computed in UTC so server and browser agree. */
function nextRun(every: 'daily' | 'weekly' | 'monthly', time: string, now: string) {
const [h, m] = time.split(':').map(Number);
const base = new Date(`${now.slice(0, 16)}:00Z`);
const d = new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth(), base.getUTCDate(), h || 0, m || 0));
if (every === 'monthly') {
d.setUTCDate(1);
if (d <= base) d.setUTCMonth(d.getUTCMonth() + 1);
} else if (every === 'weekly') {
d.setUTCDate(d.getUTCDate() + ((8 - d.getUTCDay()) % 7));
if (d <= base) d.setUTCDate(d.getUTCDate() + 7);
} else if (d <= base) d.setUTCDate(d.getUTCDate() + 1);
return d.toISOString().slice(0, 16);
}
const FORMAT_ICON: Record<ExportFormat, IconName> = { csv: 'table', xlsx: 'layout', json: 'file-code', pdf: 'file' };
const control = 'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-10 sm:text-[14px]';
function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
<h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
function Main({ children, className }: { children: React.ReactNode; className?: string }) {
return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}
function Card({ title, description, action, children, className, bodyClassName }: { title?: React.ReactNode; description?: React.ReactNode; action?: React.ReactNode; children: React.ReactNode; className?: string; bodyClassName?: string }) {
return (
<section className={cn('flex min-w-0 flex-col rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)}>
{(title || action) && (
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
<div className="min-w-0">
{title && <h2 className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">{title}</h2>}
{description && <p className="mt-0.5 text-pretty text-[13px] text-muted-foreground">{description}</p>}
</div>
{action}
</div>
)}
<div className={cn('flex-1 p-4 sm:p-5', bodyClassName)}>{children}</div>
</section>
);
}
function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
const ref = useGlide<HTMLDivElement>(value);
const onKey = (e: React.KeyboardEvent, i: number) => {
const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!dir) return;
e.preventDefault();
const next = (i + dir + options.length) % options.length;
onChange(options[next].value);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
};
return (
<div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-muted p-1', className)}>
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
tabIndex={value === o.value ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={(e) => onKey(e, i)}
className={cn(
'relative inline-flex h-11 min-w-11 sm:h-10 sm:min-w-0 flex-1 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] sm:h-8',
value === o.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
>
{value === o.value && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
<span className="relative inline-flex items-center gap-1.5">{o.label}</span>
</button>
))}
</div>
);
}
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
/** A drawn file with its type on the fold */
function FileArt({ format, className }: { format: ExportFormat; className?: string }) {
const tone = format === 'csv' || format === 'xlsx' ? 'bg-success' : format === 'json' ? 'bg-primary' : 'bg-destructive';
return (
<span aria-hidden className={cn('relative grid h-11 w-9 shrink-0 place-items-end rounded-md border border-border bg-card shadow-[var(--shadow-sm)]', className)}>
<span className="absolute end-0 top-0 size-2.5 rounded-bl-[4px] border-b border-s border-border bg-surface-2" />
<span className={cn('mb-1.5 me-auto ms-1 rounded-[3px] px-1 text-[8.5px] font-bold uppercase leading-[14px] text-white', tone)}>{format}</span>
</span>
);
}
/** A radio card in a grid, the picked one lit */
function Tiles<T extends string>({ label, value, onChange, items, className }: { label: string; value: T; onChange: (v: T) => void; items: { value: T; title: string; text: string; icon?: IconName; art?: React.ReactNode }[]; className?: string }) {
const id = useId();
return (
<fieldset className={className}>
<legend className="mb-2 text-[13px] font-medium text-foreground">{label}</legend>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{items.map((x) => {
const on = x.value === value;
return (
<label key={x.value} className={cn('relative flex cursor-pointer flex-col gap-2 rounded-xl border p-3 transition-[border-color,background-color,box-shadow] duration-200 has-[:focus-visible]:outline has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))] shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_12%,transparent)]' : 'border-border hover:border-border-strong')}>
<input type="radio" name={id} checked={on} onChange={() => onChange(x.value)} className="peer sr-only" />
<span className="flex items-start justify-between">
{x.art ?? (x.icon && <Icon name={x.icon} size={18} className="text-muted-foreground" />)}
<span aria-hidden className={cn('grid size-[18px] place-items-center rounded-full border transition-colors', on ? 'border-primary bg-primary' : 'border-border-strong')}>
<span className={cn('size-[7px] rounded-full bg-primary-foreground transition-transform duration-300', on ? 'scale-100' : 'scale-0')} />
</span>
</span>
<span>
<span className="block text-[14px] font-semibold text-foreground">{x.title}</span>
<span className="block text-[12px] leading-snug text-muted-foreground">{x.text}</span>
</span>
</label>
);
})}
</div>
</fieldset>
);
}
/* ------------------------------------------------------------------ */
/* quick-export */
/* ------------------------------------------------------------------ */
function QuickExport({ t, title, subtitle, entity, counts = { all: 0 }, locale, onAction }: P) {
const id = useId();
const [format, setFormat] = useState<ExportFormat>('csv');
const [scope, setScope] = useState<'all' | 'filtered' | 'selected'>(counts.filtered !== undefined ? 'filtered' : 'all');
const [headers, setHeaders] = useState(true);
const [iso, setIso] = useState(false);
const [state, setState] = useState<'idle' | 'busy' | 'done'>('idle');
const [progress, setProgress] = useState(0);
const [area, enter] = useEnter<HTMLDivElement>();
const n = scope === 'all' ? counts.all : scope === 'filtered' ? counts.filtered ?? 0 : counts.selected ?? 0;
const size = Math.round(n * (format === 'pdf' ? 420 : format === 'xlsx' ? 160 : format === 'json' ? 380 : 210));
const file = `${entity.toLowerCase().replace(/\s+/g, '-')}-2026-10-19.${format}`;
const go = async () => {
setState('busy');
setProgress(0);
const steps = prefersReducedMotion() ? 1 : 16;
for (let i = 1; i <= steps; i++) {
await wait(70);
setProgress(i / steps);
}
await run(onAction, 'export', { format, scope, headers, iso }, 0);
setState('done');
};
const scopes: { v: typeof scope; label: string; n?: number }[] = [
{ v: 'all', label: fill(t.all, { entity }), n: counts.all },
...(counts.filtered !== undefined ? [{ v: 'filtered' as const, label: t.filtered, n: counts.filtered }] : []),
...(counts.selected !== undefined ? [{ v: 'selected' as const, label: t.selected, n: counts.selected }] : []),
];
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={area} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] lg:items-start">
<section className="kitm-up min-w-0 rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-6">
<Tiles
label={t.format}
value={format}
onChange={(v) => {
setFormat(v);
setState('idle');
}}
items={(['csv', 'xlsx', 'json', 'pdf'] as ExportFormat[]).map((f) => ({ value: f, title: t[f], text: t[`${f}Text` as 'csvText'], art: <FileArt format={f} /> }))}
/>
<fieldset className="mt-6">
<legend className="mb-2 text-[13px] font-medium text-foreground">{t.which}</legend>
<div className="grid gap-2">
{scopes.map((s) => (
<label key={s.v} className={cn('flex min-h-12 cursor-pointer items-center gap-3 rounded-lg border px-3.5 transition-colors', scope === s.v ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_5%,var(--card))]' : 'border-border hover:border-border-strong')}>
<input
type="radio"
name={`${id}-scope`}
checked={scope === s.v}
onChange={() => {
setScope(s.v);
setState('idle');
}}
className="size-4 accent-[var(--primary)]"
/>
<span className="flex-1 text-[14px] text-foreground">{s.label}</span>
<span className="text-[13px] tabular-nums text-muted-foreground">{fill(t.rowsN, { n: num(locale, s.n ?? 0) })}</span>
</label>
))}
</div>
</fieldset>
<div className="mt-6 grid gap-1 border-t border-border pt-4">
<p className="mb-1 text-[13px] font-medium text-foreground">{t.options}</p>
<Switch checked={headers} onCheckedChange={setHeaders} label={t.headers} className="py-1.5" />
<Switch checked={iso} onCheckedChange={setIso} label={t.dates} className="py-1.5" />
</div>
</section>
{/* The file that comes out */}
<aside className="kitm-up [--kitm-d:120ms] rounded-xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] lg:sticky lg:top-6">
<div className="flex items-center gap-3.5">
<FileArt key={format} format={format} className="kitm-pop h-14 w-11" />
<div className="min-w-0">
<p className="truncate text-[14px] font-semibold text-foreground">{file}</p>
<p className="text-[12.5px] tabular-nums text-muted-foreground">
{fill(t.rowsN, { n: num(locale, n) })} · ~{bytes(locale, size)}
</p>
</div>
</div>
<div className="mt-5" role="status" aria-live="polite">
{state === 'busy' && (
<div className="kitm-in">
<p className="text-[13px] text-muted-foreground">{t.exporting}</p>
<span className="mt-2 block h-2 overflow-hidden rounded-full bg-track">
<span className="block h-full origin-left rounded-full bg-primary transition-transform duration-100" style={{ transform: `scaleX(${round(progress, 3)})` }} />
</span>
</div>
)}
{state === 'done' && (
<div className="kitm-in rounded-lg bg-[color-mix(in_oklab,var(--success)_9%,var(--card))] p-3.5">
<p className="flex items-center gap-2 text-[14px] font-semibold text-foreground">
<Icon name="check-circle" size={16} className="text-success-foreground" />
{t.ready}
</p>
<p className="mt-1 text-[12.5px] text-muted-foreground">{t.emailToo}</p>
</div>
)}
</div>
<div className="mt-5 grid gap-2">
{state === 'done' ? (
<>
<Button iconLeft="download" onClick={() => void onAction?.('download', { file })}>
{t.download}
</Button>
<Button variant="ghost" size="sm" onClick={() => setState('idle')}>
{t.another}
</Button>
</>
) : (
<Button disabled={!n || state === 'busy'} iconLeft={state === 'busy' ? 'loader' : 'download'} onClick={go}>
{state === 'busy' ? t.exporting : fill(t.export, { n: num(locale, n) })}
</Button>
)}
</div>
</aside>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* columns */
/* ------------------------------------------------------------------ */
function Columns({ t, title, subtitle, columns: start = [], onAction }: P) {
const [cols, setCols] = useState(start.map((c) => ({ ...c, on: c.on ?? true })));
const [saved, setSaved] = useState(false);
const [area, enter] = useEnter<HTMLDivElement>();
const listRef = useRef<HTMLUListElement>(null);
const [rowRef, fade] = useScrollFade<HTMLDivElement>();
const on = cols.filter((c) => c.on);
const set = (fn: (l: typeof cols) => typeof cols) => {
setCols(fn);
setSaved(false);
};
const move = (i: number, d: number) =>
set((l) => {
const j = i + d;
if (j < 0 || j >= l.length) return l;
const c = [...l];
[c[i], c[j]] = [c[j], c[i]];
return c;
});
const before = useRef<Map<string, number>>(new Map());
if (typeof window !== 'undefined' && listRef.current) before.current = new Map(Array.from(listRef.current.children, (el) => [(el as HTMLElement).dataset.id ?? '', el.getBoundingClientRect().top]));
useIso(() => {
if (!listRef.current || prefersReducedMotion()) return;
Array.from(listRef.current.children).forEach((el) => {
const a = before.current.get((el as HTMLElement).dataset.id ?? '');
const b = el.getBoundingClientRect().top;
if (a !== undefined && Math.abs(a - b) > 1) el.animate([{ transform: `translateY(${a - b}px)` }, { transform: 'none' }], { duration: 400, easing: KITM_EASE });
});
}, [cols.map((c) => c.id).join()]);
const rows = Math.max(0, ...on.map((c) => c.samples?.length ?? 0));
return (
<Main>
<Header title={title} subtitle={subtitle} />
<p className="mt-2 text-[13.5px] text-muted-foreground">{t.columnsText}</p>
<div ref={area} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[20rem_minmax(0,1fr)] lg:items-start">
<Card
title={t.columns}
description={<span key={on.length} className="kitm-in tabular-nums">{fill(t.picked, { n: on.length, total: cols.length })}</span>}
className="kitm-up"
bodyClassName="p-0 sm:p-0"
action={
<span className="flex gap-1">
<button type="button" onClick={() => set((l) => l.map((c) => ({ ...c, on: true })))} className="h-11 min-w-11 rounded-md px-2.5 text-[12.5px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground sm:h-8 sm:min-w-0 sm:px-2">
{t.pickAll}
</button>
<button type="button" onClick={() => set((l) => l.map((c) => ({ ...c, on: false })))} className="h-11 min-w-11 rounded-md px-2.5 text-[12.5px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground sm:h-8 sm:min-w-0 sm:px-2">
{t.pickNone}
</button>
</span>
}
>
<ul ref={listRef} className="divide-y divide-border">
{cols.map((c, i) => (
<li key={c.id} data-id={c.id} className={cn('flex items-center gap-2 bg-card px-3 py-1.5 transition-colors', !c.on && 'bg-surface')}>
<label className="flex min-h-11 min-w-0 flex-1 cursor-pointer items-center gap-3 px-1">
<input type="checkbox" checked={c.on} onChange={() => set((l) => l.map((x) => (x.id === c.id ? { ...x, on: !x.on } : x)))} className="size-4 shrink-0 accent-[var(--primary)]" />
<span className={cn('truncate text-[14px]', c.on ? 'text-foreground' : 'text-muted-foreground')}>{c.label}</span>
</label>
<button type="button" aria-label={fill(t.moveUp, { name: c.label })} disabled={i === 0} onClick={() => move(i, -1)} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 sm:size-8">
<Icon name="chevron-up" size={15} />
</button>
<button type="button" aria-label={fill(t.moveDown, { name: c.label })} disabled={i === cols.length - 1} onClick={() => move(i, 1)} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 sm:size-8">
<Icon name="chevron-down" size={15} />
</button>
</li>
))}
</ul>
</Card>
<section className="kitm-up [--kitm-d:100ms] min-w-0 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
<h2 className="text-[14px] font-semibold text-foreground">{t.preview}</h2>
<Button
size="sm"
variant={saved ? 'ghost' : 'outline'}
iconLeft={saved ? 'check' : 'bookmark'}
disabled={!on.length}
onClick={async () => {
await run(onAction, 'save-columns', { columns: on.map((c) => c.id) });
setSaved(true);
}}
>
{saved ? t.saved : t.saveSet}
</Button>
</div>
{on.length ? (
<div ref={rowRef} className={cn('kit-no-scrollbar overflow-x-auto outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)]', fade)} tabIndex={0} aria-label={t.preview}>
<table className="w-full min-w-max border-collapse text-left text-[13px]">
<thead>
<tr className="border-b border-border bg-surface">
{on.map((c) => (
<th key={c.id} scope="col" className="kitm-in whitespace-nowrap px-4 py-2.5 text-[12px] font-semibold text-foreground">
{c.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{Array.from({ length: rows }, (_, r) => (
<tr key={r}>
{on.map((c) => (
<td key={c.id} className="max-w-[14rem] truncate whitespace-nowrap px-4 py-2.5 tabular-nums text-muted-foreground">
{c.samples?.[r] ?? ''}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="px-5 py-12 text-center text-[13.5px] text-muted-foreground">{t.noColumns}</p>
)}
</section>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* scheduled */
/* ------------------------------------------------------------------ */
function Scheduled({ t, title, subtitle, schedules = [], now, locale, onAction }: P) {
const id = useId();
const [items, setItems] = useState(schedules);
const [name, setName] = useState('');
const [format, setFormat] = useState<ExportFormat>('xlsx');
const [every, setEvery] = useState<'daily' | 'weekly' | 'monthly'>('weekly');
const [time, setTime] = useState('07:00');
const [toKind, setToKind] = useState<'email' | 'folder'>('email');
const [to, setTo] = useState('');
const [tried, setTried] = useState(false);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
const [fresh, setFresh] = useState('');
const [area, enter] = useEnter<HTMLDivElement>();
const add = async (e: React.FormEvent) => {
e.preventDefault();
setTried(true);
if (!name.trim() || !to.trim()) return;
setBusy(true);
await run(onAction, 'schedule', { name, format, every, time, toKind, to });
const s: ScheduledExport = { id: `s${Date.now()}`, name: name.trim(), format, when: `${t[every]}, ${time}`, to: to.trim(), toKind, enabled: true, next: nextRun(every, time, now) };
setItems((l) => [s, ...l]);
setFresh(s.id);
setName('');
setTo('');
setTried(false);
setBusy(false);
setMsg(fill(t.added, { name: s.name }));
};
return (
<Main>
<Header title={title} subtitle={subtitle} />
<p className="mt-2 text-[13.5px] text-muted-foreground">{t.scheduledText}</p>
<div ref={area} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-start">
<ul className="grid grid-cols-[minmax(0,1fr)] gap-3">
{items.map((s, i) => (
<li key={s.id} className={cn('kitm-up flex items-start gap-4 rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] transition-opacity sm:p-5', !s.enabled && 'opacity-70', s.id === fresh && 'kitm-flash')} style={{ '--kitm-d': `${i * 60}ms` } as React.CSSProperties}>
<FileArt format={s.format} />
<div className="min-w-0 flex-1">
<p className="truncate text-[15px] font-semibold text-foreground">{s.name}</p>
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12.5px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<Icon name="repeat" size={13} />
{s.when}
</span>
<span className="inline-flex min-w-0 items-center gap-1.5">
<Icon name={s.toKind === 'email' ? 'mail' : 'folder'} size={13} className="shrink-0" />
<span className="truncate">{s.to}</span>
</span>
</p>
<p className={cn('mt-1 text-[12.5px] font-medium', s.enabled ? 'text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]' : 'text-subtle-foreground')}>{s.enabled ? (s.next ? fill(t.next, { when: rel(locale, s.next, now) }) : '') : t.paused}</p>
</div>
<Switch
checked={s.enabled}
aria-label={fill(t.toggle, { name: s.name })}
onCheckedChange={(v) => {
setItems((l) => l.map((x) => (x.id === s.id ? { ...x, enabled: v } : x)));
void run(onAction, 'toggle', { id: s.id, enabled: v });
}}
/>
</li>
))}
</ul>
<form noValidate onSubmit={add} className="kitm-up [--kitm-d:120ms] grid gap-4 rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5 lg:sticky lg:top-6">
<h2 className="text-[14px] font-semibold text-foreground">{t.newSchedule}</h2>
<div className="grid gap-1.5">
<label className="text-[13px] font-medium text-foreground" htmlFor={`${id}-n`}>{t.name}</label>
<input id={`${id}-n`} value={name} onChange={(e) => setName(e.target.value)} aria-invalid={tried && !name.trim()} aria-describedby={tried && !name.trim() ? `${id}-ne` : undefined} className={cn(control, tried && !name.trim() && 'border-[color-mix(in_oklab,var(--destructive)_70%,var(--border))]')} />
{tried && !name.trim() && <span id={`${id}-ne`} className="kitm-in text-[12.5px] font-medium text-destructive">{t.nameError}</span>}
</div>
<div className="grid gap-1.5">
<span className="text-[13px] font-medium text-foreground">{t.format}</span>
<Segmented label={t.format} value={format} onChange={setFormat} className="flex w-full" options={(['csv', 'xlsx', 'json', 'pdf'] as ExportFormat[]).map((f) => ({ value: f, label: t[f] }))} />
</div>
<div className="grid grid-cols-[minmax(0,1fr)_6.5rem] gap-3">
<label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-e`}>
{t.every}
<span className="relative block">
<select id={`${id}-e`} value={every} onChange={(e) => setEvery(e.target.value as typeof every)} className={cn(control, 'appearance-none pe-8')}>
<option value="daily">{t.daily}</option>
<option value="weekly">{t.weekly}</option>
<option value="monthly">{t.monthly}</option>
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</span>
</label>
<label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-t`}>
{t.at}
<input id={`${id}-t`} type="time" value={time} onChange={(e) => setTime(e.target.value)} className={cn(control, 'tabular-nums')} />
</label>
</div>
<div className="grid gap-1.5">
<span className="text-[13px] font-medium text-foreground">{t.sendTo}</span>
<Segmented
label={t.sendTo}
value={toKind}
onChange={setToKind}
className="flex w-full"
options={[
{ value: 'email', label: <><Icon name="mail" size={14} />{t.toEmail}</> },
{ value: 'folder', label: <><Icon name="folder" size={14} />{t.toFolder}</> },
]}
/>
<input aria-label={toKind === 'email' ? t.address : t.folder} value={to} onChange={(e) => setTo(e.target.value)} placeholder={toKind === 'email' ? 'team@example.com' : '/exports/weekly'} aria-invalid={tried && !to.trim()} aria-describedby={tried && !to.trim() ? `${id}-te` : undefined} className={cn(control, 'mt-1', tried && !to.trim() && 'border-[color-mix(in_oklab,var(--destructive)_70%,var(--border))]')} />
{tried && !to.trim() && <span id={`${id}-te`} className="kitm-in text-[12.5px] font-medium text-destructive">{t.toError}</span>}
</div>
<Button type="submit" disabled={busy} iconLeft={busy ? 'loader' : 'plus'}>
{busy ? t.adding : t.add}
</Button>
</form>
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* history */
/* ------------------------------------------------------------------ */
function History({ t, title, subtitle, files = [], now, locale, onAction }: P) {
const [items, setItems] = useState(files);
const [filter, setFilter] = useState<'all' | ExportFile['status']>('all');
const [area, enter] = useEnter<HTMLDivElement>();
const shown = items.filter((f) => filter === 'all' || f.status === filter);
const label: Record<ExportFile['status'], string> = { ready: t.statusReady, running: t.statusRunning, expired: t.statusExpired, failed: t.statusFailed };
const tone: Record<ExportFile['status'], string> = {
ready: 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground',
running: 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]',
expired: 'bg-muted text-muted-foreground',
failed: 'bg-[color-mix(in_oklab,var(--destructive)_11%,transparent)] text-destructive',
};
// A running export finishes on its own after a moment
useEffect(() => {
if (!items.some((f) => f.status === 'running')) return;
const id = window.setTimeout(() => setItems((l) => l.map((f) => (f.status === 'running' ? { ...f, status: 'ready', expires: f.expires ?? now } : f))), prefersReducedMotion() ? 0 : 3200);
return () => window.clearTimeout(id);
}, [items, now]);
const rerun = (f: ExportFile) => {
const n: ExportFile = { ...f, id: `e${Date.now()}`, created: now, status: 'running', expires: undefined };
setItems((l) => [n, ...l]);
void onAction?.('rerun', { id: f.id });
};
return (
<Main>
<Header title={title} subtitle={subtitle}>
<Segmented
label={t.history}
value={filter}
onChange={setFilter}
className="grid w-full grid-cols-2 gap-1 sm:flex sm:w-auto"
options={[
{ value: 'all', label: t.allStatus },
{ value: 'ready', label: t.statusReady },
{ value: 'expired', label: t.statusExpired },
{ value: 'failed', label: t.statusFailed },
]}
/>
</Header>
<p className="mt-2 text-[13.5px] text-muted-foreground">{t.historyText}</p>
<div ref={area} {...enter} className="mt-6">
<ul className="kitm-up overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
{shown.map((f) => (
<li key={f.id} className={cn('grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-4 gap-y-2 border-b border-border px-4 py-3.5 last:border-b-0 sm:px-5 md:grid-cols-[auto_minmax(0,1.4fr)_minmax(0,1fr)_7rem_10rem]', f.status === 'running' && 'kitm-fly')}>
<FileArt format={f.format} className={cn(f.status === 'expired' && 'opacity-50')} />
<div className="min-w-0">
<p className={cn('truncate text-[14px] font-medium', f.status === 'expired' ? 'text-muted-foreground' : 'text-foreground')}>{f.name}</p>
<p className="truncate text-[12.5px] tabular-nums text-muted-foreground">
{fill(t.rowsN, { n: num(locale, f.rows) })} · {bytes(locale, f.size)}
<span className="md:hidden"> · {rel(locale, f.created, now)}</span>
</p>
</div>
<p className="hidden min-w-0 text-[12.5px] text-muted-foreground md:block">
<span className="block tabular-nums">{rel(locale, f.created, now)}</span>
{f.by && <span className="block truncate">{fill(t.by, { name: f.by })}</span>}
</p>
<span className={cn('col-start-2 inline-flex h-6 w-fit items-center gap-1.5 rounded-md px-2 text-[12px] font-semibold md:col-start-auto', tone[f.status])}>
{f.status === 'running' && <Icon name="loader" size={12} className="motion-safe:animate-spin" />}
{label[f.status]}
</span>
<div className="col-start-3 row-span-2 row-start-1 flex items-center justify-end gap-1 md:col-start-auto md:row-span-1 md:row-start-auto">
{f.status === 'ready' && (
<Button size="sm" variant="outline" iconLeft="download" onClick={() => void onAction?.('download', { id: f.id })} title={f.expires ? fill(t.expiresIn, { when: rel(locale, f.expires, now) }) : undefined}>
<span className="max-sm:sr-only">{t.download}</span>
</Button>
)}
{(f.status === 'expired' || f.status === 'failed') && (
<Button size="sm" variant="ghost" iconLeft="rotate-cw" onClick={() => rerun(f)}>
<span className="max-sm:sr-only">{f.status === 'failed' ? t.retry : t.rerun}</span>
</Button>
)}
</div>
</li>
))}
</ul>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* account-archive */
/* ------------------------------------------------------------------ */
function Archive({ t, title, subtitle, parts = [], email = '', locale, onAction }: P) {
const id = useId();
const [picked, setPicked] = useState<Set<string>>(new Set(parts.map((p) => p.id)));
const [pw, setPw] = useState('');
const [tried, setTried] = useState(false);
const [state, setState] = useState<'idle' | 'busy' | 'done'>('idle');
const [area, enter] = useEnter<HTMLDivElement>();
const total = parts.filter((p) => picked.has(p.id)).reduce((a, p) => a + p.size, 0);
const max = parts.reduce((a, p) => a + p.size, 0) || 1;
const minutes = Math.max(5, Math.round(total / 40_000_000));
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setTried(true);
if (!pw || !picked.size) return;
setState('busy');
await run(onAction, 'archive', { parts: [...picked] }, 900);
setState('done');
};
const steps = [t.step1, t.step2, t.step3];
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={area} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
<form noValidate onSubmit={submit} className="kitm-up min-w-0 rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
<div className="flex items-start gap-3.5 border-b border-border p-4 sm:p-6">
<span className="grid size-10 shrink-0 place-items-center rounded-lg bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
<Icon name="archive" size={18} />
</span>
<p className="max-w-[40rem] text-pretty text-[14px] leading-relaxed text-muted-foreground">{t.archiveText}</p>
</div>
{state === 'done' ? (
<div className="kitm-in px-4 py-10 text-center sm:px-6">
<span className="mx-auto grid size-14 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
<Icon name="archive" size={24} className="kitm-pop" />
</span>
<h3 className="mt-5 text-[18px] font-semibold text-foreground">{t.requested}</h3>
<p className="mx-auto mt-2 max-w-[28rem] text-pretty text-[14px] text-muted-foreground">{fill(t.requestedText, { email })}</p>
<p className="mt-4 inline-flex items-center gap-2 rounded-full bg-muted px-3 py-1 text-[12.5px] font-medium text-foreground">
<Icon name="clock" size={13} />
{fill(t.eta, { n: minutes })}
</p>
</div>
) : (
<div className="grid gap-6 p-4 sm:p-6">
<fieldset>
<legend className="flex w-full items-baseline justify-between gap-3 text-[13px] font-medium text-foreground">
{t.whatIn}
<span key={total} className="kitm-in text-[12.5px] font-normal tabular-nums text-muted-foreground">
{fill(t.total, { size: bytes(locale, total) })}
</span>
</legend>
{/* One bar for the whole archive, each part as wide as its size */}
<span aria-hidden className="mt-3 flex h-2.5 gap-[2px] overflow-hidden rounded-full bg-track">
{parts.map((p, i) => (
<span key={p.id} className="h-full transition-[flex-grow,opacity] duration-500 ease-[var(--ease-kit)]" style={{ flexGrow: picked.has(p.id) ? p.size / max : 0.0001, flexBasis: 0, background: `color-mix(in oklab, var(--primary) ${100 - i * 14}%, var(--muted))` }} />
))}
</span>
<ul className="mt-4 grid gap-2 sm:grid-cols-2">
{parts.map((p) => {
const on = picked.has(p.id);
return (
<li key={p.id}>
<label className={cn('flex min-h-14 cursor-pointer items-start gap-3 rounded-lg border p-3 transition-[border-color,background-color]', on ? 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_5%,var(--card))]' : 'border-border hover:border-border-strong')}>
<input
type="checkbox"
checked={on}
onChange={() =>
setPicked((s) => {
const n = new Set(s);
n.has(p.id) ? n.delete(p.id) : n.add(p.id);
return n;
})
}
className="mt-0.5 size-4 shrink-0 accent-[var(--primary)]"
/>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2 text-[13.5px] font-medium text-foreground">
<span className="flex min-w-0 items-center gap-2">
{p.icon && <Icon name={p.icon} size={14} className="shrink-0 text-muted-foreground" />}
<span className="truncate">{p.label}</span>
</span>
<span className="shrink-0 text-[12px] font-normal tabular-nums text-subtle-foreground">{bytes(locale, p.size)}</span>
</span>
{p.description && <span className="mt-0.5 block text-pretty text-[12.5px] leading-snug text-muted-foreground">{p.description}</span>}
</span>
</label>
</li>
);
})}
</ul>
</fieldset>
<div className="grid gap-1.5 sm:max-w-sm">
<label className="text-[13px] font-medium text-foreground" htmlFor={`${id}-pw`}>{t.confirm}</label>
<input id={`${id}-pw`} type="password" autoComplete="current-password" value={pw} onChange={(e) => setPw(e.target.value)} placeholder={t.password} aria-invalid={tried && !pw} aria-describedby={tried && !pw ? `${id}-pwe` : undefined} className={cn(control, tried && !pw && 'border-[color-mix(in_oklab,var(--destructive)_70%,var(--border))]')} />
{tried && !pw && <span id={`${id}-pwe`} className="kitm-in text-[12.5px] font-medium text-destructive">{t.passwordError}</span>}
</div>
<Button type="submit" disabled={state === 'busy' || !picked.size} iconLeft={state === 'busy' ? 'loader' : 'archive'} className="max-sm:w-full sm:justify-self-start">
{state === 'busy' ? t.requesting : t.request}
</Button>
</div>
)}
</form>
<aside className="kitm-up [--kitm-d:120ms] rounded-xl border border-border bg-surface p-4 sm:p-5 lg:sticky lg:top-6">
<h2 className="text-[14px] font-semibold text-foreground">{t.steps}</h2>
<ol className="relative mt-4 grid gap-4">
<span aria-hidden className="absolute bottom-3 start-[11px] top-3 w-px bg-border" />
{steps.map((s, i) => (
<li key={s} className="relative flex items-center gap-3">
<span className={cn('relative z-[1] grid size-6 shrink-0 place-items-center rounded-full text-[11.5px] font-semibold tabular-nums ring-4 ring-surface transition-colors duration-500', state === 'done' && i === 0 ? 'bg-primary text-primary-foreground' : 'bg-card text-foreground ring-offset-0 [box-shadow:inset_0_0_0_1px_var(--border)]')}>
{state === 'done' && i === 0 ? <Icon name="loader" size={12} className="motion-safe:animate-spin" /> : i + 1}
</span>
<span className="text-[13.5px] text-foreground">{s}</span>
</li>
))}
</ol>
</aside>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* DataExport */
/* ------------------------------------------------------------------ */
/**
* Export screens for apps: a quick export with format, rows and a ready
* file, columns to pick and order with a preview, scheduled exports to an
* address or folder, the export history and a full account archive.
* <DataExport variant="quick-export" title="Export" entity="contacts" counts={{ all, filtered, selected }} />
*/
export function DataExport({ variant = 'quick-export', sidebar, labels, className, entity = 'records', now = '2026-10-19T14:30', ...props }: DataExportProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const p: P = { ...props, t, locale, entity, now };
const body = (() => {
switch (variant) {
case 'columns':
return <Columns {...p} />;
case 'scheduled':
return <Scheduled {...p} />;
case 'history':
return <History {...p} />;
case 'account-archive':
return <Archive {...p} />;
default:
return <QuickExport {...p} />;
}
})();
if (!sidebar)
return (
<div className={cn('min-h-dvh bg-background', className)}>
<KitmStyle />
{body}
</div>
);
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
<KitmStyle />
{body}
</Sidebar>
);
}Häufige Fragen
Wie installiere ich Datenexport?
Mit aiw add @ai-website/data-export. Der Befehl legt die Datei components/ui/data-export.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, switch und sidebar. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Datenexport?
5 Varianten, Schneller Export, Spalten wählen, Geplante Exporte, Exportverlauf und Kontoarchiv. Du wählst sie mit der Prop variant, zum Beispiel variant="columns".
Kann ich die Texte von Datenexport übersetzen?
Ja. Datenexport bringt 83 eigene Texte auf Deutsch und Englisch mit, etwa „Format“, „CSV“ und „Excel“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Datenexport einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Datenexport mit aiw_components und installieren sie mit aiw_add @ai-website/data-export. Nenn im Prompt die Variante, etwa variant="quick-export", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Datenexport ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden DataExport-Code zum Kopieren.