chore(portal): pensionera den döda metadata-uppladdningen #431
+5
-175
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user