initial commit

This commit is contained in:
Khairul Hidayat 2024-09-02 23:59:12 +07:00
commit 9178ea08ac
35 changed files with 4931 additions and 0 deletions

18
frontend/.eslintrc.cjs Normal file
View File

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

30
frontend/README.md Normal file
View File

@ -0,0 +1,30 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: __dirname,
},
}
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kidokoding</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

46
frontend/package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"@radix-ui/react-slot": "^1.1.0",
"clsx": "^2.1.1",
"console-feed": "^3.6.0",
"lucide-react": "^0.417.0",
"nanoid": "^5.0.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.25.1",
"tailwind-merge": "^2.4.0",
"tailwind-variants": "^0.2.1",
"zod": "^3.23.8",
"zustand": "^4.5.4"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.13",
"@types/node": "^22.0.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.15.0",
"@typescript-eslint/parser": "^7.15.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.19",
"daisyui": "^4.12.10",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"monaco-editor": "^0.50.0",
"postcss": "^8.4.40",
"tailwindcss": "^3.4.7",
"typescript": "^5.2.2",
"vite": "^5.3.4"
}
}

3222
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

12
frontend/src/app/app.tsx Normal file
View File

@ -0,0 +1,12 @@
import Providers from "./providers";
import Router from "./router";
const App = () => {
return (
<Providers>
<Router />
</Providers>
);
};
export default App;

View File

@ -0,0 +1,13 @@
import { PropsWithChildren } from "react";
import ThemeProvider from "./theme-provider";
const Providers = ({ children }: PropsWithChildren) => {
return (
<>
{children}
<ThemeProvider />
</>
);
};
export default Providers;

View File

@ -0,0 +1,35 @@
import { useMemo } from "react";
import {
createBrowserRouter,
Navigate,
RouterProvider,
} from "react-router-dom";
import LearnViewPage from "@/pages/learn/view/page";
const appRouter = createBrowserRouter([
{
children: [
{
index: true,
element: <Navigate to="/learn/123" />,
},
{
path: "learn/:id",
Component: LearnViewPage,
},
],
},
]);
// const authRouter = createBrowserRouter([]);
const Router = () => {
const router = useMemo(() => {
return appRouter;
}, []);
return <RouterProvider router={router} />;
};
export default Router;

View File

@ -0,0 +1,26 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
*,
::before,
::after {
@apply border-base-content/10;
}
}
.console-feed > div {
@apply flex flex-col-reverse;
}
.console-feed > div > div {
@apply py-3;
}
.console-feed span,
.console-feed summary {
@apply text-base;
}
.monaco-editor {
@apply outline-none;
}

View File

@ -0,0 +1,17 @@
import { themeStore } from "@/stores/theme.store";
import { useEffect } from "react";
import { useStore } from "zustand";
const ThemeProvider = () => {
const theme = useStore(themeStore, (i) => i.theme);
const isDarkMode = useStore(themeStore, (i) => i.isDarkMode);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
document.documentElement.classList.toggle("dark", isDarkMode);
}, [theme, isDarkMode]);
return null;
};
export default ThemeProvider;

View File

@ -0,0 +1,38 @@
//
export const themes = [
{
name: "light",
dark: false,
},
{
name: "dark",
dark: true,
},
{
name: "nord",
dark: false,
},
{
name: "winter",
dark: false,
},
{
name: "pastel",
dark: false,
},
{
name: "dracula",
dark: true,
},
{
name: "dim",
dark: true,
},
{
name: "black",
dark: true,
},
] as const;
export type Themes = (typeof themes)[number]["name"];

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,220 @@
import { Editor } from "@monaco-editor/react";
import { editor } from "monaco-editor";
import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
import Tabs from "@ui/tabs";
import { LucideIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useStore } from "zustand";
import { themeStore } from "@/stores/theme.store";
export type CodeEditorFile = {
uri: string;
title?: string;
icon?: LucideIcon;
lang: "html" | "javascript" | "css";
content: string;
hidden?: boolean;
pinned?: boolean;
};
export type CodeEditorRef = {
editor: editor.IStandaloneCodeEditor;
monaco: typeof monaco;
getModels: () => editor.IModel[];
loadFiles: (files: CodeEditorFile[]) => void;
getData: () => {
lang: string;
value: string;
fullUri: monaco.Uri;
uri: string;
}[];
onSave: () => void;
onFormat: () => void;
clearPersistedData: () => void;
};
type CodeEditorProps = {
files?: CodeEditorFile[];
persistId?: string;
onLoad?: (ref: CodeEditorRef) => void;
showHiddenFiles?: boolean;
};
const CodeEditor = ({
persistId,
onLoad,
showHiddenFiles,
...props
}: CodeEditorProps) => {
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const monacoRef = useRef<typeof monaco | null>(null);
const [files, setCurrentFiles] = useState<CodeEditorFile[]>([]);
const [currentFile, setCurrentFile] = useState("");
const [isEditorReady, setEditorReady] = useState(false);
const isDarkMode = useStore(themeStore, (i) => i.isDarkMode);
useEffect(() => {
const editor = editorRef.current;
const monaco = monacoRef.current;
if (!editor || !monaco || !isEditorReady || !props.files?.length) {
return;
}
loadFiles(props.files);
onLoad?.({
editor,
monaco,
getModels,
loadFiles,
getData,
onSave,
onFormat,
clearPersistedData,
});
}, [isEditorReady, props.files, persistId]);
useEffect(() => {
if (!currentFile?.length || !isEditorReady) {
return;
}
if (!files.find((i) => i.uri === currentFile)) {
return;
}
const uri = monaco.Uri.parse(currentFile);
const model = monacoRef.current?.editor.getModel(uri);
if (model) {
editorRef.current?.setModel(model);
}
}, [currentFile, isEditorReady, files]);
const loadFiles = (files: CodeEditorFile[]) => {
const monaco = monacoRef.current;
if (!monaco) {
return;
}
// Clear existing models
getModels().forEach((model) => {
model.dispose();
});
files.forEach((file) => {
const uri = monaco.Uri.parse(file.uri);
const exist = monaco.editor.getModel(uri);
if (exist) {
return;
}
let content = file.content;
if (persistId) {
content = localStorage.getItem(`${persistId}:${file.uri}`) || content;
}
// Create model
monaco.editor.createModel(content, file.lang, uri);
});
const visibleFiles = files
.filter((i) => !i.hidden || showHiddenFiles)
.sort((a, b) => {
return a.pinned && b.pinned ? 0 : a.pinned ? -1 : b.pinned ? 1 : 0;
});
setCurrentFiles(visibleFiles);
// Open first model
setCurrentFile(visibleFiles[0]?.uri || "");
};
const getModels = () => {
return monacoRef?.current?.editor.getModels() || [];
};
const getData = () => {
const models = getModels();
return models.map((model) => {
const lang = model.getLanguageId();
const value = model.getValue();
const fullUri = model.uri;
const uri = model.uri.path.slice(1);
return { lang, value, fullUri, uri };
});
};
const onSave = () => {
const editor = editorRef.current;
if (!editor || !persistId) {
return;
}
const data = getData();
data.forEach((file) => {
localStorage.setItem(`${persistId}:${file.uri}`, file.value);
});
return data;
};
const onFormat = () => {
const editor = editorRef.current;
if (editor) {
editor?.getAction("editor.action.formatDocument")?.run();
}
};
const clearPersistedData = () => {
if (!persistId) {
return;
}
const data = getData();
data.forEach((file) => localStorage.removeItem(`${persistId}:${file.uri}`));
loadFiles(props.files || []);
};
return (
<>
<Tabs
hideIfOneTab
tabs={files.map((file) => ({
title: file.title || file.uri,
icon: file.icon,
key: file.uri,
}))}
current={currentFile}
onChange={(key) => {
setCurrentFile(key);
}}
/>
<div className="flex-1 overflow-hidden">
<Editor
height="100%"
defaultLanguage="javascript"
theme={isDarkMode ? "vs-dark" : "light"}
onMount={(editor, monaco) => {
editorRef.current = editor;
monacoRef.current = monaco;
setEditorReady(true);
}}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 16,
lineNumbersMinChars: 3,
// lineNumbers: "off",
showFoldingControls: "never",
// codeLens: false,
wordWrap: "on",
}}
/>
</div>
</>
);
};
export default CodeEditor;

View File

@ -0,0 +1,32 @@
import { cn } from "@/lib/utils";
import { themeStore } from "@/stores/theme.store";
import { Console } from "console-feed";
import { Message } from "console-feed/lib/definitions/Component";
import { useStore } from "zustand";
type ConsoleLogsProps = {
logs: Message[];
className?: string;
};
const ConsoleLogs = ({ logs, className }: ConsoleLogsProps) => {
const colorScheme = useStore(themeStore, (i) =>
i.isDarkMode ? "dark" : "light"
);
if (!logs?.length) {
return (
<div className={cn("flex items-center justify-center p-4", className)}>
<p className="text-center">Tidak ada log.</p>
</div>
);
}
return (
<div className={cn("console-feed overflow-y-auto", className)}>
<Console logs={logs} variant={colorScheme} />
</div>
);
};
export default ConsoleLogs;

View File

@ -0,0 +1,141 @@
import { TestCase } from "@/lib/playground";
import { Decode, Hook } from "console-feed";
import { Message } from "console-feed/lib/definitions/Component";
import { Bug, ClipboardCheck, PanelsTopLeft } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import PlaygroundTests from "./playground-tests";
import ConsoleLogs from "./console-logs";
import { TabView, TabViewItem } from "@ui/tabs";
import { useStore } from "zustand";
import { themeStore } from "@/stores/theme.store";
export type PlaygroundPanelsRef = {
iframe: HTMLIFrameElement;
setFrameData: (data: string) => void;
clearLogs: () => void;
};
type PlaygroundPanelsProps = {
tests?: TestCase[];
onTestResult?: (id: string, result: any) => void;
onLoad: (ctx: PlaygroundPanelsRef) => void;
showPreview?: boolean;
showLogs?: boolean;
};
const PlaygroundPanels = ({
tests,
onTestResult,
onLoad,
showPreview,
showLogs,
}: PlaygroundPanelsProps) => {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [frameLogs, setFrameLogs] = useState<Message[]>([]);
const colorScheme = useStore(themeStore, (i) =>
i.isDarkMode ? "dark" : "light"
);
const onFrameLoad = useCallback(
(el: any) => {
if (el.contentWindow.consoleFn) {
Hook(el.contentWindow.consoleFn, (log) => {
const data = Decode(log) as never;
setFrameLogs((cur) => [...cur, data]);
});
}
if (typeof el.contentWindow.loadScript === "function") {
el.contentWindow.loadScript();
}
tests?.forEach((item) => {
let result;
try {
result = el.contentWindow.eval(item.test);
} catch (err) {
console.error(err);
} finally {
onTestResult?.(item.id!, result);
}
});
onLoad({
iframe: el,
setFrameData: (data: string) => {
el.srcdoc = data;
},
clearLogs: () => {
setFrameLogs([]);
},
});
},
[setFrameLogs, tests, onLoad]
);
const frameElement = useMemo(() => {
return (
<iframe
ref={iframeRef}
referrerPolicy="origin"
srcDoc=""
onLoad={() => {
const el = iframeRef.current as any;
if (el?.contentWindow) {
onFrameLoad(el);
}
}}
/>
);
}, [onFrameLoad]);
useEffect(() => {
const frameDoc = iframeRef.current?.contentWindow?.document;
if (frameDoc) {
frameDoc
.querySelector("meta[name='color-scheme']")
?.setAttribute("content", colorScheme);
}
}, [colorScheme]);
const tabs = useMemo(() => {
return [
{
title: "Pratinjau",
key: "preview",
icon: PanelsTopLeft,
element: frameElement,
show: showPreview === true,
},
{
title: "Hasil",
key: "tests",
icon: ClipboardCheck,
Component: ({ className }: any) => (
<PlaygroundTests tests={tests || []} className={className} />
),
enabled: tests && tests?.length > 0,
},
{
title: "Log",
key: "logs",
icon: Bug,
Component: ({ className }: any) => (
<ConsoleLogs logs={frameLogs} className={className} />
),
enabled: showLogs !== false,
},
] satisfies TabViewItem[];
}, [frameElement, frameLogs, tests, showPreview, showLogs]);
return (
<TabView
tabs={tabs}
className="rounded-none"
contentClassName="flex-1 shrink-0"
hideIfOneTab
/>
);
};
export default PlaygroundPanels;

View File

@ -0,0 +1,39 @@
import { cn } from "@/lib/utils";
import { TestCase } from "../../lib/playground";
type PlaygroundTestsProps = {
tests: TestCase[];
className?: string;
};
const PlaygroundTests = ({ tests, className }: PlaygroundTestsProps) => {
return (
<div className={cn("p-4 flex flex-col gap-2 overflow-y-auto", className)}>
{tests.map((test) => (
<div
key={test.id}
className={cn(
"flex flex-row gap-2 justify-between px-4 py-2 rounded-lg bg-base-200",
test.isFinished && test.expected !== test.result
? "bg-error text-error-content"
: "",
test.isFinished && test.expected === test.result
? "bg-success text-success-content"
: ""
)}
>
<p>{test.name}</p>
<p>Expect: {test.expected || "-"}</p>
<p>
Result:{" "}
{typeof test.result === "undefined"
? "-"
: JSON.stringify(test.result)}
</p>
</div>
))}
</div>
);
};
export default PlaygroundTests;

View File

@ -0,0 +1,300 @@
import { Fragment } from "react";
import {
Code,
PlayCircle,
Expand,
Shrink,
X,
EllipsisVertical,
RotateCw,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import Button from "@ui/button";
import { nanoid } from "nanoid";
import { cn } from "@/lib/utils";
import { frameTemplate, TestCase } from "../../lib/playground";
import CodeEditor, { CodeEditorFile, CodeEditorRef } from "./code-editor";
import PlaygroundPanels, { PlaygroundPanelsRef } from "./playground-panels";
import Dropdown from "@ui/dropdown";
type PlaygroundProps = {
className?: string;
files?: CodeEditorFile[];
tests?: TestCase[];
persistId?: string;
showHiddenFiles?: boolean;
showLogs?: boolean;
};
const Playground = ({
className,
files,
persistId,
showHiddenFiles,
showLogs,
...props
}: PlaygroundProps) => {
const panelsRef = useRef<PlaygroundPanelsRef | null>(null);
const editorRef = useRef<CodeEditorRef | null>(null);
const [tests, setTests] = useState<TestCase[]>([]);
const [hasRun, setHasRun] = useState(false);
const onEditorLoad = useCallback((ctx: CodeEditorRef) => {
editorRef.current = ctx;
const { monaco, editor } = ctx;
editor.addAction({
id: "run",
label: "Run",
keybindings: [
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter,
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter,
],
run: onRun,
});
editor.addAction({
id: "save",
label: "Save",
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
run: onRun,
});
}, []);
const onTestResult = useCallback(
(id: string, result: any) => {
const idx = tests.findIndex((i) => i.id === id);
if (idx < 0) {
return;
}
setTests((cur) => {
const res = [...cur];
res[idx] = { ...res[idx], result, isFinished: true };
return res;
});
},
[tests, setTests]
);
const onRun = () => {
const panels = panelsRef.current;
const editor = editorRef.current;
if (!panels || !editor) {
return;
}
let body = "",
script = "",
style = "";
editor.getData().forEach((file) => {
const value = file.value + "\n";
switch (file.lang) {
case "html":
body += value;
break;
case "javascript":
script += value;
break;
case "css":
style += value;
break;
}
});
const newTests = props.tests?.map((i) => ({ ...i, id: nanoid() })) || [];
const doc = frameTemplate({
body,
script,
style,
tests: newTests.map((i) => ({ id: i.id, test: i.test })),
});
setTests(newTests);
setHasRun(true);
panels.setFrameData(doc);
panels.clearLogs();
editor.onFormat();
setTimeout(() => editor.onSave(), 100);
};
const onResetFiles = () => {
if (window.confirm("Apakah anda yakin ingin mengembalikan data ke awal?")) {
editorRef.current?.clearPersistedData();
}
};
if (!files?.length) {
return null;
}
return (
<PlaygroundContainer className={className}>
{(ctx) => (
<Fragment>
<div className="p-2 flex flex-row items-center">
<Code size={28} className="hidden lg:block ml-4 mr-3" />
<Button
icon={X}
variant="ghost"
className="lg:hidden -ml-2 -mr-1"
onClick={() => ctx.setOpen(false)}
/>
<p className="font-medium text-lg flex-1 truncate">Playground</p>
<Button
onClick={onRun}
icon={PlayCircle}
title="Ctrl + Enter to Run"
>
Mulai
</Button>
<Dropdown
trigger={
<Button variant="ghost" size="icon" icon={EllipsisVertical} />
}
>
<Dropdown.Item
icon={ctx.isExtended ? Shrink : Expand}
onClick={() => ctx.setExtended(!ctx.isExtended)}
className="hidden lg:block"
title="Ctrl + Alt + M"
>
Tampilan Penuh
</Dropdown.Item>
<Dropdown.Item icon={RotateCw} onClick={onResetFiles}>
Reset Files
</Dropdown.Item>
</Dropdown>
</div>
<div
className={cn(
"flex-1 flex flex-col items-stretch overflow-hidden",
ctx.isExtended && "sm:grid sm:grid-cols-12"
)}
>
<div className="flex-1 flex flex-col overflow-hidden sm:col-span-6 lg:col-span-7">
<CodeEditor
files={files}
persistId={persistId}
showHiddenFiles={showHiddenFiles}
onLoad={onEditorLoad}
/>
</div>
<div
className={cn(
"flex-1 flex flex-col overflow-hidden sm:col-span-6 lg:col-span-5 transition-all duration-1000 ease-in-out opacity-100 border-t",
!hasRun && !ctx.isExtended
? "max-h-0 opacity-0"
: "max-h-[100vh]",
ctx.isExtended && "border-l border-t-0 max-h-none"
)}
>
<PlaygroundPanels
onLoad={(ref) => {
panelsRef.current = ref;
}}
tests={tests}
onTestResult={onTestResult}
showPreview={files?.find((i) => i.lang === "html") != null}
showLogs={showLogs}
/>
</div>
</div>
</Fragment>
)}
</PlaygroundContainer>
);
};
type IPlaygroundContext = {
isExtended: boolean;
setExtended: (isExtended: boolean) => void;
isOpen: boolean;
setOpen: (isOpen: boolean) => void;
};
type PlaygroundContainerProps = {
className?: string;
children?: (ctx: IPlaygroundContext) => React.ReactNode;
};
const PlaygroundContainer = ({
className,
children,
}: PlaygroundContainerProps) => {
const [isExtended, setExtended] = useState(false);
const [isOpen, setOpen] = useState(false);
useEffect(() => {
const onMouseDown = (e: MouseEvent) => {
if (!isOpen) {
return;
}
const element = document.getElementById("playground");
if (element && !element.contains(e.target as Node)) {
setOpen(false);
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === "Escape" && (isOpen || isExtended)) {
setOpen(false);
setExtended(false);
}
// ctrl+alt+m to go extended
if (e.ctrlKey && e.altKey && e.key === "m") {
setExtended(!isExtended);
}
};
window.addEventListener("mousedown", onMouseDown);
window.addEventListener("keyup", onKeyUp);
return () => {
window.removeEventListener("mousedown", onMouseDown);
window.removeEventListener("keyup", onKeyUp);
};
}, [isOpen, isExtended]);
const contextValues = {
isExtended,
setExtended,
isOpen,
setOpen,
};
return (
<Fragment>
<Button
className="fixed bottom-4 right-4 sm:bottom-8 sm:right-8 lg:hidden z-[39]"
onClick={() => setOpen(true)}
>
<Code />
<p>Coba</p>
</Button>
<div
id="playground"
className={cn(
"flex flex-col overflow-hidden playground bg-base-100 w-full max-w-[400px] h-full fixed right-0 border-l top-0 translate-x-full transition-transform z-40 lg:static lg:translate-x-0 lg:max-w-[33%] shadow-xl lg:shadow-none",
className,
isOpen && "translate-x-0",
isExtended && "!flex !max-w-none !fixed inset-0"
)}
>
{children?.(contextValues)}
</div>
</Fragment>
);
};
export default Playground;

View File

@ -0,0 +1,29 @@
import { themes } from "@/app/themes";
import { ucfirst } from "@/lib/utils";
import { setTheme, themeStore } from "@/stores/theme.store";
import Button from "@ui/button";
import Dropdown from "@ui/dropdown";
import { Check, Palette } from "lucide-react";
import { ComponentPropsWithoutRef } from "react";
import { useStore } from "zustand";
type ThemeSelectorProps = ComponentPropsWithoutRef<typeof Dropdown>;
const ThemeSelector = (props: ThemeSelectorProps) => {
const curTheme = useStore(themeStore, (i) => i.theme);
return (
<Dropdown trigger={<Button icon={Palette}>Tema</Button>} {...props}>
{themes.map((theme) => (
<Dropdown.Item key={theme.name} onClick={() => setTheme(theme.name)}>
<div className="w-6">
{curTheme === theme.name && <Check size={16} />}
</div>
{ucfirst(theme.name)}
</Dropdown.Item>
))}
</Dropdown>
);
};
export default ThemeSelector;

View File

@ -0,0 +1,75 @@
import { cn } from "@/lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { LucideIcon } from "lucide-react";
import { ComponentPropsWithoutRef, forwardRef } from "react";
import { tv, VariantProps } from "tailwind-variants";
const button = tv({
base: "btn rounded-xl",
variants: {
color: {
none: "",
primary: "btn-primary",
secondary: "btn-secondary",
neutral: "btn-neutral",
accent: "btn-accent",
info: "btn-info",
success: "btn-success",
warning: "btn-warning",
error: "btn-error",
},
variant: {
link: "btn-link",
outline: "btn-outline",
ghost: "btn-ghost",
},
size: {
xs: "btn-xs",
sm: "btn-sm h-10",
lg: "btn-lg",
icon: "btn-square",
},
},
defaultVariants: {
color: "neutral",
},
});
type ButtonVariants = VariantProps<typeof button>;
type ButtonProps = ComponentPropsWithoutRef<"button"> &
ButtonVariants & {
as?: "a" | "button" | "div";
asChild?: boolean;
href?: string;
isLoading?: boolean;
icon?: LucideIcon;
};
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{ as = "button", className, children, isLoading, icon: Icon, ...props },
ref
) => {
const Comp = props.asChild ? Slot : (as as any);
return (
<Comp
ref={ref}
role="button"
className={cn(button(props), className)}
{...props}
>
{isLoading ? (
<span className="loading loading-spinner"></span>
) : Icon ? (
<Icon />
) : null}
{children}
</Comp>
);
}
);
export default Button;

View File

@ -0,0 +1,107 @@
import { cn } from "@/lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { LucideIcon } from "lucide-react";
import { useMemo } from "react";
import { tv, VariantProps } from "tailwind-variants";
const dropdown = tv({
base: "dropdown",
variants: {
position: {
top: "dropdown-top",
bottom: "dropdown-bottom",
left: "dropdown-left",
right: "dropdown-right",
},
align: {
default: "",
end: "dropdown-end",
},
},
defaultVariants: {
position: "bottom",
align: "end",
},
});
type DropdownProps = VariantProps<typeof dropdown> & {
tabIndex?: number;
className?: string;
contentClassName?: string;
children?: React.ReactNode;
trigger?: React.ReactNode;
};
const Dropdown = ({
tabIndex,
className,
contentClassName,
children,
trigger,
...props
}: DropdownProps) => {
const tabIdx = useMemo(
() => tabIndex || Math.round(Math.random() * 10000),
[tabIndex]
);
return (
<div className={cn(dropdown(props), className)}>
<Slot tabIndex={tabIdx} role="button" {...({ as: "div" } as any)}>
{trigger}
</Slot>
<ul
tabIndex={tabIdx}
className={cn(
"dropdown-content menu bg-base-200 rounded-box z-[1] w-52 p-2 shadow",
contentClassName
)}
>
{children}
</ul>
</div>
);
};
type DropdownItemProps = {
className?: string;
btnClassName?: string;
children?: React.ReactNode;
href?: string;
onClick?: () => void;
icon?: LucideIcon;
title?: string;
};
export const DropdownItem = ({
className,
btnClassName,
children,
href = "#",
onClick,
icon: Icon,
title,
}: DropdownItemProps) => {
return (
<li className={className}>
<a
href={href}
onClick={(e) => {
if (onClick) {
e.preventDefault();
onClick();
}
}}
className={btnClassName}
title={title}
>
{Icon ? <Icon size={16} /> : null}
{children}
</a>
</li>
);
};
Dropdown.Item = DropdownItem;
export default Dropdown;

View File

@ -0,0 +1,118 @@
import { cn } from "@/lib/utils";
import { LucideIcon } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { Slot } from "@radix-ui/react-slot";
export type Tab = {
title: string;
key: string;
icon?: LucideIcon;
};
type TabsProps = {
tabs?: Tab[];
current?: string;
onChange?: (idx: string) => void;
hideIfOneTab?: boolean;
className?: string;
tabClassName?: string;
};
const Tabs = ({
tabs = [],
current,
onChange,
hideIfOneTab,
className,
tabClassName,
}: TabsProps) => {
if (tabs.length < 2 && hideIfOneTab) {
return null;
}
return (
<div role="tablist" className={cn("tabs tabs-boxed p-2", className)}>
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
className={cn(
"tab h-10 text-sm gap-2",
tabClassName,
tab.key === current && "tab-active !border-b-primary text-primary"
)}
onClick={() => onChange?.(tab.key)}
>
{tab.icon ? <tab.icon size={18} /> : null}
{tab.title}
</button>
))}
</div>
);
};
export type TabViewItem = Tab & {
enabled?: boolean;
show?: boolean;
element?: React.ReactNode;
Component?: React.ComponentType<any>;
};
type TabViewProps = Omit<TabsProps, "tabs"> & {
tabs: TabViewItem[];
contentClassName?: string;
};
export const TabView = ({ tabs, contentClassName, ...props }: TabViewProps) => {
const [curTab, setCurTab] = useState("");
const tabList = useMemo(() => {
if (!tabs) {
return [];
}
return tabs
.filter((tab) => tab.enabled !== false)
.map((tab) => {
const content = tab.element ? (
tab.element
) : tab.Component ? (
<tab.Component />
) : null;
return { ...tab, content };
});
}, [tabs]);
const visibleTabs = tabList.filter((tab) => tab.show !== false);
useEffect(() => {
setCurTab(visibleTabs[0]?.key || "");
}, [visibleTabs.length]);
return (
<>
<Tabs
tabs={visibleTabs}
current={curTab}
onChange={setCurTab}
{...props}
/>
{tabList.map((tab) => (
<Slot
key={tab.key}
role="tabpanel"
className={cn(
contentClassName,
tab.key !== curTab ? "hidden" : undefined
)}
>
{tab.content}
</Slot>
))}
</>
);
};
export default Tabs;

View File

@ -0,0 +1,60 @@
import { themeStore } from "@/stores/theme.store";
export type TestCase = {
id?: string;
name: string;
test: string;
expected?: any;
result?: any;
isFinished?: boolean;
};
type FrameTemplateData = Partial<{
body: string;
script: string;
style: string;
tests: Pick<TestCase, "id" | "test">[];
}>;
export const frameTemplate = (data: FrameTemplateData) => {
const colorScheme = themeStore.getState().isDarkMode ? "dark" : "light";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="${colorScheme}">
<style>
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
* {
box-sizing: border-box;
}
pre {
margin: 0;
padding: 1rem;
font-size: 1.2rem;
}
${data.style}
</style>
</head>
<body id="body">
${data.body}
</body>
<script>
window.consoleFn = {};
Object.keys(console).forEach(key => { window.consoleFn[key] = function (){}; console[key] = function (...args){ setTimeout(() => window.consoleFn[key](...args), 100); }});
${data.script}
</script>
</html>`;
};

10
frontend/src/lib/utils.ts Normal file
View File

@ -0,0 +1,10 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function ucfirst(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./app/app.tsx";
import "./app/styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,183 @@
import { useEffect, useMemo, useState } from "react";
import Playground from "../../../components/containers/playground";
import { Braces, Code, CodeXml, PencilRuler } from "lucide-react";
import { TestCase } from "../../../lib/playground";
import Button from "@ui/button";
import { cn } from "@/lib/utils";
import { CodeEditorFile } from "@/components/containers/code-editor";
import ThemeSelector from "@/components/containers/theme-selector";
const initialBody = `
<pre id="print-area"></pre>
`;
const initialInitScript = `
const printAreaEl = document.getElementById("print-area");
function print(...args) {
printAreaEl.innerHTML += args.map(String).join('');
}
`;
const initialScript = `
let a = 4;
// Hasil dari 'a' dikali 2
let b = a * 2;
// Hasil dari penjumlahan a dan b
let c = // lengkapi disini
// Fungsi untuk menambahkan dua bilangan
function tambah(a, b) {
// lengkapi disini
}
for (let i=0; i<=5; i++) {
for (let j=0; j<=i; j++) {
print('*');
}
print('\\n');
}
`;
const initialStyle = `
body {
padding: 1rem;
}
button {
background: #f5f5f5;
border: 1px solid #f5f5f5;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
cursor: pointer;
display: block;
}
button:hover {
background: #e5e5e5;
}
`;
const LearnViewPage = () => {
const files: CodeEditorFile[] = useMemo(() => {
return [
{
uri: "body.html",
// title: "HTML",
// icon: CodeXml,
lang: "html",
content: initialBody,
hidden: true,
},
{
uri: "init.js",
icon: Braces,
lang: "javascript",
content: initialInitScript,
hidden: true,
},
{
uri: "script.js",
title: "Script",
icon: Braces,
lang: "javascript",
content: initialScript,
pinned: true,
},
// {
// uri: "style.css",
// title: "Style",
// icon: PencilRuler,
// lang: "css",
// content: initialStyle,
// },
];
}, []);
const tests: TestCase[] = [
{
name: "Hasil dari b = a x 2",
test: "b",
expected: 8,
},
{
name: "Hasil dari c = a + b",
test: "c",
expected: 12,
},
{
name: "Hasil 1 + 2",
test: "tambah(1, 2)",
expected: 3,
},
];
return (
<div className="flex flex-row items-stretch h-screen overflow-hidden">
<aside className="w-[250px] bg-base-100 fixed top-0 left-0 h-full z-10 -translate-x-full md:static md:translate-x-0">
Sidebar
<ThemeSelector
className="absolute left-4 bottom-4"
position="top"
align="default"
/>
</aside>
<main className="flex-1 bg-base-100 overflow-y-auto">
<article className="p-4 md:p-8 prose">
<h1>Lorem Ipsum</h1>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam
laoreet suscipit pretium. Nunc vehicula interdum condimentum.
Maecenas vitae enim dui. Vestibulum luctus tincidunt nisl et
molestie. Mauris sollicitudin velit consectetur ante porttitor, quis
commodo justo porta. Integer vestibulum ante urna, et fringilla
lorem faucibus egestas. Nunc laoreet mollis blandit. Fusce ut mauris
nibh. Sed orci dolor, fringilla a magna quis, aliquam imperdiet
purus. Nullam tellus sapien, luctus non volutpat non, venenatis eu
purus. Etiam elit arcu, dapibus nec pulvinar eu, blandit sed lacus.
Aliquam euismod id ligula in blandit.
</p>
<p>
In vel vulputate dolor. Duis bibendum viverra ex, sit amet molestie
dui pulvinar quis. Donec varius dictum orci. Donec sollicitudin quam
eu massa ullamcorper porta. In sed ex mauris. Vestibulum ante ipsum
primis in faucibus orci luctus et ultrices posuere cubilia curae;
Nunc ultrices, enim a porttitor feugiat, leo nisl venenatis enim, in
pretium velit leo quis purus. Quisque eget sapien sem. Pellentesque
hendrerit, sapien ac sodales vestibulum, massa nulla sollicitudin
neque, vitae condimentum metus leo nec magna. Aliquam sit amet
porttitor leo. Morbi a massa nec magna ultrices efficitur et
vulputate nunc. Aliquam id convallis nisl.
</p>
<p>
Praesent efficitur quis dui nec sagittis. Vestibulum finibus
vulputate nisi a ultricies. Quisque auctor nunc ut felis placerat,
eget scelerisque sapien rhoncus. Suspendisse sollicitudin sed lacus
in mattis. Duis ut egestas arcu. Integer convallis consequat dolor.
Nulla tincidunt sem nunc, rutrum congue eros ornare euismod. Quisque
vitae massa risus. Sed sodales facilisis iaculis. Etiam interdum
massa sit amet purus consequat finibus at sit amet mauris.
Vestibulum dapibus velit sit amet mauris lobortis, ut scelerisque
nulla lacinia. Donec quis dictum leo, id luctus mi.
</p>
</article>
</main>
<Playground
className="flex-1"
files={files}
tests={tests}
persistId="test"
// showHiddenFiles
// showLogs={false}
// preview
/>
</div>
);
};
export default LearnViewPage;

View File

@ -0,0 +1,20 @@
import { themes, Themes } from "@/app/themes";
import { createStore } from "zustand";
import { persist } from "zustand/middleware";
export const themeStore = createStore(
persist(
() => ({
theme: "light" as Themes,
isDarkMode: false,
}),
{
name: "theme",
}
)
);
export const setTheme = (theme: Themes) => {
const isDarkMode = themes.find((t) => t.name === theme)?.dark ?? false;
themeStore.setState({ theme, isDarkMode });
};

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,13 @@
const themes = require("./src/app/themes").themes;
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [require("@tailwindcss/typography"), require("daisyui")],
daisyui: {
themes: themes.map((i) => i.name),
},
};

View File

@ -0,0 +1,33 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@ui/*": ["./src/components/ui/*"],
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

11
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}

View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}

14
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,14 @@
import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@ui": path.resolve(__dirname, "./src/components/ui"),
"@": path.resolve(__dirname, "./src"),
},
},
});