mirror of
https://github.com/PlatypusPus/MushroomEmpire.git
synced 2026-02-07 22:18:59 +00:00
merge
This commit is contained in:
63
frontend/components/Navbar.tsx
Normal file
63
frontend/components/Navbar.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function Navbar() {
|
||||
const pathname = usePathname();
|
||||
const onTry = pathname?.startsWith('/try');
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 4);
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav className={`w-full sticky top-0 z-50 transition-colors ${scrolled ? 'bg-white/90 border-b border-slate-200/70 shadow-sm' : 'bg-white/70 border-b border-transparent'}`}>
|
||||
<div className="container-max flex items-center justify-between h-16">
|
||||
<Link href="/" className="font-semibold text-brand-700 text-lg tracking-tight">Nordic Privacy AI</Link>
|
||||
{/* Desktop nav */}
|
||||
{onTry ? (
|
||||
<div className="hidden md:flex items-center gap-6 text-sm">
|
||||
<Link href="/" className="hover:text-brand-600 transition-colors">Home</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden md:flex items-center gap-6 text-sm">
|
||||
<Link href="#features" className="hover:text-brand-600 transition-colors">Features</Link>
|
||||
<Link href="#agents" className="hover:text-brand-600 transition-colors">Agents</Link>
|
||||
<Link href="#contact" className="hover:text-brand-600 transition-colors">Contact</Link>
|
||||
<Link href="/try" className="inline-flex items-center rounded-md bg-brand-600 px-4 py-2 text-white font-medium shadow hover:bg-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2">Try me</Link>
|
||||
</div>
|
||||
)}
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
aria-label="Toggle menu"
|
||||
className="md:hidden inline-flex items-center justify-center w-10 h-10 rounded-md border border-slate-300 text-slate-700"
|
||||
onClick={() => setMenuOpen(v => !v)}
|
||||
>
|
||||
<span className="sr-only">Menu</span>
|
||||
{menuOpen ? '✕' : '☰'}
|
||||
</button>
|
||||
</div>
|
||||
{/* Mobile menu panel */}
|
||||
<div className={`md:hidden border-t border-slate-200 transition-[max-height,opacity] duration-200 overflow-hidden ${menuOpen ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<div className="container-max py-3 flex flex-col gap-3 text-sm">
|
||||
{onTry ? (
|
||||
<Link href="/" onClick={() => setMenuOpen(false)} className="py-2">Home</Link>
|
||||
) : (
|
||||
<>
|
||||
<a href="#features" onClick={() => setMenuOpen(false)} className="py-2">Features</a>
|
||||
<a href="#agents" onClick={() => setMenuOpen(false)} className="py-2">Agents</a>
|
||||
<a href="#contact" onClick={() => setMenuOpen(false)} className="py-2">Contact</a>
|
||||
<Link href="/try" onClick={() => setMenuOpen(false)} className="inline-flex items-center justify-center rounded-md bg-brand-600 px-4 py-2 text-white font-medium shadow hover:bg-brand-500">Try me</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
42
frontend/components/common/Reveal.tsx
Normal file
42
frontend/components/common/Reveal.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface RevealProps {
|
||||
children: React.ReactNode;
|
||||
as?: keyof JSX.IntrinsicElements;
|
||||
className?: string;
|
||||
delayMs?: number;
|
||||
}
|
||||
|
||||
export function Reveal({ children, as = "div", className, delayMs = 0 }: RevealProps) {
|
||||
const Cmp: any = as;
|
||||
const ref = useRef<HTMLElement | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current as Element | null;
|
||||
if (!el || typeof IntersectionObserver === "undefined") {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
const obs = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
if (delayMs > 0) {
|
||||
const t = setTimeout(() => setVisible(true), delayMs);
|
||||
return () => clearTimeout(t);
|
||||
} else {
|
||||
setVisible(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.12 }
|
||||
);
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, [delayMs]);
|
||||
|
||||
return (
|
||||
<Cmp ref={ref} className={(visible ? "reveal-visible " : "reveal ") + (className ?? "")}>{children}</Cmp>
|
||||
);
|
||||
}
|
||||
46
frontend/components/landing/AgentsOverview.tsx
Normal file
46
frontend/components/landing/AgentsOverview.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Reveal } from "../common/Reveal";
|
||||
|
||||
export function AgentsOverview() {
|
||||
const agents = [
|
||||
{
|
||||
title: "Discovery Agent",
|
||||
desc:
|
||||
"Continuously inventories systems to locate personal and sensitive data across sources, fixing visibility gaps.",
|
||||
emoji: "🛰️",
|
||||
},
|
||||
{
|
||||
title: "Cleaner Agent",
|
||||
desc:
|
||||
"Identifies PII and sensitive attributes, classifies content, and prepares data for remediation and audits.",
|
||||
emoji: "🧽",
|
||||
},
|
||||
{
|
||||
title: "Remediation Agent",
|
||||
desc:
|
||||
"Suggests anonymization, consent validation, or deletion; generates compliance reports and monitors posture.",
|
||||
emoji: "🛡️",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container-max py-16 content-auto" id="agents">
|
||||
<Reveal className="text-center" as="div">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-slate-900">Our agents</h2>
|
||||
<p className="mt-3 text-slate-600 max-w-2xl mx-auto">
|
||||
Modular, AI-driven roles that work together to keep your data compliant.
|
||||
</p>
|
||||
</Reveal>
|
||||
<div className="mt-10 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 sm:gap-6">
|
||||
{agents.map((a, i) => (
|
||||
<Reveal key={a.title} delayMs={i * 110}>
|
||||
<div className="rounded-xl border border-slate-200 bg-white/80 p-6 shadow-sm transition-transform duration-300 hover:-translate-y-0.5">
|
||||
<div className="text-3xl">{a.emoji}</div>
|
||||
<h3 className="mt-3 font-semibold text-slate-900">{a.title}</h3>
|
||||
<p className="mt-2 text-sm text-slate-600">{a.desc}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
frontend/components/landing/FeatureGrid.tsx
Normal file
52
frontend/components/landing/FeatureGrid.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Reveal } from "../common/Reveal";
|
||||
|
||||
export function FeatureGrid() {
|
||||
const items = [
|
||||
{
|
||||
title: "Discovery",
|
||||
desc: "Continuously map data across apps, logs, DBs, and cloud storage to restore visibility.",
|
||||
emoji: "🧭",
|
||||
},
|
||||
{
|
||||
title: "Classification",
|
||||
desc: "Detect PII and sensitive categories (health, finance) with AI-driven labeling.",
|
||||
emoji: "🔎",
|
||||
},
|
||||
{
|
||||
title: "Remediation",
|
||||
desc: "Anonymize, minimize, and automate consent workflows to reduce exposure.",
|
||||
emoji: "🧹",
|
||||
},
|
||||
{
|
||||
title: "Monitoring",
|
||||
desc: "Continuous compliance checks with alerts and reports aligned to GDPR.",
|
||||
emoji: "📈",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container-max py-16 content-auto" id="features">
|
||||
<Reveal className="text-center" as="div">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-slate-900">Core capabilities</h2>
|
||||
<p className="mt-3 text-slate-600 max-w-2xl mx-auto">
|
||||
Proactive privacy protection tailored for Nordic identity ecosystems and EU data law.
|
||||
</p>
|
||||
</Reveal>
|
||||
<div className="mt-10 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6 items-stretch">
|
||||
{items.map((f, i) => (
|
||||
<Reveal key={f.title} delayMs={i * 90} className="h-full">
|
||||
<div className="h-full rounded-xl border border-slate-200 bg-white/80 p-6 shadow-sm transition-transform duration-300 hover:-translate-y-0.5 flex flex-col">
|
||||
<div className="text-3xl">{f.emoji}</div>
|
||||
<h3 className="mt-3 font-semibold text-slate-900">{f.title}</h3>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{f.desc}
|
||||
</p>
|
||||
{/* Spacer to ensure consistent padding at bottom when descriptions vary */}
|
||||
<div className="mt-auto" />
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
frontend/components/landing/Footer.tsx
Normal file
17
frontend/components/landing/Footer.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="mt-24 border-t border-slate-200 bg-white/80">
|
||||
<div className="container-max py-10 flex flex-col sm:flex-row items-center justify-between gap-6">
|
||||
<div className="text-sm text-slate-600">© {new Date().getFullYear()} Nordic Privacy AI. Hackathon prototype.</div>
|
||||
<nav className="flex gap-6 text-sm">
|
||||
<Link href="#features" className="hover:text-brand-600">Features</Link>
|
||||
<Link href="#agents" className="hover:text-brand-600">Agents</Link>
|
||||
<Link href="#contact" className="hover:text-brand-600">Contact</Link>
|
||||
<Link href="/try" className="hover:text-brand-600">Try</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
32
frontend/components/landing/Steps.tsx
Normal file
32
frontend/components/landing/Steps.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Reveal } from "../common/Reveal";
|
||||
|
||||
export function Steps() {
|
||||
const steps = [
|
||||
{ title: 'Discover', desc: 'Continuously inventory data across apps, logs, DBs, and cloud.' , emoji: '🧭' },
|
||||
{ title: 'Classify', desc: 'Detect PII and sensitive categories with AI labeling.', emoji: '🔎' },
|
||||
{ title: 'Mitigate', desc: 'Anonymize, minimize, and enforce consent governance.', emoji: '🛡️' },
|
||||
{ title: 'Monitor', desc: 'Continuous checks and reports mapped to GDPR.', emoji: '📈' },
|
||||
];
|
||||
return (
|
||||
<section className="container-max py-16 content-auto">
|
||||
<Reveal className="text-center" as="div">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-slate-900">How it works</h2>
|
||||
<p className="mt-3 text-slate-600 max-w-2xl mx-auto">Simple, proactive steps to keep your data compliant.</p>
|
||||
</Reveal>
|
||||
<div className="mt-10 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 sm:gap-6">
|
||||
{steps.map((s, i) => (
|
||||
<Reveal key={s.title} delayMs={i * 100}>
|
||||
<div className="relative rounded-xl border border-slate-200 bg-white/80 p-6 shadow-sm transition-transform duration-300 hover:-translate-y-0.5">
|
||||
<div className="text-3xl">{s.emoji}</div>
|
||||
<div className="mt-3 flex items-baseline gap-2">
|
||||
<span className="text-xs text-slate-500">Step {i + 1}</span>
|
||||
<h3 className="font-semibold text-slate-900">{s.title}</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-600">{s.desc}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
21
frontend/components/landing/ValueProps.tsx
Normal file
21
frontend/components/landing/ValueProps.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
export function ValueProps() {
|
||||
const items = [
|
||||
{ title: 'EU-first compliance', desc: 'Designed around GDPR principles: lawfulness, purpose limitation, minimization, and accountability.', emoji: '🇪🇺' },
|
||||
{ title: 'Nordic identity ready', desc: 'Built with BankID, MitID, and Suomi.fi contexts in mind for seamless identity-aware workflows.', emoji: '🧩' },
|
||||
{ title: 'Continuous by default', desc: 'Move from manual audits to ongoing monitoring with alerts and clear reports.', emoji: '⏱️' },
|
||||
];
|
||||
return (
|
||||
<section className="container-max py-16">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-slate-900 text-center">Why it fits the Nordics</h2>
|
||||
<div className="mt-8 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{items.map((v) => (
|
||||
<div key={v.title} className="rounded-xl border border-slate-200 bg-white/70 backdrop-blur p-6 shadow-sm">
|
||||
<div className="text-3xl">{v.emoji}</div>
|
||||
<h3 className="mt-3 font-semibold text-slate-900">{v.title}</h3>
|
||||
<p className="mt-2 text-sm text-slate-600">{v.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
620
frontend/components/try/CenterPanel.tsx
Normal file
620
frontend/components/try/CenterPanel.tsx
Normal file
@@ -0,0 +1,620 @@
|
||||
"use client";
|
||||
import { TryTab } from "./Sidebar";
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { saveLatestUpload, getLatestUpload, deleteLatestUpload } from "../../lib/indexeddb";
|
||||
import { analyzeDataset, cleanDataset, getReportUrl, type AnalyzeResponse, type CleanResponse } from "../../lib/api";
|
||||
|
||||
interface CenterPanelProps {
|
||||
tab: TryTab;
|
||||
onAnalyze?: () => void;
|
||||
}
|
||||
|
||||
interface UploadedFileMeta {
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
contentPreview: string;
|
||||
}
|
||||
|
||||
interface TablePreviewData {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
origin: 'csv';
|
||||
}
|
||||
|
||||
export function CenterPanel({ tab, onAnalyze }: CenterPanelProps) {
|
||||
const PREVIEW_BYTES = 64 * 1024; // read first 64KB slice for large-file preview
|
||||
const [fileMeta, setFileMeta] = useState<UploadedFileMeta | null>(null);
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [progressLabel, setProgressLabel] = useState<string>("Processing");
|
||||
const [tablePreview, setTablePreview] = useState<TablePreviewData | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [loadedFromCache, setLoadedFromCache] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Analysis results
|
||||
const [analyzeResult, setAnalyzeResult] = useState<AnalyzeResponse | null>(null);
|
||||
const [cleanResult, setCleanResult] = useState<CleanResponse | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setFileMeta(null);
|
||||
setUploadedFile(null);
|
||||
setProgress(0);
|
||||
setProgressLabel("Processing");
|
||||
setTablePreview(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
// Handle API calls
|
||||
const handleAnalyze = async () => {
|
||||
if (!uploadedFile) {
|
||||
setError("No file uploaded");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
setProgressLabel("Analyzing dataset...");
|
||||
|
||||
try {
|
||||
const result = await analyzeDataset(uploadedFile);
|
||||
setAnalyzeResult(result);
|
||||
setProgressLabel("Analysis complete!");
|
||||
onAnalyze?.(); // Navigate to bias-analysis tab
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Analysis failed");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClean = async () => {
|
||||
if (!uploadedFile) {
|
||||
setError("No file uploaded");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
setProgressLabel("Cleaning dataset...");
|
||||
|
||||
try {
|
||||
const result = await cleanDataset(uploadedFile);
|
||||
setCleanResult(result);
|
||||
setProgressLabel("Cleaning complete!");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Cleaning failed");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}; function tryParseCSV(text: string, maxRows = 50, maxCols = 40): TablePreviewData | null {
|
||||
const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0);
|
||||
if (lines.length < 2) return null;
|
||||
const commaDensity = lines.slice(0, 10).filter(l => l.includes(',')).length;
|
||||
if (commaDensity < 2) return null;
|
||||
const parseLine = (line: string) => {
|
||||
const out: string[] = [];
|
||||
let cur = '';
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (ch === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') { cur += '"'; i++; } else { inQuotes = !inQuotes; }
|
||||
} else if (ch === ',' && !inQuotes) {
|
||||
out.push(cur);
|
||||
cur = '';
|
||||
} else { cur += ch; }
|
||||
}
|
||||
out.push(cur);
|
||||
return out.map(c => c.trim());
|
||||
};
|
||||
const raw = lines.slice(0, maxRows).map(parseLine);
|
||||
if (raw.length === 0) return null;
|
||||
const headers = raw[0];
|
||||
const colCount = Math.min(headers.length, maxCols);
|
||||
const rows = raw.slice(1).map(r => r.slice(0, colCount));
|
||||
return { headers: headers.slice(0, colCount), rows, origin: 'csv' };
|
||||
}
|
||||
|
||||
// We no longer build table preview for JSON; revert JSON to raw text view.
|
||||
|
||||
const processFile = useCallback(async (f: File) => {
|
||||
if (!f) return;
|
||||
const isCSV = /\.csv$/i.test(f.name);
|
||||
setProgress(0);
|
||||
setUploadedFile(f); // Save the file for API calls
|
||||
|
||||
// For large files, show a progress bar while reading the file stream (no preview)
|
||||
if (f.size > 1024 * 1024) {
|
||||
setProgressLabel("Uploading");
|
||||
const metaObj: UploadedFileMeta = {
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
type: f.type || "unknown",
|
||||
contentPreview: `Loading partial preview (first ${Math.round(PREVIEW_BYTES/1024)}KB)...`,
|
||||
};
|
||||
setFileMeta(metaObj);
|
||||
setTablePreview(null);
|
||||
// Save to IndexedDB immediately so it persists without needing full read
|
||||
(async () => {
|
||||
try { await saveLatestUpload(f, metaObj); } catch {}
|
||||
})();
|
||||
// Read head slice for partial preview & possible CSV table extraction
|
||||
try {
|
||||
const headBlob = f.slice(0, PREVIEW_BYTES);
|
||||
const headReader = new FileReader();
|
||||
headReader.onload = async () => {
|
||||
try {
|
||||
const buf = headReader.result as ArrayBuffer;
|
||||
const decoder = new TextDecoder();
|
||||
const text = decoder.decode(buf);
|
||||
setFileMeta(prev => prev ? { ...prev, contentPreview: text.slice(0, 4000) } : prev);
|
||||
if (isCSV) {
|
||||
const parsed = tryParseCSV(text);
|
||||
setTablePreview(parsed);
|
||||
} else {
|
||||
setTablePreview(null);
|
||||
}
|
||||
try { await saveLatestUpload(f, { ...metaObj, contentPreview: text.slice(0, 4000) }); } catch {}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
headReader.readAsArrayBuffer(headBlob);
|
||||
} catch { /* ignore */ }
|
||||
// Use streaming read for progress without buffering entire file in memory
|
||||
try {
|
||||
const stream: ReadableStream<Uint8Array> | undefined = (typeof (f as any).stream === "function" ? (f as any).stream() : undefined);
|
||||
if (stream && typeof stream.getReader === "function") {
|
||||
const reader = stream.getReader();
|
||||
let loaded = 0;
|
||||
const total = f.size || 1;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
loaded += value ? value.length : 0;
|
||||
const pct = Math.min(100, Math.round((loaded / total) * 100));
|
||||
setProgress(pct);
|
||||
}
|
||||
setProgress(100);
|
||||
} else {
|
||||
// Fallback to FileReader progress events
|
||||
const reader = new FileReader();
|
||||
reader.onprogress = (evt) => {
|
||||
if (evt.lengthComputable) {
|
||||
const pct = Math.min(100, Math.round((evt.loaded / evt.total) * 100));
|
||||
setProgress(pct);
|
||||
} else {
|
||||
setProgress((p) => (p < 90 ? p + 5 : p));
|
||||
}
|
||||
};
|
||||
reader.onloadend = () => setProgress(100);
|
||||
reader.onerror = () => setProgress(0);
|
||||
reader.readAsArrayBuffer(f);
|
||||
}
|
||||
} catch {
|
||||
setProgress(100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onprogress = (evt) => {
|
||||
if (evt.lengthComputable) {
|
||||
const pct = Math.min(100, Math.round((evt.loaded / evt.total) * 100));
|
||||
setProgress(pct);
|
||||
} else {
|
||||
setProgress((p) => (p < 90 ? p + 5 : p));
|
||||
}
|
||||
};
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const buf = reader.result as ArrayBuffer;
|
||||
const decoder = new TextDecoder();
|
||||
const text = decoder.decode(buf);
|
||||
const metaObj: UploadedFileMeta = {
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
type: f.type || "unknown",
|
||||
contentPreview: text.slice(0, 4000),
|
||||
};
|
||||
setFileMeta(metaObj);
|
||||
if (isCSV) {
|
||||
const parsed = tryParseCSV(text);
|
||||
setTablePreview(parsed);
|
||||
} else {
|
||||
setTablePreview(null);
|
||||
}
|
||||
// Save file blob and meta to browser cache (IndexedDB)
|
||||
try {
|
||||
await saveLatestUpload(f, metaObj);
|
||||
} catch {}
|
||||
setProgressLabel("Processing");
|
||||
setProgress(100);
|
||||
} catch (e) {
|
||||
const metaObj: UploadedFileMeta = {
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
type: f.type || "unknown",
|
||||
contentPreview: "Unable to decode preview.",
|
||||
};
|
||||
setFileMeta(metaObj);
|
||||
setTablePreview(null);
|
||||
try {
|
||||
await saveLatestUpload(f, metaObj);
|
||||
} catch {}
|
||||
setProgressLabel("Processing");
|
||||
setProgress(100);
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setProgress(0);
|
||||
};
|
||||
reader.readAsArrayBuffer(f);
|
||||
}, []);
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = e.target.files?.[0];
|
||||
processFile(f as File);
|
||||
}
|
||||
|
||||
const onDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
const onDragLeave = () => setIsDragging(false);
|
||||
const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
processFile(f as File);
|
||||
};
|
||||
|
||||
// Load last cached upload on mount (processing tab only)
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
if (tab !== "processing") return;
|
||||
(async () => {
|
||||
try {
|
||||
const { file, meta } = await getLatestUpload();
|
||||
if (!ignore && meta) {
|
||||
setFileMeta(meta as UploadedFileMeta);
|
||||
if (file) {
|
||||
setUploadedFile(file);
|
||||
}
|
||||
setLoadedFromCache(true);
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [tab]); function renderTabContent() {
|
||||
switch (tab) {
|
||||
case "processing":
|
||||
return (
|
||||
<div className="space-y-4 max-w-[1100px] xl:max-w-[1200px] w-full mx-auto">
|
||||
<h2 className="text-xl font-semibold">Upload & Process Data</h2>
|
||||
<p className="text-sm text-slate-600">Upload a CSV / JSON / text file. We will later parse, detect PII, and queue analyses.</p>
|
||||
<div className="flex flex-col gap-3 min-w-0">
|
||||
<div
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
className={
|
||||
"rounded-lg border-2 border-dashed p-6 text-center transition-colors " +
|
||||
(isDragging ? "border-brand-600 bg-brand-50" : "border-slate-300 hover:border-brand-300")
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-slate-600">Drag & drop a CSV / JSON / TXT here, or click to browse.</p>
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="inline-flex items-center rounded-md bg-brand-600 px-4 py-2 text-white text-sm font-medium shadow hover:bg-brand-500"
|
||||
>
|
||||
Choose file
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".csv,.json,.txt"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
aria-hidden
|
||||
/>
|
||||
{progress > 0 && (
|
||||
<div className="w-full">
|
||||
<div className="h-2 w-full rounded-full bg-slate-200 overflow-hidden">
|
||||
<div
|
||||
className="h-2 bg-brand-600 transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{progressLabel} {progress}%</div>
|
||||
</div>
|
||||
)}
|
||||
{fileMeta && (
|
||||
<div className="rounded-md border border-slate-200 p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm font-medium">{fileMeta.name}</div>
|
||||
<div className="text-xs text-slate-500">{Math.round(fileMeta.size / 1024)} KB</div>
|
||||
</div>
|
||||
{loadedFromCache && (
|
||||
<div className="mb-2 text-[11px] text-brand-700">Loaded from browser cache</div>
|
||||
)}
|
||||
<div className="mb-3 text-xs text-slate-500">{fileMeta.type || "Unknown type"}</div>
|
||||
{/* Table preview when structured data detected; otherwise show text */}
|
||||
{tablePreview && tablePreview.origin === 'csv' ? (
|
||||
<div className="max-h-64 w-full min-w-0 overflow-x-auto overflow-y-auto rounded-md bg-slate-50">
|
||||
<table className="min-w-full text-xs">
|
||||
<thead className="sticky top-0 bg-slate-100">
|
||||
<tr>
|
||||
{tablePreview.headers.map((h, idx) => (
|
||||
<th key={idx} className="text-left font-semibold px-3 py-2 border-b border-slate-200 whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tablePreview.rows.map((r, i) => (
|
||||
<tr key={i} className={i % 2 === 0 ? "" : "bg-slate-100/50"}>
|
||||
{r.map((c, j) => (
|
||||
<td key={j} className="px-3 py-1.5 border-b border-slate-100 whitespace-nowrap max-w-[24ch] overflow-hidden text-ellipsis">{c}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="max-h-64 overflow-auto text-xs bg-slate-50 p-3 rounded-md whitespace-pre-wrap leading-relaxed">
|
||||
{fileMeta.contentPreview || "(no preview)"}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-700">
|
||||
❌ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analyzeResult && (
|
||||
<div className="mt-3 p-3 bg-green-50 border border-green-200 rounded-md text-sm text-green-700">
|
||||
✅ Analysis complete! View results in tabs.
|
||||
<a
|
||||
href={getReportUrl(analyzeResult.report_file)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-2 underline"
|
||||
>
|
||||
Download Report
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cleanResult && (
|
||||
<div className="mt-3 p-3 bg-green-50 border border-green-200 rounded-md text-sm text-green-700">
|
||||
✅ Cleaning complete! {cleanResult.summary.total_cells_affected} cells anonymized.
|
||||
<div className="mt-2 flex gap-2">
|
||||
<a
|
||||
href={getReportUrl(cleanResult.files.cleaned_csv)}
|
||||
download
|
||||
className="underline"
|
||||
>
|
||||
Download Cleaned CSV
|
||||
</a>
|
||||
<a
|
||||
href={getReportUrl(cleanResult.files.audit_report)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
View Audit Report
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
reset();
|
||||
try { await deleteLatestUpload(); } catch {}
|
||||
setLoadedFromCache(false);
|
||||
setAnalyzeResult(null);
|
||||
setCleanResult(null);
|
||||
}}
|
||||
className="text-xs rounded-md border px-3 py-1.5 hover:bg-slate-50"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClean}
|
||||
disabled={isProcessing}
|
||||
className="text-xs rounded-md bg-green-600 text-white px-3 py-1.5 hover:bg-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isProcessing ? "Processing..." : "Clean (PII)"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAnalyze}
|
||||
disabled={isProcessing}
|
||||
className="text-xs rounded-md bg-brand-600 text-white px-3 py-1.5 hover:bg-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isProcessing ? "Processing..." : "Analyze"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "bias-analysis":
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Bias Analysis</h2>
|
||||
{analyzeResult ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-white rounded-lg border">
|
||||
<div className="text-sm text-slate-600">Overall Bias Score</div>
|
||||
<div className="text-2xl font-bold">{(analyzeResult.bias_metrics.overall_bias_score * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="p-4 bg-white rounded-lg border">
|
||||
<div className="text-sm text-slate-600">Violations Detected</div>
|
||||
<div className="text-2xl font-bold">{analyzeResult.bias_metrics.violations_detected.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white rounded-lg border">
|
||||
<h3 className="font-semibold mb-2">Model Performance</h3>
|
||||
<div className="grid grid-cols-4 gap-2 text-sm">
|
||||
<div>
|
||||
<div className="text-slate-600">Accuracy</div>
|
||||
<div className="font-medium">{(analyzeResult.model_performance.accuracy * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-600">Precision</div>
|
||||
<div className="font-medium">{(analyzeResult.model_performance.precision * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-600">Recall</div>
|
||||
<div className="font-medium">{(analyzeResult.model_performance.recall * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-600">F1 Score</div>
|
||||
<div className="font-medium">{(analyzeResult.model_performance.f1_score * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-600">Upload and analyze a dataset to see bias metrics.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "risk-analysis":
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Risk Analysis</h2>
|
||||
{analyzeResult ? (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-white rounded-lg border">
|
||||
<div className="text-sm text-slate-600">Overall Risk Score</div>
|
||||
<div className="text-2xl font-bold">{(analyzeResult.risk_assessment.overall_risk_score * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
|
||||
{cleanResult && (
|
||||
<div className="p-4 bg-white rounded-lg border">
|
||||
<h3 className="font-semibold mb-2">PII Detection Results</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<div>Cells Anonymized: <span className="font-medium">{cleanResult.summary.total_cells_affected}</span></div>
|
||||
<div>Columns Removed: <span className="font-medium">{cleanResult.summary.columns_removed.length}</span></div>
|
||||
<div>Columns Anonymized: <span className="font-medium">{cleanResult.summary.columns_anonymized.length}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-600">Upload and analyze a dataset to see risk assessment.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "bias-risk-mitigation":
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Mitigation Suggestions</h2>
|
||||
{analyzeResult && analyzeResult.recommendations.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{analyzeResult.recommendations.map((rec, i) => (
|
||||
<div key={i} className="p-3 bg-blue-50 border border-blue-200 rounded-md text-sm">
|
||||
{rec}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-600">
|
||||
Recommendations will appear here after analysis.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "results":
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Results Summary</h2>
|
||||
{(analyzeResult || cleanResult) ? (
|
||||
<div className="space-y-4">
|
||||
{analyzeResult && (
|
||||
<div className="p-4 bg-white rounded-lg border">
|
||||
<h3 className="font-semibold mb-2">Analysis Results</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<div>Dataset: {analyzeResult.filename}</div>
|
||||
<div>Rows: {analyzeResult.dataset_info.rows}</div>
|
||||
<div>Columns: {analyzeResult.dataset_info.columns}</div>
|
||||
<div>Bias Score: {(analyzeResult.bias_metrics.overall_bias_score * 100).toFixed(1)}%</div>
|
||||
<div>Risk Score: {(analyzeResult.risk_assessment.overall_risk_score * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
<a
|
||||
href={getReportUrl(analyzeResult.report_file)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-block text-sm text-brand-600 underline"
|
||||
>
|
||||
Download Full Report →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cleanResult && (
|
||||
<div className="p-4 bg-white rounded-lg border">
|
||||
<h3 className="font-semibold mb-2">Cleaning Results</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<div>Original: {cleanResult.dataset_info.original_rows} rows × {cleanResult.dataset_info.original_columns} cols</div>
|
||||
<div>Cleaned: {cleanResult.dataset_info.cleaned_rows} rows × {cleanResult.dataset_info.cleaned_columns} cols</div>
|
||||
<div>Cells Anonymized: {cleanResult.summary.total_cells_affected}</div>
|
||||
<div>Columns Removed: {cleanResult.summary.columns_removed.length}</div>
|
||||
<div>GDPR Compliant: {cleanResult.gdpr_compliance.length} articles applied</div>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<a
|
||||
href={getReportUrl(cleanResult.files.cleaned_csv)}
|
||||
download
|
||||
className="text-sm text-brand-600 underline"
|
||||
>
|
||||
Download Cleaned CSV →
|
||||
</a>
|
||||
<a
|
||||
href={getReportUrl(cleanResult.files.audit_report)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-brand-600 underline"
|
||||
>
|
||||
View Audit Report →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-600">
|
||||
Process a dataset to see aggregated results.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6 bg-white/60">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
383
frontend/components/try/CenterPanel_BACKUP.tsx
Normal file
383
frontend/components/try/CenterPanel_BACKUP.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
import { TryTab } from "./Sidebar";
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
|
||||
interface CenterPanelProps {
|
||||
tab: TryTab;
|
||||
onAnalyze?: () => void;
|
||||
}
|
||||
|
||||
interface UploadedFileMeta {
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
contentPreview: string;
|
||||
}
|
||||
|
||||
interface TablePreviewData {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
origin: 'csv';
|
||||
}
|
||||
|
||||
export function CenterPanel({ tab, onAnalyze }: CenterPanelProps) {
|
||||
const PREVIEW_BYTES = 64 * 1024; // read first 64KB slice for large-file preview
|
||||
const [fileMeta, setFileMeta] = useState<UploadedFileMeta | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [progressLabel, setProgressLabel] = useState<string>("Processing");
|
||||
const [tablePreview, setTablePreview] = useState<TablePreviewData | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [loadedFromCache, setLoadedFromCache] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setFileMeta(null);
|
||||
setProgress(0);
|
||||
setProgressLabel("Processing");
|
||||
setTablePreview(null);
|
||||
};
|
||||
|
||||
function tryParseCSV(text: string, maxRows = 50, maxCols = 40): TablePreviewData | null {
|
||||
const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0);
|
||||
if (lines.length < 2) return null;
|
||||
const commaDensity = lines.slice(0, 10).filter(l => l.includes(',')).length;
|
||||
if (commaDensity < 2) return null;
|
||||
const parseLine = (line: string) => {
|
||||
const out: string[] = [];
|
||||
let cur = '';
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (ch === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') { cur += '"'; i++; } else { inQuotes = !inQuotes; }
|
||||
} else if (ch === ',' && !inQuotes) {
|
||||
out.push(cur);
|
||||
cur = '';
|
||||
} else { cur += ch; }
|
||||
}
|
||||
out.push(cur);
|
||||
return out.map(c => c.trim());
|
||||
};
|
||||
const raw = lines.slice(0, maxRows).map(parseLine);
|
||||
if (raw.length === 0) return null;
|
||||
const headers = raw[0];
|
||||
const colCount = Math.min(headers.length, maxCols);
|
||||
const rows = raw.slice(1).map(r => r.slice(0, colCount));
|
||||
return { headers: headers.slice(0, colCount), rows, origin: 'csv' };
|
||||
}
|
||||
|
||||
// We no longer build table preview for JSON; revert JSON to raw text view.
|
||||
|
||||
const processFile = useCallback(async (f: File) => {
|
||||
if (!f) return;
|
||||
const isCSV = /\.csv$/i.test(f.name);
|
||||
setProgress(0);
|
||||
// For large files, show a progress bar while reading the file stream (no preview)
|
||||
if (f.size > 1024 * 1024) {
|
||||
setProgressLabel("Uploading");
|
||||
const metaObj: UploadedFileMeta = {
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
type: f.type || "unknown",
|
||||
contentPreview: `Loading partial preview (first ${Math.round(PREVIEW_BYTES/1024)}KB)...`,
|
||||
};
|
||||
setFileMeta(metaObj);
|
||||
setTablePreview(null);
|
||||
// Save to IndexedDB immediately so it persists without needing full read
|
||||
(async () => {
|
||||
try { await saveLatestUpload(f, metaObj); } catch {}
|
||||
})();
|
||||
// Read head slice for partial preview & possible CSV table extraction
|
||||
try {
|
||||
const headBlob = f.slice(0, PREVIEW_BYTES);
|
||||
const headReader = new FileReader();
|
||||
headReader.onload = async () => {
|
||||
try {
|
||||
const buf = headReader.result as ArrayBuffer;
|
||||
const decoder = new TextDecoder();
|
||||
const text = decoder.decode(buf);
|
||||
setFileMeta(prev => prev ? { ...prev, contentPreview: text.slice(0, 4000) } : prev);
|
||||
if (isCSV) {
|
||||
const parsed = tryParseCSV(text);
|
||||
setTablePreview(parsed);
|
||||
} else {
|
||||
setTablePreview(null);
|
||||
}
|
||||
try { await saveLatestUpload(f, { ...metaObj, contentPreview: text.slice(0, 4000) }); } catch {}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
headReader.readAsArrayBuffer(headBlob);
|
||||
} catch { /* ignore */ }
|
||||
// Use streaming read for progress without buffering entire file in memory
|
||||
try {
|
||||
const stream: ReadableStream<Uint8Array> | undefined = (typeof (f as any).stream === "function" ? (f as any).stream() : undefined);
|
||||
if (stream && typeof stream.getReader === "function") {
|
||||
const reader = stream.getReader();
|
||||
let loaded = 0;
|
||||
const total = f.size || 1;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
loaded += value ? value.length : 0;
|
||||
const pct = Math.min(100, Math.round((loaded / total) * 100));
|
||||
setProgress(pct);
|
||||
}
|
||||
setProgress(100);
|
||||
} else {
|
||||
// Fallback to FileReader progress events
|
||||
const reader = new FileReader();
|
||||
reader.onprogress = (evt) => {
|
||||
if (evt.lengthComputable) {
|
||||
const pct = Math.min(100, Math.round((evt.loaded / evt.total) * 100));
|
||||
setProgress(pct);
|
||||
} else {
|
||||
setProgress((p) => (p < 90 ? p + 5 : p));
|
||||
}
|
||||
};
|
||||
reader.onloadend = () => setProgress(100);
|
||||
reader.onerror = () => setProgress(0);
|
||||
reader.readAsArrayBuffer(f);
|
||||
}
|
||||
} catch {
|
||||
setProgress(100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onprogress = (evt) => {
|
||||
if (evt.lengthComputable) {
|
||||
const pct = Math.min(100, Math.round((evt.loaded / evt.total) * 100));
|
||||
setProgress(pct);
|
||||
} else {
|
||||
setProgress((p) => (p < 90 ? p + 5 : p));
|
||||
}
|
||||
};
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const buf = reader.result as ArrayBuffer;
|
||||
const decoder = new TextDecoder();
|
||||
const text = decoder.decode(buf);
|
||||
const metaObj: UploadedFileMeta = {
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
type: f.type || "unknown",
|
||||
contentPreview: text.slice(0, 4000),
|
||||
};
|
||||
setFileMeta(metaObj);
|
||||
if (isCSV) {
|
||||
const parsed = tryParseCSV(text);
|
||||
setTablePreview(parsed);
|
||||
} else {
|
||||
setTablePreview(null);
|
||||
}
|
||||
// Save file blob and meta to browser cache (IndexedDB)
|
||||
try {
|
||||
await saveLatestUpload(f, metaObj);
|
||||
} catch {}
|
||||
setProgressLabel("Processing");
|
||||
setProgress(100);
|
||||
} catch (e) {
|
||||
const metaObj: UploadedFileMeta = {
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
type: f.type || "unknown",
|
||||
contentPreview: "Unable to decode preview.",
|
||||
};
|
||||
setFileMeta(metaObj);
|
||||
setTablePreview(null);
|
||||
try {
|
||||
await saveLatestUpload(f, metaObj);
|
||||
} catch {}
|
||||
setProgressLabel("Processing");
|
||||
setProgress(100);
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setProgress(0);
|
||||
};
|
||||
reader.readAsArrayBuffer(f);
|
||||
}, []);
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = e.target.files?.[0];
|
||||
processFile(f as File);
|
||||
}
|
||||
|
||||
const onDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
const onDragLeave = () => setIsDragging(false);
|
||||
const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
processFile(f as File);
|
||||
};
|
||||
|
||||
// Load last cached upload on mount (processing tab only)
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
if (tab !== "processing") return;
|
||||
(async () => {
|
||||
try {
|
||||
const { meta } = await getLatestUpload();
|
||||
if (!ignore && meta) {
|
||||
setFileMeta(meta as UploadedFileMeta);
|
||||
setLoadedFromCache(true);
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [tab]);
|
||||
|
||||
function renderTabContent() {
|
||||
switch (tab) {
|
||||
case "processing":
|
||||
return (
|
||||
<div className="space-y-4 max-w-[1100px] xl:max-w-[1200px] w-full mx-auto">
|
||||
<h2 className="text-xl font-semibold">Upload & Process Data</h2>
|
||||
<p className="text-sm text-slate-600">Upload a CSV / JSON / text file. We will later parse, detect PII, and queue analyses.</p>
|
||||
<div className="flex flex-col gap-3 min-w-0">
|
||||
<div
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
className={
|
||||
"rounded-lg border-2 border-dashed p-6 text-center transition-colors " +
|
||||
(isDragging ? "border-brand-600 bg-brand-50" : "border-slate-300 hover:border-brand-300")
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-slate-600">Drag & drop a CSV / JSON / TXT here, or click to browse.</p>
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="inline-flex items-center rounded-md bg-brand-600 px-4 py-2 text-white text-sm font-medium shadow hover:bg-brand-500"
|
||||
>
|
||||
Choose file
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".csv,.json,.txt"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
aria-hidden
|
||||
/>
|
||||
{progress > 0 && (
|
||||
<div className="w-full">
|
||||
<div className="h-2 w-full rounded-full bg-slate-200 overflow-hidden">
|
||||
<div
|
||||
className="h-2 bg-brand-600 transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{progressLabel} {progress}%</div>
|
||||
</div>
|
||||
)}
|
||||
{fileMeta && (
|
||||
<div className="rounded-md border border-slate-200 p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm font-medium">{fileMeta.name}</div>
|
||||
<div className="text-xs text-slate-500">{Math.round(fileMeta.size / 1024)} KB</div>
|
||||
</div>
|
||||
{loadedFromCache && (
|
||||
<div className="mb-2 text-[11px] text-brand-700">Loaded from browser cache</div>
|
||||
)}
|
||||
<div className="mb-3 text-xs text-slate-500">{fileMeta.type || "Unknown type"}</div>
|
||||
{/* Table preview when structured data detected; otherwise show text */}
|
||||
{tablePreview && tablePreview.origin === 'csv' ? (
|
||||
<div className="max-h-64 w-full min-w-0 overflow-x-auto overflow-y-auto rounded-md bg-slate-50">
|
||||
<table className="min-w-full text-xs">
|
||||
<thead className="sticky top-0 bg-slate-100">
|
||||
<tr>
|
||||
{tablePreview.headers.map((h, idx) => (
|
||||
<th key={idx} className="text-left font-semibold px-3 py-2 border-b border-slate-200 whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tablePreview.rows.map((r, i) => (
|
||||
<tr key={i} className={i % 2 === 0 ? "" : "bg-slate-100/50"}>
|
||||
{r.map((c, j) => (
|
||||
<td key={j} className="px-3 py-1.5 border-b border-slate-100 whitespace-nowrap max-w-[24ch] overflow-hidden text-ellipsis">{c}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="max-h-64 overflow-auto text-xs bg-slate-50 p-3 rounded-md whitespace-pre-wrap leading-relaxed">
|
||||
{fileMeta.contentPreview || "(no preview)"}
|
||||
</pre>
|
||||
)}
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
reset();
|
||||
try { await deleteLatestUpload(); } catch {}
|
||||
setLoadedFromCache(false);
|
||||
}}
|
||||
className="text-xs rounded-md border px-3 py-1.5 hover:bg-slate-50"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAnalyze?.()}
|
||||
className="text-xs rounded-md bg-brand-600 text-white px-3 py-1.5 hover:bg-brand-500"
|
||||
>
|
||||
Analyze
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "bias-analysis":
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Bias Analysis (Placeholder)</h2>
|
||||
<p className="text-sm text-slate-600">Once processing completes, bias metrics will appear here (distribution, representation, fairness indicators).</p>
|
||||
</div>
|
||||
);
|
||||
case "risk-analysis":
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Risk Analysis (Placeholder)</h2>
|
||||
<p className="text-sm text-slate-600">Potential privacy exposure, sensitive attribute concentration, consent gaps will be displayed.</p>
|
||||
</div>
|
||||
);
|
||||
case "bias-risk-mitigation":
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Mitigation Suggestions (Placeholder)</h2>
|
||||
<p className="text-sm text-slate-600">Recommended transformations, anonymization strategies, sampling adjustments, consent workflows.</p>
|
||||
</div>
|
||||
);
|
||||
case "results":
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Results Summary (Placeholder)</h2>
|
||||
<p className="text-sm text-slate-600">Aggregated findings and downloadable compliance report will appear here.</p>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6 bg-white/60">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
frontend/components/try/ChatbotPanel.tsx
Normal file
42
frontend/components/try/ChatbotPanel.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
export function ChatbotPanel() {
|
||||
const [messages] = useState<{ role: "user" | "assistant"; content: string }[]>([
|
||||
{ role: "assistant", content: "Hi! I'll help you interpret compliance results soon." },
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full border-l border-slate-200 bg-white/80">
|
||||
<div className="h-14 flex items-center px-4 border-b border-slate-200">
|
||||
<h2 className="font-semibold text-sm text-brand-700">Privacy Copilot</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={"rounded-md px-3 py-2 text-sm max-w-[80%] " + (m.role === "assistant" ? "bg-brand-600/10 text-brand-800" : "bg-brand-600 text-white ml-auto")}
|
||||
>
|
||||
{m.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-3 border-t border-slate-200">
|
||||
<form className="flex gap-2" onSubmit={e => e.preventDefault()}>
|
||||
<input
|
||||
disabled
|
||||
placeholder="Chat coming soon..."
|
||||
className="flex-1 rounded-md border border-slate-300 bg-slate-50 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 disabled:opacity-60"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled
|
||||
className="rounded-md bg-brand-600 text-white px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
frontend/components/try/Sidebar.tsx
Normal file
50
frontend/components/try/Sidebar.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
import clsx from "clsx";
|
||||
|
||||
export type TryTab =
|
||||
| "processing"
|
||||
| "bias-analysis"
|
||||
| "risk-analysis"
|
||||
| "bias-risk-mitigation"
|
||||
| "results";
|
||||
|
||||
const tabs: { id: TryTab; label: string; description: string }[] = [
|
||||
{ id: "processing", label: "Processing", description: "Upload & parse" },
|
||||
{ id: "bias-analysis", label: "Bias Analysis", description: "Detect patterns" },
|
||||
{ id: "risk-analysis", label: "Risk Analysis", description: "Assess exposure" },
|
||||
{ id: "bias-risk-mitigation", label: "Bias & Risk Mitigation", description: "Recommend actions" },
|
||||
{ id: "results", label: "Results", description: "View summaries" },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
value: TryTab;
|
||||
onChange: (tab: TryTab) => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ value, onChange }: SidebarProps) {
|
||||
return (
|
||||
<aside className={clsx("flex-none w-64 h-full border-r border-slate-200 bg-white/80 flex flex-col")}>
|
||||
<div className="flex items-center px-3 h-14 border-b border-slate-200">
|
||||
<span className="font-semibold text-sm text-brand-700">Workflow</span>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto py-2 space-y-1">
|
||||
{tabs.map((t) => {
|
||||
const selected = t.id === value;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => onChange(t.id)}
|
||||
className={clsx(
|
||||
"group w-full text-left px-4 py-3 text-sm font-medium flex flex-col gap-0.5 transition-colors",
|
||||
selected ? "bg-brand-600/10 text-brand-800" : "hover:bg-brand-50 text-slate-700"
|
||||
)}
|
||||
>
|
||||
<span className={clsx("", selected && "font-semibold")}>{t.label}</span>
|
||||
<span className="text-xs text-slate-500 group-hover:text-slate-600 line-clamp-1">{t.description}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user