New

Import / Export

A data import flow with a glass-surfaced drop zone, progress states, and results summary — plus a structured export flow with format and field selection.

Demo

Drop file here or click to browse

Supports CSV and JSON — up to 50 MB

Import flow states

PhaseUI
IdleDrop zone with dashed border, upload icon, format hint
Drag overGlass surface activates: backdrop-filter, accent border, specular top edge
UploadingProgress bar at ~40%, “Uploading…” label
ProcessingProgress bar at ~85%, “Processing rows…” label
Results3-tile summary (imported / skipped / errors) + scrollable error table

Drop zone glass behaviour

The drop zone uses backdrop-filter: blur(8px) saturate(160%) and a specular inset highlight (inset 0 1px 0 rgba(255,255,255,0.08)) when a file is dragged over it. This is the same Liquid Glass treatment used on the Feature Gate overlay and the Context Menu — the brightening mimics sfBrandLitSurface.

Apply @media (prefers-reduced-transparency) fallback: replace the backdrop filter with a solid accent-tinted background.

Implementation

ImportExport.tsx
tsx
"use client";

import { useState, useRef, DragEvent } from "react";
import { Upload, Download } from "lucide-react";

// ── Import Flow ────────────────────────────────────────────────

type ImportPhase = "idle" | "uploading" | "processing" | "results";

interface ImportResult {
  imported: number;
  skipped: number;
  errors: Array<{ row: number; reason: string }>;
}

export function ImportFlow({ onComplete }: { onComplete?: (result: ImportResult) => void }) {
  const [phase, setPhase] = useState<ImportPhase>("idle");
  const [isDragOver, setIsDragOver] = useState(false);
  const [result, setResult] = useState<ImportResult | null>(null);
  const inputRef = useRef<HTMLInputElement>(null);

  function handleFile(file: File) {
    setPhase("uploading");
    // Simulate processing
    setTimeout(() => {
      setPhase("processing");
      setTimeout(() => {
        const mockResult: ImportResult = {
          imported: 142,
          skipped: 3,
          errors: [
            { row: 17, reason: "Missing required field: company" },
            { row: 45, reason: "Invalid date format" },
            { row: 98, reason: "Duplicate entry" },
          ],
        };
        setResult(mockResult);
        setPhase("results");
        onComplete?.(mockResult);
      }, 1500);
    }, 1000);
  }

  function handleDrop(e: DragEvent<HTMLDivElement>) {
    e.preventDefault();
    setIsDragOver(false);
    const file = e.dataTransfer.files[0];
    if (file) handleFile(file);
  }

  if (phase === "idle") {
    return (
      <div
        onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
        onDragLeave={() => setIsDragOver(false)}
        onDrop={handleDrop}
        onClick={() => inputRef.current?.click()}
        style={{
          border: `2px dashed ${isDragOver ? "rgb(var(--accent))" : "rgb(var(--border))"}`,
          borderRadius: 12,
          padding: "40px 24px",
          textAlign: "center",
          cursor: "pointer",
          background: isDragOver
            ? "rgb(var(--accent) / 0.06)"
            : "rgb(var(--surface))",
          // Glass specular on drag-over
          boxShadow: isDragOver ? "inset 0 1px 0 rgba(255,255,255,0.08)" : "none",
          transition: "background 0.15s, border-color 0.15s",
          backdropFilter: isDragOver ? "blur(8px)" : "none",
        }}
      >
        <input ref={inputRef} type="file" accept=".csv,.json" style={{ display: "none" }}
          onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])} />

        <Upload size={32} style={{ margin: "0 auto 12px", color: isDragOver ? "rgb(var(--accent))" : "rgb(var(--text-tertiary))" }} />
        <p style={{ fontWeight: 600, marginBottom: 4 }}>
          {isDragOver ? "Drop to import" : "Drop file here or click to browse"}
        </p>
        <p style={{ fontSize: 13, color: "rgb(var(--text-tertiary))" }}>
          Supports CSV and JSON — up to 50 MB
        </p>
      </div>
    );
  }

  if (phase === "uploading" || phase === "processing") {
    return (
      <div style={{ textAlign: "center", padding: "40px 24px" }}>
        <p style={{ fontWeight: 600, marginBottom: 16 }}>
          {phase === "uploading" ? "Uploading…" : "Processing rows…"}
        </p>
        <div style={{ height: 6, borderRadius: 99, background: "rgb(var(--progress-track))", overflow: "hidden" }}>
          <div style={{
            height: "100%",
            width: phase === "uploading" ? "40%" : "85%",
            borderRadius: 99,
            background: "rgb(var(--accent))",
            transition: "width 1s ease-in-out",
          }} />
        </div>
      </div>
    );
  }

  if (phase === "results" && result) {
    return (
      <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
        {/* Summary */}
        <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10 }}>
          {[
            { label: "Imported", value: result.imported, color: "rgb(var(--status-success))" },
            { label: "Skipped", value: result.skipped, color: "rgb(var(--status-warning))" },
            { label: "Errors", value: result.errors.length, color: "rgb(var(--status-danger))" },
          ].map((s) => (
            <div key={s.label} style={{ padding: "12px", borderRadius: 8, background: "rgb(var(--surface))", border: "1px solid rgb(var(--border))", textAlign: "center" }}>
              <div style={{ fontSize: 22, fontWeight: 700, color: s.color, fontVariantNumeric: "tabular-nums" }}>{s.value}</div>
              <div style={{ fontSize: 11, color: "rgb(var(--text-tertiary))", marginTop: 2 }}>{s.label}</div>
            </div>
          ))}
        </div>

        {/* Error table */}
        {result.errors.length > 0 && (
          <div style={{ borderRadius: 8, border: "1px solid rgb(var(--border))", overflow: "hidden" }}>
            <div style={{ padding: "8px 12px", background: "rgb(var(--surface-raised))", borderBottom: "1px solid rgb(var(--border))", fontSize: 11, fontWeight: 600, color: "rgb(var(--text-tertiary))" }}>
              Errors ({result.errors.length})
            </div>
            {result.errors.map((err) => (
              <div key={err.row} style={{ display: "flex", gap: 12, alignItems: "center", padding: "8px 12px", borderBottom: "1px solid rgb(var(--border-subtle))", fontSize: 12 }}>
                <span style={{ color: "rgb(var(--text-tertiary))", fontVariantNumeric: "tabular-nums", flexShrink: 0 }}>Row {err.row}</span>
                <span style={{ color: "rgb(var(--status-danger))" }}>{err.reason}</span>
              </div>
            ))}
          </div>
        )}

        <button onClick={() => { setPhase("idle"); setResult(null); }} style={{ alignSelf: "flex-start", padding: "8px 16px", borderRadius: 8, border: "1px solid rgb(var(--border))", background: "rgb(var(--surface-raised))", cursor: "pointer", fontSize: 13 }}>
          Import another file
        </button>
      </div>
    );
  }

  return null;
}

// ── Export Flow ────────────────────────────────────────────────

type ExportFormat = "csv" | "json";

export function ExportFlow() {
  const [format, setFormat] = useState<ExportFormat>("csv");
  const allFields = ["name", "company", "status", "date", "salary", "notes", "tags"];
  const [selected, setSelected] = useState<Set<string>>(new Set(allFields.slice(0, 5)));

  function toggleField(f: string) {
    setSelected((prev) => {
      const next = new Set(prev);
      next.has(f) ? next.delete(f) : next.add(f);
      return next;
    });
  }

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
      {/* Format picker */}
      <div>
        <p style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>Format</p>
        <div style={{ display: "flex", gap: 8 }}>
          {(["csv", "json"] as ExportFormat[]).map((f) => (
            <button key={f} onClick={() => setFormat(f)} style={{
              padding: "7px 16px", borderRadius: 8, fontSize: 13, cursor: "pointer",
              background: format === f ? "rgb(var(--accent))" : "rgb(var(--surface-raised))",
              color: format === f ? "#fff" : "rgb(var(--text-primary))",
              border: `1px solid ${format === f ? "rgb(var(--accent))" : "rgb(var(--border))"}`,
              fontWeight: format === f ? 600 : 400,
              transition: "all 0.15s",
            }}>
              {f.toUpperCase()}
            </button>
          ))}
        </div>
      </div>

      {/* Field selection */}
      <div>
        <p style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>Fields to include</p>
        <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
          {allFields.map((field) => (
            <label key={field} style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
              <input type="checkbox" checked={selected.has(field)} onChange={() => toggleField(field)} style={{ width: 15, height: 15, accentColor: "rgb(var(--accent))" }} />
              <span style={{ fontSize: 13, textTransform: "capitalize" }}>{field}</span>
            </label>
          ))}
        </div>
      </div>

      <button style={{
        display: "flex", alignItems: "center", gap: 8, padding: "9px 18px",
        borderRadius: 8, background: "rgb(var(--accent))", color: "#fff",
        border: "none", cursor: "pointer", fontSize: 14, fontWeight: 600, alignSelf: "flex-start",
      }}>
        <Download size={15} />
        Export {selected.size} fields as {format.toUpperCase()}
      </button>
    </div>
  );
}