Zum Inhalt springen
UI Kit
Accent

Dateivorschau für Apps

Dateivorschau für Apps. Bilder mit Zoom und Filmstreifen, Code mit Suche und Gliederung, Tabellenraster, PDF mit Seiten und Suche und eine Schnellansicht.

5 VariantsEine von 2 Components in Files

Jede Datei öffnen, ohne die App zu verlassen

Eine Dateivorschau erspart den Download, nur um zu sehen, was drinsteht. Diese fünf Betrachter öffnen Bilder, Code, Tabellen, Dokumente und gemischte Anhänge direkt in deiner App, jeder mit den Werkzeugen, die seine Dateiart braucht, und voll per Tastatur bedienbar.

Der „Bildbetrachter“ stellt das Bild auf eine dunkle Bühne mit Zoom, Verschieben, Drehen und Wischen, einen Filmstreifen für die Pfeiltasten und die Details daneben, am Telefon darunter. Die „Codedatei“ hebt Code mit Zeilennummern hervor, mit Suche und Treffern, einer Gliederung der Symbole, Zeilenauswahl mit Link, Kopieren und Herunterladen. Die „Tabelle“ zeigt ein Raster mit festen Köpfen, Zellauswahl mit Tastatur und Maus, Summe und Mittelwert des Bereichs, Sortieren, Zeilenfilter und Tabellenblättern. Das „PDF-Dokument“ blättert mit Seitenvorschau und Inhalt, Seitensprung, Zoom, Volltextsuche und Drucken. Die „Schnellansicht“ legt gemischte Anhänge als gezeichnete Kacheln aus, die Leertaste öffnet jede groß, für Bilder, PDF, Code, Tabellen, Audio, Archive und Text.

Öffne die Vorschau dort, wo die Datei steht, im Chat, im Ticket oder im Ordner, und halte den Download einen Klick entfernt. Lass Leertaste und Escape öffnen und schließen, das kennen Menschen vom Desktop. Zeig Name, Größe und Typ in jedem Betrachter, dann fragt sich niemand, was er gerade sieht.

Variants

BildbetrachterNeu in 1.2

Dunkle Bühne mit Zoom, Verschieben, Drehen und Wischen, ein Filmstreifen für die Pfeiltasten und die Details daneben, auf dem Telefon darunter.

CodedateiNeu in 1.2

Hervorgehobener Code mit Zeilennummern, Suche mit Treffern, Gliederung der Symbole, Zeilenauswahl mit Link, Kopieren und Herunterladen.

TabelleNeu in 1.2

Raster mit festen Köpfen, Zellauswahl mit Tastatur und Maus, Summe und Mittelwert des Bereichs, Sortieren, Zeilenfilter und Tabellenblätter.

PDF-DokumentNeu in 1.2

Dokument mit Seitenvorschau und Inhalt, Seitensprung, Zoom, Volltextsuche mit Treffern und Drucken. Telefone öffnen den Inhalt bei Bedarf.

SchnellansichtNeu in 1.2

Gemischte Anhänge als gezeichnete Kacheln. Die Leertaste öffnet jede Datei groß, für Bilder, PDF, Code, Tabellen, Audio, Archive und Text.

Details

Was Dateivorschau mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Files
Bereich
App
Variants
5
Version
1.0.0 · seit 1.2.0
Datei
components/ui/file-preview.tsx
Bringt mit
utilskitbuttoniconcode-blockdialog
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 82 eigene Texte
Studio
Im Code anpassen
In Sammlungen
SaaS-App-Screens

Props

Die Schnittstelle FilePreviewProps aus components/ui/file-preview.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    FilePreviewVariant

    image-viewercodespreadsheetdocumentquick-look

  • filesoptional
    PreviewFile[]

    image-viewer, quick-look: the set to page through

  • fileoptional
    PreviewFile

    code, spreadsheet, document: the one file on screen

  • startoptional
    number

    image-viewer: index to start with

  • titleoptional
    string

    A heading above the preview, e.g. the folder or the message the files belong to

  • subtitleoptional
    string
  • pathoptional
    string[]

    code: folders above the file, shown as a path

  • onActionoptional
    (action: string, data: Record<string, unknown>) => void | Promise<void>

    Called on download, share and open. Without it the block downloads what it can and simulates the rest.

Eingebaute Texte

Diese Texte schreibt Dateivorschau selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 82 Texte anzeigen
ofEnglisch {n} of {total}Deutsch {n} von {total}
previousEnglisch Previous fileDeutsch Vorherige Datei
nextEnglisch Next fileDeutsch Nächste Datei
zoomInEnglisch Zoom inDeutsch Vergrößern
zoomOutEnglisch Zoom outDeutsch Verkleinern
fitEnglisch Fit to screenDeutsch An Fenster anpassen
fitShortEnglisch FitDeutsch Passend
rotateEnglisch RotateDeutsch Drehen
detailsEnglisch DetailsDeutsch Details
hideDetailsEnglisch Hide detailsDeutsch Details ausblenden
downloadEnglisch DownloadDeutsch Herunterladen
shareEnglisch ShareDeutsch Teilen
openEnglisch OpenDeutsch Öffnen
closeEnglisch Close previewDeutsch Vorschau schließen
filesEnglisch FilesDeutsch Dateien
viewerEnglisch Image viewer. Arrow keys switch files, plus and minus zoom, zero fits the image.Deutsch Bildbetrachter. Pfeiltasten wechseln die Datei, Plus und Minus zoomen, Null passt das Bild ein.
dimensionsEnglisch DimensionsDeutsch Abmessungen
sizeEnglisch SizeDeutsch Größe
modifiedEnglisch ModifiedDeutsch Geändert
ownerEnglisch OwnerDeutsch Besitzer
typeEnglisch TypeDeutsch Typ
pixelsEnglisch {w} × {h} pxDeutsch {w} × {h} px
showingEnglisch Showing {name}, {n} of {total}Deutsch {name} wird angezeigt, {n} von {total}
zoomAtEnglisch Zoom {v}Deutsch Zoom {v}
searchEnglisch Search in fileDeutsch In Datei suchen
noHitsEnglisch No matchesDeutsch Keine Treffer
hitsEnglisch {n} of {total}Deutsch {n} von {total}
prevHitEnglisch Previous matchDeutsch Vorheriger Treffer
nextHitEnglisch Next matchDeutsch Nächster Treffer
wrapEnglisch Wrap linesDeutsch Zeilen umbrechen
copyEnglisch CopyDeutsch Kopieren
copiedEnglisch CopiedDeutsch Kopiert
linesEnglisch {n} linesDeutsch {n} Zeilen
lineEnglisch Line {n}Deutsch Zeile {n}
lineSelEnglisch Line {n} selectedDeutsch Zeile {n} ausgewählt
linesSelEnglisch Lines {a} to {b} selectedDeutsch Zeilen {a} bis {b} ausgewählt
copyLinkEnglisch Copy link to linesDeutsch Link zu den Zeilen kopieren
linkCopiedEnglisch Link copiedDeutsch Link kopiert
outlineEnglisch OutlineDeutsch Gliederung
jumpEnglisch Jump toDeutsch Springen zu
positionEnglisch Ln {l}, Col 1Deutsch Z. {l}, Sp. 1
codeAreaEnglisch Code of {name}Deutsch Code von {name}
spacesEnglisch Spaces 2Deutsch Leerzeichen 2
sheetsEnglisch SheetsDeutsch Tabellenblätter
rowsEnglisch {n} rowsDeutsch {n} Zeilen
colsEnglisch {n} columnsDeutsch {n} Spalten
filterRowsEnglisch Filter rowsDeutsch Zeilen filtern
shownRowsEnglisch {n} of {total} rowsDeutsch {n} von {total} Zeilen
sortByEnglisch Sort by {col}Deutsch Nach {col} sortieren
sumEnglisch SumDeutsch Summe
averageEnglisch AverageDeutsch Mittelwert
countEnglisch CountDeutsch Anzahl
cellEnglisch CellDeutsch Zelle
gridEnglisch Table {name}. Arrow keys move, shift and arrows select a range.Deutsch Tabelle {name}. Pfeiltasten bewegen, Umschalt und Pfeile wählen einen Bereich.
selectedEnglisch {n} cells selectedDeutsch {n} Zellen ausgewählt
downloadCsvEnglisch Download CSVDeutsch Als CSV laden
pagesEnglisch PagesDeutsch Seiten
pageEnglisch Page {n}Deutsch Seite {n}
pageOfEnglisch of {total}Deutsch von {total}
goToPageEnglisch Go to pageDeutsch Zur Seite
contentsEnglisch ContentsDeutsch Inhalt
zoomEnglisch ZoomDeutsch Zoom
printEnglisch PrintDeutsch Drucken
thumbsEnglisch Page thumbnailsDeutsch Seitenvorschau
noOutlineEnglisch This document has no headings.Deutsch Dieses Dokument hat keine Überschriften.
playEnglisch PlayDeutsch Abspielen
pauseEnglisch PauseDeutsch Pausieren
seekEnglisch Playback positionDeutsch Wiedergabeposition
archiveEnglisch {n} files, {size} unpackedDeutsch {n} Dateien, entpackt {size}
previewEnglisch Preview {name}Deutsch Vorschau von {name}
gridHintEnglisch Space or Enter opens the preview, arrow keys move between files.Deutsch Leertaste oder Eingabe öffnet die Vorschau, Pfeiltasten wechseln die Datei.
quickLookEnglisch Quick lookDeutsch Schnellansicht
downloadedEnglisch {name} is downloadingDeutsch {name} wird heruntergeladen
sharedEnglisch Share link for {name} is readyDeutsch Freigabelink für {name} ist bereit
openedEnglisch {name} opensDeutsch {name} wird geöffnet
imageEnglisch ImageDeutsch Bild
pdfEnglisch PDF documentDeutsch PDF-Dokument
codeEnglisch CodeDeutsch Code
sheetEnglisch SpreadsheetDeutsch Tabelle
audioEnglisch AudioDeutsch Audio
archiveKindEnglisch ArchiveDeutsch Archiv
textEnglisch TextDeutsch Text

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Dateivorschau reagiert selbst auf die Tasten Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende, Bild hoch, Bild runter und Leertaste.
  • Baut auf dem nativen dialog-Element auf. Der Browser hält den Fokus im Fenster und schließt es mit Escape.
  • Setzt aria-colcount, aria-controls, aria-current, aria-describedby, aria-expanded, aria-label, aria-labelledby, aria-live, aria-orientation, aria-pressed, aria-roledescription, aria-rowcount, aria-rowindex, aria-selected, aria-sort, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen grid, gridcell, group, listbox, option, region, slider, status, tab, tablist und tabpanel.
  • 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

  • Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 2.151 Zeilen
components/ui/file-preview.tsx
'use client';

import { useEffect, useId, 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 { tokenize, type CodeLanguage } from '@/components/ui/code-block';
import { DialogMotionStyle, rememberTrigger, scrim, useModalMotion } from '@/components/ui/dialog';

export type FilePreviewVariant = 'image-viewer' | 'code' | 'spreadsheet' | 'document' | 'quick-look';

export type PreviewKind = 'image' | 'pdf' | 'code' | 'sheet' | 'audio' | 'archive' | 'text';

export interface PreviewColumn {
  label: string;
  /** How the values of this column are shown. Dates are ISO strings, percent values 0 to 1. */
  type?: 'text' | 'number' | 'currency' | 'percent' | 'date';
  /** ISO currency for type currency, default EUR */
  currency?: string;
}

export interface PreviewSheet {
  name: string;
  columns: PreviewColumn[];
  rows: (string | number | null)[][];
}

/** One block on a drawn document page */
export interface PageBlock {
  type: 'title' | 'meta' | 'heading' | 'text' | 'list' | 'table' | 'chart' | 'quote';
  text?: string;
  /** list items, chart labels */
  items?: string[];
  /** table: first row is the header */
  rows?: string[][];
  /** chart: one value per label */
  values?: number[];
}

export interface PreviewPage {
  blocks: PageBlock[];
}

export interface PreviewFile {
  id: string;
  name: string;
  kind: PreviewKind;
  /** Bytes */
  size: number;
  /** ISO date and time */
  modified?: string;
  owner?: string;
  /** image: pixels */
  width?: number;
  height?: number;
  /** image: the picture itself, a drawn scene or an <img> */
  media?: React.ReactNode;
  /** code */
  code?: string;
  language?: CodeLanguage;
  /** sheet */
  sheets?: PreviewSheet[];
  /** pdf */
  pages?: PreviewPage[];
  /** audio: seconds */
  duration?: number;
  /** archive: the files inside */
  entries?: { name: string; size: number }[];
  /** text: paragraphs */
  text?: string[];
}

export interface FilePreviewProps {
  variant?: FilePreviewVariant;
  /** image-viewer, quick-look: the set to page through */
  files?: PreviewFile[];
  /** code, spreadsheet, document: the one file on screen */
  file?: PreviewFile;
  /** image-viewer: index to start with */
  start?: number;
  /** A heading above the preview, e.g. the folder or the message the files belong to */
  title?: string;
  subtitle?: string;
  /** code: folders above the file, shown as a path */
  path?: string[];
  /** Called on download, share and open. Without it the block downloads what it can and simulates the rest. */
  onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    of: '{n} of {total}',
    previous: 'Previous file',
    next: 'Next file',
    zoomIn: 'Zoom in',
    zoomOut: 'Zoom out',
    fit: 'Fit to screen',
    fitShort: 'Fit',
    rotate: 'Rotate',
    details: 'Details',
    hideDetails: 'Hide details',
    download: 'Download',
    share: 'Share',
    open: 'Open',
    close: 'Close preview',
    files: 'Files',
    viewer: 'Image viewer. Arrow keys switch files, plus and minus zoom, zero fits the image.',
    dimensions: 'Dimensions',
    size: 'Size',
    modified: 'Modified',
    owner: 'Owner',
    type: 'Type',
    pixels: '{w} × {h} px',
    showing: 'Showing {name}, {n} of {total}',
    zoomAt: 'Zoom {v}',
    search: 'Search in file',
    noHits: 'No matches',
    hits: '{n} of {total}',
    prevHit: 'Previous match',
    nextHit: 'Next match',
    wrap: 'Wrap lines',
    copy: 'Copy',
    copied: 'Copied',
    lines: '{n} lines',
    line: 'Line {n}',
    lineSel: 'Line {n} selected',
    linesSel: 'Lines {a} to {b} selected',
    copyLink: 'Copy link to lines',
    linkCopied: 'Link copied',
    outline: 'Outline',
    jump: 'Jump to',
    position: 'Ln {l}, Col 1',
    codeArea: 'Code of {name}',
    spaces: 'Spaces 2',
    sheets: 'Sheets',
    rows: '{n} rows',
    cols: '{n} columns',
    filterRows: 'Filter rows',
    shownRows: '{n} of {total} rows',
    sortBy: 'Sort by {col}',
    sum: 'Sum',
    average: 'Average',
    count: 'Count',
    cell: 'Cell',
    grid: 'Table {name}. Arrow keys move, shift and arrows select a range.',
    selected: '{n} cells selected',
    downloadCsv: 'Download CSV',
    pages: 'Pages',
    page: 'Page {n}',
    pageOf: 'of {total}',
    goToPage: 'Go to page',
    contents: 'Contents',
    zoom: 'Zoom',
    print: 'Print',
    thumbs: 'Page thumbnails',
    noOutline: 'This document has no headings.',
    play: 'Play',
    pause: 'Pause',
    seek: 'Playback position',
    archive: '{n} files, {size} unpacked',
    preview: 'Preview {name}',
    gridHint: 'Space or Enter opens the preview, arrow keys move between files.',
    quickLook: 'Quick look',
    downloaded: '{name} is downloading',
    shared: 'Share link for {name} is ready',
    opened: '{name} opens',
    image: 'Image',
    pdf: 'PDF document',
    code: 'Code',
    sheet: 'Spreadsheet',
    audio: 'Audio',
    archiveKind: 'Archive',
    text: 'Text',
  },
  de: {
    of: '{n} von {total}',
    previous: 'Vorherige Datei',
    next: 'Nächste Datei',
    zoomIn: 'Vergrößern',
    zoomOut: 'Verkleinern',
    fit: 'An Fenster anpassen',
    fitShort: 'Passend',
    rotate: 'Drehen',
    details: 'Details',
    hideDetails: 'Details ausblenden',
    download: 'Herunterladen',
    share: 'Teilen',
    open: 'Öffnen',
    close: 'Vorschau schließen',
    files: 'Dateien',
    viewer: 'Bildbetrachter. Pfeiltasten wechseln die Datei, Plus und Minus zoomen, Null passt das Bild ein.',
    dimensions: 'Abmessungen',
    size: 'Größe',
    modified: 'Geändert',
    owner: 'Besitzer',
    type: 'Typ',
    pixels: '{w} × {h} px',
    showing: '{name} wird angezeigt, {n} von {total}',
    zoomAt: 'Zoom {v}',
    search: 'In Datei suchen',
    noHits: 'Keine Treffer',
    hits: '{n} von {total}',
    prevHit: 'Vorheriger Treffer',
    nextHit: 'Nächster Treffer',
    wrap: 'Zeilen umbrechen',
    copy: 'Kopieren',
    copied: 'Kopiert',
    lines: '{n} Zeilen',
    line: 'Zeile {n}',
    lineSel: 'Zeile {n} ausgewählt',
    linesSel: 'Zeilen {a} bis {b} ausgewählt',
    copyLink: 'Link zu den Zeilen kopieren',
    linkCopied: 'Link kopiert',
    outline: 'Gliederung',
    jump: 'Springen zu',
    position: 'Z. {l}, Sp. 1',
    codeArea: 'Code von {name}',
    spaces: 'Leerzeichen 2',
    sheets: 'Tabellenblätter',
    rows: '{n} Zeilen',
    cols: '{n} Spalten',
    filterRows: 'Zeilen filtern',
    shownRows: '{n} von {total} Zeilen',
    sortBy: 'Nach {col} sortieren',
    sum: 'Summe',
    average: 'Mittelwert',
    count: 'Anzahl',
    cell: 'Zelle',
    grid: 'Tabelle {name}. Pfeiltasten bewegen, Umschalt und Pfeile wählen einen Bereich.',
    selected: '{n} Zellen ausgewählt',
    downloadCsv: 'Als CSV laden',
    pages: 'Seiten',
    page: 'Seite {n}',
    pageOf: 'von {total}',
    goToPage: 'Zur Seite',
    contents: 'Inhalt',
    zoom: 'Zoom',
    print: 'Drucken',
    thumbs: 'Seitenvorschau',
    noOutline: 'Dieses Dokument hat keine Überschriften.',
    play: 'Abspielen',
    pause: 'Pausieren',
    seek: 'Wiedergabeposition',
    archive: '{n} Dateien, entpackt {size}',
    preview: 'Vorschau von {name}',
    gridHint: 'Leertaste oder Eingabe öffnet die Vorschau, Pfeiltasten wechseln die Datei.',
    quickLook: 'Schnellansicht',
    downloaded: '{name} wird heruntergeladen',
    shared: 'Freigabelink für {name} ist bereit',
    opened: '{name} wird geöffnet',
    image: 'Bild',
    pdf: 'PDF-Dokument',
    code: 'Code',
    sheet: 'Tabelle',
    audio: 'Audio',
    archiveKind: 'Archiv',
    text: 'Text',
  },
};
type Texts = (typeof TEXTS)['en'];

/* ------------------------------------------------------------------ */
/* Styles                                                              */
/* ------------------------------------------------------------------ */

const CSS = `
.kitfp .tk-keyword{color:color-mix(in oklch,var(--primary) 88%,var(--foreground));font-weight:560}
.kitfp .tk-tag{color:color-mix(in oklch,var(--primary) 78%,var(--foreground));font-weight:560}
.kitfp .tk-function{color:var(--foreground);font-weight:560}
.kitfp .tk-type{color:color-mix(in oklch,var(--primary) 58%,var(--foreground))}
.kitfp .tk-string{color:color-mix(in oklch,var(--primary) 42%,var(--muted-foreground))}
.kitfp .tk-number,.kitfp .tk-literal,.kitfp .tk-variable{color:color-mix(in oklch,var(--primary) 70%,var(--foreground))}
.kitfp .tk-attr{color:color-mix(in oklch,var(--primary) 34%,var(--muted-foreground))}
.kitfp .tk-property{color:color-mix(in oklch,var(--foreground) 80%,var(--primary))}
.kitfp .tk-comment{color:var(--subtle-foreground);font-style:italic}
.kitfp .tk-punct,.kitfp .tk-operator{color:var(--muted-foreground)}
.kitfp mark{background:color-mix(in oklab,var(--warning) 38%,transparent);color:inherit;border-radius:2px}
.kitfp mark[data-current]{background:color-mix(in oklab,var(--warning) 75%,transparent);box-shadow:0 0 0 2px color-mix(in oklab,var(--warning) 75%,transparent)}
.kitfp-paper mark{background:#ffe27a;color:#15171c}
.kitfp-paper mark[data-current]{background:#ffc93d;box-shadow:0 0 0 2px #ffc93d}
@keyframes kitfp-in{from{opacity:0;transform:scale(.97)}}
@keyframes kitfp-up{from{opacity:0;transform:translateY(10px)}}
@keyframes kitfp-flash{from{background-color:color-mix(in oklab,var(--primary) 26%,transparent)}}
@keyframes kitfp-bar{from{transform:scaleY(.15)}}
@media (prefers-reduced-motion:no-preference){
.kitfp-in{animation:kitfp-in .42s var(--ease-kit) both}
.kitfp-up{animation:kitfp-up .5s var(--ease-kit) var(--kitfp-d,0ms) both}
.kitfp-flash{animation:kitfp-flash 1.4s ease-out both}
.kitfp-bar{transform-origin:bottom;animation:kitfp-bar .7s var(--ease-kit) var(--kitfp-d,0ms) both}
}
@media print{.kitfp-noprint{display:none!important}.kitfp-scroll{max-height:none!important;overflow:visible!important}}
`;

function Style() {
  return (
    <style href="kitb5-file-preview" precedence="default">
      {CSS}
    </style>
  );
}

/* ------------------------------------------------------------------ */
/* Helpers                                                             */
/* ------------------------------------------------------------------ */

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k: string) => String(v[k] ?? ''));
const intlOf = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const num = (l: Locale, n: number, o?: Intl.NumberFormatOptions) => new Intl.NumberFormat(intlOf(l), o).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 `${num(l, v, { maximumFractionDigits: v < 10 && i > 0 ? 1 : 0 })} ${units[i]}`;
}

const parseIso = (iso: string) => new Date(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z'));
const dateTime = (l: Locale, iso: string) => new Intl.DateTimeFormat(intlOf(l), { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'UTC' }).format(parseIso(iso));
const dateOnly = (l: Locale, iso: string) => new Intl.DateTimeFormat(intlOf(l), { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }).format(parseIso(iso));
const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;

const KIND_ICON: Record<PreviewKind, IconName> = { image: 'image', pdf: 'file', code: 'file-code', sheet: 'table', audio: 'music', archive: 'archive', text: 'file' };
const kindLabel = (t: Texts, k: PreviewKind) => ({ image: t.image, pdf: t.pdf, code: t.code, sheet: t.sheet, audio: t.audio, archive: t.archiveKind, text: t.text })[k];
const ext = (name: string) => (name.includes('.') ? name.split('.').pop()!.toUpperCase() : '');

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));

async function copyText(text: string) {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch {
    return false;
  }
}

function saveBlob(name: string, text: string, type = 'text/plain') {
  try {
    const url = URL.createObjectURL(new Blob([text], { type }));
    const a = document.createElement('a');
    a.href = url;
    a.download = name;
    document.body.append(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 1000);
  } catch {
    /* the browser blocked it, nothing to do */
  }
}

function Live({ text }: { text: string }) {
  return (
    <p role="status" aria-live="polite" className="sr-only">
      {text}
    </p>
  );
}

const ring = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';

/** A square tool button with an icon and a name for screen readers */
function Tool({ icon, label, onClick, pressed, disabled, className, size = 16 }: { icon: IconName; label: string; onClick?: () => void; pressed?: boolean; disabled?: boolean; className?: string; size?: number }) {
  return (
    <button
      type="button"
      aria-label={label}
      title={label}
      aria-pressed={pressed}
      disabled={disabled}
      onClick={onClick}
      className={cn(
        'inline-grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground transition-[color,background-color,transform] duration-200 hover:bg-accent hover:text-foreground active:scale-95 disabled:pointer-events-none disabled:opacity-40 sm:size-9',
        pressed && 'bg-accent text-foreground',
        ring,
        className,
      )}
    >
      <Icon name={icon} size={size} />
    </button>
  );
}

const fieldCls =
  '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-9 sm:text-[13px]';

/** Runs an action through onAction or simulates it, then reports it */
function useActions(onAction: FilePreviewProps['onAction'], t: Texts) {
  const [msg, setMsg] = useState('');
  const act = async (action: 'download' | 'share' | 'open', f: PreviewFile, fallback?: () => void) => {
    if (onAction) await onAction(action, { id: f.id, name: f.name });
    else {
      if (action === 'download' && fallback) fallback();
      await wait(prefersReducedMotion() ? 0 : 300);
    }
    setMsg(fill(action === 'download' ? t.downloaded : action === 'share' ? t.shared : t.opened, { name: f.name }) + '​'.repeat(Math.random() * 3));
  };
  return [msg, act, setMsg] as const;
}

/** Text from a file for a real download, where the block has it */
function fileText(f: PreviewFile): string | null {
  if (f.code) return f.code;
  if (f.text) return f.text.join('\n\n');
  if (f.sheets?.[0]) return toCsv(f.sheets[0]);
  return null;
}

function toCsv(s: PreviewSheet) {
  const q = (v: string | number | null) => {
    const x = v === null ? '' : String(v);
    return /[",\n;]/.test(x) ? `"${x.replace(/"/g, '""')}"` : x;
  };
  return [s.columns.map((c) => q(c.label)).join(','), ...s.rows.map((r) => r.map(q).join(','))].join('\n');
}

function Header({ title, subtitle, children }: { title?: string; subtitle?: string; children?: React.ReactNode }) {
  if (!title && !subtitle && !children) return null;
  return (
    <div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
      <div className="min-w-0">
        {subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
        {title && <h2 className="mt-0.5 text-balance text-[clamp(1.375rem,1.15rem+0.9vw,1.75rem)] font-semibold tracking-[-0.03em] text-foreground">{title}</h2>}
      </div>
      {children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Highlighting search hits in plain text                              */
/* ------------------------------------------------------------------ */

/** Splits text into plain parts and hits. counter carries the running hit index across calls. */
function marked(text: string, q: string, counter: { n: number }, current: number): React.ReactNode {
  if (!q) return text;
  const lower = text.toLowerCase();
  const out: React.ReactNode[] = [];
  let i = 0;
  let at = lower.indexOf(q);
  while (at >= 0) {
    if (at > i) out.push(text.slice(i, at));
    const n = counter.n++;
    out.push(
      <mark key={at} data-hit={n} data-current={n === current ? '' : undefined}>
        {text.slice(at, at + q.length)}
      </mark>,
    );
    i = at + q.length;
    at = lower.indexOf(q, i);
  }
  if (i < text.length) out.push(text.slice(i));
  return out;
}

const countHits = (text: string, q: string) => {
  if (!q) return 0;
  let n = 0;
  let at = text.toLowerCase().indexOf(q);
  while (at >= 0) {
    n++;
    at = text.toLowerCase().indexOf(q, at + q.length);
  }
  return n;
};

/** Scrolls an element into the middle of its scroll container, never the page */
function scrollInside(container: HTMLElement | null, el: Element | null, axis: 'y' | 'x' = 'y') {
  if (!container || !el) return;
  const c = container.getBoundingClientRect();
  const r = el.getBoundingClientRect();
  const behavior: ScrollBehavior = prefersReducedMotion() ? 'auto' : 'smooth';
  if (axis === 'y') container.scrollTo({ top: container.scrollTop + r.top - c.top - c.height / 2 + r.height / 2, behavior });
  else container.scrollTo({ left: container.scrollLeft + r.left - c.left - c.width / 2 + r.width / 2, behavior });
}

/** Which edge of a scroll row still hides content, for the matching fade */
function useEdgeFade<T extends HTMLElement>(ref: React.RefObject<T | null>, deps: unknown[] = []) {
  const [fade, setFade] = useState('kit-fade-end');
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const read = () => {
      const start = el.scrollLeft > 4;
      const end = el.scrollLeft + el.clientWidth < el.scrollWidth - 4;
      setFade(start && end ? 'kit-fade-x' : end ? 'kit-fade-end' : start ? 'kit-fade-start' : '');
    };
    read();
    el.addEventListener('scroll', read, { passive: true });
    window.addEventListener('resize', read);
    return () => {
      el.removeEventListener('scroll', read);
      window.removeEventListener('resize', read);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);
  return fade;
}

/* ------------------------------------------------------------------ */
/* image-viewer                                                        */
/* ------------------------------------------------------------------ */

const ZOOMS = [1, 1.5, 2, 3, 4];

function ImageViewer({ t, files = [], start = 0, locale, onAction, title, subtitle }: Inner) {
  const [idx, setIdx] = useState(Math.min(Math.max(0, start), Math.max(0, files.length - 1)));
  const [zoom, setZoom] = useState(1);
  const [rot, setRot] = useState(0);
  const [pan, setPan] = useState({ x: 0, y: 0 });
  const [info, setInfo] = useState(true);
  const [mobileInfo, setMobileInfo] = useState(false);
  const [dragging, setDragging] = useState(false);
  const [live, setLive] = useState('');
  const [msg, act] = useActions(onAction, t);
  const stage = useRef<HTMLDivElement>(null);
  const strip = useRef<HTMLDivElement>(null);
  const drag = useRef<{ x: number; y: number; px: number; py: number; moved: boolean } | null>(null);
  const infoId = useId();
  const f = files[idx];
  const stripFade = useEdgeFade(strip, [files.length]);

  const go = (n: number, focusStrip = false) => {
    if (!files.length) return;
    const next = (n + files.length) % files.length;
    setIdx(next);
    setZoom(1);
    setRot(0);
    setPan({ x: 0, y: 0 });
    setLive(fill(t.showing, { name: files[next].name, n: next + 1, total: files.length }));
    if (focusStrip) requestAnimationFrame(() => strip.current?.querySelectorAll<HTMLElement>('[role=option]')[next]?.focus());
  };
  useEffect(() => {
    scrollInside(strip.current, strip.current?.querySelectorAll('[role=option]')[idx] ?? null, 'x');
  }, [idx]);

  const limit = (p: { x: number; y: number }, z = zoom) => {
    const el = stage.current;
    if (!el || z <= 1) return { x: 0, y: 0 };
    const mx = (el.clientWidth * (z - 1)) / 2;
    const my = (el.clientHeight * (z - 1)) / 2;
    return { x: Math.max(-mx, Math.min(mx, p.x)), y: Math.max(-my, Math.min(my, p.y)) };
  };
  const setZ = (z: number) => {
    const v = Math.max(1, Math.min(4, z));
    setZoom(v);
    setPan((p) => limit(p, v));
    setLive(fill(t.zoomAt, { v: v === 1 ? t.fitShort : formatPct(v) }));
  };
  const step = (dir: 1 | -1) => {
    const i = ZOOMS.findIndex((z) => z >= zoom - 0.01);
    setZ(ZOOMS[Math.max(0, Math.min(ZOOMS.length - 1, (i < 0 ? 0 : i) + dir))]);
  };
  const formatPct = (z: number) => num(locale, z, { style: 'percent', maximumFractionDigits: 0 });

  const onKey = (e: React.KeyboardEvent) => {
    const k = e.key;
    if (k === '+' || k === '=') setZ(zoom + 0.5);
    else if (k === '-' || k === '_') setZ(zoom - 0.5);
    else if (k === '0') setZ(1);
    else if (k === 'r' || k === 'R') setRot((r) => r + 90);
    else if (zoom > 1 && k.startsWith('Arrow')) {
      const d = 48;
      setPan((p) => limit({ x: p.x + (k === 'ArrowLeft' ? d : k === 'ArrowRight' ? -d : 0), y: p.y + (k === 'ArrowUp' ? d : k === 'ArrowDown' ? -d : 0) }));
    } else if (k === 'ArrowLeft') go(idx - 1);
    else if (k === 'ArrowRight') go(idx + 1);
    else if (k === 'Home') go(0);
    else if (k === 'End') go(files.length - 1);
    else return;
    e.preventDefault();
  };

  if (!f) return null;
  const ratio = f.width && f.height ? (rot % 180 === 0 ? f.width / f.height : f.height / f.width) : 4 / 3;
  const baseRatio = f.width && f.height ? f.width / f.height : 4 / 3;
  const sideways = rot % 180 !== 0;

  const details = (
    <dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2.5 text-[13px]">
      {f.width && f.height ? (
        <>
          <dt className="text-muted-foreground">{t.dimensions}</dt>
          <dd className="text-end tabular-nums text-foreground">{fill(t.pixels, { w: num(locale, f.width), h: num(locale, f.height) })}</dd>
        </>
      ) : null}
      <dt className="text-muted-foreground">{t.size}</dt>
      <dd className="text-end tabular-nums text-foreground">{bytes(locale, f.size)}</dd>
      <dt className="text-muted-foreground">{t.type}</dt>
      <dd className="text-end text-foreground">{ext(f.name) || kindLabel(t, f.kind)}</dd>
      {f.modified && (
        <>
          <dt className="text-muted-foreground">{t.modified}</dt>
          <dd className="text-end tabular-nums text-foreground">{dateTime(locale, f.modified)}</dd>
        </>
      )}
      {f.owner && (
        <>
          <dt className="text-muted-foreground">{t.owner}</dt>
          <dd className="truncate text-end text-foreground">{f.owner}</dd>
        </>
      )}
    </dl>
  );

  return (
    <div className="mx-auto w-full max-w-[80rem] p-4 sm:p-6 lg:p-8">
      <Header title={title} subtitle={subtitle} />
      <div className={cn('grid grid-cols-[minmax(0,1fr)] overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]', info && 'lg:grid-cols-[minmax(0,1fr)_18.5rem]')}>
        <div className="flex min-w-0 flex-col">
          {/* Toolbar */}
          <div className="kitfp-noprint flex items-center gap-1 border-b border-border px-2 py-1.5 sm:px-3">
            <div className="me-auto min-w-0 ps-1.5">
              <p className="truncate text-[13.5px] font-semibold text-foreground">{f.name}</p>
              <p className="text-[12px] tabular-nums text-muted-foreground">{fill(t.of, { n: idx + 1, total: files.length })}</p>
            </div>
            <Tool icon="zoom-out" label={t.zoomOut} onClick={() => step(-1)} disabled={zoom <= 1} />
            <button type="button" onClick={() => setZ(1)} aria-label={t.fit} title={t.fit} className={cn('hidden h-9 min-w-14 rounded-md px-2 text-[12.5px] font-medium tabular-nums text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:inline-block', ring)}>
              {zoom === 1 ? t.fitShort : formatPct(zoom)}
            </button>
            <Tool icon="zoom-in" label={t.zoomIn} onClick={() => step(1)} disabled={zoom >= 4} />
            <Tool icon="rotate-cw" label={t.rotate} onClick={() => setRot((r) => r + 90)} className="max-[370px]:hidden" />
            <span aria-hidden className="mx-1 hidden h-5 w-px bg-border sm:block" />
            <Tool icon="download" label={t.download} onClick={() => void act('download', f)} />
            <Tool icon="info" label={info ? t.hideDetails : t.details} pressed={info} onClick={() => setInfo((v) => !v)} className="hidden lg:inline-grid" />
          </div>
          {/* Stage, always dark like a light table */}
          <div className="dark relative">
            <div
              ref={stage}
              tabIndex={0}
              role="group"
              aria-roledescription={t.viewer}
              aria-label={f.name}
              onKeyDown={onKey}
              onDoubleClick={() => setZ(zoom > 1 ? 1 : 2)}
              onWheel={(e) => {
                if (!e.ctrlKey && !e.metaKey) return;
                e.preventDefault();
                setZ(zoom + (e.deltaY < 0 ? 0.25 : -0.25));
              }}
              onPointerDown={(e) => {
                if (e.button !== 0) return;
                drag.current = { x: e.clientX, y: e.clientY, px: pan.x, py: pan.y, moved: false };
                (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
              }}
              onPointerMove={(e) => {
                const d = drag.current;
                if (!d) return;
                const dx = e.clientX - d.x;
                const dy = e.clientY - d.y;
                if (Math.abs(dx) + Math.abs(dy) > 4) d.moved = true;
                if (zoom > 1) {
                  setDragging(true);
                  setPan(limit({ x: d.px + dx, y: d.py + dy }));
                }
              }}
              onPointerUp={(e) => {
                const d = drag.current;
                drag.current = null;
                setDragging(false);
                // A swipe pages on touch screens when the image is not zoomed
                if (d && zoom === 1 && Math.abs(e.clientX - d.x) > 56 && Math.abs(e.clientY - d.y) < 60) go(idx + (e.clientX < d.x ? 1 : -1));
              }}
              onPointerCancel={() => {
                drag.current = null;
                setDragging(false);
              }}
              className={cn(
                'relative h-[min(56svh,22rem)] min-h-[16rem] touch-pan-y select-none overflow-hidden bg-background [container-type:size] sm:h-[34rem] lg:h-[38rem]',
                zoom > 1 ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in',
                'outline-none focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_70%,transparent)]',
              )}
              style={{ backgroundImage: 'radial-gradient(color-mix(in oklab, var(--foreground) 9%, transparent) 1px, transparent 1px)', backgroundSize: '20px 20px' }}
            >
              <div className="absolute inset-5 grid place-items-center sm:inset-8 [container-type:size]">
                <div
                  key={f.id}
                  className="kitfp-in relative"
                  style={{
                    width: `min(100cqw, calc(100cqh * ${round(ratio, 4)}))`,
                    aspectRatio: String(round(ratio, 4)),
                  }}
                >
                  <div
                    className={cn('absolute left-1/2 top-1/2 overflow-hidden rounded-[3px] shadow-[0_24px_60px_-20px_rgb(0_0_0/0.7)] [&>*]:size-full', !dragging && 'transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none')}
                    style={{
                      // Turned sideways the picture spans the height of the box
                      width: sideways ? `${round(100 / ratio, 3)}%` : '100%',
                      aspectRatio: String(round(baseRatio, 4)),
                      transform: `translate(calc(-50% + ${round(pan.x)}px), calc(-50% + ${round(pan.y)}px)) scale(${zoom}) rotate(${rot}deg)`,
                    }}
                  >
                    {f.media ?? <div className="grid size-full place-items-center bg-surface-2 text-muted-foreground"><Icon name="image" size={32} /></div>}
                  </div>
                </div>
              </div>
              {files.length > 1 && (
                <>
                  <button type="button" aria-label={t.previous} onClick={() => go(idx - 1)} onPointerDown={(e) => e.stopPropagation()} className={cn('kitfp-noprint absolute start-2 top-1/2 grid size-11 -translate-y-1/2 place-items-center rounded-full border border-border bg-[color-mix(in_oklab,var(--background)_72%,transparent)] text-foreground shadow-[var(--shadow-md)] backdrop-blur transition-[transform,background-color] duration-200 hover:-translate-x-0.5 hover:bg-background sm:start-4', ring)}>
                    <Icon name="chevron-left" size={18} />
                  </button>
                  <button type="button" aria-label={t.next} onClick={() => go(idx + 1)} onPointerDown={(e) => e.stopPropagation()} className={cn('kitfp-noprint absolute end-2 top-1/2 grid size-11 -translate-y-1/2 place-items-center rounded-full border border-border bg-[color-mix(in_oklab,var(--background)_72%,transparent)] text-foreground shadow-[var(--shadow-md)] backdrop-blur transition-[transform,background-color] duration-200 hover:translate-x-0.5 hover:bg-background sm:end-4', ring)}>
                    <Icon name="chevron-right" size={18} />
                  </button>
                </>
              )}
              <span aria-hidden className={cn('pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full bg-[color-mix(in_oklab,var(--background)_78%,transparent)] px-2.5 py-1 text-[12px] font-medium tabular-nums text-foreground shadow-[var(--shadow-sm)] backdrop-blur transition-opacity duration-300', zoom > 1 ? 'opacity-100' : 'opacity-0')}>
                {formatPct(zoom)}
              </span>
            </div>
          </div>
          {/* Filmstrip */}
          {files.length > 1 && (
            <div className="kitfp-noprint border-t border-border bg-surface">
              <div
                ref={strip}
                role="listbox"
                aria-label={t.files}
                aria-orientation="horizontal"
                className={cn('kit-no-scrollbar flex gap-2 overflow-x-auto px-3 py-3 sm:px-4', stripFade)}
                onKeyDown={(e) => {
                  const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
                  if (e.key === 'Home' || e.key === 'End') {
                    e.preventDefault();
                    go(e.key === 'Home' ? 0 : files.length - 1, true);
                  } else if (d) {
                    e.preventDefault();
                    go(idx + d, true);
                  }
                }}
              >
                {files.map((x, i) => (
                  <button
                    key={x.id}
                    type="button"
                    role="option"
                    aria-selected={i === idx}
                    aria-label={x.name}
                    tabIndex={i === idx ? 0 : -1}
                    onClick={() => go(i)}
                    className={cn(
                      'relative h-14 w-20 shrink-0 overflow-hidden rounded-md border bg-surface-2 transition-[opacity,transform,border-color] duration-300 ease-[var(--ease-kit)] [&>*]:size-full sm:h-16 sm:w-24',
                      i === idx ? 'border-transparent opacity-100 ring-2 ring-primary ring-offset-2 ring-offset-surface' : 'border-border opacity-70 hover:-translate-y-0.5 hover:opacity-100',
                      ring,
                    )}
                  >
                    <span aria-hidden className="pointer-events-none block size-full [&>*]:size-full">{x.media ?? <Icon name={KIND_ICON[x.kind]} size={18} />}</span>
                  </button>
                ))}
              </div>
            </div>
          )}
          {/* Phone and tablet: details as a disclosure under the strip */}
          <div className="border-t border-border lg:hidden">
            <button type="button" aria-expanded={mobileInfo} aria-controls={infoId} onClick={() => setMobileInfo((v) => !v)} className={cn('flex h-12 w-full items-center justify-between px-4 text-[13.5px] font-medium text-foreground', ring)}>
              {t.details}
              <Icon name="chevron-down" size={16} className={cn('text-muted-foreground transition-transform duration-300', mobileInfo && 'rotate-180')} />
            </button>
            <div id={infoId} className={cn('grid transition-[grid-template-rows] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', mobileInfo ? 'grid-rows-[1fr]' : 'invisible grid-rows-[0fr]')}>
              <div className="min-h-0 overflow-hidden">
                <div className="px-4 pb-4">
                  {details}
                  <div className="mt-4 grid grid-cols-2 gap-2">
                    <Button size="sm" variant="outline" iconLeft="share" onClick={() => void act('share', f)}>
                      {t.share}
                    </Button>
                    <Button size="sm" iconLeft="download" onClick={() => void act('download', f)}>
                      {t.download}
                    </Button>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
        {info && (
          <aside className="kitfp-noprint hidden min-w-0 flex-col border-s border-border bg-surface lg:flex">
            <div key={f.id} className="kitfp-up flex flex-1 flex-col p-5">
              <p className="text-[12px] font-medium uppercase tracking-[0.06em] text-subtle-foreground">{t.details}</p>
              <h3 className="mt-2 break-words text-[15px] font-semibold leading-snug text-foreground">{f.name}</h3>
              <div className="mt-5">{details}</div>
              <div className="mt-auto grid gap-2 pt-6">
                <Button size="sm" iconLeft="download" onClick={() => void act('download', f)}>
                  {t.download}
                </Button>
                <Button size="sm" variant="outline" iconLeft="share" onClick={() => void act('share', f)}>
                  {t.share}
                </Button>
              </div>
            </div>
          </aside>
        )}
      </div>
      <Live text={live || msg} />
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* code                                                                */
/* ------------------------------------------------------------------ */

type Tok = { t: string; v: string };

function toLines(tokens: Tok[]): Tok[][] {
  const lines: Tok[][] = [[]];
  for (const tok of tokens) {
    tok.v.split('\n').forEach((part, i) => {
      if (i > 0) lines.push([]);
      if (part) lines[lines.length - 1].push({ t: tok.t, v: part });
    });
  }
  return lines;
}

/** Leading spaces become a fixed width per column, so code lines up in the one proportional face */
function indentOf(tokens: Tok[]) {
  let cols = 0;
  const rest: Tok[] = [];
  let leading = true;
  for (const tok of tokens) {
    if (!leading) {
      rest.push(tok);
      continue;
    }
    const m = tok.v.match(/^[ \t]*/)![0];
    cols += Array.from(m).reduce((a, c) => a + (c === '\t' ? 2 : 1), 0);
    if (m.length < tok.v.length) {
      leading = false;
      rest.push({ t: tok.t, v: tok.v.slice(m.length) });
    }
  }
  return { cols, rest };
}

/** Top level symbols only, helpers inside functions stay out of the outline */
const SYMBOL = /^(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:(function|class|interface|type|enum)\s+([A-Za-z_$][\w$]*)|(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=)/;

function outlineOf(code: string, language?: CodeLanguage) {
  const out: { line: number; name: string; kind: string }[] = [];
  code.split('\n').forEach((l, i) => {
    if (language === 'css') {
      const m = l.match(/^([^\s{@][^{]*)\{/);
      if (m) out.push({ line: i + 1, name: m[1].trim(), kind: 'rule' });
      return;
    }
    const m = l.match(SYMBOL);
    if (m) out.push({ line: i + 1, name: m[2] ?? m[3], kind: m[1] ?? 'const' });
  });
  return out;
}

function CodeLines({ lines, q, current, wrap, sel, onLine, compact }: { lines: Tok[][]; q: string; current: number; wrap: boolean; sel?: [number, number] | null; onLine?: (n: number, extend: boolean) => void; compact?: boolean }) {
  const counter = { n: 0 };
  return (
    <div className={cn('min-w-full py-3 text-[13px] leading-[1.7]', !wrap && 'w-max')}>
      {lines.map((toks, i) => {
        const n = i + 1;
        const { cols, rest } = indentOf(toks);
        const on = !!sel && n >= sel[0] && n <= sel[1];
        return (
          <div key={i} data-line={n} className={cn('group/line flex', on ? 'bg-[color-mix(in_oklab,var(--primary)_11%,transparent)]' : 'hover:bg-[color-mix(in_oklab,var(--foreground)_3%,transparent)]')}>
            <span
              aria-hidden
              onClick={onLine ? (e) => onLine(n, e.shiftKey) : undefined}
              className={cn(
                'sticky start-0 z-[1] w-12 shrink-0 select-none border-e pe-3 text-end tabular-nums',
                compact ? 'w-10' : 'w-12',
                onLine && 'cursor-pointer',
                on ? 'border-[color-mix(in_oklab,var(--primary)_60%,transparent)] bg-[color-mix(in_oklab,var(--primary)_11%,var(--card))] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'border-border bg-card text-subtle-foreground group-hover/line:text-muted-foreground',
              )}
            >
              {n}
            </span>
            <span className={cn('min-w-0 ps-4 pe-6 text-foreground', wrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre')} style={wrap ? { paddingInlineStart: `calc(1rem + ${round(cols * 0.6, 2)}em)`, textIndent: `-${round(cols * 0.6, 2)}em` } : undefined}>
              {cols > 0 && <span aria-hidden className="inline-block" style={{ width: `${round(cols * 0.6, 2)}em` }} />}
              {cols > 0 && <span className="sr-only">{' '.repeat(cols)}</span>}
              {rest.length ? rest.map((tok, j) => (tok.t === 'plain' ? <span key={j}>{marked(tok.v, q, counter, current)}</span> : <span key={j} className={`tk-${tok.t}`}>{marked(tok.v, q, counter, current)}</span>)) : '​'}
            </span>
          </div>
        );
      })}
    </div>
  );
}

function CodeView({ t, file, path = [], locale, onAction }: Inner) {
  const f = file;
  const code = f?.code ?? '';
  const lines = useMemo(() => toLines(tokenize(code, f?.language ?? 'text') as Tok[]), [code, f?.language]);
  const outline = useMemo(() => outlineOf(code, f?.language), [code, f?.language]);
  const [query, setQuery] = useState('');
  const [hit, setHit] = useState(0);
  const [wrap, setWrap] = useState(false);
  const [sel, setSel] = useState<[number, number] | null>(null);
  const [anchor, setAnchor] = useState<number | null>(null);
  const [copied, setCopied] = useState<'code' | 'link' | null>(null);
  const [live, setLive] = useState('');
  const [msg, act] = useActions(onAction, t);
  const scroller = useRef<HTMLDivElement>(null);
  const q = query.trim().toLowerCase();
  const total = useMemo(() => (q ? lines.reduce((a, l) => a + l.reduce((b, tok) => b + countHits(tok.v, q), 0), 0) : 0), [lines, q]);
  const current = total ? ((hit % total) + total) % total : -1;

  useEffect(() => {
    if (current < 0) return;
    scrollInside(scroller.current, scroller.current?.querySelector(`[data-hit="${current}"]`) ?? null);
  }, [current, q]);

  if (!f) return null;
  const pick = (n: number, extend: boolean) => {
    const a = extend && anchor ? anchor : n;
    const range: [number, number] = [Math.min(a, n), Math.max(a, n)];
    if (!extend) setAnchor(n);
    setSel(range);
    setLive(range[0] === range[1] ? fill(t.lineSel, { n: range[0] }) : fill(t.linesSel, { a: range[0], b: range[1] }));
  };
  const jump = (n: number) => {
    pick(n, false);
    requestAnimationFrame(() => scrollInside(scroller.current, scroller.current?.querySelector(`[data-line="${n}"]`) ?? null));
  };
  const flash = (k: 'code' | 'link') => {
    setCopied(k);
    setTimeout(() => setCopied(null), 1600);
  };
  const nextHit = (d: number) => {
    if (!total) return;
    setHit((h) => h + d);
    setLive(fill(t.hits, { n: (((current + d) % total) + total) % total + 1, total }));
  };

  const searchBox = (
    <div className="flex min-w-0 flex-1 items-center gap-1 sm:max-w-80">
      <label className="relative block min-w-0 flex-1">
        <span className="sr-only">{t.search}</span>
        <Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
        <input
          type="search"
          value={query}
          onChange={(e) => {
            setQuery(e.target.value);
            setHit(0);
          }}
          onKeyDown={(e) => {
            if (e.key === 'Enter') {
              e.preventDefault();
              nextHit(e.shiftKey ? -1 : 1);
            }
          }}
          placeholder={t.search}
          className={cn(fieldCls, 'ps-9 pe-16')}
        />
        {q && <span aria-live="polite" className="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-[12px] tabular-nums text-muted-foreground">{total ? fill(t.hits, { n: current + 1, total }) : t.noHits}</span>}
      </label>
      <Tool icon="chevron-up" label={t.prevHit} onClick={() => nextHit(-1)} disabled={!total} />
      <Tool icon="chevron-down" label={t.nextHit} onClick={() => nextHit(1)} disabled={!total} />
    </div>
  );

  return (
    <div className="mx-auto w-full max-w-[80rem] p-4 sm:p-6 lg:p-8">
      <div className="overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
        {/* File head */}
        <div className="flex flex-col gap-3 border-b border-border px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-5">
          <div className="min-w-0">
            <nav aria-label="Path" className="flex min-w-0 flex-wrap items-center gap-1 text-[12.5px] text-muted-foreground">
              {path.map((p, i) => (
                <span key={i} className="inline-flex items-center gap-1">
                  {p}
                  <Icon name="chevron-right" size={12} className="text-subtle-foreground" />
                </span>
              ))}
            </nav>
            <p className="mt-0.5 flex min-w-0 items-center gap-2">
              <Icon name="file-code" size={16} className="shrink-0 text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" />
              <span className="truncate text-[15px] font-semibold text-foreground">{f.name}</span>
            </p>
            <p className="mt-1 flex flex-wrap gap-x-3 text-[12.5px] tabular-nums text-subtle-foreground">
              <span>{fill(t.lines, { n: num(locale, lines.length) })}</span>
              <span>{bytes(locale, f.size)}</span>
              {f.modified && <span>{dateTime(locale, f.modified)}</span>}
            </p>
          </div>
          <div className="kitfp-noprint flex items-center gap-1.5">
            <Tool icon="align-left" label={t.wrap} pressed={wrap} onClick={() => setWrap((v) => !v)} />
            <Button
              size="sm"
              variant="outline"
              iconLeft={copied === 'code' ? 'check' : 'copy'}
              onClick={async () => {
                if (await copyText(code)) flash('code');
                setLive(t.copied);
              }}
            >
              {copied === 'code' ? t.copied : t.copy}
            </Button>
            <Button size="sm" iconLeft="download" onClick={() => void act('download', f, () => saveBlob(f.name, code))}>
              {t.download}
            </Button>
          </div>
        </div>
        {/* Search and jump */}
        <div className="kitfp-noprint flex flex-wrap items-center gap-2 border-b border-border bg-surface px-3 py-2 sm:px-4">
          {searchBox}
          {outline.length > 0 && (
            <label className="relative block w-full sm:hidden">
              <span className="sr-only">{t.jump}</span>
              <select value="" onChange={(e) => e.target.value && jump(Number(e.target.value))} className={cn(fieldCls, 'cursor-pointer appearance-none pe-8')}>
                <option value="">{t.jump}</option>
                {outline.map((o) => (
                  <option key={o.line} value={o.line}>
                    {o.name}
                  </option>
                ))}
              </select>
              <Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
            </label>
          )}
        </div>
        <div className={cn('grid grid-cols-[minmax(0,1fr)]', outline.length > 0 && 'sm:grid-cols-[minmax(0,1fr)_13rem] lg:grid-cols-[minmax(0,1fr)_15rem]')}>
          <div
            ref={scroller}
            tabIndex={0}
            role="region"
            aria-label={fill(t.codeArea, { name: f.name })}
            className={cn('kitfp-scroll kit-scrollbar relative max-h-[34rem] overflow-auto', ring, 'focus-visible:outline-offset-[-2px]')}
          >
            <CodeLines lines={lines} q={q} current={current} wrap={wrap} sel={sel} onLine={pick} />
          </div>
          {outline.length > 0 && (
            <nav aria-label={t.outline} className="kitfp-noprint hidden border-s border-border bg-surface sm:block">
              <p className="px-4 pb-1 pt-3.5 text-[12px] font-medium uppercase tracking-[0.06em] text-subtle-foreground">{t.outline}</p>
              <ul className="kit-scrollbar relative max-h-[30rem] overflow-y-auto px-2 pb-3">
                {outline.map((o, i) => {
                  const on = !!sel && o.line >= sel[0] && o.line <= sel[1];
                  return (
                    <li key={o.line} className="kitfp-up" style={{ '--kitfp-d': `${i * 30}ms` } as React.CSSProperties}>
                      <button type="button" onClick={() => jump(o.line)} aria-current={on ? 'location' : undefined} className={cn('flex min-h-9 w-full items-center gap-2 rounded-md px-2 text-start text-[13px] transition-colors', on ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground', ring)}>
                        <span aria-hidden className={cn('grid size-5 shrink-0 place-items-center rounded text-[10px] font-semibold uppercase', o.kind === 'function' ? 'bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'bg-muted text-muted-foreground')}>
                          {o.kind === 'function' ? 'ƒ' : o.kind.charAt(0)}
                        </span>
                        <span className="min-w-0 flex-1 truncate">{o.name}</span>
                        <span className="text-[11.5px] tabular-nums text-subtle-foreground">{o.line}</span>
                      </button>
                    </li>
                  );
                })}
              </ul>
            </nav>
          )}
        </div>
        {/* Status bar */}
        <div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-border px-4 py-2 text-[12px] tabular-nums text-muted-foreground sm:px-5">
          <span>{sel ? (sel[0] === sel[1] ? fill(t.line, { n: sel[0] }) : fill(t.linesSel, { a: sel[0], b: sel[1] })) : fill(t.position, { l: 1 })}</span>
          {sel && (
            <button
              type="button"
              onClick={async () => {
                await copyText(`#L${sel[0]}${sel[1] !== sel[0] ? `-L${sel[1]}` : ''}`);
                flash('link');
                setLive(t.linkCopied);
              }}
              className={cn('kitfp-noprint inline-flex min-h-9 items-center gap-1 rounded px-1.5 font-medium text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))] hover:underline max-sm:min-h-11', ring)}
            >
              <Icon name={copied === 'link' ? 'check' : 'link'} size={13} />
              {copied === 'link' ? t.linkCopied : t.copyLink}
            </button>
          )}
          <span className="ms-auto">{t.spaces}</span>
          <span>UTF-8</span>
          <span className="font-medium text-foreground">{(f.language ?? 'text').toUpperCase()}</span>
        </div>
      </div>
      <Live text={live || msg} />
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* spreadsheet                                                         */
/* ------------------------------------------------------------------ */

const colName = (i: number) => {
  let s = '';
  let n = i + 1;
  while (n > 0) {
    const m = (n - 1) % 26;
    s = String.fromCharCode(65 + m) + s;
    n = Math.floor((n - 1) / 26);
  }
  return s;
};

function cellText(l: Locale, v: string | number | null, c: PreviewColumn) {
  if (v === null || v === '') return '';
  if (typeof v === 'number') {
    if (c.type === 'currency') return num(l, v, { style: 'currency', currency: c.currency ?? 'EUR' });
    if (c.type === 'percent') return num(l, v, { style: 'percent', maximumFractionDigits: 1 });
    return num(l, v, { maximumFractionDigits: 2 });
  }
  if (c.type === 'date' && /^\d{4}-\d{2}-\d{2}/.test(v)) return dateOnly(l, v);
  return v;
}

function Spreadsheet({ t, file, locale, onAction }: Inner) {
  const sheets = file?.sheets ?? [];
  const [si, setSi] = useState(0);
  const [cur, setCur] = useState({ r: 0, c: 0 });
  const [anchor, setAnchor] = useState({ r: 0, c: 0 });
  const [sort, setSort] = useState<{ c: number; dir: 'ascending' | 'descending' } | null>(null);
  const [query, setQuery] = useState('');
  const [msg, act] = useActions(onAction, t);
  const [live, setLive] = useState('');
  const grid = useRef<HTMLTableElement>(null);
  const scroller = useRef<HTMLDivElement>(null);
  const tabs = useRef<HTMLDivElement>(null);
  const sheet = sheets[si];

  const rows = useMemo(() => {
    if (!sheet) return [];
    const q = query.trim().toLowerCase();
    let list = sheet.rows.map((r, i) => ({ r, i }));
    if (q) list = list.filter(({ r }) => r.some((v, c) => cellText(locale, v, sheet.columns[c] ?? { label: '' }).toLowerCase().includes(q)));
    if (sort) {
      const k = sort.c;
      const sgn = sort.dir === 'ascending' ? 1 : -1;
      list = [...list].sort((a, b) => {
        const x = a.r[k];
        const y = b.r[k];
        if (x === null || x === undefined) return 1;
        if (y === null || y === undefined) return -1;
        return (typeof x === 'number' && typeof y === 'number' ? x - y : String(x).localeCompare(String(y), intlOf(locale))) * sgn;
      });
    }
    return list;
  }, [sheet, query, sort, locale]);

  if (!file || !sheet) return null;
  const cols = sheet.columns;
  const R = rows.length;
  const C = cols.length;
  const rect = { r0: Math.min(anchor.r, cur.r), r1: Math.max(anchor.r, cur.r), c0: Math.min(anchor.c, cur.c), c1: Math.max(anchor.c, cur.c) };
  const inSel = (r: number, c: number) => r >= rect.r0 && r <= rect.r1 && c >= rect.c0 && c <= rect.c1;
  const cells = (rect.r1 - rect.r0 + 1) * (rect.c1 - rect.c0 + 1);
  const values: number[] = [];
  let fmtCol: PreviewColumn | null = null;
  for (let r = rect.r0; r <= rect.r1 && r < R; r++)
    for (let c = rect.c0; c <= rect.c1; c++) {
      const v = rows[r]?.r[c];
      if (typeof v === 'number') {
        values.push(v);
        fmtCol ??= cols[c];
      }
    }
  const sum = values.reduce((a, b) => a + b, 0);
  const fmt = (n: number) => cellText(locale, round(n, 4), fmtCol ?? { label: '', type: 'number' });

  const move = (r: number, c: number, extend: boolean) => {
    const nr = Math.max(0, Math.min(R - 1, r));
    const nc = Math.max(0, Math.min(C - 1, c));
    setCur({ r: nr, c: nc });
    if (!extend) setAnchor({ r: nr, c: nc });
    requestAnimationFrame(() => {
      const el = grid.current?.querySelector<HTMLElement>(`[data-cell="${nr}-${nc}"]`);
      el?.focus({ preventScroll: true });
      const s = scroller.current;
      if (el && s) {
        const head = 41;
        const side = 48;
        const er = el.getBoundingClientRect();
        const sr = s.getBoundingClientRect();
        if (er.top < sr.top + head) s.scrollTop -= sr.top + head - er.top;
        else if (er.bottom > sr.bottom) s.scrollTop += er.bottom - sr.bottom;
        if (er.left < sr.left + side) s.scrollLeft -= sr.left + side - er.left;
        else if (er.right > sr.right) s.scrollLeft += er.right - sr.right;
      }
    });
  };
  const onKey = (e: React.KeyboardEvent) => {
    const ext = e.shiftKey;
    const ctrl = e.ctrlKey || e.metaKey;
    const map: Record<string, [number, number]> = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] };
    if (map[e.key]) {
      const [dr, dc] = map[e.key];
      move(ctrl ? (dr ? (dr > 0 ? R - 1 : 0) : cur.r) : cur.r + dr, ctrl ? (dc ? (dc > 0 ? C - 1 : 0) : cur.c) : cur.c + dc, ext);
    } else if (e.key === 'Home') move(ctrl ? 0 : cur.r, 0, ext);
    else if (e.key === 'End') move(ctrl ? R - 1 : cur.r, C - 1, ext);
    else if (e.key === 'PageDown') move(cur.r + 10, cur.c, ext);
    else if (e.key === 'PageUp') move(cur.r - 10, cur.c, ext);
    else if (ctrl && e.key.toLowerCase() === 'a') {
      setAnchor({ r: 0, c: 0 });
      setCur({ r: R - 1, c: C - 1 });
      setLive(fill(t.selected, { n: R * C }));
    } else if (ctrl && e.key.toLowerCase() === 'c') {
      const text = [];
      for (let r = rect.r0; r <= rect.r1; r++) text.push(rows[r].r.slice(rect.c0, rect.c1 + 1).map((v, k) => cellText(locale, v, cols[rect.c0 + k])).join('\t'));
      void copyText(text.join('\n'));
      setLive(t.copied);
      return;
    } else return;
    e.preventDefault();
  };
  const curVal = rows[cur.r]?.r[cur.c];
  const switchSheet = (i: number, focus = false) => {
    setSi(i);
    setCur({ r: 0, c: 0 });
    setAnchor({ r: 0, c: 0 });
    setSort(null);
    if (focus) requestAnimationFrame(() => tabs.current?.querySelectorAll<HTMLElement>('[role=tab]')[i]?.focus());
  };

  return (
    <div className="mx-auto w-full max-w-[80rem] p-4 sm:p-6 lg:p-8">
      <div className="overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
        <div className="flex flex-col gap-3 border-b border-border px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-5">
          <div className="flex min-w-0 items-center gap-3">
            <span aria-hidden className="grid size-10 shrink-0 place-items-center rounded-lg bg-[color-mix(in_oklab,var(--success)_14%,var(--card))] text-success-foreground">
              <Icon name="table" size={18} />
            </span>
            <div className="min-w-0">
              <p className="truncate text-[15px] font-semibold text-foreground">{file.name}</p>
              <p className="flex flex-wrap gap-x-3 text-[12.5px] tabular-nums text-subtle-foreground">
                <span>{fill(t.rows, { n: num(locale, sheet.rows.length) })}</span>
                <span>{fill(t.cols, { n: C })}</span>
                <span>{bytes(locale, file.size)}</span>
              </p>
            </div>
          </div>
          <div className="kitfp-noprint flex items-center gap-2">
            <label className="relative block min-w-0 flex-1 sm:w-56 sm:flex-none">
              <span className="sr-only">{t.filterRows}</span>
              <Icon name="filter" size={14} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
              <input
                type="search"
                value={query}
                onChange={(e) => {
                  setQuery(e.target.value);
                  setCur({ r: 0, c: 0 });
                  setAnchor({ r: 0, c: 0 });
                }}
                placeholder={t.filterRows}
                className={cn(fieldCls, 'ps-9')}
              />
            </label>
            <Button size="sm" variant="outline" iconLeft="download" onClick={() => void act('download', file, () => saveBlob(file.name.replace(/\.\w+$/, '') + '.csv', toCsv(sheet), 'text/csv'))}>
              <span className="max-sm:sr-only">{t.downloadCsv}</span>
            </Button>
          </div>
        </div>
        {/* Formula bar */}
        <div className="flex items-stretch border-b border-border bg-surface text-[13px]">
          <span className="flex w-16 shrink-0 items-center justify-center border-e border-border font-semibold tabular-nums text-foreground" aria-label={t.cell}>
            {colName(cur.c)}
            {R ? cur.r + 1 : ''}
          </span>
          <span aria-hidden className="flex items-center px-3 italic text-subtle-foreground">fx</span>
          <span className="flex min-w-0 flex-1 items-center truncate py-2.5 pe-4 tabular-nums text-foreground">{curVal === null || curVal === undefined ? '' : String(curVal)}</span>
        </div>
        <div ref={scroller} className="kitfp-scroll kit-scrollbar relative max-h-[30rem] overflow-auto">
          <table
            ref={grid}
            role="grid"
            aria-label={fill(t.grid, { name: sheet.name })}
            aria-rowcount={R + 1}
            aria-colcount={C + 1}
            aria-multiselectable
            onKeyDown={onKey}
            className="w-max min-w-full border-separate border-spacing-0 text-[13px]"
          >
            <thead>
              <tr>
                <th scope="col" className="sticky start-0 top-0 z-[3] h-11 w-12 sm:h-10 min-w-12 border-b border-e border-border bg-surface-2">
                  <span className="sr-only">#</span>
                </th>
                {cols.map((c, i) => {
                  const s = sort?.c === i ? sort.dir : undefined;
                  const isNum = c.type === 'number' || c.type === 'currency' || c.type === 'percent';
                  return (
                    <th key={i} scope="col" aria-sort={s ?? 'none'} className={cn('sticky top-0 z-[2] h-11 border-b sm:h-10 border-e border-border bg-surface-2 p-0 font-normal', i >= rect.c0 && i <= rect.c1 && 'bg-[color-mix(in_oklab,var(--primary)_10%,var(--surface-2))]')}>
                      <button
                        type="button"
                        aria-label={fill(t.sortBy, { col: c.label })}
                        onClick={() => setSort((p) => (p?.c === i ? (p.dir === 'ascending' ? { c: i, dir: 'descending' } : null) : { c: i, dir: 'ascending' }))}
                        className={cn('group/h flex h-11 w-full sm:h-10 min-w-28 items-center gap-1.5 px-3 text-[12.5px]', isNum ? 'justify-end' : 'justify-start', ring, 'focus-visible:outline-offset-[-2px]')}
                      >
                        <span aria-hidden className="text-[11px] font-medium text-subtle-foreground">{colName(i)}</span>
                        <span className="truncate font-semibold text-foreground">{c.label}</span>
                        <Icon name={s === 'descending' ? 'arrow-down' : 'arrow-up'} size={12} className={cn('shrink-0 transition-opacity', s ? 'text-foreground opacity-100' : 'opacity-0 group-hover/h:opacity-50')} />
                      </button>
                    </th>
                  );
                })}
              </tr>
            </thead>
            <tbody>
              {rows.map(({ r, i: orig }, ri) => (
                <tr key={orig} aria-rowindex={ri + 2}>
                  <th scope="row" className={cn('sticky start-0 z-[1] h-11 border-b sm:h-9 border-e border-border bg-surface-2 px-2 text-end text-[11.5px] font-medium tabular-nums', ri >= rect.r0 && ri <= rect.r1 ? 'bg-[color-mix(in_oklab,var(--primary)_10%,var(--surface-2))] text-foreground' : 'text-subtle-foreground')}>
                    {orig + 1}
                  </th>
                  {cols.map((c, ci) => {
                    const v = r[ci];
                    const on = inSel(ri, ci);
                    const focus = cur.r === ri && cur.c === ci;
                    const numeric = typeof v === 'number';
                    return (
                      <td
                        key={ci}
                        role="gridcell"
                        data-cell={`${ri}-${ci}`}
                        tabIndex={focus ? 0 : -1}
                        aria-selected={on}
                        onMouseDown={(e) => {
                          e.preventDefault();
                          move(ri, ci, e.shiftKey);
                        }}
                        onMouseEnter={(e) => {
                          if (e.buttons === 1) move(ri, ci, true);
                        }}
                        className={cn(
                          'h-11 max-w-64 sm:h-9 cursor-cell truncate border-b border-e border-border px-3 outline-none',
                          numeric ? 'text-end tabular-nums' : 'text-start',
                          numeric && v < 0 ? 'text-destructive' : 'text-foreground',
                          on && 'bg-[color-mix(in_oklab,var(--primary)_9%,transparent)]',
                          focus && 'shadow-[inset_0_0_0_2px_var(--primary)]',
                        )}
                      >
                        {cellText(locale, v ?? null, c)}
                      </td>
                    );
                  })}
                </tr>
              ))}
            </tbody>
          </table>
          {!R && <p className="px-4 py-10 text-center text-[13.5px] text-muted-foreground">{t.noHits}</p>}
        </div>
        {/* Sheet tabs and the summary of the selection */}
        <div className="flex flex-col gap-1 border-t border-border bg-surface sm:flex-row sm:items-center sm:justify-between">
          <div
            ref={tabs}
            role="tablist"
            aria-label={t.sheets}
            className="kit-no-scrollbar flex overflow-x-auto px-2"
            onKeyDown={(e) => {
              const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
              if (!d) return;
              e.preventDefault();
              switchSheet((si + d + sheets.length) % sheets.length, true);
            }}
          >
            {sheets.map((s, i) => (
              <button key={s.name} type="button" role="tab" aria-selected={i === si} tabIndex={i === si ? 0 : -1} onClick={() => switchSheet(i)} className={cn('relative h-11 shrink-0 whitespace-nowrap px-3.5 text-[13px] font-medium transition-colors sm:h-10', i === si ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', ring, 'focus-visible:outline-offset-[-2px]')}>
                {s.name}
                <span aria-hidden className={cn('absolute inset-x-2 top-0 h-0.5 rounded-full bg-primary transition-transform duration-300 ease-[var(--ease-kit)]', i === si ? 'scale-x-100' : 'scale-x-0')} />
              </button>
            ))}
          </div>
          <p className="flex flex-wrap items-center gap-x-4 px-4 pb-2.5 text-[12.5px] tabular-nums text-muted-foreground sm:pb-0">
            {query && <span>{fill(t.shownRows, { n: num(locale, R), total: num(locale, sheet.rows.length) })}</span>}
            {values.length > 1 ? (
              <>
                <span>
                  {t.sum} <span className="font-medium text-foreground">{fmt(sum)}</span>
                </span>
                <span>
                  {t.average} <span className="font-medium text-foreground">{fmt(sum / values.length)}</span>
                </span>
                <span>
                  {t.count} <span className="font-medium text-foreground">{num(locale, cells)}</span>
                </span>
              </>
            ) : (
              <span className="hidden sm:inline">{fill(t.rows, { n: num(locale, R) })}</span>
            )}
          </p>
        </div>
      </div>
      <Live text={live || msg} />
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* document                                                            */
/* ------------------------------------------------------------------ */

/** A page drawn as paper. Text scales with the page width through container units. */
function Page({ page, n, q, counter, current, thumb }: { page: PreviewPage; n: number; q: string; counter: { n: number }; current: number; thumb?: boolean }) {
  const m = (s?: string) => (thumb || !s ? s : marked(s, q, counter, current));
  const max = (v?: number[]) => Math.max(1, ...(v ?? [1]));
  return (
    <div className="kitfp-paper relative aspect-[210/297] w-full overflow-hidden rounded-[2px] text-start bg-[#fdfdfc] text-[#1d2027] shadow-[0_1px_2px_rgb(0_0_0/0.12),0_12px_32px_-12px_rgb(0_0_0/0.35)] [container-type:inline-size]">
      <div className="absolute inset-0 flex flex-col gap-[2.6cqw] px-[9cqw] py-[10cqw] text-[2.15cqw] leading-[1.55]">
        {page.blocks.map((b, i) => {
          switch (b.type) {
            case 'title':
              return (
                <h3 key={i} data-head className="text-[5cqw] font-semibold leading-[1.15] tracking-[-0.02em] text-[#111317]">
                  {m(b.text)}
                </h3>
              );
            case 'meta':
              return (
                <p key={i} className="-mt-[1cqw] text-[1.9cqw] text-[#5d626d]">
                  {m(b.text)}
                </p>
              );
            case 'heading':
              return (
                <h4 key={i} data-head className="mt-[1.6cqw] text-[2.9cqw] font-semibold text-[#111317]">
                  {m(b.text)}
                </h4>
              );
            case 'quote':
              return (
                <blockquote key={i} className="border-s-[0.6cqw] border-[color-mix(in_oklab,var(--primary)_70%,#fff)] ps-[3cqw] italic text-[#3a3f49]">
                  {m(b.text)}
                </blockquote>
              );
            case 'list':
              return (
                <ul key={i} className="grid gap-[1cqw] ps-[3.5cqw]">
                  {b.items?.map((it, k) => (
                    <li key={k} className="list-disc marker:text-[#8a8f99]">
                      {m(it)}
                    </li>
                  ))}
                </ul>
              );
            case 'table':
              return (
                <table key={i} className="w-full border-collapse text-[1.95cqw] tabular-nums">
                  <tbody>
                    {b.rows?.map((r, k) => (
                      <tr key={k} className={k === 0 ? 'border-b-[0.25cqw] border-[#1d2027] font-semibold' : 'border-b-[0.15cqw] border-[#e2e4e8]'}>
                        {r.map((c, j) => (
                          <td key={j} className={cn('py-[1cqw]', j > 0 && 'text-end')}>
                            {m(c)}
                          </td>
                        ))}
                      </tr>
                    ))}
                  </tbody>
                </table>
              );
            case 'chart':
              return (
                <div key={i} className="flex h-[22cqw] items-end gap-[2cqw] border-b-[0.2cqw] border-[#c9ccd2] pb-[0.4cqw]" aria-hidden={thumb}>
                  {b.values?.map((v, k) => (
                    <div key={k} className="flex h-full flex-1 flex-col items-center justify-end gap-[0.8cqw]">
                      <span className="text-[1.6cqw] tabular-nums text-[#5d626d]">{v}</span>
                      <span className="kitfp-bar w-full rounded-t-[0.6cqw]" style={{ height: `${round((v / max(b.values)) * 78, 1)}%`, background: k === (b.values?.length ?? 0) - 1 ? 'var(--primary)' : 'color-mix(in oklab, var(--primary) 32%, #e6e8ec)', '--kitfp-d': `${k * 60}ms` } as React.CSSProperties} />
                      <span className="text-[1.6cqw] text-[#5d626d]">{b.items?.[k]}</span>
                    </div>
                  ))}
                </div>
              );
            default:
              return (
                <p key={i} className="text-pretty text-[#2b2f37]">
                  {m(b.text)}
                </p>
              );
          }
        })}
      </div>
      <span className="absolute bottom-[4cqw] end-[9cqw] text-[1.7cqw] tabular-nums text-[#8a8f99]">{n}</span>
    </div>
  );
}

const pageText = (p: PreviewPage) => p.blocks.flatMap((b) => [b.text ?? '', ...(b.items && b.type === 'list' ? b.items : []), ...(b.rows?.flat() ?? [])]);

function DocumentView({ t, file, locale, onAction }: Inner) {
  const pages = file?.pages ?? [];
  const [page, setPage] = useState(1);
  const [zoom, setZoom] = useState(1);
  const [rail, setRail] = useState<'pages' | 'outline'>('pages');
  const [railOpen, setRailOpen] = useState(true);
  const [contents, setContents] = useState(false);
  const [query, setQuery] = useState('');
  const [hit, setHit] = useState(0);
  const [pageInput, setPageInput] = useState('1');
  const [live, setLive] = useState('');
  const [msg, act] = useActions(onAction, t);
  const scroller = useRef<HTMLDivElement>(null);
  const thumbs = useRef<HTMLDivElement>(null);
  const contentsId = useId();
  const q = query.trim().toLowerCase();
  const total = useMemo(() => (q ? pages.reduce((a, p) => a + pageText(p).reduce((b, s) => b + countHits(s, q), 0), 0) : 0), [pages, q]);
  const current = total ? ((hit % total) + total) % total : -1;
  const outline = useMemo(() => pages.flatMap((p, i) => p.blocks.filter((b) => b.type === 'title' || b.type === 'heading').map((b) => ({ page: i + 1, text: b.text ?? '', level: b.type === 'title' ? 1 : 2 }))), [pages]);

  useEffect(() => setPageInput(String(page)), [page]);
  // The page in the middle of the viewport counts as current
  useEffect(() => {
    const root = scroller.current;
    if (!root || typeof IntersectionObserver === 'undefined') return;
    const io = new IntersectionObserver(
      (entries) => {
        const vis = entries.filter((e) => e.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
        if (vis) setPage(Number((vis.target as HTMLElement).dataset.page));
      },
      { root, threshold: [0.3, 0.6] },
    );
    root.querySelectorAll('[data-page]').forEach((el) => io.observe(el));
    return () => io.disconnect();
  }, [pages.length, zoom]);
  useEffect(() => {
    scrollInside(thumbs.current, thumbs.current?.querySelector(`[data-thumb="${page}"]`) ?? null);
  }, [page]);
  useEffect(() => {
    if (current < 0) return;
    scrollInside(scroller.current, scroller.current?.querySelector(`[data-hit="${current}"]`) ?? null);
  }, [current, q]);

  if (!file || !pages.length) return null;
  const goTo = (n: number) => {
    const p = Math.max(1, Math.min(pages.length, n));
    setPage(p);
    const el = scroller.current?.querySelector<HTMLElement>(`[data-page="${p}"]`);
    const root = scroller.current;
    if (el && root) root.scrollTo({ top: root.scrollTop + el.getBoundingClientRect().top - root.getBoundingClientRect().top - 16, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
    setLive(fill(t.page, { n: p }) + ' ' + fill(t.pageOf, { total: pages.length }));
  };
  const nextHit = (d: number) => {
    if (!total) return;
    setHit((h) => h + d);
    setLive(fill(t.hits, { n: (((current + d) % total) + total) % total + 1, total }));
  };
  const zooms = [0.75, 1, 1.25, 1.5];
  const counter = { n: 0 };
  const pct = (z: number) => num(locale, z, { style: 'percent', maximumFractionDigits: 0 });

  const outlineList = (onPick?: () => void) =>
    outline.length ? (
      <ul className="grid gap-0.5">
        {outline.map((o, i) => (
          <li key={i}>
            <button
              type="button"
              onClick={() => {
                goTo(o.page);
                onPick?.();
              }}
              aria-current={o.page === page ? 'page' : undefined}
              className={cn('flex min-h-10 w-full items-start gap-2 rounded-md px-2 py-2 text-start text-[13px] leading-snug transition-colors', o.level === 2 && 'ps-5', o.page === page ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground', ring)}
            >
              <span className={cn('min-w-0 flex-1', o.level === 1 && 'font-medium')}>{o.text}</span>
              <span className="text-[11.5px] tabular-nums text-subtle-foreground">{o.page}</span>
            </button>
          </li>
        ))}
      </ul>
    ) : (
      <p className="px-2 py-3 text-[13px] text-muted-foreground">{t.noOutline}</p>
    );

  return (
    <div className="mx-auto w-full max-w-[80rem] p-4 sm:p-6 lg:p-8">
      <div className="overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
        {/* Toolbar */}
        <div className="kitfp-noprint flex flex-wrap items-center gap-x-1 gap-y-2 border-b border-border px-2 py-2 sm:px-3">
          <Tool icon="panel-left" label={t.thumbs} pressed={railOpen} onClick={() => setRailOpen((v) => !v)} className="hidden lg:inline-grid" />
          <div className="me-auto flex min-w-0 items-center gap-2 ps-1.5">
            <Icon name="file" size={16} className="shrink-0 text-destructive" />
            <span className="truncate text-[13.5px] font-semibold text-foreground">{file.name}</span>
          </div>
          <div className="flex items-center gap-0.5">
            <Tool icon="chevron-up" label={fill(t.page, { n: page - 1 })} onClick={() => goTo(page - 1)} disabled={page <= 1} />
            <form
              onSubmit={(e) => {
                e.preventDefault();
                goTo(Number(pageInput) || page);
              }}
              className="flex items-center gap-1.5 text-[13px] tabular-nums text-muted-foreground"
            >
              <label className="sr-only" htmlFor={`${contentsId}-p`}>
                {t.goToPage}
              </label>
              <input id={`${contentsId}-p`} inputMode="numeric" value={pageInput} onChange={(e) => setPageInput(e.target.value.replace(/\D/g, ''))} onBlur={() => goTo(Number(pageInput) || page)} className={cn(fieldCls, 'w-12 px-1 text-center tabular-nums')} />
              <span className="whitespace-nowrap">{fill(t.pageOf, { total: pages.length })}</span>
            </form>
            <Tool icon="chevron-down" label={fill(t.page, { n: page + 1 })} onClick={() => goTo(page + 1)} disabled={page >= pages.length} />
          </div>
          <span aria-hidden className="mx-1 hidden h-5 w-px bg-border sm:block" />
          <div className="hidden items-center gap-0.5 sm:flex">
            <Tool icon="zoom-out" label={t.zoomOut} onClick={() => setZoom(zooms[Math.max(0, zooms.indexOf(zoom) - 1)])} disabled={zoom <= zooms[0]} />
            <span className="min-w-12 text-center text-[12.5px] tabular-nums text-muted-foreground" aria-live="polite">
              {pct(zoom)}
            </span>
            <Tool icon="zoom-in" label={t.zoomIn} onClick={() => setZoom(zooms[Math.min(zooms.length - 1, zooms.indexOf(zoom) + 1)])} disabled={zoom >= zooms[zooms.length - 1]} />
          </div>
          <Tool icon="printer" label={t.print} onClick={() => (onAction ? void onAction('print', { id: file.id }) : window.print())} className="hidden sm:inline-grid" />
          <Tool icon="download" label={t.download} onClick={() => void act('download', file)} />
        </div>
        {/* Search, with contents on phones */}
        <div className="kitfp-noprint flex items-center gap-1 border-b border-border bg-surface px-2 py-2 sm:px-3">
          <label className="relative block min-w-0 flex-1 sm:max-w-80">
            <span className="sr-only">{t.search}</span>
            <Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
            <input
              type="search"
              value={query}
              onChange={(e) => {
                setQuery(e.target.value);
                setHit(0);
              }}
              onKeyDown={(e) => {
                if (e.key === 'Enter') {
                  e.preventDefault();
                  nextHit(e.shiftKey ? -1 : 1);
                }
              }}
              placeholder={t.search}
              className={cn(fieldCls, 'ps-9 pe-16')}
            />
            {q && <span aria-live="polite" className="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-[12px] tabular-nums text-muted-foreground">{total ? fill(t.hits, { n: current + 1, total }) : t.noHits}</span>}
          </label>
          <Tool icon="chevron-up" label={t.prevHit} onClick={() => nextHit(-1)} disabled={!total} />
          <Tool icon="chevron-down" label={t.nextHit} onClick={() => nextHit(1)} disabled={!total} />
          <button type="button" aria-expanded={contents} aria-controls={contentsId} onClick={() => setContents((v) => !v)} className={cn('ms-auto inline-flex h-11 min-w-11 items-center justify-center gap-1.5 rounded-md px-3 text-[13px] font-medium text-foreground hover:bg-accent lg:hidden', ring)}>
            <Icon name="list" size={15} />
            <span className="max-[400px]:sr-only">{t.contents}</span>
          </button>
        </div>
        <div id={contentsId} className={cn('grid border-b border-border transition-[grid-template-rows] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none lg:hidden', contents ? 'grid-rows-[1fr]' : 'invisible grid-rows-[0fr] border-b-0')}>
          <div className="min-h-0 overflow-hidden">
            <nav aria-label={t.contents} className="p-2">
              {outlineList(() => setContents(false))}
            </nav>
          </div>
        </div>
        <div className={cn('grid grid-cols-[minmax(0,1fr)]', railOpen && 'lg:grid-cols-[15rem_minmax(0,1fr)]')}>
          {railOpen && (
            <aside className="kitfp-noprint hidden min-h-0 flex-col border-e border-border bg-surface lg:flex">
              <div role="tablist" aria-label={t.thumbs} className="flex gap-1 border-b border-border p-2">
                {(['pages', 'outline'] as const).map((k) => (
                  <button
                    key={k}
                    type="button"
                    role="tab"
                    aria-selected={rail === k}
                    tabIndex={rail === k ? 0 : -1}
                    onClick={() => setRail(k)}
                    onKeyDown={(e) => {
                      if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
                        e.preventDefault();
                        const n = k === 'pages' ? 'outline' : 'pages';
                        setRail(n);
                        (e.currentTarget.parentElement?.querySelector(`[data-tab="${n}"]`) as HTMLElement | null)?.focus();
                      }
                    }}
                    data-tab={k}
                    className={cn('h-9 flex-1 rounded-md text-[13px] font-medium transition-colors', rail === k ? 'bg-background text-foreground shadow-[var(--shadow-sm)] ring-1 ring-border' : 'text-muted-foreground hover:text-foreground', ring)}
                  >
                    {k === 'pages' ? t.pages : t.contents}
                  </button>
                ))}
              </div>
              <div ref={thumbs} role="tabpanel" className="kit-scrollbar relative max-h-[40rem] flex-1 overflow-y-auto p-3">
                {rail === 'pages' ? (
                  <ul className="grid gap-4 px-6">
                    {pages.map((p, i) => (
                      <li key={i} data-thumb={i + 1}>
                        <button type="button" onClick={() => goTo(i + 1)} aria-label={fill(t.page, { n: i + 1 })} aria-current={page === i + 1 ? 'page' : undefined} className={cn('group/t block w-full rounded-[3px] transition-transform duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5', ring)}>
                          <span aria-hidden className={cn('block rounded-[3px] transition-shadow duration-300', page === i + 1 ? 'ring-2 ring-primary ring-offset-2 ring-offset-surface' : 'opacity-80 group-hover/t:opacity-100')} inert>
                            <Page page={p} n={i + 1} q="" counter={{ n: 0 }} current={-1} thumb />
                          </span>
                          <span className={cn('mt-1.5 block text-center text-[12px] tabular-nums', page === i + 1 ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{i + 1}</span>
                        </button>
                      </li>
                    ))}
                  </ul>
                ) : (
                  <nav aria-label={t.contents}>{outlineList()}</nav>
                )}
              </div>
            </aside>
          )}
          <div ref={scroller} tabIndex={0} role="region" aria-label={file.name} className={cn('kitfp-scroll kit-scrollbar relative max-h-[44rem] overflow-auto bg-surface-2 px-3 py-5 sm:px-8 sm:py-8', ring, 'focus-visible:outline-offset-[-2px]')}>
            <div className="mx-auto grid gap-6 sm:gap-8" style={{ width: `min(100%, ${round(46 * zoom, 2)}rem)`, minWidth: zoom > 1 ? `${round(30 * zoom, 2)}rem` : undefined }}>
              {pages.map((p, i) => (
                <section key={i} data-page={i + 1} aria-label={fill(t.page, { n: i + 1 })} className="kitfp-up" style={{ '--kitfp-d': `${Math.min(i, 2) * 90}ms` } as React.CSSProperties}>
                  <Page page={p} n={i + 1} q={q} counter={counter} current={current} />
                </section>
              ))}
            </div>
          </div>
        </div>
      </div>
      <Live text={live || msg} />
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* quick-look                                                          */
/* ------------------------------------------------------------------ */

/** A small drawn picture of a file for the grid */
function Thumb({ f, locale }: { f: PreviewFile; locale: Locale }) {
  if (f.kind === 'image' && f.media) return <span className="block size-full [&>*]:size-full">{f.media}</span>;
  if (f.kind === 'pdf' && f.pages?.[0])
    return (
      <span className="flex size-full items-start justify-center overflow-hidden bg-surface-2 px-[18%] pt-[10%]" inert>
        <span className="block w-full">
          <Page page={f.pages[0]} n={1} q="" counter={{ n: 0 }} current={-1} thumb />
        </span>
      </span>
    );
  if (f.kind === 'code') {
    const lines = (f.code ?? '').split('\n').slice(0, 9);
    return (
      <span className="kitfp block size-full overflow-hidden bg-card p-3 text-[7.5px] leading-[1.6]" aria-hidden>
        {lines.map((l, i) => (
          <span key={i} className="block whitespace-pre text-muted-foreground">
            <span className="me-2 text-subtle-foreground">{i + 1}</span>
            {l}
          </span>
        ))}
      </span>
    );
  }
  if (f.kind === 'sheet') {
    const s = f.sheets?.[0];
    return (
      <span className="grid size-full grid-cols-4 grid-rows-6 gap-px overflow-hidden bg-border p-px" aria-hidden>
        {Array.from({ length: 24 }, (_, i) => (
          <span key={i} className={cn('flex items-center truncate px-1 text-[7px]', i < 4 ? 'bg-surface-2 font-semibold text-foreground' : 'bg-card text-muted-foreground')}>
            {i < 4 ? s?.columns[i]?.label : s ? cellText(locale, s.rows[Math.floor(i / 4) - 1]?.[i % 4] ?? null, s.columns[i % 4] ?? { label: '' }) : ''}
          </span>
        ))}
      </span>
    );
  }
  if (f.kind === 'audio')
    return (
      <span className="flex size-full items-center justify-center gap-[3px] bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))] px-5" aria-hidden>
        {wave(f.id, 28).map((h, i) => (
          <span key={i} className="w-[3px] rounded-full bg-[color-mix(in_oklab,var(--primary)_70%,var(--foreground))]" style={{ height: `${round(h * 60, 1)}%`, opacity: 0.45 + h * 0.55 }} />
        ))}
      </span>
    );
  if (f.kind === 'archive' && f.entries?.length)
    return (
      <span className="flex size-full flex-col justify-center gap-1.5 bg-surface-2 px-[12%]" aria-hidden>
        {f.entries.slice(0, 4).map((e, i) => (
          <span key={i} className="flex items-center gap-2 rounded-md border border-border bg-card px-2 py-1.5 text-[10px] text-muted-foreground shadow-[var(--shadow-sm)]" style={{ marginInlineStart: `${i * 6}%` }}>
            <Icon name={/.(png|jpe?g|svg|webp)$/i.test(e.name) ? 'image' : 'file'} size={11} className="shrink-0" />
            <span className="truncate">{e.name}</span>
          </span>
        ))}
      </span>
    );
  if (f.kind === 'text' && f.text?.length)
    return (
      <span className="flex size-full items-start justify-center overflow-hidden bg-surface-2 px-[14%] pt-[9%]" aria-hidden>
        <span className="block h-full w-full rounded-t-[3px] bg-card px-3 pt-3 text-[8px] leading-[1.55] text-muted-foreground shadow-[var(--shadow-sm)]">
          {f.text.join(' ').slice(0, 240)}
        </span>
      </span>
    );
  return (
    <span className="grid size-full place-items-center bg-surface-2 text-muted-foreground" aria-hidden>
      <Icon name={KIND_ICON[f.kind]} size={30} />
    </span>
  );
}

/** A stable waveform from the file id, the same on server and client */
function wave(seed: string, n: number) {
  let h = 0;
  for (const c of seed) h = (h * 31 + c.charCodeAt(0)) >>> 0;
  return Array.from({ length: n }, (_, i) => round(0.25 + 0.75 * Math.abs(Math.sin(h * 0.0001 + i * 0.7) * Math.cos(i * 0.31 + (h % 7))), 3));
}

function AudioPlayer({ f, t, locale }: { f: PreviewFile; t: Texts; locale: Locale }) {
  const dur = f.duration ?? 60;
  const [pos, setPos] = useState(0);
  const [playing, setPlaying] = useState(false);
  const bars = useMemo(() => wave(f.id, 56), [f.id]);
  useEffect(() => {
    if (!playing) return;
    let last = performance.now();
    let raf = 0;
    const tick = (now: number) => {
      setPos((p) => {
        const n = p + (now - last) / 1000;
        if (n >= dur) {
          setPlaying(false);
          return dur;
        }
        return n;
      });
      last = now;
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [playing, dur]);
  const share = pos / dur;
  return (
    <div className="grid w-full max-w-xl gap-5 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-sm)] sm:p-6">
      <div className="flex items-center gap-4">
        <button
          type="button"
          aria-label={playing ? t.pause : t.play}
          onClick={() => {
            if (pos >= dur) setPos(0);
            setPlaying((v) => !v);
          }}
          className={cn('grid size-12 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground shadow-[var(--shadow-md)] transition-transform duration-200 hover:scale-105 active:scale-95', ring)}
        >
          <Icon name={playing ? 'pause' : 'play'} size={18} />
        </button>
        <div className="min-w-0">
          <p className="truncate text-[14.5px] font-semibold text-foreground">{f.name}</p>
          <p className="text-[12.5px] tabular-nums text-muted-foreground">
            {clock(pos)} / {clock(dur)} · {bytes(locale, f.size)}
          </p>
        </div>
      </div>
      <div
        role="slider"
        tabIndex={0}
        aria-label={t.seek}
        aria-valuemin={0}
        aria-valuemax={Math.round(dur)}
        aria-valuenow={Math.round(pos)}
        aria-valuetext={`${clock(pos)} / ${clock(dur)}`}
        onKeyDown={(e) => {
          const d = e.key === 'ArrowRight' || e.key === 'ArrowUp' ? 5 : e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -5 : 0;
          if (e.key === 'Home') setPos(0);
          else if (e.key === 'End') setPos(dur);
          else if (d) setPos((p) => Math.max(0, Math.min(dur, p + d)));
          else return;
          e.preventDefault();
        }}
        onPointerDown={(e) => {
          const r = e.currentTarget.getBoundingClientRect();
          setPos(Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * dur);
        }}
        className={cn('flex h-16 cursor-pointer items-center gap-[2px] rounded-md', ring)}
      >
        {bars.map((h, i) => (
          <span key={i} className={cn('flex-1 rounded-full transition-colors duration-150', i / bars.length < share ? 'bg-primary' : 'bg-track')} style={{ height: `${round(h * 100, 1)}%` }} />
        ))}
      </div>
    </div>
  );
}

function LookBody({ f, t, locale }: { f: PreviewFile; t: Texts; locale: Locale }) {
  switch (f.kind) {
    case 'image':
      return (
        <div className="dark grid size-full place-items-center bg-background p-4 sm:p-8 [container-type:size]">
          <div className="overflow-hidden rounded-[3px] shadow-[0_24px_60px_-20px_rgb(0_0_0/0.7)] [&>*]:size-full" style={{ width: `min(100cqw, calc(100cqh * ${round((f.width ?? 4) / (f.height ?? 3), 4)}))`, aspectRatio: String(round((f.width ?? 4) / (f.height ?? 3), 4)) }}>
            {f.media}
          </div>
        </div>
      );
    case 'pdf':
      return (
        <div className="kit-scrollbar relative size-full overflow-y-auto bg-surface-2 px-4 py-6">
          <div className="mx-auto grid w-[min(100%,34rem)] gap-6">
            {(f.pages ?? []).map((p, i) => (
              <Page key={i} page={p} n={i + 1} q="" counter={{ n: 0 }} current={-1} />
            ))}
          </div>
        </div>
      );
    case 'code': {
      const lines = toLines(tokenize(f.code ?? '', f.language ?? 'text') as Tok[]);
      return (
        <div tabIndex={0} role="region" aria-label={fill(t.codeArea, { name: f.name })} className={cn('kitfp kit-scrollbar relative size-full overflow-auto bg-card', ring, 'focus-visible:outline-offset-[-2px]')}>
          <CodeLines lines={lines} q="" current={-1} wrap={false} compact />
        </div>
      );
    }
    case 'sheet': {
      const s = f.sheets?.[0];
      if (!s) return null;
      return (
        <div tabIndex={0} role="region" aria-label={s.name} className={cn('kit-scrollbar relative size-full overflow-auto bg-card', ring, 'focus-visible:outline-offset-[-2px]')}>
          <table className="w-max min-w-full border-separate border-spacing-0 text-[13px]">
            <thead>
              <tr>
                {s.columns.map((c, i) => (
                  <th key={i} scope="col" className={cn('sticky top-0 h-10 border-b border-e border-border bg-surface-2 px-3 font-semibold text-foreground', c.type && c.type !== 'text' && c.type !== 'date' ? 'text-end' : 'text-start')}>
                    {c.label}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {s.rows.map((r, ri) => (
                <tr key={ri}>
                  {s.columns.map((c, ci) => (
                    <td key={ci} className={cn('h-9 border-b border-e border-border px-3 text-foreground', typeof r[ci] === 'number' && 'text-end tabular-nums')}>
                      {cellText(locale, r[ci] ?? null, c)}
                    </td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      );
    }
    case 'audio':
      return (
        <div className="grid size-full place-items-center bg-surface-2 p-4 sm:p-8">
          <AudioPlayer f={f} t={t} locale={locale} />
        </div>
      );
    case 'archive': {
      const sum = (f.entries ?? []).reduce((a, e) => a + e.size, 0);
      return (
        <div className="kit-scrollbar relative size-full overflow-y-auto bg-card p-4 sm:p-6">
          <p className="text-[13px] text-muted-foreground">{fill(t.archive, { n: f.entries?.length ?? 0, size: bytes(locale, sum) })}</p>
          <ul className="mt-3 divide-y divide-border rounded-lg border border-border">
            {(f.entries ?? []).map((e, i) => (
              <li key={i} className="flex items-center gap-3 px-3 py-2.5 text-[13.5px]">
                <Icon name={/\.(png|jpe?g|svg|webp)$/i.test(e.name) ? 'image' : /\.(pdf|docx?)$/i.test(e.name) ? 'file' : /\.(ts|js|css|html|json)$/i.test(e.name) ? 'file-code' : 'file'} size={15} className="shrink-0 text-muted-foreground" />
                <span className="min-w-0 flex-1 truncate text-foreground">{e.name}</span>
                <span className="tabular-nums text-muted-foreground">{bytes(locale, e.size)}</span>
              </li>
            ))}
          </ul>
        </div>
      );
    }
    default:
      return (
        <div className="kit-scrollbar relative size-full overflow-y-auto bg-card px-5 py-6 sm:px-10">
          <div className="mx-auto grid max-w-[34rem] gap-4 text-[15px] leading-relaxed text-foreground">
            {(f.text ?? []).map((p, i) => (
              <p key={i} className="text-pretty">
                {p}
              </p>
            ))}
          </div>
        </div>
      );
  }
}

function QuickLook({ t, files = [], locale, onAction, title, subtitle }: Inner) {
  const [active, setActive] = useState(0);
  const [open, setOpen] = useState(false);
  const [msg, act] = useActions(onAction, t);
  const [modal, present] = useModalMotion(open, setOpen);
  const gridRef = useRef<HTMLDivElement>(null);
  const titleId = useId();
  const hintId = useId();
  const f = files[active];

  const cols = () => {
    const el = gridRef.current;
    if (!el) return 1;
    return getComputedStyle(el).gridTemplateColumns.split(' ').length || 1;
  };
  const focusItem = (i: number) => requestAnimationFrame(() => gridRef.current?.querySelectorAll<HTMLElement>('[role=option]')[i]?.focus());
  const onGridKey = (e: React.KeyboardEvent) => {
    const c = cols();
    const map: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: c, ArrowUp: -c };
    if (map[e.key] !== undefined) {
      const n = Math.max(0, Math.min(files.length - 1, active + map[e.key]));
      setActive(n);
      focusItem(n);
    } else if (e.key === 'Home' || e.key === 'End') {
      const n = e.key === 'Home' ? 0 : files.length - 1;
      setActive(n);
      focusItem(n);
    } else if (e.key === ' ' || e.key === 'Enter') {
      rememberTrigger(e.target as Element);
      setOpen(true);
    } else return;
    e.preventDefault();
  };
  const step = (d: number) => setActive((a) => (a + d + files.length) % files.length);

  return (
    <div className="mx-auto w-full max-w-[80rem] p-4 sm:p-6 lg:p-8">
      <Header title={title} subtitle={subtitle} />
      <p id={hintId} className="sr-only">
        {t.gridHint}
      </p>
      <div
        ref={gridRef}
        role="listbox"
        aria-label={title || t.files}
        aria-describedby={hintId}
        onKeyDown={onGridKey}
        className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4"
      >
        {files.map((x, i) => (
          <div
            key={x.id}
            role="option"
            aria-selected={i === active}
            tabIndex={i === active ? 0 : -1}
            onClick={(e) => {
              setActive(i);
              rememberTrigger(e.currentTarget);
              setOpen(true);
            }}
            onFocus={() => setActive(i)}
            className={cn(
              'kitfp-up group/card relative flex cursor-pointer flex-col overflow-hidden rounded-xl border bg-card text-start shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow,border-color] duration-300 ease-[var(--ease-kit)] hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-md)]',
              i === active ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))]' : 'border-border',
              ring,
            )}
            style={{ '--kitfp-d': `${i * 50}ms` } as React.CSSProperties}
          >
            <span className="relative block aspect-[4/3] overflow-hidden border-b border-border">
              <span className="block size-full transition-transform duration-500 ease-[var(--ease-kit)] group-hover/card:scale-[1.04] motion-reduce:transition-none">
                <Thumb f={x} locale={locale} />
              </span>
              <span aria-hidden className="absolute end-2 top-2 inline-flex items-center gap-1 rounded-md bg-[color-mix(in_oklab,var(--background)_82%,transparent)] px-1.5 py-0.5 text-[10.5px] font-semibold text-foreground opacity-0 shadow-[var(--shadow-sm)] backdrop-blur transition-opacity duration-200 group-hover/card:opacity-100 group-focus-visible/card:opacity-100">
                <Icon name="eye" size={11} />
                {t.quickLook}
              </span>
            </span>
            <span className="flex items-start gap-2.5 p-3">
              <Icon name={KIND_ICON[x.kind]} size={15} className="mt-0.5 shrink-0 text-muted-foreground" />
              <span className="min-w-0">
                <span title={x.name} className="line-clamp-2 text-[13.5px] font-medium leading-snug text-foreground [overflow-wrap:anywhere]">{x.name}</span>
                <span className="block text-[12px] tabular-nums text-subtle-foreground">
                  {ext(x.name) || kindLabel(t, x.kind)} · {bytes(locale, x.size)}
                </span>
              </span>
            </span>
          </div>
        ))}
      </div>
      <DialogMotionStyle />
      <dialog
        {...modal}
        aria-labelledby={titleId}
        onKeyDown={(e) => {
          const tag = (e.target as HTMLElement).getAttribute('role');
          if (tag === 'slider') return;
          if (e.key === 'ArrowRight') {
            e.preventDefault();
            step(1);
          } else if (e.key === 'ArrowLeft') {
            e.preventDefault();
            step(-1);
          }
        }}
        className={cn('kitfx-dialog m-auto h-[min(48rem,calc(100dvh-1rem))] w-full max-w-none overflow-visible bg-transparent p-2 text-left outline-none data-[closing]:pointer-events-none sm:h-[min(46rem,calc(100dvh-4rem))] sm:w-[calc(100%-3rem)] sm:max-w-[64rem] sm:p-0', scrim)}
      >
        {present && f && (
          <div className="flex size-full flex-col overflow-hidden rounded-2xl border border-border bg-popover text-popover-foreground shadow-[var(--highlight),var(--shadow-lg)]">
            <div className="kitfx-dialog-part flex items-center gap-2 border-b border-border px-3 py-2 sm:px-4">
              <span aria-hidden className="grid size-9 shrink-0 place-items-center rounded-lg bg-surface-2 text-muted-foreground">
                <Icon name={KIND_ICON[f.kind]} size={16} />
              </span>
              <div className="min-w-0 flex-1">
                <h2 id={titleId} className="truncate text-[14.5px] font-semibold text-foreground">
                  {f.name}
                </h2>
                <p className="text-[12px] tabular-nums text-muted-foreground">
                  {kindLabel(t, f.kind)} · {bytes(locale, f.size)} · {fill(t.of, { n: active + 1, total: files.length })}
                </p>
              </div>
              <Tool icon="chevron-left" label={t.previous} onClick={() => step(-1)} />
              <Tool icon="chevron-right" label={t.next} onClick={() => step(1)} />
              <Tool icon="x" label={t.close} onClick={() => setOpen(false)} />
            </div>
            <div key={f.id} className="kitfp kitfp-in relative min-h-0 flex-1">
              <LookBody f={f} t={t} locale={locale} />
            </div>
            <div className="kitfx-dialog-part flex flex-wrap items-center justify-between gap-2 border-t border-border bg-surface px-3 py-2.5 sm:px-4">
              <p className="hidden truncate text-[12.5px] text-muted-foreground sm:block">
                {f.owner}
                {f.owner && f.modified ? ' · ' : ''}
                {f.modified && dateTime(locale, f.modified)}
              </p>
              <div className="flex w-full gap-2 sm:w-auto">
                <Button size="sm" variant="outline" iconLeft="share" className="flex-1 sm:flex-none" onClick={() => void act('share', f)}>
                  {t.share}
                </Button>
                <Button
                  size="sm"
                  iconLeft="download"
                  className="flex-1 sm:flex-none"
                  onClick={() => {
                    const txt = fileText(f);
                    void act('download', f, txt ? () => saveBlob(f.kind === 'sheet' ? f.name.replace(/\.\w+$/, '') + '.csv' : f.name, txt) : undefined);
                  }}
                >
                  {t.download}
                </Button>
              </div>
            </div>
          </div>
        )}
      </dialog>
      <Live text={msg} />
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Component                                                           */
/* ------------------------------------------------------------------ */

type Inner = Omit<FilePreviewProps, 'variant' | 'labels' | 'className'> & { t: Texts; locale: Locale };

/**
 * Previews for files in apps: an image viewer with zoom and filmstrip, code with search and outline,
 * a spreadsheet grid with selection sums, a paged document with search and contents, and a quick look
 * over mixed attachments.
 *   <FilePreview variant="code" file={{ id, name: 'routes.ts', kind: 'code', size, code, language: 'ts' }} />
 */
export function FilePreview({ variant = 'image-viewer', labels, className, ...props }: FilePreviewProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const p: Inner = { ...props, t, locale };
  const body = (() => {
    switch (variant) {
      case 'code':
        return <CodeView {...p} />;
      case 'spreadsheet':
        return <Spreadsheet {...p} />;
      case 'document':
        return <DocumentView {...p} />;
      case 'quick-look':
        return <QuickLook {...p} />;
      default:
        return <ImageViewer {...p} />;
    }
  })();
  return (
    <div className={cn('kitfp min-h-dvh bg-background', className)}>
      <Style />
      {body}
    </div>
  );
}

Häufige Fragen

Wie installiere ich Dateivorschau?

Mit aiw add @ai-website/file-preview. Der Befehl legt die Datei components/ui/file-preview.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, code-block und dialog. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Dateivorschau?

5 Varianten, Bildbetrachter, Codedatei, Tabelle, PDF-Dokument und Schnellansicht. Du wählst sie mit der Prop variant, zum Beispiel variant="code".

Kann ich die Texte von Dateivorschau übersetzen?

Ja. Dateivorschau bringt 82 eigene Texte auf Deutsch und Englisch mit, etwa „Vorherige Datei“, „Nächste Datei“ und „Vergrößern“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Dateivorschau einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Dateivorschau mit aiw_components und installieren sie mit aiw_add @ai-website/file-preview. Nenn im Prompt die Variante, etwa variant="image-viewer", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert