mirror of
https://github.com/khairul169/code-share.git
synced 2025-04-28 16:49:36 +07:00
feat: using swc as transpiler
This commit is contained in:
parent
37df5c40cf
commit
4d1c56d6d8
1
.env.example
Normal file
1
.env.example
Normal file
@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
18
package.json
18
package.json
@ -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
1150
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
18
public/js/debug-console.js
Normal file
18
public/js/debug-console.js
Normal 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);
|
||||
};
|
||||
}
|
@ -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 };
|
@ -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 */
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
235
src/app/project/[slug]/_components/editor.tsx
Normal file
235
src/app/project/[slug]/_components/editor.tsx
Normal 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;
|
@ -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">
|
||||
|
@ -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;
|
23
src/app/project/[slug]/_components/sidebar.tsx
Normal file
23
src/app/project/[slug]/_components/sidebar.tsx
Normal 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;
|
@ -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;
|
||||
|
@ -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;
|
18
src/app/project/[slug]/context/project.tsx
Normal file
18
src/app/project/[slug]/context/project.tsx
Normal 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;
|
50
src/app/project/[slug]/file/[...path]/route.ts
Normal file
50
src/app/project/[slug]/file/[...path]/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
};
|
51
src/app/project/[slug]/file/[...path]/serve-html.ts
Normal file
51
src/app/project/[slug]/file/[...path]/serve-html.ts
Normal 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;
|
||||
};
|
41
src/app/project/[slug]/file/[...path]/serve-js.ts
Normal file
41
src/app/project/[slug]/file/[...path]/serve-js.ts
Normal 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;
|
||||
};
|
@ -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;
|
||||
|
@ -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" });
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
@ -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) => {
|
||||
|
@ -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":
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
20
src/hooks/useCommandKey.ts
Normal file
20
src/hooks/useCommandKey.ts
Normal 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
8
src/hooks/usePortrait.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { useScreen } from "usehooks-ts";
|
||||
|
||||
export const usePortrait = () => {
|
||||
const screen = useScreen();
|
||||
const isPortrait = screen?.width < screen?.height;
|
||||
|
||||
return isPortrait;
|
||||
};
|
@ -1 +1,2 @@
|
||||
export const IS_DEV = process.env.NODE_ENV === "development";
|
||||
export const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "";
|
||||
|
@ -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, "/");
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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();
|
||||
};
|
||||
|
@ -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 },
|
||||
});
|
||||
|
||||
|
46
src/server/transformer/server.ts
Normal file
46
src/server/transformer/server.ts
Normal 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");
|
||||
});
|
9
tsconfig-transformer.json
Normal file
9
tsconfig-transformer.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [
|
||||
"./src/server/transformer/server.ts"
|
||||
]
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user