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

View File

@ -21,7 +21,10 @@ const ConsoleLogger = forwardRef((_, ref: any) => {
return; return;
} }
if (data.args[0]?.includes("Babel transformer")) { if (
typeof data.args[0] === "string" &&
data.args[0]?.includes("Babel transformer")
) {
return; 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"; "use client";
import React, { useMemo, useState } from "react"; import React, { Fragment, useMemo, useState } from "react";
import { UseDiscloseReturn, useDisclose } from "@/hooks/useDisclose"; import { UseDiscloseReturn, useDisclose } from "@/hooks/useDisclose";
import { import {
FiChevronRight, FiChevronRight,
@ -8,11 +8,12 @@ import {
FiFolderPlus, FiFolderPlus,
FiMoreVertical, FiMoreVertical,
} from "react-icons/fi"; } from "react-icons/fi";
import { FaCheck, FaThumbtack } from "react-icons/fa";
import trpc from "@/lib/trpc"; import trpc from "@/lib/trpc";
import type { FileSchema } from "@/server/db/schema/file"; import type { FileSchema } from "@/server/db/schema/file";
import CreateFileDialog, { CreateFileSchema } from "./createfile-dialog"; import CreateFileDialog, { CreateFileSchema } from "./createfile-dialog";
import ActionButton from "../../../../components/ui/action-button"; import ActionButton from "../../../../components/ui/action-button";
import { useProjectContext } from "../context"; import { useEditorContext } from "../context/editor";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -20,18 +21,20 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuSeparator, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn, getUrl } from "@/lib/utils";
import FileIcon from "@/components/ui/file-icon"; import FileIcon from "@/components/ui/file-icon";
import copy from "copy-to-clipboard";
import { useParams } from "next/navigation";
const FileListing = () => { const FileListing = () => {
const { onOpenFile, onFileChanged } = useProjectContext(); const { onOpenFile, onFileChanged } = useEditorContext();
const createFileDlg = useDisclose<CreateFileSchema>(); const createFileDlg = useDisclose<CreateFileSchema>();
const files = trpc.file.getAll.useQuery(); const files = trpc.file.getAll.useQuery();
const fileList = useMemo(() => groupFiles(files.data, null), [files.data]); const fileList = useMemo(() => groupFiles(files.data, null), [files.data]);
return ( return (
<div className="flex flex-col items-stretch"> <Fragment>
<div className="h-10 flex items-center pl-4 pr-1"> <div className="h-10 flex items-center pl-4 pr-1">
<p className="text-xs uppercase truncate flex-1">My Project</p> <p className="text-xs uppercase truncate flex-1">My Project</p>
<ActionButton <ActionButton
@ -70,7 +73,7 @@ const FileListing = () => {
onSuccess={(file, type) => { onSuccess={(file, type) => {
files.refetch(); files.refetch();
if (type === "create") { if (type === "create" && !file.isDirectory && !file.isFile) {
onOpenFile && onOpenFile(file.id); onOpenFile && onOpenFile(file.id);
} }
if (onFileChanged) { if (onFileChanged) {
@ -78,7 +81,7 @@ const FileListing = () => {
} }
}} }}
/> />
</div> </Fragment>
); );
}; };
@ -90,13 +93,22 @@ type FileItemProps = {
}; };
const FileItem = ({ file, createFileDlg }: FileItemProps) => { const FileItem = ({ file, createFileDlg }: FileItemProps) => {
const { onOpenFile, onDeleteFile } = useProjectContext(); const { slug } = useParams();
const { onOpenFile, onDeleteFile } = useEditorContext();
const [isCollapsed, setCollapsed] = useState(false); const [isCollapsed, setCollapsed] = useState(false);
const trpcUtils = trpc.useUtils();
const updateFile = trpc.file.update.useMutation({
onSuccess() {
trpcUtils.file.getAll.invalidate();
},
});
return ( return (
<div className="w-full"> <div className="w-full">
<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 <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" className="flex items-center pl-5 pr-3 gap-1 w-full text-left"
onClick={() => { onClick={() => {
if (file.isDirectory) { if (file.isDirectory) {
setCollapsed((i) => !i); setCollapsed((i) => !i);
@ -117,6 +129,9 @@ const FileItem = ({ file, createFileDlg }: FileItemProps) => {
<FileIcon file={file} /> <FileIcon file={file} />
<span className="flex-1 truncate">{file.filename}</span> <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"> <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 ? ( {file.isDirectory ? (
<> <>
@ -152,10 +167,44 @@ const FileItem = ({ file, createFileDlg }: FileItemProps) => {
<DropdownMenuItem onClick={() => onDeleteFile(file.id)}> <DropdownMenuItem onClick={() => onDeleteFile(file.id)}>
Delete Delete
</DropdownMenuItem> </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> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
</button> </div>
{isCollapsed && file.children?.length > 0 ? ( {isCollapsed && file.children?.length > 0 ? (
<div className="flex flex-col items-stretch pl-4"> <div className="flex flex-col items-stretch pl-4">

View File

@ -10,8 +10,7 @@ type Props = {
onFileContentChange?: () => void; onFileContentChange?: () => void;
}; };
const FilePreview = ({ id, onFileContentChange }: Props) => { const FileViewer = ({ id, onFileContentChange }: Props) => {
const type = "text";
const { data, isLoading, refetch } = trpc.file.getById.useQuery(id); const { data, isLoading, refetch } = trpc.file.getById.useQuery(id);
const updateFileContent = trpc.file.update.useMutation({ const updateFileContent = trpc.file.update.useMutation({
onSuccess: () => { onSuccess: () => {
@ -23,13 +22,13 @@ const FilePreview = ({ id, onFileContentChange }: Props) => {
if (isLoading) { if (isLoading) {
return <p>Loading...</p>; return <p>Loading...</p>;
} }
if (!data) { if (!data || data.isDirectory) {
return <p>File not found.</p>; return <p>File not found.</p>;
} }
const { filename } = data; const { filename } = data;
if (type === "text") { if (!data.isFile) {
const ext = getFileExt(filename); const ext = getFileExt(filename);
return ( return (
@ -45,4 +44,4 @@ const FilePreview = ({ id, onFileContentChange }: Props) => {
return null; 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 */ /* eslint-disable react/display-name */
import Panel from "@/components/ui/panel"; 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 frameRef = useRef<HTMLIFrameElement>(null);
const project = useProjectContext();
const refresh = useCallback(() => { const refresh = useCallback(() => {
if (frameRef.current) { 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(() => { useEffect(() => {
if (ref) { previewStore.setState({ refresh });
ref.current = { refresh }; refresh();
} }, [refresh]);
}, [ref, refresh]);
const PanelComponent = !project.isCompact ? Panel : Fragment;
return ( return (
<Panel> <PanelComponent>
<iframe <iframe
id="web-preview"
ref={frameRef} ref={frameRef}
className="border-none w-full h-full bg-white" className="border-none w-full h-full bg-white"
sandbox="allow-scripts" sandbox="allow-scripts"
/> />
</Panel> </PanelComponent>
); );
}); };
export default WebPreview; export default WebPreview;

View File

@ -1,21 +1,21 @@
import type { FileSchema } from "@/server/db/schema/file"; import type { FileSchema } from "@/server/db/schema/file";
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
type TProjectViewContext = { type TEditorContext = {
onOpenFile: (fileId: number) => void; onOpenFile: (fileId: number) => void;
onFileChanged: (file: Omit<FileSchema, "content">) => void; onFileChanged: (file: Omit<FileSchema, "content">) => void;
onDeleteFile: (fileId: number) => void; onDeleteFile: (fileId: number) => void;
}; };
const ProjectViewContext = createContext<TProjectViewContext | null>(null); const EditorContext = createContext<TEditorContext | null>(null);
export const useProjectContext = () => { export const useEditorContext = () => {
const ctx = useContext(ProjectViewContext); const ctx = useContext(EditorContext);
if (!ctx) { if (!ctx) {
throw new Error("Component not in ProjectViewContext!"); throw new Error("Component not in EditorContext!");
} }
return ctx; 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"; "use client";
import React, { import React, { useCallback, useEffect, useRef, useState } from "react";
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
ResizablePanelGroup, ResizablePanelGroup,
} from "@/components/ui/resizable"; } 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 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 ViewProjectPage = () => {
const webPreviewRef = useRef<any>(null);
const consoleLoggerRef = useRef<any>(null);
const [isMounted, setMounted] = useState(false); const [isMounted, setMounted] = useState(false);
const screen = useScreen(); const isPortrait = usePortrait();
const isPortrait = screen?.width < screen?.height; const searchParams = useSearchParams();
const trpcUtils = trpc.useUtils(); const isCompact =
searchParams.get("compact") === "1" || searchParams.get("embed") === "1";
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);
}
},
});
useEffect(() => { useEffect(() => {
setMounted(true); 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) { if (!isMounted) {
return null; return null;
} }
return ( return (
<ProjectViewContext.Provider <ProjectContext.Provider value={{ isCompact }}>
value={{
onOpenFile,
onFileChanged,
onDeleteFile,
}}
>
<ResizablePanelGroup <ResizablePanelGroup
autoSaveId="main-panel" autoSaveId="main-panel"
direction={isPortrait ? "vertical" : "horizontal"} direction={isPortrait ? "vertical" : "horizontal"}
className="w-full !h-dvh" className={cn("w-full !h-dvh", !isCompact ? "md:p-4" : "")}
> >
<ResizablePanel <ResizablePanel
defaultSize={isPortrait ? 50 : 60} defaultSize={isPortrait ? 50 : 60}
@ -157,62 +41,15 @@ const HomePage = () => {
collapsedSize={0} collapsedSize={0}
minSize={isPortrait ? 10 : 30} minSize={isPortrait ? 10 : 30}
> >
<div <Editor />
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>
</ResizablePanel> </ResizablePanel>
<ResizableHandle <ResizableHandle
withHandle 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 <ResizablePanel
defaultSize={isPortrait ? 50 : 40} defaultSize={isPortrait ? 50 : 40}
@ -220,18 +57,11 @@ const HomePage = () => {
collapsedSize={0} collapsedSize={0}
minSize={10} minSize={10}
> >
<div <WebPreview />
className={cn(
"w-full h-full p-2 pt-0",
!isPortrait ? "p-4 pt-4 pl-0" : ""
)}
>
<WebPreview ref={webPreviewRef} />
</div>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
</ProjectViewContext.Provider> </ProjectContext.Provider>
); );
}; };
export default HomePage; export default ViewProjectPage;

View File

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

View File

@ -16,7 +16,7 @@ const ActionButton = forwardRef(
variant="ghost" variant="ghost"
size="sm" size="sm"
className={cn( 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 className
)} )}
onClick={(e) => { onClick={(e) => {

View File

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

View File

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

View File

@ -29,7 +29,7 @@ const ResizableHandle = ({
}) => ( }) => (
<ResizablePrimitive.PanelResizeHandle <ResizablePrimitive.PanelResizeHandle
className={cn( 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 className
)} )}
{...props} {...props}

View File

@ -108,26 +108,28 @@ const TabItem = ({
const filename = title.substring(0, lastDotFile); const filename = title.substring(0, lastDotFile);
return ( return (
<button <div
data-idx={index} data-idx={index}
className={cn( 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]", "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" : "" isActive ? "border-slate-500 text-white" : ""
)} )}
onClick={onSelect} onClick={onSelect}
> >
<button className="pl-4 pr-0 truncate flex items-center self-stretch">
<FileIcon <FileIcon
file={{ isDirectory: false, filename: title }} file={{ isDirectory: false, filename: title }}
className="mr-1" className="mr-1"
/> />
<span className="truncate">{filename}</span> <span className="truncate">{filename}</span>
<span>{ext}</span> <span>{ext}</span>
</button>
<ActionButton <ActionButton
icon={FiX} icon={FiX}
className="opacity-0 group-hover:opacity-100 transition-colors" className="opacity-0 group-hover:opacity-100 transition-colors"
onClick={onClose} 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 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 { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { BASE_URL } from "./consts";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
@ -8,3 +9,7 @@ export function cn(...inputs: ClassValue[]) {
export function getFileExt(filename: string) { export function getFileExt(filename: string) {
return filename.substring(filename.lastIndexOf(".") + 1); 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() .notNull()
.references(() => user.id), .references(() => user.id),
path: text("path").notNull().unique(), path: text("path").notNull().unique(),
filename: text("filename").notNull().unique(), filename: text("filename").notNull(),
isDirectory: integer("is_directory", { mode: "boolean" }) isDirectory: integer("is_directory", { mode: "boolean" })
.notNull() .notNull()
.default(false), .default(false),
isFile: integer("is_file", { 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"), content: text("content"),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()

View File

@ -12,7 +12,9 @@ const main = async () => {
}) })
.returning(); .returning();
await db.insert(file).values([ await db
.insert(file)
.values([
{ {
userId: adminUser.id, userId: adminUser.id,
path: "index.html", path: "index.html",
@ -31,7 +33,30 @@ const main = async () => {
filename: "script.js", filename: "script.js",
content: "console.log('hello world!');", 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(); 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 db from "../db";
import { procedure, router } from "../trpc"; import { procedure, router } from "../trpc";
import { file, insertFileSchema, selectFileSchema } from "../db/schema/file"; import { file, insertFileSchema, selectFileSchema } from "../db/schema/file";
@ -9,7 +9,7 @@ const fileRouter = router({
getAll: procedure getAll: procedure
.input( .input(
z z
.object({ id: z.number().array().min(1) }) .object({ id: z.number().array().min(1), isPinned: z.boolean() })
.partial() .partial()
.optional() .optional()
) )
@ -17,9 +17,10 @@ const fileRouter = router({
const files = await db.query.file.findMany({ const files = await db.query.file.findMany({
where: and( where: and(
isNull(file.deletedAt), 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 }, 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"
]
}