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.
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
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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle FilePreviewProps aus components/ui/file-preview.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalFilePreviewVariantimage-viewercodespreadsheetdocumentquick-look
filesoptionalPreviewFile[]image-viewer, quick-look: the set to page through
fileoptionalPreviewFilecode, spreadsheet, document: the one file on screen
startoptionalnumberimage-viewer: index to start with
titleoptionalstringA heading above the preview, e.g. the folder or the message the files belong to
subtitleoptionalstringpathoptionalstring[]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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| of | Englisch {n} of {total} | Deutsch {n} von {total} |
| previous | Englisch Previous file | Deutsch Vorherige Datei |
| next | Englisch Next file | Deutsch Nächste Datei |
| zoomIn | Englisch Zoom in | Deutsch Vergrößern |
| zoomOut | Englisch Zoom out | Deutsch Verkleinern |
| fit | Englisch Fit to screen | Deutsch An Fenster anpassen |
| fitShort | Englisch Fit | Deutsch Passend |
| rotate | Englisch Rotate | Deutsch Drehen |
| details | Englisch Details | Deutsch Details |
| hideDetails | Englisch Hide details | Deutsch Details ausblenden |
| download | Englisch Download | Deutsch Herunterladen |
| share | Englisch Share | Deutsch Teilen |
| open | Englisch Open | Deutsch Öffnen |
| close | Englisch Close preview | Deutsch Vorschau schließen |
| files | Englisch Files | Deutsch Dateien |
| viewer | Englisch 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. |
| dimensions | Englisch Dimensions | Deutsch Abmessungen |
| size | Englisch Size | Deutsch Größe |
| modified | Englisch Modified | Deutsch Geändert |
| owner | Englisch Owner | Deutsch Besitzer |
| type | Englisch Type | Deutsch Typ |
| pixels | Englisch {w} × {h} px | Deutsch {w} × {h} px |
| showing | Englisch Showing {name}, {n} of {total} | Deutsch {name} wird angezeigt, {n} von {total} |
| zoomAt | Englisch Zoom {v} | Deutsch Zoom {v} |
| search | Englisch Search in file | Deutsch In Datei suchen |
| noHits | Englisch No matches | Deutsch Keine Treffer |
| hits | Englisch {n} of {total} | Deutsch {n} von {total} |
| prevHit | Englisch Previous match | Deutsch Vorheriger Treffer |
| nextHit | Englisch Next match | Deutsch Nächster Treffer |
| wrap | Englisch Wrap lines | Deutsch Zeilen umbrechen |
| copy | Englisch Copy | Deutsch Kopieren |
| copied | Englisch Copied | Deutsch Kopiert |
| lines | Englisch {n} lines | Deutsch {n} Zeilen |
| line | Englisch Line {n} | Deutsch Zeile {n} |
| lineSel | Englisch Line {n} selected | Deutsch Zeile {n} ausgewählt |
| linesSel | Englisch Lines {a} to {b} selected | Deutsch Zeilen {a} bis {b} ausgewählt |
| copyLink | Englisch Copy link to lines | Deutsch Link zu den Zeilen kopieren |
| linkCopied | Englisch Link copied | Deutsch Link kopiert |
| outline | Englisch Outline | Deutsch Gliederung |
| jump | Englisch Jump to | Deutsch Springen zu |
| position | Englisch Ln {l}, Col 1 | Deutsch Z. {l}, Sp. 1 |
| codeArea | Englisch Code of {name} | Deutsch Code von {name} |
| spaces | Englisch Spaces 2 | Deutsch Leerzeichen 2 |
| sheets | Englisch Sheets | Deutsch Tabellenblätter |
| rows | Englisch {n} rows | Deutsch {n} Zeilen |
| cols | Englisch {n} columns | Deutsch {n} Spalten |
| filterRows | Englisch Filter rows | Deutsch Zeilen filtern |
| shownRows | Englisch {n} of {total} rows | Deutsch {n} von {total} Zeilen |
| sortBy | Englisch Sort by {col} | Deutsch Nach {col} sortieren |
| sum | Englisch Sum | Deutsch Summe |
| average | Englisch Average | Deutsch Mittelwert |
| count | Englisch Count | Deutsch Anzahl |
| cell | Englisch Cell | Deutsch Zelle |
| grid | Englisch Table {name}. Arrow keys move, shift and arrows select a range. | Deutsch Tabelle {name}. Pfeiltasten bewegen, Umschalt und Pfeile wählen einen Bereich. |
| selected | Englisch {n} cells selected | Deutsch {n} Zellen ausgewählt |
| downloadCsv | Englisch Download CSV | Deutsch Als CSV laden |
| pages | Englisch Pages | Deutsch Seiten |
| page | Englisch Page {n} | Deutsch Seite {n} |
| pageOf | Englisch of {total} | Deutsch von {total} |
| goToPage | Englisch Go to page | Deutsch Zur Seite |
| contents | Englisch Contents | Deutsch Inhalt |
| zoom | Englisch Zoom | Deutsch Zoom |
| Englisch Print | Deutsch Drucken | |
| thumbs | Englisch Page thumbnails | Deutsch Seitenvorschau |
| noOutline | Englisch This document has no headings. | Deutsch Dieses Dokument hat keine Überschriften. |
| play | Englisch Play | Deutsch Abspielen |
| pause | Englisch Pause | Deutsch Pausieren |
| seek | Englisch Playback position | Deutsch Wiedergabeposition |
| archive | Englisch {n} files, {size} unpacked | Deutsch {n} Dateien, entpackt {size} |
| preview | Englisch Preview {name} | Deutsch Vorschau von {name} |
| gridHint | Englisch Space or Enter opens the preview, arrow keys move between files. | Deutsch Leertaste oder Eingabe öffnet die Vorschau, Pfeiltasten wechseln die Datei. |
| quickLook | Englisch Quick look | Deutsch Schnellansicht |
| downloaded | Englisch {name} is downloading | Deutsch {name} wird heruntergeladen |
| shared | Englisch Share link for {name} is ready | Deutsch Freigabelink für {name} ist bereit |
| opened | Englisch {name} opens | Deutsch {name} wird geöffnet |
| image | Englisch Image | Deutsch Bild |
| Englisch PDF document | Deutsch PDF-Dokument | |
| code | Englisch Code | Deutsch Code |
| sheet | Englisch Spreadsheet | Deutsch Tabelle |
| audio | Englisch Audio | Deutsch Audio |
| archiveKind | Englisch Archive | Deutsch Archiv |
| text | Englisch Text | Deutsch 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
'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.