feat: using swc as transpiler

This commit is contained in:
Khairul Hidayat 2024-02-21 02:01:35 +07:00
parent 37df5c40cf
commit 4d1c56d6d8
34 changed files with 1912 additions and 434 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
NEXT_PUBLIC_BASE_URL=http://localhost:3000

1
.env.example Normal file
View File

@ -0,0 +1 @@
NEXT_PUBLIC_BASE_URL=http://localhost:3000

View File

@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "concurrently --kill-others \"next dev\" \"npm run transformer:dev\"",
"build": "next build",
"start": "next start",
"lint": "next lint",
@ -12,32 +12,42 @@
"push": "drizzle-kit push:sqlite",
"migrate": "tsx src/server/db/migrate.ts",
"seed": "tsx src/server/db/seed.ts",
"reset": "rm -f storage/database.db && npm run push && npm run seed"
"reset": "rm -f storage/database.db && npm run push && npm run seed",
"transformer:start": "tsx src/server/transformer/server.ts",
"transformer:dev": "tsx --watch src/server/transformer/server.ts",
"transformer:build": "tsc -p tsconfig-transformer.json"
},
"dependencies": {
"@babel/preset-typescript": "^7.23.3",
"@codemirror/lang-css": "^6.2.1",
"@codemirror/lang-html": "^6.4.8",
"@codemirror/lang-javascript": "^6.2.1",
"@codemirror/lang-json": "^6.0.1",
"@emmetio/codemirror6-plugin": "^0.3.0",
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-slot": "^1.0.2",
"@replit/codemirror-vscode-keymap": "^6.0.2",
"@swc/core": "^1.4.2",
"@tanstack/react-query": "^5.21.7",
"@trpc/client": "11.0.0-next-beta.289",
"@trpc/next": "11.0.0-next-beta.289",
"@trpc/react-query": "11.0.0-next-beta.289",
"@trpc/server": "11.0.0-next-beta.289",
"@types/express": "^4.17.21",
"@uiw/codemirror-theme-tokyo-night": "^4.21.22",
"@uiw/react-codemirror": "^4.21.22",
"bcrypt": "^5.1.1",
"better-sqlite3": "^9.4.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"copy-to-clipboard": "^3.3.3",
"drizzle-orm": "^0.29.3",
"drizzle-zod": "^0.5.1",
"express": "^4.18.2",
"lucide-react": "^0.331.0",
"mime": "^4.0.1",
"next": "14.1.0",
"prettier": "^3.2.5",
"react": "^18",
@ -48,7 +58,8 @@
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^2.14.0",
"zod": "^3.22.4"
"zod": "^3.22.4",
"zustand": "^4.5.1"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
@ -57,6 +68,7 @@
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"concurrently": "^8.2.2",
"drizzle-kit": "^0.20.14",
"eslint": "^8",
"eslint-config-next": "14.1.0",

1150
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
if (window.parent !== window) {
const _log = console.log;
const _error = console.error;
const _warn = console.warn;
console.log = function (...args) {
parent.window.postMessage({ type: "log", args: args }, "*");
_log(...args);
};
console.error = function (...args) {
parent.window.postMessage({ type: "error", args: args }, "*");
_error(...args);
};
console.warn = function (...args) {
parent.window.postMessage({ type: "warn", args: args }, "*");
_warn(...args);
};
}

View File

@ -1,86 +0,0 @@
import { getFileExt } from "@/lib/utils";
import db from "@/server/db";
import { file } from "@/server/db/schema/file";
import { and, eq, isNull } from "drizzle-orm";
import { NextRequest } from "next/server";
const handler = async (req: NextRequest, { params }: any) => {
const path = params.path.join("/");
const { searchParams } = new URL(req.url);
const fileData = await db.query.file.findFirst({
where: and(eq(file.path, path), isNull(file.deletedAt)),
});
if (!fileData) {
return new Response("File not found!", { status: 404 });
}
let content = fileData.content || "";
if (searchParams.get("index") === "true") {
content = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com"></script>
<title>Code-Share</title>
<link rel="stylesheet" href="styles.css">
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
${content}
<script>
if (window.parent !== window) {
const _log = console.log;
const _error = console.error;
const _warn = console.warn;
console.log = function (...args) {
parent.window.postMessage({ type: "log", args: args }, "*");
_log(...args);
};
console.error = function (...args) {
parent.window.postMessage({ type: "error", args: args }, "*");
_error(...args);
};
console.warn = function (...args) {
parent.window.postMessage({ type: "warn", args: args }, "*");
_warn(...args);
};
}
</script>
<script type="text/babel" src="script.js" data-type="module"></script>
</body>
</html>`;
}
return new Response(content, {
headers: {
"Content-Type": getFileMimetype(fileData.filename),
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
};
function getFileMimetype(filename: string) {
const ext = getFileExt(filename);
switch (ext) {
case "html":
return "text/html";
case "css":
return "text/css";
case "js":
case "jsx":
return "text/javascript";
}
return "application/octet-stream";
}
export { handler as GET };

View File

@ -7,8 +7,7 @@ body {
}
.cm-theme {
font-size: 16px;
@apply h-full;
@apply h-full md:text-[16px];
}
.cm-editor {
@ -22,6 +21,6 @@ body {
/* Hide scrollbar for IE, Edge and Firefox */
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}

View File

@ -21,7 +21,10 @@ const ConsoleLogger = forwardRef((_, ref: any) => {
return;
}
if (data.args[0]?.includes("Babel transformer")) {
if (
typeof data.args[0] === "string" &&
data.args[0]?.includes("Babel transformer")
) {
return;
}

View File

@ -0,0 +1,235 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import Tabs, { Tab } from "@/components/ui/tabs";
import FileViewer from "./file-viewer";
import trpc from "@/lib/trpc";
import EditorContext from "../context/editor";
import type { FileSchema } from "@/server/db/schema/file";
import { usePortrait } from "@/hooks/usePortrait";
import Panel from "@/components/ui/panel";
import { previewStore } from "./web-preview";
import { useProjectContext } from "../context/project";
import { ImperativePanelHandle } from "react-resizable-panels";
import Sidebar from "./sidebar";
import useCommandKey from "@/hooks/useCommandKey";
import { Button } from "@/components/ui/button";
import { FaCompress, FaCompressArrowsAlt } from "react-icons/fa";
import ConsoleLogger from "./console-logger";
const Editor = () => {
const isPortrait = usePortrait();
const trpcUtils = trpc.useUtils();
const [isMounted, setMounted] = useState(false);
const project = useProjectContext();
const sidebarPanel = useRef<ImperativePanelHandle>(null);
const [sidebarExpanded, setSidebarExpanded] = useState(false);
const [curTabIdx, setCurTabIdx] = useState(0);
const [curOpenFiles, setOpenFiles] = useState<number[]>([]);
const pinnedFiles = trpc.file.getAll.useQuery(
{ isPinned: true },
{ enabled: !isMounted }
);
const openedFilesData = trpc.file.getAll.useQuery(
{ id: curOpenFiles },
{ enabled: curOpenFiles.length > 0 }
);
const [openedFiles, setOpenedFiles] = useState<any[]>([]);
const deleteFile = trpc.file.delete.useMutation({
onSuccess: (file) => {
trpcUtils.file.getAll.invalidate();
onFileChanged(file);
const openFileIdx = curOpenFiles.indexOf(file.id);
if (openFileIdx >= 0) {
onCloseFile(openFileIdx);
}
},
});
useEffect(() => {
setMounted(true);
}, []);
const toggleSidebar = useCallback(() => {
const sidebar = sidebarPanel.current;
if (!sidebar) {
return;
}
if (sidebar.isExpanded()) {
sidebar.collapse();
} else {
sidebar.expand();
sidebar.resize(25);
}
}, [sidebarPanel]);
useCommandKey("b", toggleSidebar);
useEffect(() => {
if (!pinnedFiles.data?.length || curOpenFiles.length > 0) {
return;
}
pinnedFiles.data.forEach((file) => {
onOpenFile(file.id, false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pinnedFiles.data]);
useEffect(() => {
if (openedFilesData.data) {
setOpenedFiles(openedFilesData.data);
}
}, [openedFilesData.data]);
const onOpenFile = useCallback(
(fileId: number, autoSwitchTab = true) => {
const idx = curOpenFiles.indexOf(fileId);
if (idx >= 0) {
return setCurTabIdx(idx);
}
setOpenFiles((state) => {
if (autoSwitchTab) {
setCurTabIdx(state.length);
}
return [...state, fileId];
});
},
[curOpenFiles]
);
const onDeleteFile = useCallback(
(fileId: number) => {
if (
window.confirm("Are you sure want to delete this files?") &&
!deleteFile.isPending
) {
deleteFile.mutate(fileId);
}
},
[deleteFile]
);
const onCloseFile = useCallback(
(idx: number) => {
const _f = [...curOpenFiles];
_f.splice(idx, 1);
setOpenFiles(_f);
if (curTabIdx === idx) {
setCurTabIdx(Math.max(0, idx - 1));
}
},
[curOpenFiles, curTabIdx]
);
const onFileChanged = useCallback(
(_file: Omit<FileSchema, "content">) => {
openedFilesData.refetch();
},
[openedFilesData]
);
const refreshPreview = useCallback(() => {
previewStore.getState().refresh();
}, []);
const openFileList = useMemo(() => {
return curOpenFiles.map((fileId) => {
const fileData = openedFiles?.find((i) => i.id === fileId);
return {
title: fileData?.filename || "...",
render: () => (
<FileViewer id={fileId} onFileContentChange={refreshPreview} />
),
};
}) satisfies Tab[];
}, [curOpenFiles, openedFiles, refreshPreview]);
const PanelComponent = !project.isCompact ? Panel : "div";
return (
<EditorContext.Provider
value={{
onOpenFile,
onFileChanged,
onDeleteFile,
}}
>
<PanelComponent className="h-full relative">
<ResizablePanelGroup autoSaveId="veditor-panel" direction="horizontal">
<ResizablePanel
ref={sidebarPanel}
defaultSize={isPortrait ? 0 : 25}
minSize={10}
collapsible
collapsedSize={0}
className="bg-[#1e2536]"
onExpand={() => setSidebarExpanded(true)}
onCollapse={() => setSidebarExpanded(false)}
>
<Sidebar />
</ResizablePanel>
<ResizableHandle className="bg-slate-900" />
<ResizablePanel defaultSize={isPortrait ? 100 : 75}>
<ResizablePanelGroup
autoCapitalize="code-editor"
direction="vertical"
>
<ResizablePanel defaultSize={100} minSize={20}>
<Tabs
tabs={openFileList}
current={curTabIdx}
onChange={setCurTabIdx}
onClose={onCloseFile}
/>
</ResizablePanel>
{!isPortrait ? (
<>
<ResizableHandle />
<ResizablePanel
defaultSize={20}
collapsible
collapsedSize={0}
minSize={10}
>
<ConsoleLogger />
</ResizablePanel>
</>
) : null}
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
<Button
variant="ghost"
className="absolute bottom-0 left-0 w-12 h-12 rounded-none flex items-center justify-center"
onClick={toggleSidebar}
>
{sidebarExpanded ? <FaCompressArrowsAlt /> : <FaCompress />}
</Button>
</PanelComponent>
</EditorContext.Provider>
);
};
export default Editor;

View File

@ -1,6 +1,6 @@
"use client";
import React, { useMemo, useState } from "react";
import React, { Fragment, useMemo, useState } from "react";
import { UseDiscloseReturn, useDisclose } from "@/hooks/useDisclose";
import {
FiChevronRight,
@ -8,11 +8,12 @@ import {
FiFolderPlus,
FiMoreVertical,
} from "react-icons/fi";
import { FaCheck, FaThumbtack } from "react-icons/fa";
import trpc from "@/lib/trpc";
import type { FileSchema } from "@/server/db/schema/file";
import CreateFileDialog, { CreateFileSchema } from "./createfile-dialog";
import ActionButton from "../../../../components/ui/action-button";
import { useProjectContext } from "../context";
import { useEditorContext } from "../context/editor";
import {
DropdownMenu,
DropdownMenuContent,
@ -20,18 +21,20 @@ import {
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { cn, getUrl } from "@/lib/utils";
import FileIcon from "@/components/ui/file-icon";
import copy from "copy-to-clipboard";
import { useParams } from "next/navigation";
const FileListing = () => {
const { onOpenFile, onFileChanged } = useProjectContext();
const { onOpenFile, onFileChanged } = useEditorContext();
const createFileDlg = useDisclose<CreateFileSchema>();
const files = trpc.file.getAll.useQuery();
const fileList = useMemo(() => groupFiles(files.data, null), [files.data]);
return (
<div className="flex flex-col items-stretch">
<Fragment>
<div className="h-10 flex items-center pl-4 pr-1">
<p className="text-xs uppercase truncate flex-1">My Project</p>
<ActionButton
@ -70,7 +73,7 @@ const FileListing = () => {
onSuccess={(file, type) => {
files.refetch();
if (type === "create") {
if (type === "create" && !file.isDirectory && !file.isFile) {
onOpenFile && onOpenFile(file.id);
}
if (onFileChanged) {
@ -78,7 +81,7 @@ const FileListing = () => {
}
}}
/>
</div>
</Fragment>
);
};
@ -90,32 +93,44 @@ type FileItemProps = {
};
const FileItem = ({ file, createFileDlg }: FileItemProps) => {
const { onOpenFile, onDeleteFile } = useProjectContext();
const { slug } = useParams();
const { onOpenFile, onDeleteFile } = useEditorContext();
const [isCollapsed, setCollapsed] = useState(false);
const trpcUtils = trpc.useUtils();
const updateFile = trpc.file.update.useMutation({
onSuccess() {
trpcUtils.file.getAll.invalidate();
},
});
return (
<div className="w-full">
<button
className="group text-slate-400 hover:text-white hover:bg-slate-700 transition-colors text-sm flex items-center pl-5 pr-3 gap-1 text-left relative w-full h-10"
onClick={() => {
if (file.isDirectory) {
setCollapsed((i) => !i);
} else {
onOpenFile(file.id);
}
}}
>
{file.isDirectory ? (
<FiChevronRight
className={cn(
"absolute left-1 top-3 transition-transform",
isCollapsed ? "rotate-90" : ""
)}
/>
) : null}
<div className="group text-slate-400 hover:text-white hover:bg-slate-700 transition-colors text-sm flex items-stretch relative w-full h-10">
<button
className="flex items-center pl-5 pr-3 gap-1 w-full text-left"
onClick={() => {
if (file.isDirectory) {
setCollapsed((i) => !i);
} else {
onOpenFile(file.id);
}
}}
>
{file.isDirectory ? (
<FiChevronRight
className={cn(
"absolute left-1 top-3 transition-transform",
isCollapsed ? "rotate-90" : ""
)}
/>
) : null}
<FileIcon file={file} />
<span className="flex-1 truncate">{file.filename}</span>
<FileIcon file={file} />
<span className="flex-1 truncate">{file.filename}</span>
{file.isPinned ? <FaThumbtack /> : null}
</button>
<div className="flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity absolute top-0 right-0 pr-1 h-full bg-slate-700">
{file.isDirectory ? (
@ -152,10 +167,44 @@ const FileItem = ({ file, createFileDlg }: FileItemProps) => {
<DropdownMenuItem onClick={() => onDeleteFile(file.id)}>
Delete
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => copy(file.filename)}>
Copy Name
</DropdownMenuItem>
<DropdownMenuItem onClick={() => copy(file.path)}>
Copy Path
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => copy(getUrl(`project/${slug}/file`, file.path))}
>
Copy URL
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
window.open(
getUrl(`project/${slug}/file`, file.path),
"_blank"
)
}
>
Open in new tab
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
return updateFile.mutate({
id: file.id,
isPinned: !file.isPinned,
});
}}
>
Pinned
{file.isPinned ? <FaCheck className="ml-auto" /> : null}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</button>
</div>
{isCollapsed && file.children?.length > 0 ? (
<div className="flex flex-col items-stretch pl-4">

View File

@ -10,8 +10,7 @@ type Props = {
onFileContentChange?: () => void;
};
const FilePreview = ({ id, onFileContentChange }: Props) => {
const type = "text";
const FileViewer = ({ id, onFileContentChange }: Props) => {
const { data, isLoading, refetch } = trpc.file.getById.useQuery(id);
const updateFileContent = trpc.file.update.useMutation({
onSuccess: () => {
@ -23,13 +22,13 @@ const FilePreview = ({ id, onFileContentChange }: Props) => {
if (isLoading) {
return <p>Loading...</p>;
}
if (!data) {
if (!data || data.isDirectory) {
return <p>File not found.</p>;
}
const { filename } = data;
if (type === "text") {
if (!data.isFile) {
const ext = getFileExt(filename);
return (
@ -45,4 +44,4 @@ const FilePreview = ({ id, onFileContentChange }: Props) => {
return null;
};
export default FilePreview;
export default FileViewer;

View File

@ -0,0 +1,23 @@
import React from "react";
import FileListing from "./file-listing";
import { FaUserCircle } from "react-icons/fa";
import { Button } from "@/components/ui/button";
const Sidebar = () => {
return (
<aside className="flex flex-col items-stretch h-full">
<FileListing />
<div className="h-12 bg-[#1a1b26] pl-12">
<Button
variant="ghost"
className="h-12 w-full truncate flex justify-start text-left uppercase text-xs rounded-none"
>
<FaUserCircle className="mr-2 text-xl" />
<span className="truncate">Log in</span>
</Button>
</div>
</aside>
);
};
export default Sidebar;

View File

@ -1,33 +1,48 @@
"use client";
/* eslint-disable react/display-name */
import Panel from "@/components/ui/panel";
import React, { forwardRef, useCallback, useEffect, useRef } from "react";
import { useParams } from "next/navigation";
import React, { Fragment, useCallback, useEffect, useRef } from "react";
import { createStore } from "zustand";
import { useProjectContext } from "../context/project";
type Props = {};
type PreviewStore = {
refresh: () => void;
};
const WebPreview = forwardRef((props: Props, ref: any) => {
export const previewStore = createStore<PreviewStore>(() => ({
refresh: () => {},
}));
const WebPreview = () => {
const { slug } = useParams();
const frameRef = useRef<HTMLIFrameElement>(null);
const project = useProjectContext();
const refresh = useCallback(() => {
if (frameRef.current) {
frameRef.current.src = `/api/file/index.html?index=true`;
frameRef.current.src = `/project/${slug}/file/index.html?t=${Date.now()}`;
}
}, []);
}, [slug]);
useEffect(() => {
if (ref) {
ref.current = { refresh };
}
}, [ref, refresh]);
previewStore.setState({ refresh });
refresh();
}, [refresh]);
const PanelComponent = !project.isCompact ? Panel : Fragment;
return (
<Panel>
<PanelComponent>
<iframe
id="web-preview"
ref={frameRef}
className="border-none w-full h-full bg-white"
sandbox="allow-scripts"
/>
</Panel>
</PanelComponent>
);
});
};
export default WebPreview;

View File

@ -1,21 +1,21 @@
import type { FileSchema } from "@/server/db/schema/file";
import { createContext, useContext } from "react";
type TProjectViewContext = {
type TEditorContext = {
onOpenFile: (fileId: number) => void;
onFileChanged: (file: Omit<FileSchema, "content">) => void;
onDeleteFile: (fileId: number) => void;
};
const ProjectViewContext = createContext<TProjectViewContext | null>(null);
const EditorContext = createContext<TEditorContext | null>(null);
export const useProjectContext = () => {
const ctx = useContext(ProjectViewContext);
export const useEditorContext = () => {
const ctx = useContext(EditorContext);
if (!ctx) {
throw new Error("Component not in ProjectViewContext!");
throw new Error("Component not in EditorContext!");
}
return ctx;
};
export default ProjectViewContext;
export default EditorContext;

View File

@ -0,0 +1,18 @@
import { createContext, useContext } from "react";
type TProjectContext = {
isCompact?: boolean;
};
const ProjectContext = createContext<TProjectContext | null>(null);
export const useProjectContext = () => {
const ctx = useContext(ProjectContext);
if (!ctx) {
throw new Error("Component not in ProjectContext!");
}
return ctx;
};
export default ProjectContext;

View File

@ -0,0 +1,50 @@
import { getFileExt } from "@/lib/utils";
import db from "@/server/db";
import { file } from "@/server/db/schema/file";
import { and, eq, isNull } from "drizzle-orm";
import { NextRequest } from "next/server";
import { serveHtml } from "./serve-html";
import { Mime } from "mime/lite";
import standardTypes from "mime/types/standard.js";
import otherTypes from "mime/types/other.js";
import { serveJs } from "./serve-js";
const mime = new Mime(standardTypes, otherTypes);
mime.define({ "text/javascript": ["jsx", "tsx"] }, true);
// Opt out of caching for all data requests in the route segment
export const dynamic = "force-dynamic";
export const GET = async (req: NextRequest, { params }: any) => {
const path = params.path.join("/");
const { slug } = params;
const fileData = await db.query.file.findFirst({
where: and(eq(file.path, path), isNull(file.deletedAt)),
});
if (!fileData) {
return new Response("File not found!", { status: 404 });
}
const ext = getFileExt(fileData.filename);
let content = fileData.content || "";
if (["html", "htm"].includes(ext)) {
content = await serveHtml(fileData);
}
if (["js", "ts", "jsx", "tsx"].includes(ext)) {
content = await serveJs(fileData, slug);
}
return new Response(content, {
headers: {
"Content-Type":
mime.getType(fileData.filename) || "application/octet-stream",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
};

View File

@ -0,0 +1,51 @@
import db from "@/server/db";
import { FileSchema, file } from "@/server/db/schema/file";
import { and, eq, isNull } from "drizzle-orm";
export const serveHtml = async (fileData: FileSchema) => {
const layout = await db.query.file.findFirst({
where: and(
eq(file.filename, "_layout.html"),
fileData.parentId
? eq(file.parentId, fileData.parentId)
: isNull(file.parentId),
isNull(file.deletedAt)
),
});
let content = fileData.content || "";
if (!layout?.content) {
return content;
}
content = layout.content.replace("{CONTENT}", content);
const bodyOpeningTagIdx = content.indexOf("<body");
const firstScriptTagIdx = content.indexOf("<script", bodyOpeningTagIdx);
const injectScripts = ['<script src="/js/debug-console.js"></script>'];
const importMaps = [
{ name: "react", url: "https://esm.sh/react@18.2.0" },
{ name: "react-dom/client", url: "https://esm.sh/react-dom@18.2.0/client" },
];
if (importMaps.length > 0) {
const imports = importMaps.reduce((a: any, b) => {
a[b.name] = b.url;
return a;
}, {});
const json = JSON.stringify({ imports });
injectScripts.push(`<script type="importmap">${json}</script>`);
}
if (firstScriptTagIdx >= 0 && injectScripts.length > 0) {
content =
content.substring(0, firstScriptTagIdx) +
injectScripts.filter((i) => !!i).join("") +
content.substring(firstScriptTagIdx);
}
// content = content.replace(/ {2}|\r\n|\n|\r/gm, "");
return content;
};

View File

@ -0,0 +1,41 @@
import { BASE_URL } from "@/lib/consts";
import { FileSchema } from "@/server/db/schema/file";
export const serveJs = async (file: FileSchema, slug: string) => {
let content = file.content || "";
const importRegex = /(?:import.+from.+)(?:"|')(.+)(?:"|')/g;
content = content.replace(
importRegex,
(match: string, importPath: string) => {
// local file
if (importPath.startsWith("./")) {
return match.replace(
importPath,
BASE_URL + `/project/${slug}/file` + importPath.substring(1)
);
}
// resolve to esm
return match.replace(importPath, "https://esm.sh/" + importPath);
}
);
try {
const res = await fetch("http://localhost:3001/file/" + file.path);
if (!res.ok) {
throw new Error(res.statusText);
}
const data = await res.text();
if (typeof data !== "string") {
throw new Error("Invalid response!");
}
return data;
} catch (err) {
console.error((err as any).message);
}
return content;
};

View File

@ -1,155 +1,39 @@
"use client";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { useScreen } from "usehooks-ts";
import Panel from "@/components/ui/panel";
import { cn } from "@/lib/utils";
import Tabs, { Tab } from "@/components/ui/tabs";
import FilePreview from "./_components/file-preview";
import trpc from "@/lib/trpc";
import FileListing from "./_components/file-listing";
import ProjectViewContext from "./context";
import WebPreview from "./_components/web-preview";
import type { FileSchema } from "@/server/db/schema/file";
import { usePortrait } from "@/hooks/usePortrait";
import Editor from "./_components/editor";
import ProjectContext from "./context/project";
import { cn } from "@/lib/utils";
import { useSearchParams } from "next/navigation";
const HomePage = () => {
const webPreviewRef = useRef<any>(null);
const consoleLoggerRef = useRef<any>(null);
const ViewProjectPage = () => {
const [isMounted, setMounted] = useState(false);
const screen = useScreen();
const isPortrait = screen?.width < screen?.height;
const trpcUtils = trpc.useUtils();
const [curTabIdx, setCurTabIdx] = useState(0);
const [curOpenFiles, setOpenFiles] = useState<number[]>([]);
const openedFilesData = trpc.file.getAll.useQuery(
{ id: curOpenFiles },
{ enabled: curOpenFiles.length > 0 }
);
const [openedFiles, setOpenedFiles] = useState<any[]>([]);
const deleteFile = trpc.file.delete.useMutation({
onSuccess: (file) => {
trpcUtils.file.getAll.invalidate();
onFileChanged(file);
const openFileIdx = curOpenFiles.indexOf(file.id);
if (openFileIdx >= 0) {
onCloseFile(openFileIdx);
}
},
});
const isPortrait = usePortrait();
const searchParams = useSearchParams();
const isCompact =
searchParams.get("compact") === "1" || searchParams.get("embed") === "1";
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (openedFilesData.data) {
setOpenedFiles(openedFilesData.data);
}
}, [openedFilesData.data]);
const refreshPreview = useCallback(() => {
webPreviewRef.current?.refresh();
consoleLoggerRef.current?.clear();
}, []);
useEffect(() => {
if (isMounted) {
refreshPreview();
}
}, [isMounted, refreshPreview]);
const onOpenFile = useCallback(
(fileId: number) => {
const idx = curOpenFiles.indexOf(fileId);
if (idx >= 0) {
return setCurTabIdx(idx);
}
setOpenFiles((state) => {
setCurTabIdx(state.length);
return [...state, fileId];
});
},
[curOpenFiles]
);
const onDeleteFile = useCallback(
(fileId: number) => {
if (
window.confirm("Are you sure want to delete this files?") &&
!deleteFile.isPending
) {
deleteFile.mutate(fileId);
}
},
[deleteFile]
);
const onCloseFile = useCallback(
(idx: number) => {
const _f = [...curOpenFiles];
_f.splice(idx, 1);
setOpenFiles(_f);
if (curTabIdx === idx) {
setCurTabIdx(Math.max(0, idx - 1));
}
},
[curOpenFiles, curTabIdx]
);
const onFileChanged = useCallback(
(_file: Omit<FileSchema, "content">) => {
openedFilesData.refetch();
},
[openedFilesData]
);
const openFileList = useMemo(() => {
return curOpenFiles.map((fileId) => {
const fileData = openedFiles?.find((i) => i.id === fileId);
return {
title: fileData?.filename || "...",
render: () => (
<FilePreview id={fileId} onFileContentChange={refreshPreview} />
),
};
}) satisfies Tab[];
}, [curOpenFiles, openedFiles, refreshPreview]);
if (!isMounted) {
return null;
}
return (
<ProjectViewContext.Provider
value={{
onOpenFile,
onFileChanged,
onDeleteFile,
}}
>
<ProjectContext.Provider value={{ isCompact }}>
<ResizablePanelGroup
autoSaveId="main-panel"
direction={isPortrait ? "vertical" : "horizontal"}
className="w-full !h-dvh"
className={cn("w-full !h-dvh", !isCompact ? "md:p-4" : "")}
>
<ResizablePanel
defaultSize={isPortrait ? 50 : 60}
@ -157,62 +41,15 @@ const HomePage = () => {
collapsedSize={0}
minSize={isPortrait ? 10 : 30}
>
<div
className={cn(
"w-full h-full p-2 pb-0",
!isPortrait ? "p-4 pb-4 pr-0" : ""
)}
>
<Panel>
<ResizablePanelGroup
autoSaveId="veditor-panel"
direction="horizontal"
>
<ResizablePanel
defaultSize={isPortrait ? 0 : 25}
minSize={10}
collapsible
collapsedSize={0}
className="bg-[#1e2536]"
>
<FileListing />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={isPortrait ? 100 : 75}>
<ResizablePanelGroup direction="vertical">
<ResizablePanel defaultSize={100} minSize={20}>
<Tabs
tabs={openFileList}
current={curTabIdx}
onChange={setCurTabIdx}
onClose={onCloseFile}
/>
</ResizablePanel>
{/* {!isPortrait ? (
<>
<ResizableHandle />
<ResizablePanel
defaultSize={0}
collapsible
collapsedSize={0}
minSize={10}
>
<ConsoleLogger ref={consoleLoggerRef} />
</ResizablePanel>
</>
) : null} */}
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
</Panel>
</div>
<Editor />
</ResizablePanel>
<ResizableHandle
withHandle
className="bg-transparent hover:bg-slate-500 transition-colors md:mx-1 w-2 data-[panel-group-direction=vertical]:h-2 rounded-lg"
className={
!isCompact
? "bg-slate-800 md:bg-transparent hover:bg-slate-500 transition-colors md:mx-1 w-2 md:data-[panel-group-direction=vertical]:h-2 rounded-lg"
: "bg-slate-800"
}
/>
<ResizablePanel
defaultSize={isPortrait ? 50 : 40}
@ -220,18 +57,11 @@ const HomePage = () => {
collapsedSize={0}
minSize={10}
>
<div
className={cn(
"w-full h-full p-2 pt-0",
!isPortrait ? "p-4 pt-4 pl-0" : ""
)}
>
<WebPreview ref={webPreviewRef} />
</div>
<WebPreview />
</ResizablePanel>
</ResizablePanelGroup>
</ProjectViewContext.Provider>
</ProjectContext.Provider>
);
};
export default HomePage;
export default ViewProjectPage;

View File

@ -10,16 +10,7 @@ type Props = {
};
const Providers = ({ children }: Props) => {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
})
);
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
@ -28,6 +19,9 @@ const Providers = ({ children }: Props) => {
headers() {
return {};
},
fetch(input, init = {}) {
return fetch(input, { ...init, cache: "no-store" });
},
}),
],
})

View File

@ -16,7 +16,7 @@ const ActionButton = forwardRef(
variant="ghost"
size="sm"
className={cn(
"text-slate-400 hover:bg-transparent hover:dark:bg-transparent h-8 w-6 p-0",
"text-slate-400 hover:bg-transparent hover:dark:bg-transparent h-8 w-6 p-0 flex-shrink-0",
className
)}
onClick={(e) => {

View File

@ -7,6 +7,7 @@ import ReactCodeMirror, {
import { javascript } from "@codemirror/lang-javascript";
import { css } from "@codemirror/lang-css";
import { html } from "@codemirror/lang-html";
import { json } from "@codemirror/lang-json";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { tokyoNight } from "@uiw/codemirror-theme-tokyo-night";
import { vscodeKeymap } from "@replit/codemirror-vscode-keymap";
@ -17,17 +18,19 @@ import prettierBabelPlugin from "prettier/plugins/babel";
import * as prettierPluginEstree from "prettier/plugins/estree";
import { abbreviationTracker } from "@emmetio/codemirror6-plugin";
import { useDebounce } from "@/hooks/useDebounce";
import useCommandKey from "@/hooks/useCommandKey";
type Props = {
lang?: string;
value: string;
wordWrap?: boolean;
onChange: (val: string) => void;
formatOnSave?: boolean;
};
const CodeEditor = (props: Props) => {
const codeMirror = useRef<ReactCodeMirrorRef>(null);
const { lang, value, formatOnSave, onChange } = props;
const { lang, value, formatOnSave, wordWrap, onChange } = props;
const [data, setData] = useState(value);
const [debounceChange, resetDebounceChange] = useDebounce(onChange, 3000);
const langMetadata = useMemo(() => getLangMetadata(lang || "plain"), [lang]);
@ -69,35 +72,29 @@ const CodeEditor = (props: Props) => {
}
setTimeout(() => resetDebounceChange(), 100);
}, [data, setData, formatOnSave, langMetadata, resetDebounceChange]);
}, [
data,
langMetadata.formatter,
formatOnSave,
onChange,
resetDebounceChange,
]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
e.stopPropagation();
onSave();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [onSave]);
useCommandKey("s", onSave);
useEffect(() => {
setData(value);
}, [value]);
const extensions = [...langMetadata.extensions, keymap.of(vscodeKeymap)];
if (wordWrap) {
extensions.push(EditorView.lineWrapping);
}
return (
<ReactCodeMirror
ref={codeMirror}
extensions={[
EditorView.lineWrapping,
...langMetadata.extensions,
keymap.of(vscodeKeymap),
]}
extensions={extensions}
indentWithTab={false}
basicSetup={{ defaultKeymap: false }}
value={data}
@ -124,6 +121,10 @@ function getLangMetadata(lang: string) {
extensions = [css()];
formatter = ["css", prettierCssPlugin];
break;
case "json":
extensions = [json()];
formatter = ["json", prettierBabelPlugin, prettierPluginEstree];
break;
case "jsx":
case "js":
case "ts":

View File

@ -1,18 +1,20 @@
import { cn } from "@/lib/utils";
import React from "react";
type Props = {
children?: React.ReactNode;
className?: string;
};
const Panel = ({ children }: Props) => {
const Panel = ({ children, className }: Props) => {
return (
<div className="bg-slate-800 rounded-lg w-full h-full flex flex-col items-stretch shadow-lg overflow-hidden">
<div className="flex gap-2 py-3 px-4">
<div className="bg-slate-800 w-full h-full flex-col items-stretch md:rounded-lg md:overflow-hidden flex">
<div className="gap-2 py-3 px-4 hidden md:flex">
<div className="bg-red-500 rounded-full h-3 w-3" />
<div className="bg-yellow-500 rounded-full h-3 w-3" />
<div className="bg-green-500 rounded-full h-3 w-3" />
</div>
<div className="flex-1 overflow-hidden">{children}</div>
<div className={cn("flex-1 overflow-hidden", className)}>{children}</div>
</div>
);
};

View File

@ -29,7 +29,7 @@ const ResizableHandle = ({
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-slate-950 focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
"relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}

View File

@ -108,26 +108,28 @@ const TabItem = ({
const filename = title.substring(0, lastDotFile);
return (
<button
<div
data-idx={index}
className={cn(
"group border-b-2 border-transparent truncate flex-shrink-0 text-white text-center max-w-[140px] md:max-w-[180px] pl-4 pr-0 text-sm flex items-center gap-0 relative z-[1]",
isActive ? "border-slate-500" : ""
"group border-b-2 border-transparent truncate flex-shrink-0 text-white/70 transition-all hover:text-white text-center max-w-[140px] md:max-w-[180px] text-sm flex items-center gap-0 relative z-[1]",
isActive ? "border-slate-500 text-white" : ""
)}
onClick={onSelect}
>
<FileIcon
file={{ isDirectory: false, filename: title }}
className="mr-1"
/>
<span className="truncate">{filename}</span>
<span>{ext}</span>
<button className="pl-4 pr-0 truncate flex items-center self-stretch">
<FileIcon
file={{ isDirectory: false, filename: title }}
className="mr-1"
/>
<span className="truncate">{filename}</span>
<span>{ext}</span>
</button>
<ActionButton
icon={FiX}
className="opacity-0 group-hover:opacity-100 transition-colors"
onClick={onClose}
/>
</button>
</div>
);
};

View File

@ -0,0 +1,20 @@
import { useEffect } from "react";
const useCommandKey = (key: string, callback: () => void) => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === key) {
e.preventDefault();
e.stopPropagation();
callback();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [key, callback]);
};
export default useCommandKey;

8
src/hooks/usePortrait.ts Normal file
View File

@ -0,0 +1,8 @@
import { useScreen } from "usehooks-ts";
export const usePortrait = () => {
const screen = useScreen();
const isPortrait = screen?.width < screen?.height;
return isPortrait;
};

View File

@ -1 +1,2 @@
export const IS_DEV = process.env.NODE_ENV === "development";
export const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "";

View File

@ -1,5 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { BASE_URL } from "./consts";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@ -8,3 +9,7 @@ export function cn(...inputs: ClassValue[]) {
export function getFileExt(filename: string) {
return filename.substring(filename.lastIndexOf(".") + 1);
}
export function getUrl(...path: string[]) {
return BASE_URL + ("/" + path.join("/")).replace(/\/\/+/g, "/");
}

View File

@ -18,11 +18,14 @@ export const file = sqliteTable(
.notNull()
.references(() => user.id),
path: text("path").notNull().unique(),
filename: text("filename").notNull().unique(),
filename: text("filename").notNull(),
isDirectory: integer("is_directory", { mode: "boolean" })
.notNull()
.default(false),
isFile: integer("is_file", { mode: "boolean" }).notNull().default(false),
isPinned: integer("is_pinned", { mode: "boolean" })
.notNull()
.default(false),
content: text("content"),
createdAt: text("created_at")
.notNull()

View File

@ -12,26 +12,51 @@ const main = async () => {
})
.returning();
await db.insert(file).values([
{
userId: adminUser.id,
path: "index.html",
filename: "index.html",
content: "<p>Hello world!</p>",
},
{
userId: adminUser.id,
path: "styles.css",
filename: "styles.css",
content: "body { padding: 16px; }",
},
{
userId: adminUser.id,
path: "script.js",
filename: "script.js",
content: "console.log('hello world!');",
},
]);
await db
.insert(file)
.values([
{
userId: adminUser.id,
path: "index.html",
filename: "index.html",
content: "<p>Hello world!</p>",
},
{
userId: adminUser.id,
path: "styles.css",
filename: "styles.css",
content: "body { padding: 16px; }",
},
{
userId: adminUser.id,
path: "script.js",
filename: "script.js",
content: "console.log('hello world!');",
},
{
userId: adminUser.id,
path: "_layout.html",
filename: "_layout.html",
content: `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Code-Share</title>
<link rel="stylesheet" href="styles.css">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
{CONTENT}
<script type="text/babel" src="script.js" data-type="module"></script>
</body>
</html>`,
},
])
.execute();
process.exit();
};

View File

@ -1,4 +1,4 @@
import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import db from "../db";
import { procedure, router } from "../trpc";
import { file, insertFileSchema, selectFileSchema } from "../db/schema/file";
@ -9,7 +9,7 @@ const fileRouter = router({
getAll: procedure
.input(
z
.object({ id: z.number().array().min(1) })
.object({ id: z.number().array().min(1), isPinned: z.boolean() })
.partial()
.optional()
)
@ -17,9 +17,10 @@ const fileRouter = router({
const files = await db.query.file.findMany({
where: and(
isNull(file.deletedAt),
opt?.id && opt.id.length > 0 ? inArray(file.id, opt.id) : undefined
opt?.id && opt.id.length > 0 ? inArray(file.id, opt.id) : undefined,
opt?.isPinned ? eq(file.isPinned, true) : undefined
),
orderBy: [desc(file.isDirectory)],
orderBy: [desc(file.isDirectory), asc(file.filename)],
columns: { content: false },
});

View File

@ -0,0 +1,46 @@
import express from "express";
import db from "../db";
import { and, eq, isNull } from "drizzle-orm";
import { file } from "../db/schema/file";
import * as swc from "@swc/core";
const app = express();
const getFileByPath = async (path: string) => {
const fileData = await db.query.file.findFirst({
where: and(eq(file.path, path), isNull(file.deletedAt)),
});
return fileData;
};
app.get("/file/*", async (req, res) => {
const pathname = Object.values(req.params).join("/");
const fileData = await getFileByPath(pathname);
if (!fileData) {
return res.status(404).send("File not found!");
}
try {
let code = fileData.content || "";
const result = await swc.transform(code, {
jsc: {
parser: {
jsx: true,
syntax: "ecmascript",
},
target: "es5",
},
});
res.contentType("text/javascript").send(result.code);
} catch (err) {
console.log(err);
return res.status(400).send("Cannot transform file!");
}
});
app.listen(3001, () => {
console.log("App listening on http://localhost:3001");
});

View File

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist"
},
"include": [
"./src/server/transformer/server.ts"
]
}