feat: file listing functionality

This commit is contained in:
Khairul Hidayat 2024-02-20 18:03:43 +07:00
parent eb2fd4404b
commit 37df5c40cf
16 changed files with 1303 additions and 302 deletions

View File

@ -21,6 +21,7 @@
"@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",
"@tanstack/react-query": "^5.21.7",

250
pnpm-lock.yaml generated
View File

@ -23,6 +23,9 @@ dependencies:
'@radix-ui/react-dialog':
specifier: ^1.0.5
version: 1.0.5(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-dropdown-menu':
specifier: ^2.0.6
version: 2.0.6(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-slot':
specifier: ^1.0.2
version: 1.0.2(@types/react@18.2.56)(react@18.2.0)
@ -842,6 +845,34 @@ packages:
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dev: true
/@floating-ui/core@1.6.0:
resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==}
dependencies:
'@floating-ui/utils': 0.2.1
dev: false
/@floating-ui/dom@1.6.3:
resolution: {integrity: sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==}
dependencies:
'@floating-ui/core': 1.6.0
'@floating-ui/utils': 0.2.1
dev: false
/@floating-ui/react-dom@2.0.8(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
dependencies:
'@floating-ui/dom': 1.6.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@floating-ui/utils@0.2.1:
resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==}
dev: false
/@hookform/resolvers@3.3.4(react-hook-form@7.50.1):
resolution: {integrity: sha512-o5cgpGOuJYrd+iMKvkttOclgwRW86EsWJZZRC23prf0uU2i48Htq4PuT73AVb9ionFyZrwYEITuOFGF+BydEtQ==}
peerDependencies:
@ -1095,6 +1126,51 @@ packages:
'@babel/runtime': 7.23.9
dev: false
/@radix-ui/react-arrow@1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.23.9
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@types/react': 18.2.56
'@types/react-dom': 18.2.19
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-collection@1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.23.9
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-slot': 1.0.2(@types/react@18.2.56)(react@18.2.0)
'@types/react': 18.2.56
'@types/react-dom': 18.2.19
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-compose-refs@1.0.1(@types/react@18.2.56)(react@18.2.0):
resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==}
peerDependencies:
@ -1157,6 +1233,20 @@ packages:
react-remove-scroll: 2.5.5(@types/react@18.2.56)(react@18.2.0)
dev: false
/@radix-ui/react-direction@1.0.1(@types/react@18.2.56)(react@18.2.0):
resolution: {integrity: sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.23.9
'@types/react': 18.2.56
react: 18.2.0
dev: false
/@radix-ui/react-dismissable-layer@1.0.5(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==}
peerDependencies:
@ -1182,6 +1272,33 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-dropdown-menu@2.0.6(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.23.9
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-id': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-menu': 2.0.6(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@types/react': 18.2.56
'@types/react-dom': 18.2.19
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-focus-guards@1.0.1(@types/react@18.2.56)(react@18.2.0):
resolution: {integrity: sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==}
peerDependencies:
@ -1234,6 +1351,74 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-menu@2.0.6(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.23.9
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-direction': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-focus-guards': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-id': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-popper': 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-slot': 1.0.2(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@types/react': 18.2.56
'@types/react-dom': 18.2.19
aria-hidden: 1.2.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-remove-scroll: 2.5.5(@types/react@18.2.56)(react@18.2.0)
dev: false
/@radix-ui/react-popper@1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.23.9
'@floating-ui/react-dom': 2.0.8(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-arrow': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-use-rect': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-use-size': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/rect': 1.0.1
'@types/react': 18.2.56
'@types/react-dom': 18.2.19
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-portal@1.0.4(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==}
peerDependencies:
@ -1298,6 +1483,35 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.23.9
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-direction': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-id': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@types/react': 18.2.56
'@types/react-dom': 18.2.19
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-slot@1.0.2(@types/react@18.2.56)(react@18.2.0):
resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==}
peerDependencies:
@ -1371,6 +1585,42 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-use-rect@1.0.1(@types/react@18.2.56)(react@18.2.0):
resolution: {integrity: sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.23.9
'@radix-ui/rect': 1.0.1
'@types/react': 18.2.56
react: 18.2.0
dev: false
/@radix-ui/react-use-size@1.0.1(@types/react@18.2.56)(react@18.2.0):
resolution: {integrity: sha512-ibay+VqrgcaI6veAojjofPATwledXiSmX+C0KrBk/xgpX9rBzPV3OsfwlhQdUOFbh+LKQorLYT+xTXW9V8yd0g==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.23.9
'@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@types/react': 18.2.56
react: 18.2.0
dev: false
/@radix-ui/rect@1.0.1:
resolution: {integrity: sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==}
dependencies:
'@babel/runtime': 7.23.9
dev: false
/@replit/codemirror-vscode-keymap@6.0.2(@codemirror/autocomplete@6.12.0)(@codemirror/commands@6.3.3)(@codemirror/language@6.10.1)(@codemirror/lint@6.5.0)(@codemirror/search@6.5.6)(@codemirror/state@6.4.0)(@codemirror/view@6.24.0):
resolution: {integrity: sha512-j45qTwGxzpsv82lMD/NreGDORFKSctMDVkGRopaP+OrzSzv+pXDQuU3LnFvKpasyjVT0lf+PKG1v2DSCn/vxxg==}
peerDependencies:

View File

@ -1,261 +0,0 @@
"use client";
import React, {
useCallback,
useEffect,
useMemo,
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/containers/file-preview";
import trpc from "@/lib/trpc";
import CreateFileDialog from "@/components/containers/createfile-dialog";
import { useDisclose } from "@/hooks/useDisclose";
import { Button } from "@/components/ui/button";
import { FaFileAlt } from "react-icons/fa";
const HomePage = () => {
const frameRef = useRef<HTMLIFrameElement>(null);
const [isMounted, setMounted] = useState(false);
const screen = useScreen();
const isPortrait = screen?.width < screen?.height;
const files = trpc.file.getAll.useQuery();
const [curTabIdx, setCurTabIdx] = useState(0);
const [curOpenFiles, setOpenFiles] = useState<number[]>([]);
const createFileDlg = useDisclose();
const [iframeLogs, setIframeLogs] = useState<any[]>([]);
const refreshPreview = useCallback(() => {
if (frameRef.current) {
frameRef.current.src = `/api/file/index.html?index=true`;
}
setIframeLogs([]);
}, []);
useEffect(() => {
setMounted(true);
const onMessage = (event: MessageEvent<any>) => {
const { data } = event;
if (!data) {
return;
}
if (!["log", "warn", "error"].includes(data.type) || !data.args?.length) {
return;
}
if (data.args[0]?.includes("Babel transformer")) {
return;
}
setIframeLogs((i) => [data, ...i]);
};
window.addEventListener("message", onMessage);
return () => {
window.removeEventListener("message", onMessage);
};
}, [setIframeLogs]);
useEffect(() => {
if (isMounted) {
refreshPreview();
}
}, [isMounted]);
const onOpenFile = (fileId: number) => {
const idx = curOpenFiles.indexOf(fileId);
if (idx >= 0) {
return setCurTabIdx(idx);
}
setOpenFiles((state) => {
setCurTabIdx(state.length);
return [...state, fileId];
});
};
const onFileChange = (_fileId: number) => {
refreshPreview();
};
useEffect(() => {
if (files.data && files.data?.length > 0 && !curOpenFiles.length) {
files.data.forEach((file) => onOpenFile(file.id));
}
}, [files.data]);
const openFileList = useMemo(() => {
return curOpenFiles.map((fileId) => {
const fileData = files.data?.find((i) => i.id === fileId);
return {
title: fileData?.filename || "...",
render: () => (
<FilePreview id={fileId} onFileChange={() => onFileChange(fileId)} />
),
};
}) satisfies Tab[];
}, [curOpenFiles, files]);
if (!isMounted) {
return null;
}
return (
<ResizablePanelGroup
autoSaveId="main-panel"
direction={isPortrait ? "vertical" : "horizontal"}
className="w-full !h-dvh"
>
<ResizablePanel
defaultSize={isPortrait ? 50 : 60}
collapsible
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]"
>
<div className="h-10 flex items-center pl-3">
<p className="text-xs uppercase truncate flex-1">
My Project
</p>
<Button
variant="ghost"
size="sm"
className="text-slate-400 text-xs"
onClick={() => createFileDlg.onOpen()}
>
<FaFileAlt />
</Button>
</div>
<div className="flex flex-col items-stretch">
{files.data?.map((file) => (
<button
key={file.id}
className="text-slate-400 hover:text-white transition-colors text-sm flex items-center px-3 py-1.5"
onClick={() => onOpenFile(file.id)}
>
{file.filename}
</button>
))}
</div>
<CreateFileDialog
disclose={createFileDlg}
onSuccess={(file, type) => {
files.refetch();
if (type === "create") {
onOpenFile(file.id);
}
}}
/>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={isPortrait ? 100 : 75}>
<ResizablePanelGroup direction="vertical">
<ResizablePanel defaultSize={100} minSize={20}>
<Tabs
tabs={openFileList}
current={curTabIdx}
onChange={setCurTabIdx}
/>
</ResizablePanel>
{!isPortrait ? (
<>
<ResizableHandle />
<ResizablePanel
defaultSize={0}
collapsible
collapsedSize={0}
minSize={10}
>
<div className="pt-2 h-full border-t border-slate-700">
<div className="flex flex-col-reverse overflow-y-auto items-stretch h-full font-mono text-slate-400">
{iframeLogs.map((item, idx) => (
<p
key={idx}
className="text-xs border-b border-slate-900 first:border-b-0 px-2 py-1"
>
{item.args
?.map((arg: any) => {
if (typeof arg === "object") {
return JSON.stringify(arg, null, 2);
}
return arg;
})
.join(" ")}
</p>
))}
</div>
</div>
</ResizablePanel>
</>
) : null}
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
</Panel>
</div>
</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"
/>
<ResizablePanel
defaultSize={isPortrait ? 50 : 40}
collapsible
collapsedSize={0}
minSize={10}
>
<div
className={cn(
"w-full h-full p-2 pt-0",
!isPortrait ? "p-4 pt-4 pl-0" : ""
)}
>
<Panel>
<iframe
ref={frameRef}
className="border-none w-full h-full bg-white"
sandbox="allow-scripts"
/>
</Panel>
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
};
export default HomePage;

View File

@ -0,0 +1,60 @@
/* eslint-disable react/display-name */
import React, { forwardRef, useEffect, useState } from "react";
const ConsoleLogger = forwardRef((_, ref: any) => {
const [iframeLogs, setIframeLogs] = useState<any[]>([]);
useEffect(() => {
if (ref) {
ref.current = {
clear: () => setIframeLogs([]),
};
}
const onMessage = (event: MessageEvent<any>) => {
const { data } = event;
if (!data) {
return;
}
if (!["log", "warn", "error"].includes(data.type) || !data.args?.length) {
return;
}
if (data.args[0]?.includes("Babel transformer")) {
return;
}
setIframeLogs((i) => [data, ...i]);
};
window.addEventListener("message", onMessage);
return () => {
window.removeEventListener("message", onMessage);
};
}, [ref]);
return (
<div className="pt-2 h-full border-t border-slate-700">
<div className="flex flex-col-reverse overflow-y-auto items-stretch h-full font-mono text-slate-400">
{iframeLogs.map((item, idx) => (
<p
key={idx}
className="text-xs border-b border-slate-900 first:border-b-0 px-2 py-1"
>
{item.args
?.map((arg: any) => {
if (typeof arg === "object") {
return JSON.stringify(arg, null, 2);
}
return arg;
})
.join(" ")}
</p>
))}
</div>
</div>
);
});
export default ConsoleLogger;

View File

@ -1,31 +1,42 @@
import React, { useMemo } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "../../../../components/ui/dialog";
import { UseDiscloseReturn } from "@/hooks/useDisclose";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
import { Input } from "../../../../components/ui/input";
import { Button } from "../../../../components/ui/button";
import { useForm } from "@/hooks/useForm";
import { z } from "zod";
import FormErrorMessage from "../ui/form-error-message";
import FormErrorMessage from "../../../../components/ui/form-error-message";
import trpc from "@/lib/trpc";
import type { FileSchema } from "@/server/db/schema/file";
import { useWatch } from "react-hook-form";
type Props = {
disclose: UseDiscloseReturn;
disclose: UseDiscloseReturn<CreateFileSchema>;
onSuccess?: (file: FileSchema, type: "create" | "update") => void;
};
const fileSchema = z.object({
id: z.number().optional(),
parentId: z.number().optional(),
parentId: z.number().optional().nullable(),
filename: z.string().min(1),
isDirectory: z.boolean().optional(),
});
export type CreateFileSchema = z.infer<typeof fileSchema>;
const defaultValues: z.infer<typeof fileSchema> = {
filename: "",
};
const CreateFileDialog = ({ disclose, onSuccess }: Props) => {
const form = useForm(fileSchema, disclose.data || defaultValues);
const isDir = useWatch({ name: "isDirectory", control: form.control });
const create = trpc.file.create.useMutation({
onSuccess(data) {
if (onSuccess) onSuccess(data, "create");
@ -55,20 +66,25 @@ const CreateFileDialog = ({ disclose, onSuccess }: Props) => {
<Dialog open={disclose.isOpen} onOpenChange={disclose.onChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create File</DialogTitle>
<DialogTitle>
{[
!disclose.data?.id ? "Create" : "Rename",
isDir ? "Directory" : "File",
].join(" ")}
</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit}>
<FormErrorMessage form={form} />
<Input
placeholder="Filename"
placeholder={isDir ? "Directory Name" : "Filename"}
autoFocus
{...form.register("filename")}
/>
<div className="flex justify-end gap-4 mt-4">
<Button size="sm" variant="ghost">
<Button size="sm" variant="ghost" onClick={disclose.onClose}>
Cancel
</Button>
<Button type="submit" size="sm">

View File

@ -0,0 +1,190 @@
"use client";
import React, { useMemo, useState } from "react";
import { UseDiscloseReturn, useDisclose } from "@/hooks/useDisclose";
import {
FiChevronRight,
FiFilePlus,
FiFolderPlus,
FiMoreVertical,
} from "react-icons/fi";
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import FileIcon from "@/components/ui/file-icon";
const FileListing = () => {
const { onOpenFile, onFileChanged } = useProjectContext();
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">
<div className="h-10 flex items-center pl-4 pr-1">
<p className="text-xs uppercase truncate flex-1">My Project</p>
<ActionButton
icon={FiFilePlus}
onClick={() => createFileDlg.onOpen()}
/>
<ActionButton
icon={FiFolderPlus}
onClick={() =>
createFileDlg.onOpen({ isDirectory: true, filename: "" })
}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ActionButton icon={FiMoreVertical} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem>Upload File</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Project Settings</DropdownMenuItem>
<DropdownMenuItem>Download Project</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex flex-col items-stretch flex-1 overflow-y-auto">
{fileList.map((file) => (
<FileItem key={file.id} file={file} createFileDlg={createFileDlg} />
))}
</div>
<CreateFileDialog
disclose={createFileDlg}
onSuccess={(file, type) => {
files.refetch();
if (type === "create") {
onOpenFile && onOpenFile(file.id);
}
if (onFileChanged) {
onFileChanged(file);
}
}}
/>
</div>
);
};
type TFile = Omit<FileSchema, "content"> & { children: TFile[] };
type FileItemProps = {
file: TFile;
createFileDlg: UseDiscloseReturn<CreateFileSchema>;
};
const FileItem = ({ file, createFileDlg }: FileItemProps) => {
const { onOpenFile, onDeleteFile } = useProjectContext();
const [isCollapsed, setCollapsed] = useState(false);
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}
<FileIcon file={file} />
<span className="flex-1 truncate">{file.filename}</span>
<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 ? (
<>
<ActionButton
icon={FiFilePlus}
onClick={() =>
createFileDlg.onOpen({ parentId: file.id, filename: "" })
}
/>
<ActionButton
icon={FiFolderPlus}
onClick={() =>
createFileDlg.onOpen({
parentId: file.id,
isDirectory: true,
filename: "",
})
}
/>
</>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ActionButton icon={FiMoreVertical} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => createFileDlg.onOpen(file)}>
Rename
</DropdownMenuItem>
{/* <DropdownMenuItem>Duplicate</DropdownMenuItem> */}
<DropdownMenuItem onClick={() => onDeleteFile(file.id)}>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</button>
{isCollapsed && file.children?.length > 0 ? (
<div className="flex flex-col items-stretch pl-4">
{file.children.map((file) => (
<FileItem key={file.id} file={file} createFileDlg={createFileDlg} />
))}
</div>
) : null}
</div>
);
};
function groupFiles(files?: any[] | null, parentId?: number | null) {
if (!files) {
return [];
}
const groupedFiles: TFile[] = [];
files.forEach((file) => {
if (file.parentId !== parentId) {
return;
}
groupedFiles.push(file);
if (file.isDirectory) {
file.children = groupFiles(files, file.id);
}
});
return groupedFiles;
}
export default FileListing;

View File

@ -2,20 +2,20 @@
import { getFileExt } from "@/lib/utils";
import React from "react";
import CodeEditor from "../ui/code-editor";
import CodeEditor from "../../../../components/ui/code-editor";
import trpc from "@/lib/trpc";
type Props = {
id: number;
onFileChange?: () => void;
onFileContentChange?: () => void;
};
const FilePreview = ({ id, onFileChange }: Props) => {
const FilePreview = ({ id, onFileContentChange }: Props) => {
const type = "text";
const { data, isLoading, refetch } = trpc.file.getById.useQuery(id);
const updateFileContent = trpc.file.update.useMutation({
onSuccess: () => {
if (onFileChange) onFileChange();
if (onFileContentChange) onFileContentChange();
refetch();
},
});

View File

@ -0,0 +1,33 @@
/* eslint-disable react/display-name */
import Panel from "@/components/ui/panel";
import React, { forwardRef, useCallback, useEffect, useRef } from "react";
type Props = {};
const WebPreview = forwardRef((props: Props, ref: any) => {
const frameRef = useRef<HTMLIFrameElement>(null);
const refresh = useCallback(() => {
if (frameRef.current) {
frameRef.current.src = `/api/file/index.html?index=true`;
}
}, []);
useEffect(() => {
if (ref) {
ref.current = { refresh };
}
}, [ref, refresh]);
return (
<Panel>
<iframe
ref={frameRef}
className="border-none w-full h-full bg-white"
sandbox="allow-scripts"
/>
</Panel>
);
});
export default WebPreview;

View File

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

View File

@ -0,0 +1,237 @@
"use client";
import React, {
useCallback,
useEffect,
useMemo,
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";
const HomePage = () => {
const webPreviewRef = useRef<any>(null);
const consoleLoggerRef = useRef<any>(null);
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);
}
},
});
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,
}}
>
<ResizablePanelGroup
autoSaveId="main-panel"
direction={isPortrait ? "vertical" : "horizontal"}
className="w-full !h-dvh"
>
<ResizablePanel
defaultSize={isPortrait ? 50 : 60}
collapsible
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>
</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"
/>
<ResizablePanel
defaultSize={isPortrait ? 50 : 40}
collapsible
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>
</ResizablePanel>
</ResizablePanelGroup>
</ProjectViewContext.Provider>
);
};
export default HomePage;

View File

@ -0,0 +1,37 @@
/* eslint-disable react/display-name */
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import React, { forwardRef } from "react";
import { IconType } from "react-icons/lib";
type Props = React.ComponentProps<typeof Button> & {
icon: IconType;
};
const ActionButton = forwardRef(
({ icon: Icon, className, onClick, ...props }: Props, ref: any) => {
return (
<Button
ref={ref}
variant="ghost"
size="sm"
className={cn(
"text-slate-400 hover:bg-transparent hover:dark:bg-transparent h-8 w-6 p-0",
className
)}
onClick={(e) => {
if (onClick) {
e.preventDefault();
e.stopPropagation();
onClick(e);
}
}}
{...props}
>
<Icon />
</Button>
);
}
);
export default ActionButton;

View File

@ -0,0 +1,206 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-slate-100 data-[state=open]:bg-slate-100 dark:focus:bg-slate-800 dark:data-[state=open]:bg-slate-800",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-200 bg-white p-1 text-slate-950 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-200 bg-white p-1 text-slate-950 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, onClick, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-800 dark:focus:text-slate-50",
inset && "pl-8",
className
)}
onClick={(e) => {
if (onClick) {
e.stopPropagation();
onClick(e);
}
}}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-800 dark:focus:text-slate-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-800 dark:focus:text-slate-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-slate-100 dark:bg-slate-800", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@ -0,0 +1,22 @@
import { FileSchema } from "@/server/db/schema/file";
import { ComponentProps } from "react";
import { FiFile, FiFolder } from "react-icons/fi";
type FileIconProps = {
file: Pick<FileSchema, "filename" | "isDirectory">;
className?: string;
};
const FileIcon = ({ file, className }: FileIconProps) => {
const props: ComponentProps<"svg"> = {
className,
};
if (file.isDirectory) {
return <FiFolder {...props} />;
}
return <FiFile {...props} />;
};
export default FileIcon;

View File

@ -1,5 +1,8 @@
import { cn, getFileExt } from "@/lib/utils";
import React, { useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import React, { useEffect, useMemo, useRef } from "react";
import ActionButton from "./action-button";
import { FiX } from "react-icons/fi";
import FileIcon from "./file-icon";
export type Tab = {
title: string;
@ -11,9 +14,47 @@ type Props = {
tabs: Tab[];
current?: number;
onChange?: (idx: number) => void;
onClose?: (idx: number) => void;
};
const Tabs = ({ tabs, current = 0, onChange }: Props) => {
const Tabs = ({ tabs, current = 0, onChange, onClose }: Props) => {
const tabContainerRef = useRef<HTMLDivElement>(null);
const onWheel = (e: WheelEvent) => {
e.cancelable && e.preventDefault();
if (tabContainerRef.current) {
tabContainerRef.current.scrollLeft += e.deltaY + e.deltaX;
}
};
useEffect(() => {
if (!tabs.length || !tabContainerRef.current) {
return;
}
const container = tabContainerRef.current;
container.addEventListener("wheel", onWheel);
return () => {
container.removeEventListener("wheel", onWheel);
};
}, [tabs]);
useEffect(() => {
if (!tabs.length) {
return;
}
const container = tabContainerRef.current;
const tabEl: any = container?.querySelector(`[data-idx="${current}"]`);
if (!container || !tabEl) {
return;
}
const containerRect = container.getBoundingClientRect();
const scrollX = tabEl.offsetLeft - containerRect.left;
container.scrollTo({ left: scrollX, behavior: "smooth" });
}, [tabs, current]);
const tabView = useMemo(() => {
const tab = tabs[current];
const element = tab?.render ? tab.render() : null;
@ -23,15 +64,19 @@ const Tabs = ({ tabs, current = 0, onChange }: Props) => {
return (
<div className="w-full h-full flex flex-col items-stretch bg-slate-800">
{tabs.length > 0 ? (
<nav className="flex items-stretch overflow-x-auto w-full h-10 min-h-10 hide-scrollbar">
<nav
ref={tabContainerRef}
className="flex items-stretch overflow-x-auto w-full h-10 min-h-10 hide-scrollbar"
>
{tabs.map((tab, idx) => (
<TabItem
key={idx}
index={idx}
title={tab.title}
icon={tab.icon}
isActive={idx === current}
onSelect={() => onChange && onChange(idx)}
onClose={() => {}}
onClose={() => onClose && onClose(idx)}
/>
))}
</nav>
@ -43,6 +88,7 @@ const Tabs = ({ tabs, current = 0, onChange }: Props) => {
};
type TabItemProps = {
index: number;
title: string;
icon?: React.ReactNode;
isActive?: boolean;
@ -51,8 +97,8 @@ type TabItemProps = {
};
const TabItem = ({
index,
title,
icon,
isActive,
onSelect,
onClose,
@ -63,14 +109,24 @@ const TabItem = ({
return (
<button
data-idx={index}
className={cn(
"border-b-2 border-transparent text-white text-center max-w-[140px] md:max-w-[180px] px-4 text-sm flex items-center gap-0 relative z-[1]",
"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" : ""
)}
onClick={onSelect}
>
<FileIcon
file={{ isDirectory: false, filename: title }}
className="mr-1"
/>
<span className="truncate">{filename}</span>
<span>{ext}</span>
<ActionButton
icon={FiX}
className="opacity-0 group-hover:opacity-100 transition-colors"
onClick={onClose}
/>
</button>
);
};

View File

@ -1,13 +1,13 @@
import { useCallback, useState } from "react";
export const useDisclose = () => {
export const useDisclose = <T = any>() => {
const [isOpen, setOpen] = useState(false);
const [data, setData] = useState<any>(null);
const [data, setData] = useState<T | null | undefined>(null);
const onOpen = useCallback(
(_data?: any) => {
(_data?: T | null) => {
setOpen(true);
setData(data);
setData(_data);
},
[setOpen]
);
@ -19,4 +19,4 @@ export const useDisclose = () => {
return { isOpen, onOpen, onClose, onChange: setOpen, data };
};
export type UseDiscloseReturn = ReturnType<typeof useDisclose>;
export type UseDiscloseReturn<T = any> = ReturnType<typeof useDisclose<T>>;

View File

@ -1,18 +1,30 @@
import { and, eq, isNull } from "drizzle-orm";
import { and, 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";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
const fileRouter = router({
getAll: procedure.query(async () => {
const files = await db.query.file.findMany({
where: and(isNull(file.deletedAt)),
columns: { content: false },
});
getAll: procedure
.input(
z
.object({ id: z.number().array().min(1) })
.partial()
.optional()
)
.query(async ({ input: opt }) => {
const files = await db.query.file.findMany({
where: and(
isNull(file.deletedAt),
opt?.id && opt.id.length > 0 ? inArray(file.id, opt.id) : undefined
),
orderBy: [desc(file.isDirectory)],
columns: { content: false },
});
return files;
}),
return files;
}),
getById: procedure.input(z.number()).query(async ({ input }) => {
return db.query.file.findFirst({
@ -21,13 +33,32 @@ const fileRouter = router({
}),
create: procedure
.input(insertFileSchema.pick({ parentId: true, filename: true }))
.input(
insertFileSchema.pick({
parentId: true,
filename: true,
isDirectory: true,
})
)
.mutation(async ({ input }) => {
let basePath = "";
if (input.parentId) {
const parent = await db.query.file.findFirst({
where: and(eq(file.id, input.parentId), isNull(file.deletedAt)),
});
if (!parent?.isDirectory) {
throw new Error("Parent is not a directory!");
}
basePath = parent.path + "/";
}
const data: z.infer<typeof insertFileSchema> = {
userId: 1,
parentId: input.parentId,
path: input.filename,
path: basePath + input.filename,
filename: input.filename,
isDirectory: input.isDirectory,
};
const [result] = await db.insert(file).values(data).returning();
@ -37,13 +68,115 @@ const fileRouter = router({
update: procedure
.input(selectFileSchema.partial().required({ id: true }))
.mutation(async ({ input }) => {
const [result] = await db
.update(file)
.set(input)
.where(and(eq(file.id, input.id), isNull(file.deletedAt)))
.returning();
return result;
const fileData = await db.query.file.findFirst({
where: and(eq(file.id, input.id), isNull(file.deletedAt)),
});
if (!fileData) {
throw new TRPCError({ code: "NOT_FOUND" });
}
return db.transaction(async (tx) => {
const data = { ...input };
// also rename path
if (data.filename) {
const path = fileData.path.split("/");
const idx = path.length - 1;
path[idx] = data.filename;
data.path = path.join("/");
// recursively update files path in directory
if (data.isDirectory) {
await renameFilesPath(fileData.id, idx, data.filename, tx);
}
}
const [result] = await tx
.update(file)
.set(data)
.where(and(eq(file.id, input.id), isNull(file.deletedAt)))
.returning();
return result;
});
}),
delete: procedure.input(z.number()).mutation(async ({ input }) => {
const fileData = await db.query.file.findFirst({
where: and(eq(file.id, input), isNull(file.deletedAt)),
});
if (!fileData) {
throw new TRPCError({ code: "NOT_FOUND" });
}
return db.transaction(async (tx) => {
const [result] = await tx
.update(file)
.set({ deletedAt: sql`CURRENT_TIMESTAMP` })
.where(eq(file.id, fileData.id))
.returning();
if (fileData.isDirectory) {
await deleteChildrenFiles(fileData.id, tx);
}
return result;
});
}),
});
const renameFilesPath = async (
id: number,
idx: number,
pathname: string,
tx: typeof db
) => {
const files = await tx.query.file.findMany({
where: and(eq(file.parentId, id), isNull(file.deletedAt)),
});
if (!files.length) {
return;
}
for (const f of files) {
const path = f.path.split("/");
if (idx >= 0 && idx < path.length) {
path[idx] = pathname;
}
await tx
.update(file)
.set({ path: path.join("/") })
.where(and(eq(file.id, f.id), isNull(file.deletedAt)))
.execute();
if (f.isDirectory) {
await renameFilesPath(f.id, idx, pathname, tx);
}
}
};
const deleteChildrenFiles = async (id: number, tx: typeof db) => {
const files = await tx.query.file.findMany({
where: and(eq(file.parentId, id), isNull(file.deletedAt)),
});
if (!files.length) {
return;
}
for (const f of files) {
await tx
.update(file)
.set({ deletedAt: sql`CURRENT_TIMESTAMP` })
.where(eq(file.id, f.id))
.execute();
if (f.isDirectory) {
await deleteChildrenFiles(f.id, tx);
}
}
};
export default fileRouter;