chore(portal): pensionera den döda metadata-uppladdningen #431

Merged
admin merged 1 commits from chore/retire-dead-upload-surface into main 2026-11-02 14:16:30 +00:00
3 changed files with 5 additions and 177 deletions
+5 -175
View File
@@ -1,180 +1,10 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useT } from "@/components/locale-provider";
import { PortalShell } from "@/components/portal-shell";
import { LoadingSkeleton, EmptyState, ErrorState } from "@/components/states";
import { formatSEK } from "@/lib/data";
import { redirect } from 'next/navigation';
/**
* /portal/files — kundens filer. En kvadratisk mono drag-drop-zon + fillista.
* Vid släpp: POST /api/uploads { name, mime, bytes }. Om lagring inte är kopplad
* svarar endpointen 501 → lugn not "aktiveras vid lagring", aldrig en feltoast.
* Uppladdade filer matar matchning & berikning.
* The old `/portal/files` page only sent metadata and always hit the 501
* storage stub. Real uploads now live at `/portal/dokument`; keep the old URL
* working instead of leaving a second, non-functional upload surface.
*/
interface FileRow {
file_id: string;
name: string;
mime: string;
bytes: number;
status: string; // received · matching · enriched
uploaded_at: string;
}
const PROV_BY_STATUS: Record<string, string> = {
enriched: "var(--prov-3)",
matching: "var(--prov-2)",
received: "var(--prov-1)",
};
function fmtBytes(b: number): string {
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(0)} kB`;
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
}
export default function FilesPage() {
const t = useT();
const [files, setFiles] = useState<FileRow[] | null>(null);
const [error, setError] = useState(false);
const [dragging, setDragging] = useState(false);
const [busy, setBusy] = useState(false);
const [storageOff, setStorageOff] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
setError(false);
try {
const r = await fetch("/api/uploads", { cache: "no-store" });
if (r.status === 501) { setStorageOff(true); setFiles([]); return; }
setFiles(await r.json());
} catch {
setError(true);
}
}, []);
useEffect(() => { void load(); }, [load]);
const upload = useCallback(async (list: FileList | File[]) => {
const arr = Array.from(list);
if (arr.length === 0 || busy) return;
setBusy(true);
try {
for (const f of arr) {
const r = await fetch("/api/uploads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: f.name, mime: f.type || "application/octet-stream", bytes: f.size }),
});
// Env-guarded: storage not wired yet → calm note, no error.
if (r.status === 501) { setStorageOff(true); break; }
}
} catch {
// Network hiccup degrades to the storage note, never a hard error toast.
setStorageOff(true);
}
setBusy(false);
void load();
}, [busy, load]);
function onDrop(e: React.DragEvent) {
e.preventDefault();
setDragging(false);
if (e.dataTransfer?.files?.length) void upload(e.dataTransfer.files);
}
function openPicker() { inputRef.current?.click(); }
return (
<PortalShell variant="customer" title={t({ en: "Files", sv: "Filer" })}>
{/* dropzone */}
<div
role="button"
tabIndex={0}
aria-label={t({ en: "Drop files to upload", sv: "Släpp filer för att ladda upp" })}
onClick={openPicker}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openPicker(); } }}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
className="relative flex aspect-[3/1] w-full cursor-pointer flex-col items-center justify-center border border-dashed bg-background text-center transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
style={{
borderColor: dragging ? "var(--accent-red)" : "var(--border)",
backgroundColor: dragging ? "color-mix(in srgb, var(--accent-red) 4%, var(--background))" : undefined,
}}
>
<span
aria-hidden
className="mb-5 inline-block h-3.5 w-3.5"
style={{ backgroundColor: dragging ? "var(--accent-red)" : "var(--foreground)" }}
/>
<p className="font-mono text-[10px] uppercase tracking-[0.22em] text-muted-foreground">
{busy
? t({ en: "uploading…", sv: "laddar upp…" })
: dragging
? t({ en: "drop here", sv: "släpp här" })
: t({ en: "drag here · or click", sv: "dra hit · eller klicka" })}
</p>
<p className="mt-3 max-w-sm text-pretty text-[15px] leading-relaxed text-muted-foreground">
{t({ en: "CSV, Excel, lists.", sv: "CSV, Excel, listor." })}
<br />{t({ en: "Fed straight into matching & enrichment.", sv: "Matas in i matchning & berikning." })}
</p>
<input
ref={inputRef}
type="file"
multiple
className="sr-only"
onChange={(e) => { if (e.target.files) void upload(e.target.files); e.target.value = ""; }}
/>
</div>
{storageOff ? (
<div className="mt-4 flex items-center gap-3 border border-border bg-secondary/40 px-4 py-3">
<span aria-hidden className="inline-block h-2 w-2 shrink-0" style={{ backgroundColor: "var(--muted-foreground)" }} />
<p className="font-mono text-[11px] leading-relaxed tracking-tight text-muted-foreground">
{t({ en: "File upload activates once storage is connected.", sv: "Filuppladdning aktiveras när lagring kopplas." })}
</p>
</div>
) : null}
{/* file list */}
<div className="mt-8">
{error ? (
<ErrorState onRetry={load} />
) : !files ? (
<LoadingSkeleton rows={4} />
) : files.length === 0 ? (
<EmptyState
title={t({ en: "No files yet.", sv: "Inga filer än." })}
line={t({ en: "Drag in a list. It feeds straight into matching and enrichment.", sv: "Dra in en lista. Den matas direkt in i matchning och berikning." })}
/>
) : (
<div className="border border-border">
<div className="grid grid-cols-[2fr_1fr_1fr_1fr] border-b border-border bg-secondary/50 px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
<span>{t({ en: "File", sv: "Fil" })}</span><span>{t({ en: "Type", sv: "Typ" })}</span><span>{t({ en: "Size", sv: "Storlek" })}</span><span>{t({ en: "Status", sv: "Status" })}</span>
</div>
{files.map((f) => (
<div key={f.file_id} className="grid grid-cols-[2fr_1fr_1fr_1fr] items-center border-b border-border px-4 py-3 text-sm transition-colors duration-200 last:border-b-0 hover:bg-secondary">
<span className="truncate text-[15px] tracking-tight">{f.name}</span>
<span className="truncate font-mono text-[11px] text-muted-foreground">{f.mime.split("/").pop()}</span>
<span className="font-mono text-[12px] tabular-nums text-muted-foreground">{fmtBytes(f.bytes)}</span>
<span className="inline-flex items-center gap-2 font-mono text-[10.5px] uppercase tracking-[0.08em]">
<span
aria-hidden
className="inline-block h-2 w-2 shrink-0"
style={{ backgroundColor: PROV_BY_STATUS[f.status] ?? "var(--prov-0)" }}
/>
{f.status}
</span>
</div>
))}
</div>
)}
</div>
<p className="mt-6 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
{t({ en: "Uploads are deduplicated against your file · no cost until you order", sv: "Uppladdat dedupliceras mot din fil · ingen kostnad förrän du beställer" })}{files && files.length ? ` · ${formatSEK(0)} ${t({ en: "so far", sv: "hittills" })}` : ""}
</p>
</PortalShell>
);
redirect('/portal/dokument');
}
-1
View File
@@ -38,7 +38,6 @@ const NAV: { label: { en: string; sv: string }; href: string }[] = [
{ label: { en: "Subscriptions", sv: "Prenumerationer" }, href: "/portal/subscriptions" },
{ label: { en: "Documents", sv: "Dokument" }, href: "/portal/dokument" },
{ label: { en: "Credits", sv: "Krediter" }, href: "/portal/krediter" },
{ label: { en: "Files", sv: "Filer" }, href: "/portal/files" },
{ label: { en: "Integrations", sv: "Integrationer" }, href: "/portal/integrationer" },
{ label: { en: "Settings", sv: "Inställningar" }, href: "/portal/settings" },
{ label: { en: "Admin", sv: "Admin" }, href: "/admin" },
-1
View File
@@ -12,7 +12,6 @@ const CUSTOMER_NAV: NavItem[] = [
{ label: { en: "Subscriptions", sv: "Prenumerationer" }, href: "/portal/subscriptions" },
{ label: { en: "Documents", sv: "Dokument" }, href: "/portal/dokument" },
{ label: { en: "Credits", sv: "Krediter" }, href: "/portal/krediter" },
{ label: { en: "Files", sv: "Filer" }, href: "/portal/files" },
{ label: { en: "Integrations", sv: "Integrationer" }, href: "/portal/integrationer" },
{ label: { en: "Settings", sv: "Inställningar" }, href: "/portal/settings" },
];