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",
|
"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
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 {
|
.cm-theme {
|
||||||
font-size: 16px;
|
@apply h-full md:text-[16px];
|
||||||
@apply h-full;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cm-editor {
|
.cm-editor {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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";
|
"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">
|
||||||
|
@ -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;
|
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 */
|
/* 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;
|
||||||
|
@ -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;
|
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";
|
"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;
|
||||||
|
@ -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" });
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
@ -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) => {
|
||||||
|
@ -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":
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 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 { 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, "/");
|
||||||
|
}
|
||||||
|
@ -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()
|
||||||
|
@ -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();
|
||||||
};
|
};
|
||||||
|
@ -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 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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