Data export
Export screens. A quick export with format and rows, columns to pick and order with a preview, scheduled exports, the export history and a full account archive.
One of 3 components in Import, export and history
Variants
Details
What Data export brings along, read from the registry and the source.
- Category
- Import, export and history
- Area
- App
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/data-export.tsx
- npm packages
- None
- Languages
- English and German, 83 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The DataExportProps interface from components/ui/data-export.tsx, with the comments from the code.
variantoptionalDataExportVariantquick-exportcolumnsscheduledhistoryaccount-archive
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlerequiredstringsubtitleoptionalstringentityoptionalstringWhat 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.
Built-in texts
Data export writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 83 texts
| Key | English | German |
|---|---|---|
| format | English Format | German Format |
| csv | English CSV | German CSV |
| xlsx | English Excel | German Excel |
| json | English JSON | German JSON |
| English PDF | German PDF | |
| csvText | English For any spreadsheet | German Für jede Tabelle |
| xlsxText | English With column widths | German Mit Spaltenbreiten |
| jsonText | English For developers | German Für Entwickler |
| pdfText | English To print or send | German Zum Drucken und Senden |
| which | English Which rows | German Welche Zeilen |
| all | English All {entity} | German Alle {entity} |
| filtered | English Current filter | German Aktueller Filter |
| selected | English Selected only | German Nur ausgewählte |
| rowsN | English {n} rows | German {n} Zeilen |
| options | English Options | German Optionen |
| headers | English Include column names | German Spaltennamen mitnehmen |
| dates | English Dates as YYYY-MM-DD | German Datum als JJJJ-MM-TT |
| export | English Export {n} rows | German {n} Zeilen exportieren |
| exporting | English Preparing your file… | German Deine Datei wird vorbereitet… |
| ready | English Your file is ready | German Deine Datei ist fertig |
| download | English Download | German Herunterladen |
| another | English Export again | German Erneut exportieren |
| emailToo | English We also sent the link to your email. | German Den Link haben wir dir auch per E-Mail geschickt. |
| columns | English Columns | German Spalten |
| columnsText | English Pick the columns and their order. The preview shows the first rows. | German Wähle Spalten und Reihenfolge. Die Vorschau zeigt die ersten Zeilen. |
| picked | English {n} of {total} columns | German {n} von {total} Spalten |
| pickAll | English All | German Alle |
| pickNone | English None | German Keine |
| moveUp | English Move {name} up | German {name} nach oben |
| moveDown | English Move {name} down | German {name} nach unten |
| preview | English Preview | German Vorschau |
| noColumns | English Pick at least one column. | German Wähle mindestens eine Spalte. |
| saveSet | English Save as preset | German Als Vorlage speichern |
| saved | English Preset saved | German Vorlage gespeichert |
| scheduled | English Scheduled exports | German Geplante Exporte |
| scheduledText | English Fresh files arrive on their own, no one has to remember. | German Frische Dateien kommen von selbst, niemand muss daran denken. |
| next | English Next {when} | German Nächster {when} |
| paused | English Paused | German Pausiert |
| toggle | English Run {name} | German {name} ausführen |
| newSchedule | English New scheduled export | German Neuer geplanter Export |
| name | English Name | German Name |
| every | English Repeat | German Wiederholen |
| daily | English Every day | German Jeden Tag |
| weekly | English Every Monday | German Jeden Montag |
| monthly | English On the 1st | German Am 1. des Monats |
| at | English At | German Um |
| sendTo | English Send to | German Senden an |
| toEmail | English Email | German E-Mail |
| toFolder | English Shared folder | German Geteilter Ordner |
| address | English Address | German Adresse |
| folder | English Folder path | German Ordnerpfad |
| add | English Add schedule | German Export planen |
| adding | English Adding… | German Wird geplant… |
| added | English {name} added. | German {name} hinzugefügt. |
| nameError | English Give it a name. | German Gib ihm einen Namen. |
| toError | English Fill in where the file goes. | German Trag ein, wohin die Datei geht. |
| history | English Export history | German Exportverlauf |
| historyText | English Links work for 7 days, then the files are deleted. | German Links funktionieren 7 Tage, danach werden die Dateien gelöscht. |
| statusReady | English Ready | German Bereit |
| statusRunning | English Running | German Läuft |
| statusExpired | English Expired | German Abgelaufen |
| statusFailed | English Failed | German Fehlgeschlagen |
| expiresIn | English Link works {when} | German Link gilt {when} |
| rerun | English Export again | German Erneut exportieren |
| retry | English Try again | German Erneut versuchen |
| by | English by {name} | German von {name} |
| allStatus | English All | German Alle |
| archive | English Download your data | German Deine Daten herunterladen |
| archiveText | English A ZIP with everything you pick. We email you a link as soon as it is ready, usually within an hour. | German Ein ZIP mit allem, was du wählst. Wir schicken dir einen Link, sobald es fertig ist, meist innerhalb einer Stunde. |
| whatIn | English What goes in | German Was hinein kommt |
| total | English About {size} | German Etwa {size} |
| confirm | English Confirm with your password | German Mit deinem Passwort bestätigen |
| password | English Password | German Passwort |
| passwordError | English Enter your password to continue. | German Gib dein Passwort ein, um weiterzumachen. |
| request | English Request archive | German Archiv anfordern |
| requesting | English Requesting… | German Wird angefordert… |
| steps | English What happens next | German So geht es weiter |
| step1 | English We collect your data | German Wir sammeln deine Daten |
| step2 | English We pack it into a ZIP | German Wir packen sie in ein ZIP |
| step3 | English You get an email with a link | German Du bekommst eine E-Mail mit Link |
| requested | English We are on it | German Wir sind dran |
| requestedText | English The link goes to {email}. It works for 7 days and needs your password again. | German Der Link geht an {email}. Er gilt 7 Tage und fragt noch einmal nach deinem Passwort. |
| eta | English Ready in about {n} minutes | German Fertig in etwa {n} Minuten |
Accessibility
Read from the source of the component.
- Data export handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
- Sets aria-checked, aria-describedby, aria-invalid, aria-label and aria-live, so screen readers announce purpose and state.
- Uses the roles radio, radiogroup and status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 1,150 lines
'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>
);
}Frequent questions
How do I install Data export?
With aiw add @ai-website/data-export. The command puts components/ui/data-export.tsx into your project, the code is yours from then on. It brings along button, icon, switch and sidebar. It needs no further npm packages.
Which variants does Data export have?
5 variants, Quick export, Pick columns, Scheduled exports, Export history and Account archive. You pick one with the variant prop, for example variant="columns".
Can I translate the texts of Data export?
Yes. Data export carries 83 texts of its own in English and German, such as “Format”, “CSV” and “Excel”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Data export?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Data export with aiw_components and install it with aiw_add @ai-website/data-export. Name the variant in your prompt, for example variant="quick-export", and the assistant writes your content straight into the props.
Can I adjust Data export without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching DataExport code to copy.