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.

void importFiles(e.target.files)} />
{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}}
)}
:

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
{ setName(e.target.value); setDirty(true); }} className="max-w-xs" />
{url && }
{url && {url}}
{current ? <>

{current} · {formatBytes(fileBytes(body))}

{binary && }
void replaceBinary(e.target.files?.[0])} /> {binary ? (isImageValue(body) ?
{current}
:

This file is stored as-is and will be published unchanged.

) :