import { useEffect, useMemo, useRef, useState } from "react";
import { useServerFn } from "@tanstack/react-start";
import JSZip from "jszip";
import { ArrowUpRight, Check, Download, FileCode2, FilePlus2, FolderGit2, History, ImageOff, Loader2, Plus, Rocket, Save, Trash2, Upload, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import { cleanPath, fileBytes, formatBytes, isBinaryFile, isImageValue, isSupportedFile, parseDataUrl, stripCommonRoot, toDataUrl, validateFiles, type CodeFiles } from "@/lib/codehub";
import { ConnectDomainButton } from "@/components/connect-domain";
import { deployCodeProject } from "@/lib/codehub.functions";
type Project = { id: string; name: string; description: string; files: CodeFiles; deployed_url: string | null; updated_at: string };
type Version = { id: string; note: string; created_at: string; files: CodeFiles };
const STARTER: CodeFiles = { "index.html": "\n\n
\n \n My project \n \n\n\n Hello from CodeHub \n\n\n", "style.css": "body { font-family: system-ui; padding: 2rem; }\n" };
const ACCEPT = ".zip,.html,.htm,.css,.js,.mjs,.jsx,.ts,.tsx,.json,.md,.txt,.svg,.xml,.csv,.yml,.yaml,.toml,.py,.sh,.png,.jpg,.jpeg,.gif,.webp,.avif,.bmp,.ico,.woff,.woff2,.ttf,.otf,.pdf";
/** Reads a picked file as plain base64 (no data: prefix). */
function readAsBase64(file: File) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result).split(",")[1] ?? "");
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
export function CodeHub({ publishingConfigured }: { publishingConfigured: boolean }) {
const { user } = useAuth();
const [projects, setProjects] = useState(null);
const [openId, setOpenId] = useState(null);
const [busy, setBusy] = useState(false);
const fileInput = useRef(null);
const load = async () => {
const { data, error } = await supabase.from("code_projects").select("id, name, description, files, deployed_url, updated_at").order("updated_at", { ascending: false }).limit(100);
if (error) { toast.error("Your CodeHub projects could not load."); setProjects([]); return; }
setProjects((data ?? []) as Project[]);
};
useEffect(() => { void load(); }, []);
const create = async (name: string, files: CodeFiles, note: string) => {
if (!user) return;
const invalid = validateFiles(files);
if (invalid) { toast.error(invalid); return; }
const { data, error } = await supabase.from("code_projects").insert({ user_id: user.id, name: name.slice(0, 80) || "Untitled project", files }).select("id").single();
if (error || !data) { toast.error("The project could not be saved."); return; }
await supabase.from("code_project_versions").insert({ project_id: data.id, user_id: user.id, files, note });
toast.success("Project saved to CodeHub."); await load(); setOpenId(data.id);
};
const importFiles = async (list: FileList | null) => {
if (!list?.length) return;
setBusy(true);
try {
const files: CodeFiles = {}; let skipped = 0; let name = "Imported project";
for (const file of Array.from(list)) {
if (file.name.toLowerCase().endsWith(".zip")) {
name = file.name.replace(/\.zip$/i, "");
const zip = await JSZip.loadAsync(file);
for (const entry of Object.values(zip.files)) {
if (entry.dir) continue;
const path = cleanPath(entry.name);
if (!path || !isSupportedFile(path)) { skipped++; continue; }
files[path] = isBinaryFile(path) ? toDataUrl(path, await entry.async("base64")) : await entry.async("string");
}
} else {
const path = cleanPath(file.webkitRelativePath || file.name);
if (!path || !isSupportedFile(path)) { skipped++; continue; }
files[path] = isBinaryFile(path) ? toDataUrl(path, await readAsBase64(file)) : await file.text();
}
}
const cleaned = stripCommonRoot(files);
if (!Object.keys(cleaned).length) { toast.error("No files were found to import."); return; }
if (skipped) toast.info(`${skipped} unsupported file(s) were skipped — CodeHub stores code, images, fonts and PDFs.`);
await create(name, cleaned, "Imported");
} catch { toast.error("That file could not be read. Make sure it's a valid ZIP."); }
finally { setBusy(false); if (fileInput.current) fileInput.current.value = ""; }
};
const open = projects?.find((p) => p.id === openId);
if (open) return setOpenId(null)} onChanged={load} />;
return
CODEHUB
Your code projects Private storage for your code, images and fonts. Edit, keep versions, download as ZIP, publish.
{projects === null ? Loading CodeHub…
: projects.length ? {projects.map((p) =>
{p.deployed_url ? "Published" : "Private"}
{p.name}
{Object.keys(p.files ?? {}).length} FILES · {new Date(p.updated_at).toLocaleDateString()}
{p.deployed_url && {p.deployed_url} }
setOpenId(p.id)}> Open code
)}
: No code projects yet Start a new project or upload a ZIP of your code, images and fonts.
}
;
}
function Editor({ project, publishingConfigured, onClose, onChanged }: { project: Project; publishingConfigured: boolean; onClose: () => void; onChanged: () => Promise }) {
const { user } = useAuth();
const deploy = useServerFn(deployCodeProject);
const [name, setName] = useState(project.name);
const [files, setFiles] = useState(project.files ?? {});
const paths = useMemo(() => Object.keys(files).sort(), [files]);
const [current, setCurrent] = useState(paths.includes("index.html") ? "index.html" : paths[0] ?? "");
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [versions, setVersions] = useState(null);
const [dialog, setDialog] = useState(null);
const [publishing, setPublishing] = useState(false);
const [url, setUrl] = useState(project.deployed_url);
const replaceInput = useRef(null);
useEffect(() => {
if (!dirty) return;
const warn = (e: BeforeUnloadEvent) => { e.preventDefault(); };
window.addEventListener("beforeunload", warn); return () => window.removeEventListener("beforeunload", warn);
}, [dirty]);
const edit = (next: CodeFiles) => { setFiles(next); setDirty(true); };
const addFile = () => {
const raw = window.prompt("New file name (e.g. script.js, src/app.tsx or style.css)");
const path = raw ? cleanPath(raw) : null;
if (!raw) return;
if (!path) { toast.error("That file name isn't allowed."); return; }
if (files[path] !== undefined) { toast.error("A file with that name already exists."); return; }
if (isBinaryFile(path)) { toast.info("Images, fonts and PDFs are added with Upload ZIP / files."); return; }
edit({ ...files, [path]: "" }); setCurrent(path);
};
const removeFile = (path: string) => {
if (!window.confirm(`Delete ${path}? You can still restore it from a saved version.`)) return;
const next = { ...files }; delete next[path]; edit(next); setCurrent(Object.keys(next).sort()[0] ?? "");
};
const replaceBinary = async (file: File | undefined) => {
if (!file || !current || !isBinaryFile(current)) return;
try {
edit({ ...files, [current]: toDataUrl(current, await readAsBase64(file)) });
toast.success(`${current} replaced.`);
} catch { toast.error("That file could not be read."); }
finally { if (replaceInput.current) replaceInput.current.value = ""; }
};
const save = async () => {
if (!user) return;
const invalid = validateFiles(files);
if (invalid) { toast.error(invalid); return; }
setSaving(true);
const { error } = await supabase.from("code_projects").update({ name: name.slice(0, 80) || "Untitled project", files, updated_at: new Date().toISOString() }).eq("id", project.id);
if (!error) await supabase.from("code_project_versions").insert({ project_id: project.id, user_id: user.id, files, note: "Saved" });
setSaving(false);
if (error) { toast.error("Saving failed. Please try again."); return; }
setDirty(false); setVersions(null); toast.success("Saved. A new version was recorded."); await onChanged();
};
const loadVersions = async () => {
const { data, error } = await supabase.from("code_project_versions").select("id, note, created_at, files").eq("project_id", project.id).order("created_at", { ascending: false }).limit(30);
if (error) { toast.error("Versions could not load."); return; }
setVersions((data ?? []) as Version[]);
};
const restore = (v: Version) => {
if (!window.confirm(`Restore the version from ${new Date(v.created_at).toLocaleString()}? Unsaved edits will be replaced. Click Save to keep it.`)) return;
edit(v.files); setCurrent(Object.keys(v.files).sort()[0] ?? ""); toast.info("Version loaded. Click Save to keep it.");
};
const exportZip = async () => {
const zip = new JSZip();
for (const [path, body] of Object.entries(files)) {
const data = parseDataUrl(body);
if (data) zip.file(path, data.base64, { base64: true });
else zip.file(path, body);
}
const blob = await zip.generateAsync({ type: "blob" });
const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `${(name || "project").replace(/[^\w-]+/g, "-")}.zip`; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
};
const publish = async () => {
setPublishing(true);
try {
const result = await deploy({ data: { projectId: project.id } });
if ("error" in result) throw new Error(result.error);
setUrl(result.url); setDialog(null); toast.success("Published to your Cloudflare account."); await onChanged();
} catch (e) { toast.error(e instanceof Error ? e.message : "Publishing failed."); }
finally { setPublishing(false); }
};
const remove = async () => {
const { error } = await supabase.from("code_projects").delete().eq("id", project.id);
if (error) { toast.error("The project could not be deleted."); return; }
toast.success("Project deleted from CodeHub."); onClose(); await onChanged();
};
const body = files[current] ?? "";
const binary = current ? isBinaryFile(current) : false;
return
{url && {url} }
Files
{paths.map((p) => setCurrent(p)} className={`min-w-0 flex-1 truncate rounded px-2 py-1.5 text-left font-mono text-xs ${p === current ? "bg-secondary text-foreground" : "text-muted-foreground hover:text-foreground"}`}>{p} removeFile(p)} className="px-1 text-muted-foreground opacity-0 hover:text-destructive group-hover:opacity-100 focus:opacity-100"> )}
{current ? <>
{current} · {formatBytes(fileBytes(body))}
{binary &&
replaceInput.current?.click()}> Replace file }
void replaceBinary(e.target.files?.[0])} />
{binary ? (isImageValue(body)
?
:
This file is stored as-is and will be published unchanged.
replaceInput.current?.click()}> Replace file )
:
{dialog &&
{dialog === "publish" ? <>
{url ? "Replace the published site?" : "Publish this project?"} All {paths.length} saved files — code, images and fonts — go live on your Cloudflare Pages account and become public. It needs an index.html at the top level. Each publish counts toward Cloudflare's monthly deploy limit.
setDialog(null)}>Cancel void publish()}>{publishing ? : }Approve publishing
>
: <>
Delete “{project.name}”? This removes the project, its {paths.length} files and all saved versions from CodeHub. It can't be undone. A published site stays on your Cloudflare account.
setDialog(null)}>Cancel void remove()}> Delete project
>}
}
;
}