"use client";
import { useState, useEffect, useRef } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Home,
ChevronRight,
FolderPlus,
Upload,
Trash2,
AlertCircle,
Loader2,
X,
LogOut,
} from "lucide-react";
import FileIcon from "./FileIcon";
const TAB_COLORS = [
"#c0392b",
"#2980b9",
"#27ae60",
"#8e44ad",
"#d35400",
"#16a085",
"#2c3e50",
"#b8860b",
"#7f8c8d",
];
export default function BrowseClient({ folderPath, items, crumbs, error }) {
const router = useRouter();
const [adminToken, setAdminToken] = useState(null);
const [uploading, setUploading] = useState(false);
const [uploadErr, setUploadErr] = useState(null);
const fileInputRef = useRef(null);
const [newFolder, setNewFolder] = useState("");
const [showFolder, setShowFolder] = useState(false);
const [folderLoading, setFolderLoading] = useState(false);
const [deleting, setDeleting] = useState(null);
const [confirm, setConfirm] = useState(null);
useEffect(() => {
setAdminToken(sessionStorage.getItem("adminToken"));
}, []);
function logout() {
sessionStorage.removeItem("adminToken");
setAdminToken(null);
}
async function handleUpload(e) {
const files = Array.from(e.target.files);
if (!files.length) return;
setUploading(true);
setUploadErr(null);
try {
for (const file of files) {
const uploadPath = folderPath
? `${folderPath}/${file.name}`
: file.name;
const fd = new FormData();
fd.append("path", uploadPath);
fd.append("file", file);
const res = await fetch("/api/github/upload", {
method: "POST",
headers: { "x-admin-token": adminToken },
body: fd,
});
if (!res.ok) {
const d = await res.json();
throw new Error(d.error || "Upload failed");
}
}
router.refresh();
} catch (err) {
setUploadErr(err.message);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
async function handleCreateFolder(e) {
e.preventDefault();
if (!newFolder.trim()) return;
setFolderLoading(true);
const gitkeepPath = folderPath
? `${folderPath}/${newFolder.trim()}/.gitkeep`
: `${newFolder.trim()}/.gitkeep`;
try {
const res = await fetch("/api/github/file", {
method: "PUT",
headers: {
"Content-Type": "application/json",
"x-admin-token": adminToken,
},
body: JSON.stringify({
path: gitkeepPath,
content: "",
message: `Create folder ${newFolder.trim()}`,
}),
});
if (!res.ok) {
const d = await res.json();
throw new Error(d.error);
}
setNewFolder("");
setShowFolder(false);
router.refresh();
} catch (err) {
setUploadErr(err.message);
} finally {
setFolderLoading(false);
}
}
async function handleDelete(item) {
setDeleting(item.path);
setConfirm(null);
try {
const res = await fetch("/api/github/file", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
"x-admin-token": adminToken,
},
body: JSON.stringify({
path: item.path,
sha: item.sha,
message: `Delete ${item.name}`,
}),
});
if (!res.ok) {
const d = await res.json();
throw new Error(d.error);
}
router.refresh();
} catch (err) {
setUploadErr(err.message);
} finally {
setDeleting(null);
}
}
const isAdmin = !!adminToken;
return (
<div className="min-h-screen flex flex-col bg-gray-50">
{/* Top bar */}
<div className="flex items-center justify-between px-4 py-2 bg-white border-b border-gray-200 shadow-sm">
{/* Breadcrumb */}
<nav
className="flex items-center gap-1 text-sm flex-wrap"
className="flex items-center gap-1 text-sm flex-wrap text-gray-500"
>
{crumbs.map((c, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <ChevronRight size={12} />}
{c.href ? (
<Link
href={c.href}
className="hover:text-indigo-600 transition-colors flex items-center gap-1"
>
{i === 0 && <Home size={12} />}
{c.label}
</Link>
) : (
<span className="text-gray-800 font-medium">{c.label}</span>
)}
</span>
))}
</nav>
{/* Admin controls */}
<div className="flex items-center gap-2">
{isAdmin ? (
<>
<span className="text-xs px-2 py-0.5 rounded bg-indigo-100 text-indigo-700 font-medium">
Admin
</span>
{/* Upload */}
<label
className="cursor-pointer flex items-center gap-1 text-xs px-2 py-1 rounded transition-colors text-green-600 hover:bg-green-50"
title="Upload files"
>
{uploading ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Upload size={13} />
)}
<span>Upload</span>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleUpload}
disabled={uploading}
/>
</label>
{/* New Folder */}
<button
onClick={() => setShowFolder((v) => !v)}
className="flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-blue-50 transition-colors text-blue-600"
title="New folder"
>
<FolderPlus size={13} />
<span>Folder</span>
</button>
<button
onClick={logout}
title="Logout"
className="flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-red-50 transition-colors text-red-500"
>
<LogOut size={13} />
</button>
</>
) : (
<Link
href="/admin"
className="text-xs px-2 py-1 rounded transition-colors text-gray-400 hover:text-indigo-600"
>
Login
</Link>
)}
</div>
</div>
{}
{showFolder && (
<form
onSubmit={handleCreateFolder}
className="flex items-center gap-2 px-4 py-2 bg-gray-100 border-b border-gray-200"
>
<input
autoFocus
type="text"
value={newFolder}
onChange={(e) => setNewFolder(e.target.value)}
placeholder="New folder name…"
className="flex-1 px-3 py-1.5 rounded text-sm bg-white border border-gray-300 focus:outline-none focus:border-indigo-400 text-gray-800"
/>
<button
type="submit"
disabled={folderLoading}
className="px-3 py-1.5 rounded text-sm font-semibold bg-green-600 text-white hover:bg-green-700 disabled:opacity-50"
>
{folderLoading ? "…" : "Create"}
</button>
<button type="button" onClick={() => setShowFolder(false)}>
<X size={16} className="text-gray-500" />
</button>
</form>
)}
{}
{(error || uploadErr) && (
<div className="flex items-center gap-2 px-4 py-2 text-sm bg-red-50 text-red-700 border-b border-red-200">
<AlertCircle size={14} />
{error || uploadErr}
{uploadErr && (
<button onClick={() => setUploadErr(null)} className="ml-auto">
<X size={14} />
</button>
)}
</div>
)}
<div className="flex-1 p-6">
<h1 className="text-xl font-bold text-gray-800 mb-5">
{crumbs[crumbs.length - 1]?.label === "Home"
? "Root"
: crumbs[crumbs.length - 1]?.label}
</h1>
{}
{confirm && (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ background: "rgba(0,0,0,0.6)" }}
>
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-sm w-full mx-4">
<h2 className="font-bold text-gray-900 text-lg mb-2">
Delete file?
</h2>
<p className="text-sm text-gray-600 mb-4">
<strong>{confirm.name}</strong> will be permanently deleted from
the repository.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setConfirm(null)}
className="px-4 py-1.5 rounded text-sm border border-gray-200 text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={() => handleDelete(confirm)}
className="px-4 py-1.5 rounded text-sm font-semibold bg-red-600 text-white hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
)}
{}
{items.length === 0 ? (
<p className="text-gray-400 italic text-sm">This folder is empty.</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{items.map((item, i) => {
const href =
item.type === "dir"
? `/browse/${item.path}`
: `/view/${item.path}`;
const isBeingDeleted = deleting === item.path;
return (
<div key={item.path} className="relative group">
<Link href={href}>
<div
className="bg-white border border-gray-200 rounded-xl p-3
hover:shadow-md hover:-translate-y-0.5 transition-all
cursor-pointer overflow-hidden"
>
{/* color strip for dirs */}
{item.type === "dir" && (
<div
className="h-1 rounded-t mb-2 -mt-3 -mx-3"
style={{
background: TAB_COLORS[i % TAB_COLORS.length],
}}
/>
)}
<div className="flex items-start gap-2">
<div className="shrink-0 mt-0.5">
<FileIcon
name={item.name}
type={item.type}
size={18}
/>
</div>
<div className="min-w-0">
<p className="text-xs font-semibold text-gray-800 break-words leading-snug">
{item.name}
</p>
{item.type === "file" && item.size > 0 && (
<p className="text-xs mt-0.5 text-gray-400">
{formatSize(item.size)}
</p>
)}
</div>
</div>
{/* Loading overlay */}
{isBeingDeleted && (
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-white/80">
<Loader2
size={18}
className="animate-spin text-indigo-500"
/>
</div>
)}
</div>
</Link>
{/* Delete button (admin only, files only) */}
{isAdmin && item.type === "file" && !isBeingDeleted && (
<button
onClick={(e) => {
e.preventDefault();
setConfirm(item);
}}
className="absolute top-1.5 right-1.5 opacity-0 group-hover:opacity-100
transition-opacity p-0.5 rounded bg-red-50 text-red-500"
title="Delete file"
>
<Trash2 size={12} />
</button>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}