enzostvs's picture
enzostvs HF Staff
[BIG UPDATE 🚀] New generation files
35ef307
raw
history blame
16.1 kB
"use client";
import { useState, useMemo } from "react";
import {
FolderOpen,
FileCode2,
Folder,
ChevronRight,
ChevronDown,
} from "lucide-react";
import classNames from "classnames";
import { Page } from "@/types";
import { useEditor } from "@/hooks/useEditor";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface FileNode {
name: string;
path: string;
type: "file" | "folder";
children?: FileNode[];
page?: Page;
}
export function FileBrowser() {
const { pages, currentPage, setCurrentPage, globalEditorLoading, project } =
useEditor();
const [open, setOpen] = useState(false);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set(["/"])
);
const toggleFolder = (path: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
};
const fileTree = useMemo(() => {
const root: FileNode = {
name: "root",
path: "/",
type: "folder",
children: [],
};
pages.forEach((page) => {
const parts = page.path.split("/").filter(Boolean);
let currentNode = root;
parts.forEach((part, index) => {
const isFile = index === parts.length - 1;
const currentPath = "/" + parts.slice(0, index + 1).join("/");
if (!currentNode.children) {
currentNode.children = [];
}
let existingNode = currentNode.children.find((n) => n.name === part);
if (!existingNode) {
existingNode = {
name: part,
path: currentPath,
type: isFile ? "file" : "folder",
children: isFile ? undefined : [],
page: isFile ? page : undefined,
};
currentNode.children.push(existingNode);
}
if (!isFile) {
currentNode = existingNode;
}
});
});
// Sort: folders first, then files, both alphabetically
const sortNodes = (nodes: FileNode[] = []): FileNode[] => {
return nodes
.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "folder" ? -1 : 1;
}
return a.name.localeCompare(b.name);
})
.map((node) => ({
...node,
children: node.children ? sortNodes(node.children) : undefined,
}));
};
root.children = sortNodes(root.children);
return root;
}, [pages]);
const getFileIcon = (path: string) => {
const extension = path.split(".").pop()?.toLowerCase();
switch (extension) {
case "html":
return (
<svg className="size-4 shrink-0" viewBox="0 0 32 32" fill="none">
<path
d="M5.902 27.201L3.656 2h24.688l-2.249 25.197L15.985 30 5.902 27.201z"
fill="#E44D26"
/>
<path
d="M16 27.858l8.17-2.265 1.922-21.532H16v23.797z"
fill="#F16529"
/>
<path
d="M16 13.407h4.09l.282-3.165H16V7.151h7.75l-.074.829-.759 8.518H16v-3.091z"
fill="#EBEBEB"
/>
<path
d="M16 21.434l-.014.004-3.442-.929-.22-2.465H9.221l.433 4.852 6.332 1.758.014-.004v-3.216z"
fill="#EBEBEB"
/>
<path
d="M19.90 16.18l-.372 4.148-3.543.956v3.216l6.336-1.755.047-.522.537-6.043H19.90z"
fill="#FFF"
/>
<path
d="M16 7.151v3.091h-7.3l-.062-.695-.141-1.567-.074-.829H16zM16 13.407v3.091h-3.399l-.062-.695-.14-1.566-.074-.83H16z"
fill="#FFF"
/>
</svg>
);
case "css":
return (
<svg className="size-4 shrink-0" viewBox="0 0 32 32" fill="none">
<path
d="M5.902 27.201L3.656 2h24.688l-2.249 25.197L15.985 30 5.902 27.201z"
fill="#1572B6"
/>
<path
d="M16 27.858l8.17-2.265 1.922-21.532H16v23.797z"
fill="#33A9DC"
/>
<path
d="M16 13.191h4.09l.282-3.165H16V6.935h7.75l-.074.829-.759 8.518H16v-3.091z"
fill="#FFF"
/>
<path
d="M16.019 21.218l-.014.004-3.442-.929-.22-2.465H9.24l.433 4.852 6.331 1.758.015-.004v-3.216z"
fill="#EBEBEB"
/>
<path
d="M19.827 16.151l-.372 4.148-3.436.929v3.216l6.336-1.755.047-.522.726-8.016h-7.636v3h4.335z"
fill="#FFF"
/>
<path
d="M16.011 6.935v3.091h-7.3l-.062-.695-.141-1.567-.074-.829h7.577zM16 13.191v3.091h-3.399l-.062-.695-.14-1.566-.074-.83H16z"
fill="#EBEBEB"
/>
</svg>
);
case "js":
case "jsx":
return (
<svg className="size-4 shrink-0" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="2" fill="#F7DF1E" />
<path
d="M20.63 22.3c.54.88 1.24 1.53 2.48 1.53.98 0 1.6-.48 1.6-1.16 0-.8-.64-1.1-1.72-1.57l-.59-.25c-1.7-.72-2.83-1.63-2.83-3.55 0-1.77 1.35-3.12 3.46-3.12 1.5 0 2.58.52 3.36 1.9l-1.84 1.18c-.4-.72-.84-1-1.51-1-.69 0-1.12.43-1.12 1 0 .7.43 1 1.43 1.43l.59.25c2 .86 3.13 1.73 3.13 3.7 0 2.12-1.66 3.3-3.9 3.3-2.18 0-3.6-1.04-4.3-2.4l1.96-1.12z"
fill="#000"
/>
<path
d="M11.14 22.56c.35.62.67 1.15 1.44 1.15.74 0 1.2-.29 1.2-1.42V14.7h2.4v7.63c0 2.34-1.37 3.4-3.37 3.4-1.8 0-2.85-.94-3.38-2.06l1.71-1.1z"
fill="#000"
/>
</svg>
);
case "json":
return (
<svg className="size-4 shrink-0" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="2" fill="#F7DF1E" />
<path
d="M16 2L4 8v16l12 6 12-6V8L16 2zm8.8 20.4l-8.8 4.4-8.8-4.4V9.6l8.8-4.4 8.8 4.4v12.8z"
fill="#000"
opacity="0.15"
/>
<text
x="50%"
y="50%"
dominantBaseline="middle"
textAnchor="middle"
fill="#000"
fontSize="14"
fontWeight="600"
>
{}
</text>
</svg>
);
default:
return <FileCode2 className="size-4 shrink-0 text-neutral-400" />;
}
};
const getFileExtension = (path: string) => {
return path.split(".").pop()?.toLowerCase() || "";
};
const getLanguageTag = (path: string) => {
const extension = path.split(".").pop()?.toLowerCase();
switch (extension) {
case "html":
return {
name: "HTML",
color: "bg-orange-500/20 border-orange-500/30 text-orange-400",
};
case "css":
return {
name: "CSS",
color: "bg-blue-500/20 border-blue-500/30 text-blue-400",
};
case "js":
return {
name: "JS",
color: "bg-yellow-500/20 border-yellow-500/30 text-yellow-400",
};
case "json":
return {
name: "JSON",
color: "bg-yellow-500/20 border-yellow-500/30 text-yellow-400",
};
default:
return {
name: extension?.toUpperCase() || "FILE",
color: "bg-neutral-500/20 border-neutral-500/30 text-neutral-400",
};
}
};
const currentPageData = pages.find((p) => p.path === currentPage);
const renderFileTree = (nodes: FileNode[], depth = 0) => {
return nodes.map((node) => {
if (node.type === "folder") {
const isExpanded = expandedFolders.has(node.path);
return (
<div key={node.path}>
<div
className="flex items-center gap-2 px-3 py-1 cursor-pointer text-[13px] group transition-colors hover:bg-neutral-800"
style={{ paddingLeft: `${depth * 12 + 12}px` }}
onClick={() => toggleFolder(node.path)}
>
{isExpanded ? (
<ChevronDown className="size-3.5 text-neutral-400 shrink-0" />
) : (
<ChevronRight className="size-3.5 text-neutral-400 shrink-0" />
)}
<Folder className="size-4 text-blue-400 shrink-0" />
<span className="text-neutral-300 truncate flex-1 font-normal">
{node.name}
</span>
<span className="text-[10px] text-neutral-500">
{node.children?.length || 0}
</span>
</div>
{isExpanded &&
node.children &&
renderFileTree(node.children, depth + 1)}
</div>
);
} else {
const isActive = currentPage === node.page?.path;
return (
<div
key={node.path}
className={classNames(
"flex items-center gap-2.5 px-3 py-1 cursor-pointer text-[13px] group transition-colors relative",
{
"bg-neutral-700 text-white": isActive,
"text-neutral-300 hover:bg-neutral-800": !isActive,
}
)}
style={{ paddingLeft: `${depth * 12 + 12 + 16}px` }}
onClick={() => {
if (node.page) {
setCurrentPage(node.page.path);
setOpen(false);
}
}}
>
<div className="w-4 flex justify-center shrink-0">
{getFileIcon(node.name)}
</div>
<span className="truncate flex-1 font-normal">{node.name}</span>
<span
className={classNames(
"text-[10px] px-1.5 py-0.5 rounded uppercase font-semibold transition-opacity shrink-0",
isActive
? `opacity-100 ${getLanguageTag(node.name).color}`
: "opacity-0 group-hover:opacity-100 bg-white/5 text-neutral-500"
)}
>
{getFileExtension(node.name)}
</span>
{isActive && (
<div className="absolute left-0 top-0 bottom-0 w-[2px] bg-blue-500" />
)}
</div>
);
}
});
};
return (
<div>
{/* VS Code-style Tab Bar */}
<div className="w-full flex items-center bg-neutral-900 min-h-[35px] border-b border-neutral-800">
<div className="flex items-stretch overflow-x-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-neutral-700">
{currentPageData && (
<div className="flex items-center gap-2 px-4 py-2.5 bg-neutral-900 border-r border-neutral-800 text-sm min-w-0 relative group">
<div className="flex items-center gap-2 flex-1 min-w-0">
{getFileIcon(currentPageData.path)}
<span className="text-neutral-300 truncate font-normal text-[13px]">
{currentPageData.path}
</span>
<span
className={classNames(
"text-[9px] px-1.5 py-0.5 rounded border backdrop-blur-sm font-semibold uppercase tracking-wide",
getLanguageTag(currentPageData.path).color
)}
>
{getLanguageTag(currentPageData.path).name}
</span>
</div>
</div>
)}
</div>
{/* Open Explorer Button */}
<TooltipProvider>
<Tooltip>
<Sheet open={open} onOpenChange={setOpen} modal={false}>
<TooltipTrigger asChild>
<SheetTrigger asChild>
<Button
size="sm"
variant="ghost"
disabled={pages.length === 0 || globalEditorLoading}
className="ml-auto mr-2 text-neutral-300 hover:text-white hover:bg-neutral-800 h-7 text-[13px] font-normal gap-1.5"
>
<FolderOpen className="size-3.5" />
<span className="hidden sm:inline">Files</span>
<span className="text-[11px] px-1.5 py-0.5 rounded bg-neutral-800 text-neutral-500 font-semibold">
{pages.length}
</span>
</Button>
</SheetTrigger>
</TooltipTrigger>
<TooltipContent
side="bottom"
className="bg-neutral-800 border-neutral-700 text-neutral-300 text-xs"
>
<p>
Open File Explorer ({pages.length}{" "}
{pages.length === 1 ? "file" : "files"})
</p>
</TooltipContent>
<SheetContent
side="left"
className="w-[320px] bg-neutral-900 border-neutral-800 p-0"
>
<SheetHeader className="px-5 py-2.5 border-b border-neutral-800 space-y-0">
<SheetTitle className="flex items-center gap-2">
<FolderOpen className="size-4 text-neutral-300" />
<span className="text-[11px] uppercase tracking-wider text-neutral-300 font-semibold">
Explorer
</span>
</SheetTitle>
</SheetHeader>
<div className="px-3 py-3 border-b border-neutral-800">
<div className="flex items-center gap-2 px-2 py-1">
<svg
className="size-4 text-neutral-300"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M1.5 1h11l2 2v10l-2 2h-11l-2-2V3l2-2zm0 1l-1 1v10l1 1h11l1-1V3l-1-1h-11z" />
<path d="M7.5 4.5v3h-3v1h3v3h1v-3h3v-1h-3v-3h-1z" />
</svg>
<span className="text-[13px] text-neutral-300 font-normal">
{project?.space_id || "No space selected"}
</span>
<span className="ml-auto text-[11px] text-neutral-500">
{pages.length || 0}
</span>
</div>
</div>
<div
className="py-1 overflow-y-auto"
style={{ height: "calc(100vh - 160px)" }}
>
{fileTree.children && renderFileTree(fileTree.children)}
</div>
<div className="absolute bottom-0 left-0 right-0 px-5 py-3 border-t border-neutral-800 bg-neutral-900">
<div className="grid grid-cols-2 gap-2 text-[11px]">
<div className="flex items-center gap-2 text-neutral-500">
<div className="size-2 rounded-full bg-orange-600" />
<span>
HTML:{" "}
{pages.filter((p) => p.path.endsWith(".html")).length}
</span>
</div>
<div className="flex items-center gap-2 text-neutral-500">
<div className="size-2 rounded-full bg-blue-600" />
<span>
CSS:{" "}
{pages.filter((p) => p.path.endsWith(".css")).length}
</span>
</div>
<div className="flex items-center gap-2 text-neutral-500">
<div className="size-2 rounded-full bg-yellow-400" />
<span>
JS: {pages.filter((p) => p.path.endsWith(".js")).length}
</span>
</div>
</div>
</div>
</SheetContent>
</Sheet>
</Tooltip>
</TooltipProvider>
</div>
</div>
);
}