"use client"; import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; const ACCEPTED = [".backerup", ".json"]; const MAX_BYTES = 500 * 1024 * 1024; function formatBytes(n: number) { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / 1024 / 1024).toFixed(2)} MB`; } function UploadIcon() { return ( ); } function CheckIcon() { return ( ); } export default function SharePage() { const router = useRouter(); const inputRef = useRef(null); const [file, setFile] = useState(null); const [displayName, setDisplayName] = useState(""); const [description, setDescription] = useState(""); const [group, setGroup] = useState(""); const [existingGroups, setExistingGroups] = useState([]); const [dragging, setDragging] = useState(false); const [uploading, setUploading] = useState(false); const [progress, setProgress] = useState(0); const [error, setError] = useState(null); useEffect(() => { fetch("/api/files") .then((r) => r.json()) .then((records: Array<{ group?: string }>) => { const groups = Array.from( new Set(records.map((r) => r.group).filter(Boolean) as string[]) ).sort(); setExistingGroups(groups); }) .catch(() => {}); }, []); function pickFile(f: File) { const name = f.name.toLowerCase(); if (!ACCEPTED.some((ext) => name.endsWith(ext))) { setError("Only .backerup and .json files are accepted."); return; } if (f.size > MAX_BYTES) { setError("File exceeds the 500 MB limit."); return; } setError(null); setFile(f); } function onDrop(e: React.DragEvent) { e.preventDefault(); setDragging(false); const dropped = e.dataTransfer.files[0]; if (dropped) pickFile(dropped); } async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!file) return; setUploading(true); setProgress(0); setError(null); // Animate a fake progress bar for perceived speed const interval = setInterval(() => { setProgress((p) => (p < 85 ? p + Math.random() * 8 : p)); }, 200); try { const formData = new FormData(); formData.append("file", file); if (displayName.trim()) formData.append("displayName", displayName.trim()); formData.append("description", description); if (group.trim()) formData.append("group", group.trim()); const res = await fetch("/api/upload", { method: "POST", body: formData }); const data = (await res.json()) as { token?: string; error?: string }; if (!res.ok || !data.token) throw new Error(data.error ?? "Upload failed"); clearInterval(interval); setProgress(100); await new Promise((r) => setTimeout(r, 400)); router.push(`/files/${data.token}`); } catch (err) { clearInterval(interval); setError(err instanceof Error ? err.message : "Unknown error"); setUploading(false); setProgress(0); } } const isBackerup = file?.name.toLowerCase().endsWith(".backerup"); return ( {/* Header */} Share Share a file Upload a{" "} .backerup{" "} snapshot or{" "} .json{" "} container config. Anyone with the link can browse and download it. {/* ── Drop zone ── */} inputRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={onDrop} > { const f = e.target.files?.[0]; if (f) pickFile(f); }} /> {file ? ( <> {file.name} {formatBytes(file.size)} ·{" "} { e.stopPropagation(); setFile(null); setError(null); }} > remove {isBackerup ? ".backerup archive" : ".json config"} > ) : ( <> {dragging ? "Drop it!" : "Drop a file or click to browse"} Accepts{" "} .backerup and{" "} .json — up to 500 MB > )} {/* ── Display name ── */} Display name{" "} (optional) setDisplayName(e.target.value)} placeholder="e.g. Production Postgres backup" maxLength={120} className="w-full rounded-xl border border-white/8 bg-white/4 px-4 py-3 text-sm text-slate-200 placeholder-slate-600 outline-none focus:border-cyan-500/50 focus:bg-white/6 transition-all" /> Shown on the browse page instead of the raw filename. {/* ── Group ── */} Group{" "} (optional) setGroup(e.target.value)} placeholder="e.g. production, homelab, postgres" maxLength={60} className="w-full rounded-xl border border-white/8 bg-white/4 px-4 py-3 text-sm text-slate-200 placeholder-slate-600 outline-none focus:border-cyan-500/50 focus:bg-white/6 transition-all" /> {existingGroups.length > 0 && ( {existingGroups.map((g) => )} )} {existingGroups.length > 0 && ( {existingGroups.map((g) => ( setGroup(g === group ? "" : g)} className={`rounded-full px-2.5 py-0.5 text-xs transition-all ${ group === g ? "bg-violet-500/20 border border-violet-500/40 text-violet-300" : "bg-white/4 border border-white/8 text-slate-500 hover:text-slate-300" }`} > {g} ))} )} Tag this upload so others can filter by group on the browse page. {/* ── Description ── */} Description{" "} (optional) setDescription(e.target.value)} placeholder="e.g. Production Postgres 16 with TimescaleDB extension, includes all data volumes" className="w-full rounded-xl border border-white/8 bg-white/4 px-4 py-3 text-sm text-slate-200 placeholder-slate-600 outline-none focus:border-cyan-500/50 focus:bg-white/6 transition-all resize-none" /> {/* ── Warning for .backerup ── */} {isBackerup && ( Heads up: .backerup files may contain volume data. Ensure you are not sharing sensitive database contents, secrets, or private keys. )} {/* ── Error ── */} {error && ( {error} )} {/* ── Progress bar ── */} {uploading && ( Uploading… {Math.round(progress)}% )} {/* ── Submit ── */} {uploading ? "Uploading…" : "Upload & get share link"} ); }
Share
Upload a{" "} .backerup{" "} snapshot or{" "} .json{" "} container config. Anyone with the link can browse and download it.
.backerup
.json
{file.name}
{formatBytes(file.size)} ·{" "} { e.stopPropagation(); setFile(null); setError(null); }} > remove
{dragging ? "Drop it!" : "Drop a file or click to browse"}
Accepts{" "} .backerup and{" "} .json — up to 500 MB
Shown on the browse page instead of the raw filename.
Tag this upload so others can filter by group on the browse page.
Heads up: .backerup files may contain volume data. Ensure you are not sharing sensitive database contents, secrets, or private keys.
{error}