mirror of
https://github.com/aykhans/slash-e.git
synced 2025-04-16 04:13:12 +00:00
chore: init web project
This commit is contained in:
parent
e82c821396
commit
5a96e67c6d
32
web/.eslintrc.json
Normal file
32
web/.eslintrc.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["react", "@typescript-eslint", "prettier"],
|
||||
"ignorePatterns": ["node_modules", "dist", "public"],
|
||||
"rules": {
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": ["off"],
|
||||
"react/react-in-jsx-scope": "off"
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
}
|
||||
}
|
7
web/.gitignore
vendored
Normal file
7
web/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
.yarn/*
|
6
web/.prettierrc
Normal file
6
web/.prettierrc
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"printWidth": 140,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false
|
||||
}
|
6
web/.vscode/setting.json
vendored
Normal file
6
web/.vscode/setting.json
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
}
|
||||
}
|
1
web/README.md
Normal file
1
web/README.md
Normal file
@ -0,0 +1 @@
|
||||
# Corgi
|
14
web/index.html
Normal file
14
web/index.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/logo.webp" type="image/*" />
|
||||
<meta name="theme-color" content="#FFFFFF" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<title>Corgi</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
43
web/package.json
Normal file
43
web/package.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "corgi",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"serve": "vite preview",
|
||||
"lint": "eslint --ext .js,.ts,.tsx, src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.8.1",
|
||||
"axios": "^0.27.2",
|
||||
"dayjs": "^1.11.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"qs": "^6.11.0",
|
||||
"react": "^18.1.0",
|
||||
"react-dom": "^18.1.0",
|
||||
"react-feather": "^2.0.10",
|
||||
"react-redux": "^8.0.1",
|
||||
"react-router-dom": "6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash-es": "^4.17.5",
|
||||
"@types/node": "^18.0.3",
|
||||
"@types/qs": "^6.9.7",
|
||||
"@types/react": "^18.0.9",
|
||||
"@types/react-dom": "^18.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "^5.6.0",
|
||||
"@typescript-eslint/parser": "^5.6.0",
|
||||
"@vitejs/plugin-react": "^2.0.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"eslint": "^8.4.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react": "^7.27.1",
|
||||
"less": "^4.1.1",
|
||||
"postcss": "^8.4.5",
|
||||
"prettier": "2.5.1",
|
||||
"tailwindcss": "^3.0.18",
|
||||
"typescript": "^4.3.2",
|
||||
"vite": "^3.0.0"
|
||||
}
|
||||
}
|
7
web/postcss.config.js
Normal file
7
web/postcss.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
/* eslint-disable no-undef */
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
39
web/src/App.tsx
Normal file
39
web/src/App.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { useEffect } from "react";
|
||||
import { Route, Routes, useNavigate } from "react-router-dom";
|
||||
import { userService, workspaceService } from "./services";
|
||||
import useLoading from "./hooks/useLoading";
|
||||
import Only from "./components/common/OnlyWhen";
|
||||
import Auth from "./pages/Auth";
|
||||
import Home from "./pages/Home";
|
||||
import WorkspaceDetail from "./pages/WorkspaceDetail";
|
||||
|
||||
function App() {
|
||||
const navigate = useNavigate();
|
||||
const pageLoadingStatus = useLoading();
|
||||
|
||||
useEffect(() => {
|
||||
userService.initialState().finally(() => {
|
||||
if (!userService.getState().user) {
|
||||
pageLoadingStatus.setFinish();
|
||||
navigate("/auth");
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all([workspaceService.fetchWorkspaceList()]).finally(() => {
|
||||
pageLoadingStatus.setFinish();
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Only when={!pageLoadingStatus.isLoading}>
|
||||
<Routes>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="/auth" element={<Auth />} />
|
||||
<Route path="/workspace/:workspaceId" element={<WorkspaceDetail />} />
|
||||
</Routes>
|
||||
</Only>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
108
web/src/components/CreateShortcutDialog.tsx
Normal file
108
web/src/components/CreateShortcutDialog.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { shortcutService } from "../services";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
workspaceId: WorkspaceId;
|
||||
shortcutId?: ShortcutId;
|
||||
}
|
||||
|
||||
const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy, workspaceId, shortcutId } = props;
|
||||
const [name, setName] = useState<string>("");
|
||||
const [link, setLink] = useState<string>("");
|
||||
const requestState = useLoading(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (shortcutId) {
|
||||
const shortcutTemp = shortcutService.getShortcutById(shortcutId);
|
||||
if (shortcutTemp) {
|
||||
setName(shortcutTemp.name);
|
||||
setLink(shortcutTemp.link);
|
||||
}
|
||||
}
|
||||
}, [shortcutId]);
|
||||
|
||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setName(text);
|
||||
};
|
||||
|
||||
const handleLinkInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setLink(text);
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!name) {
|
||||
toastHelper.error("Name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (shortcutId) {
|
||||
await shortcutService.patchShortcut({
|
||||
id: shortcutId,
|
||||
name,
|
||||
link,
|
||||
});
|
||||
} else {
|
||||
await shortcutService.createShortcut({
|
||||
workspaceId,
|
||||
name,
|
||||
link,
|
||||
visibility: "PRIVATE",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
}
|
||||
destroy();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex flex-row justify-between items-center mb-4">
|
||||
<p className="text-base">{shortcutId ? "Edit Shortcut" : "Create Shortcut"}</p>
|
||||
<button className="rounded p-1 hover:bg-gray-100" onClick={destroy}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
||||
<input className="rounded border px-2 py-2" type="text" placeholder="Name" value={name} onChange={handleNameInputChange} />
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
||||
<input className="rounded border px-2 py-2" type="text" placeholder="Link" value={link} onChange={handleLinkInputChange} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-end items-center mt-2">
|
||||
<div className="flex flex-row justify-start items-center">
|
||||
<button
|
||||
className={`border rounded px-2 py-1 border-green-600 text-green-600 ${requestState.isLoading ? "opacity-80" : ""}`}
|
||||
onClick={handleSaveBtnClick}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function showCreateShortcutDialog(workspaceId: WorkspaceId, shortcutId?: ShortcutId): void {
|
||||
generateDialog(
|
||||
{
|
||||
className: "px-2 sm:px-0",
|
||||
},
|
||||
CreateShortcutDialog,
|
||||
{
|
||||
workspaceId,
|
||||
shortcutId,
|
||||
}
|
||||
);
|
||||
}
|
110
web/src/components/CreateWorkspaceDialog.tsx
Normal file
110
web/src/components/CreateWorkspaceDialog.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { workspaceService } from "../services";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
workspaceId?: WorkspaceId;
|
||||
}
|
||||
|
||||
const CreateWorkspaceDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy, workspaceId } = props;
|
||||
const [name, setName] = useState<string>("");
|
||||
const [description, setDescription] = useState<string>("");
|
||||
const requestState = useLoading(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceId) {
|
||||
const workspaceTemp = workspaceService.getWorkspaceById(workspaceId);
|
||||
if (workspaceTemp) {
|
||||
setName(workspaceTemp.name);
|
||||
setDescription(workspaceTemp.description);
|
||||
}
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setName(text);
|
||||
};
|
||||
|
||||
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setDescription(text);
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!name) {
|
||||
toastHelper.error("Name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (workspaceId) {
|
||||
await workspaceService.patchWorkspace({
|
||||
id: workspaceId,
|
||||
name,
|
||||
description,
|
||||
});
|
||||
} else {
|
||||
await workspaceService.createWorkspace({
|
||||
name,
|
||||
description,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
}
|
||||
destroy();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex flex-row justify-between items-center mb-4">
|
||||
<p className="text-base">{workspaceId ? "Edit Workspace" : "Create Workspace"}</p>
|
||||
<button className="rounded p-1 hover:bg-gray-100" onClick={destroy}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
||||
<input className="rounded border px-2 py-2" type="text" placeholder="Name" value={name} onChange={handleNameInputChange} />
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
||||
<input
|
||||
className="rounded border px-2 py-2"
|
||||
type="text"
|
||||
placeholder="Description"
|
||||
value={description}
|
||||
onChange={handleDescriptionInputChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-end items-center mt-2">
|
||||
<div className="flex flex-row justify-start items-center">
|
||||
<button
|
||||
className={`border rounded px-2 py-1 border-green-600 text-green-600 ${requestState.isLoading ? "opacity-80" : ""}`}
|
||||
onClick={handleSaveBtnClick}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function showCreateWorkspaceDialog(workspaceId?: WorkspaceId): void {
|
||||
generateDialog(
|
||||
{
|
||||
className: "px-2 sm:px-0",
|
||||
},
|
||||
CreateWorkspaceDialog,
|
||||
{
|
||||
workspaceId,
|
||||
}
|
||||
);
|
||||
}
|
89
web/src/components/Dialog/BaseDialog.tsx
Normal file
89
web/src/components/Dialog/BaseDialog.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Provider } from "react-redux";
|
||||
import { ANIMATION_DURATION } from "../../helpers/consts";
|
||||
import store from "../../store";
|
||||
import "../../less/base-dialog.less";
|
||||
|
||||
interface DialogConfig {
|
||||
className: string;
|
||||
clickSpaceDestroy?: boolean;
|
||||
}
|
||||
|
||||
interface Props extends DialogConfig, DialogProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const BaseDialog: React.FC<Props> = (props: Props) => {
|
||||
const { children, className, clickSpaceDestroy, destroy } = props;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code === "Escape") {
|
||||
destroy();
|
||||
}
|
||||
};
|
||||
|
||||
document.body.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSpaceClicked = () => {
|
||||
if (clickSpaceDestroy) {
|
||||
destroy();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`dialog-wrapper ${className}`} onClick={handleSpaceClicked}>
|
||||
<div className="dialog-container" onClick={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function generateDialog<T extends DialogProps>(
|
||||
config: DialogConfig,
|
||||
DialogComponent: React.FC<T>,
|
||||
props?: Omit<T, "destroy">
|
||||
): DialogCallback {
|
||||
const tempDiv = document.createElement("div");
|
||||
const dialog = createRoot(tempDiv);
|
||||
document.body.append(tempDiv);
|
||||
|
||||
setTimeout(() => {
|
||||
tempDiv.firstElementChild?.classList.add("showup");
|
||||
}, 0);
|
||||
|
||||
const cbs: DialogCallback = {
|
||||
destroy: () => {
|
||||
tempDiv.firstElementChild?.classList.remove("showup");
|
||||
tempDiv.firstElementChild?.classList.add("showoff");
|
||||
setTimeout(() => {
|
||||
dialog.unmount();
|
||||
tempDiv.remove();
|
||||
}, ANIMATION_DURATION);
|
||||
},
|
||||
};
|
||||
|
||||
const dialogProps = {
|
||||
...props,
|
||||
destroy: cbs.destroy,
|
||||
} as T;
|
||||
|
||||
const Fragment = (
|
||||
<Provider store={store}>
|
||||
<BaseDialog destroy={cbs.destroy} clickSpaceDestroy={true} {...config}>
|
||||
<DialogComponent {...dialogProps} />
|
||||
</BaseDialog>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
dialog.render(Fragment);
|
||||
|
||||
return cbs;
|
||||
}
|
85
web/src/components/Dialog/CommonDialog.tsx
Normal file
85
web/src/components/Dialog/CommonDialog.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import Icon from "../Icon";
|
||||
import { generateDialog } from "./BaseDialog";
|
||||
import "../../less/common-dialog.less";
|
||||
|
||||
type DialogStyle = "info" | "warning";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
title: string;
|
||||
content: string;
|
||||
style?: DialogStyle;
|
||||
closeBtnText?: string;
|
||||
confirmBtnText?: string;
|
||||
onClose?: () => void;
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
title: "",
|
||||
content: "",
|
||||
style: "info",
|
||||
closeBtnText: "Close",
|
||||
confirmBtnText: "Confirm",
|
||||
onClose: () => null,
|
||||
onConfirm: () => null,
|
||||
};
|
||||
|
||||
const CommonDialog: React.FC<Props> = (props: Props) => {
|
||||
const { title, content, destroy, closeBtnText, confirmBtnText, onClose, onConfirm, style } = {
|
||||
...defaultProps,
|
||||
...props,
|
||||
};
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
onClose();
|
||||
destroy();
|
||||
};
|
||||
|
||||
const handleConfirmBtnClick = async () => {
|
||||
onConfirm();
|
||||
destroy();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">{title}</p>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<p className="content-text">{content}</p>
|
||||
<div className="btns-container">
|
||||
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
|
||||
{closeBtnText}
|
||||
</span>
|
||||
<span className={`btn confirm-btn ${style}`} onClick={handleConfirmBtnClick}>
|
||||
{confirmBtnText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface CommonDialogProps {
|
||||
title: string;
|
||||
content: string;
|
||||
className?: string;
|
||||
style?: DialogStyle;
|
||||
closeBtnText?: string;
|
||||
confirmBtnText?: string;
|
||||
onClose?: () => void;
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
export const showCommonDialog = (props: CommonDialogProps) => {
|
||||
generateDialog(
|
||||
{
|
||||
className: `common-dialog ${props?.className ?? ""}`,
|
||||
},
|
||||
CommonDialog,
|
||||
props
|
||||
);
|
||||
};
|
1
web/src/components/Dialog/index.ts
Normal file
1
web/src/components/Dialog/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { generateDialog } from "./BaseDialog";
|
42
web/src/components/Header.tsx
Normal file
42
web/src/components/Header.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAppSelector } from "../store";
|
||||
import { userService } from "../services";
|
||||
import useToggle from "../hooks/useToggle";
|
||||
import Icon from "./Icon";
|
||||
import styles from "../less/header.module.less";
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAppSelector((state) => state.user);
|
||||
const [showDropdown, toggleShowDropdown] = useToggle(false);
|
||||
|
||||
const handleSignOutButtonClick = async () => {
|
||||
await userService.doSignOut();
|
||||
navigate("/auth");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className="w-full max-w-4xl mx-auto px-3 py-4 flex flex-row justify-between items-center">
|
||||
<span>Corgi</span>
|
||||
<div className="relative">
|
||||
<div className="flex flex-row justify-end items-center" onClick={() => toggleShowDropdown()}>
|
||||
<span>{user?.name}</span>
|
||||
<Icon.ChevronDown className="ml-1 w-5 h-auto text-gray-600" />
|
||||
</div>
|
||||
<div
|
||||
className={`bg-white flex flex-col justify-start items-start p-1 w-32 absolute top-8 right-0 shadow rounded ${
|
||||
showDropdown ? "" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<span className="w-full px-2 leading-8 cursor-pointer rounded hover:bg-gray-100" onClick={() => handleSignOutButtonClick()}>
|
||||
Sign out
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
3
web/src/components/Icon.ts
Normal file
3
web/src/components/Icon.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import * as Icon from "react-feather";
|
||||
|
||||
export default Icon;
|
22
web/src/components/ShortcutListView.tsx
Normal file
22
web/src/components/ShortcutListView.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
interface Props {
|
||||
shortcutList: Shortcut[];
|
||||
}
|
||||
|
||||
const ShortcutListView: React.FC<Props> = (props: Props) => {
|
||||
const { shortcutList } = props;
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
{shortcutList.map((shortcut) => {
|
||||
return (
|
||||
<div key={shortcut.id} className="w-full flex flex-col justify-start items-start border px-6 py-4 mb-2 rounded-lg">
|
||||
<span className="text-xl font-medium">{shortcut.name}</span>
|
||||
<span className="text-base text-gray-600">{shortcut.link}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutListView;
|
110
web/src/components/Toast.tsx
Normal file
110
web/src/components/Toast.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import { useEffect } from "react";
|
||||
import { createRoot, Root } from "react-dom/client";
|
||||
import "../less/toast.less";
|
||||
|
||||
type ToastType = "normal" | "success" | "info" | "error";
|
||||
|
||||
type ToastConfig = {
|
||||
type: ToastType;
|
||||
content: string;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
type ToastItemProps = {
|
||||
type: ToastType;
|
||||
content: string;
|
||||
duration: number;
|
||||
destory: FunctionType;
|
||||
};
|
||||
|
||||
const Toast: React.FC<ToastItemProps> = (props: ToastItemProps) => {
|
||||
const { destory, duration } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
destory();
|
||||
}, duration);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="toast-container" onClick={destory}>
|
||||
<p className="content-text">{props.content}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// toast animation duration.
|
||||
const TOAST_ANIMATION_DURATION = 400;
|
||||
|
||||
const initialToastHelper = () => {
|
||||
const shownToastContainers: [Root, HTMLDivElement][] = [];
|
||||
let shownToastAmount = 0;
|
||||
|
||||
const wrapperClassName = "toast-list-container";
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.className = wrapperClassName;
|
||||
document.body.appendChild(tempDiv);
|
||||
const toastWrapper = tempDiv;
|
||||
|
||||
const showToast = (config: ToastConfig) => {
|
||||
const tempDiv = document.createElement("div");
|
||||
const toast = createRoot(tempDiv);
|
||||
tempDiv.className = `toast-wrapper ${config.type}`;
|
||||
toastWrapper.appendChild(tempDiv);
|
||||
shownToastAmount++;
|
||||
shownToastContainers.push([toast, tempDiv]);
|
||||
|
||||
const cbs = {
|
||||
destory: () => {
|
||||
tempDiv.classList.add("destory");
|
||||
|
||||
setTimeout(() => {
|
||||
if (!tempDiv.parentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
shownToastAmount--;
|
||||
if (shownToastAmount === 0) {
|
||||
for (const [root, tempDiv] of shownToastContainers) {
|
||||
root.unmount();
|
||||
tempDiv.remove();
|
||||
}
|
||||
shownToastContainers.splice(0, shownToastContainers.length);
|
||||
}
|
||||
}, TOAST_ANIMATION_DURATION);
|
||||
},
|
||||
};
|
||||
|
||||
toast.render(<Toast {...config} destory={cbs.destory} />);
|
||||
|
||||
setTimeout(() => {
|
||||
tempDiv.classList.add("showup");
|
||||
}, 10);
|
||||
|
||||
return cbs;
|
||||
};
|
||||
|
||||
const info = (content: string, duration = 3000) => {
|
||||
return showToast({ type: "normal", content, duration });
|
||||
};
|
||||
|
||||
const success = (content: string, duration = 3000) => {
|
||||
return showToast({ type: "success", content, duration });
|
||||
};
|
||||
|
||||
const error = (content: string, duration = 3000) => {
|
||||
return showToast({ type: "error", content, duration });
|
||||
};
|
||||
|
||||
return {
|
||||
info,
|
||||
success,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
const toastHelper = initialToastHelper();
|
||||
|
||||
export default toastHelper;
|
31
web/src/components/WorkspaceListView.tsx
Normal file
31
web/src/components/WorkspaceListView.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface Props {
|
||||
workspaceList: Workspace[];
|
||||
}
|
||||
|
||||
const WorkspaceListView: React.FC<Props> = (props: Props) => {
|
||||
const { workspaceList } = props;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const gotoWorkspaceDetailPage = (workspace: Workspace) => {
|
||||
navigate(`/workspace/${workspace.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
{workspaceList.map((workspace) => {
|
||||
return (
|
||||
<div key={workspace.id} className="w-full flex flex-col justify-start items-start border px-6 py-4 mb-2 rounded-lg">
|
||||
<span className="text-xl font-medium" onClick={() => gotoWorkspaceDetailPage(workspace)}>
|
||||
{workspace.name}
|
||||
</span>
|
||||
<span className="text-base text-gray-600">{workspace.description}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceListView;
|
120
web/src/components/common/DatePicker.tsx
Normal file
120
web/src/components/common/DatePicker.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { DAILY_TIMESTAMP } from "../../helpers/consts";
|
||||
import Icon from "../Icon";
|
||||
import "../../less/common/date-picker.less";
|
||||
|
||||
interface DatePickerProps {
|
||||
className?: string;
|
||||
datestamp: DateStamp;
|
||||
handleDateStampChange: (datastamp: DateStamp) => void;
|
||||
}
|
||||
|
||||
const DatePicker: React.FC<DatePickerProps> = (props: DatePickerProps) => {
|
||||
const { className, datestamp, handleDateStampChange } = props;
|
||||
const [currentDateStamp, setCurrentDateStamp] = useState<DateStamp>(getMonthFirstDayDateStamp(datestamp));
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentDateStamp(getMonthFirstDayDateStamp(datestamp));
|
||||
}, [datestamp]);
|
||||
|
||||
const firstDate = new Date(currentDateStamp);
|
||||
const firstDateDay = firstDate.getDay() === 0 ? 7 : firstDate.getDay();
|
||||
const dayList = [];
|
||||
for (let i = 1; i < firstDateDay; i++) {
|
||||
dayList.push({
|
||||
date: 0,
|
||||
datestamp: firstDate.getTime() - DAILY_TIMESTAMP * (7 - i),
|
||||
});
|
||||
}
|
||||
const dayAmount = getMonthDayAmount(currentDateStamp);
|
||||
for (let i = 1; i <= dayAmount; i++) {
|
||||
dayList.push({
|
||||
date: i,
|
||||
datestamp: firstDate.getTime() + DAILY_TIMESTAMP * (i - 1),
|
||||
});
|
||||
}
|
||||
|
||||
const handleDateItemClick = (datestamp: DateStamp) => {
|
||||
handleDateStampChange(datestamp);
|
||||
};
|
||||
|
||||
const handleChangeMonthBtnClick = (i: -1 | 1) => {
|
||||
const year = firstDate.getFullYear();
|
||||
const month = firstDate.getMonth() + 1;
|
||||
let nextDateStamp = 0;
|
||||
if (month === 1 && i === -1) {
|
||||
nextDateStamp = new Date(`${year - 1}/12/1`).getTime();
|
||||
} else if (month === 12 && i === 1) {
|
||||
nextDateStamp = new Date(`${year + 1}/1/1`).getTime();
|
||||
} else {
|
||||
nextDateStamp = new Date(`${year}/${month + i}/1`).getTime();
|
||||
}
|
||||
setCurrentDateStamp(getMonthFirstDayDateStamp(nextDateStamp));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`date-picker-wrapper ${className}`}>
|
||||
<div className="date-picker-header">
|
||||
<span className="btn-text" onClick={() => handleChangeMonthBtnClick(-1)}>
|
||||
<Icon.ChevronLeft className="icon-img" />
|
||||
</span>
|
||||
<span className="normal-text">
|
||||
{firstDate.getFullYear()}/{firstDate.getMonth() + 1}
|
||||
</span>
|
||||
<span className="btn-text" onClick={() => handleChangeMonthBtnClick(1)}>
|
||||
<Icon.ChevronRight className="icon-img" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="date-picker-day-container">
|
||||
<div className="date-picker-day-header">
|
||||
<span className="day-item">Mon</span>
|
||||
<span className="day-item">Tue</span>
|
||||
<span className="day-item">Web</span>
|
||||
<span className="day-item">Thu</span>
|
||||
<span className="day-item">Fri</span>
|
||||
<span className="day-item">Sat</span>
|
||||
<span className="day-item">Sun</span>
|
||||
</div>
|
||||
|
||||
{dayList.map((d) => {
|
||||
if (d.date === 0) {
|
||||
return (
|
||||
<span key={d.datestamp} className="day-item null">
|
||||
{""}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span
|
||||
key={d.datestamp}
|
||||
className={`day-item ${d.datestamp === datestamp ? "current" : ""}`}
|
||||
onClick={() => handleDateItemClick(d.datestamp)}
|
||||
>
|
||||
{d.date}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getMonthDayAmount(datestamp: DateStamp): number {
|
||||
const dateTemp = new Date(datestamp);
|
||||
const currentDate = new Date(`${dateTemp.getFullYear()}/${dateTemp.getMonth() + 1}/1`);
|
||||
const nextMonthDate =
|
||||
currentDate.getMonth() === 11
|
||||
? new Date(`${currentDate.getFullYear() + 1}/1/1`)
|
||||
: new Date(`${currentDate.getFullYear()}/${currentDate.getMonth() + 2}/1`);
|
||||
|
||||
return (nextMonthDate.getTime() - currentDate.getTime()) / DAILY_TIMESTAMP;
|
||||
}
|
||||
|
||||
function getMonthFirstDayDateStamp(timestamp: TimeStamp): DateStamp {
|
||||
const dateTemp = new Date(timestamp);
|
||||
const currentDate = new Date(`${dateTemp.getFullYear()}/${dateTemp.getMonth() + 1}/1`);
|
||||
return currentDate.getTime();
|
||||
}
|
||||
|
||||
export default DatePicker;
|
40
web/src/components/common/Dropdown.tsx
Normal file
40
web/src/components/common/Dropdown.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { ReactNode, useEffect, useRef } from "react";
|
||||
import useToggle from "../../hooks/useToggle";
|
||||
import Icon from "../Icon";
|
||||
import "../../less/common/dropdown.less";
|
||||
|
||||
interface DropdownProps {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Dropdown: React.FC<DropdownProps> = (props: DropdownProps) => {
|
||||
const { children, className } = props;
|
||||
const [dropdownStatus, toggleDropdownStatus] = useToggle(false);
|
||||
const dropdownWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (dropdownStatus) {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!dropdownWrapperRef.current?.contains(event.target as Node)) {
|
||||
toggleDropdownStatus(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("click", handleClickOutside, {
|
||||
capture: true,
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}, [dropdownStatus]);
|
||||
|
||||
return (
|
||||
<div ref={dropdownWrapperRef} className={`dropdown-wrapper ${className ?? ""}`} onClick={() => toggleDropdownStatus()}>
|
||||
<span className="trigger-button">
|
||||
<Icon.MoreHorizontal className="icon-img" />
|
||||
</span>
|
||||
<div className={`action-buttons-container ${dropdownStatus ? "" : "!hidden"}`}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dropdown;
|
15
web/src/components/common/OnlyWhen.tsx
Normal file
15
web/src/components/common/OnlyWhen.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface OnlyWhenProps {
|
||||
children: ReactNode;
|
||||
when: boolean;
|
||||
}
|
||||
|
||||
const OnlyWhen: React.FC<OnlyWhenProps> = (props: OnlyWhenProps) => {
|
||||
const { children, when } = props;
|
||||
return when ? <>{children}</> : null;
|
||||
};
|
||||
|
||||
const Only = OnlyWhen;
|
||||
|
||||
export default Only;
|
95
web/src/components/common/Selector.tsx
Normal file
95
web/src/components/common/Selector.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import useToggle from "../../hooks/useToggle";
|
||||
import Icon from "../Icon";
|
||||
import "../../less/common/selector.less";
|
||||
|
||||
interface SelectorItem {
|
||||
text: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
value: string;
|
||||
dataSource: SelectorItem[];
|
||||
handleValueChanged?: (value: string) => void;
|
||||
}
|
||||
|
||||
const nullItem = {
|
||||
text: "Select",
|
||||
value: "",
|
||||
};
|
||||
|
||||
const Selector: React.FC<Props> = (props: Props) => {
|
||||
const { className, dataSource, handleValueChanged, value } = props;
|
||||
const [showSelector, toggleSelectorStatus] = useToggle(false);
|
||||
|
||||
const seletorElRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
let currentItem = nullItem;
|
||||
for (const d of dataSource) {
|
||||
if (d.value === value) {
|
||||
currentItem = d;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (showSelector) {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!seletorElRef.current?.contains(event.target as Node)) {
|
||||
toggleSelectorStatus(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("click", handleClickOutside, {
|
||||
capture: true,
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}, [showSelector]);
|
||||
|
||||
const handleItemClick = (item: SelectorItem) => {
|
||||
if (handleValueChanged) {
|
||||
handleValueChanged(item.value);
|
||||
}
|
||||
toggleSelectorStatus(false);
|
||||
};
|
||||
|
||||
const handleCurrentValueClick = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
toggleSelectorStatus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`selector-wrapper ${className ?? ""}`} ref={seletorElRef}>
|
||||
<div className={`current-value-container ${showSelector ? "active" : ""}`} onClick={handleCurrentValueClick}>
|
||||
<span className="value-text">{currentItem.text}</span>
|
||||
<span className="arrow-text">
|
||||
<Icon.ChevronDown className="icon-img" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={`items-wrapper ${showSelector ? "" : "!hidden"}`}>
|
||||
{dataSource.length > 0 ? (
|
||||
dataSource.map((d) => {
|
||||
return (
|
||||
<div
|
||||
className={`item-container ${d.value === value ? "selected" : ""}`}
|
||||
key={d.value}
|
||||
onClick={() => {
|
||||
handleItemClick(d);
|
||||
}}
|
||||
>
|
||||
{d.text}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="tip-text">Null</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Selector);
|
12
web/src/css/index.css
Normal file
12
web/src/css/index.css
Normal file
@ -0,0 +1,12 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body,
|
||||
html,
|
||||
#root {
|
||||
@apply text-base w-full h-full;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans", "Noto Sans CJK SC", "Microsoft YaHei UI", "Microsoft YaHei",
|
||||
"WenQuanYi Micro Hei", sans-serif, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
}
|
100
web/src/helpers/api.ts
Normal file
100
web/src/helpers/api.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import axios from "axios";
|
||||
|
||||
type ResponseObject<T> = {
|
||||
data: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export function getSystemStatus() {
|
||||
return axios.get<ResponseObject<SystemStatus>>("/api/status");
|
||||
}
|
||||
|
||||
export function signin(email: string, password: string) {
|
||||
return axios.post<ResponseObject<User>>("/api/auth/signin", {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
export function signup(email: string, password: string) {
|
||||
return axios.post<ResponseObject<User>>("/api/auth/signup", {
|
||||
email,
|
||||
password,
|
||||
name: email,
|
||||
});
|
||||
}
|
||||
|
||||
export function signout() {
|
||||
return axios.post("/api/auth/logout");
|
||||
}
|
||||
|
||||
export function createUser(userCreate: UserCreate) {
|
||||
return axios.post<ResponseObject<User>>("/api/user", userCreate);
|
||||
}
|
||||
|
||||
export function getMyselfUser() {
|
||||
return axios.get<ResponseObject<User>>("/api/user/me");
|
||||
}
|
||||
|
||||
export function getUserList() {
|
||||
return axios.get<ResponseObject<User[]>>("/api/user");
|
||||
}
|
||||
|
||||
export function getUserById(id: number) {
|
||||
return axios.get<ResponseObject<User>>(`/api/user/${id}`);
|
||||
}
|
||||
|
||||
export function patchUser(userPatch: UserPatch) {
|
||||
return axios.patch<ResponseObject<User>>(`/api/user/${userPatch.id}`, userPatch);
|
||||
}
|
||||
|
||||
export function deleteUser(userDelete: UserDelete) {
|
||||
return axios.delete(`/api/user/${userDelete.id}`);
|
||||
}
|
||||
|
||||
export function getWorkspaceList(find?: WorkspaceFind) {
|
||||
const queryList = [];
|
||||
if (find?.creatorId) {
|
||||
queryList.push(`creatorId=${find.creatorId}`);
|
||||
}
|
||||
if (find?.memberId) {
|
||||
queryList.push(`memberId=${find.memberId}`);
|
||||
}
|
||||
return axios.get<ResponseObject<Workspace[]>>(`/api/workspace?${queryList.join("&")}`);
|
||||
}
|
||||
|
||||
export function createWorkspace(create: WorkspaceCreate) {
|
||||
return axios.post<ResponseObject<Workspace>>("/api/workspace", create);
|
||||
}
|
||||
|
||||
export function patchWorkspace(patch: WorkspacePatch) {
|
||||
return axios.patch<ResponseObject<Workspace>>(`/api/workspace/${patch.id}`, patch);
|
||||
}
|
||||
|
||||
export function deleteWorkspaceById(workspaceId: WorkspaceId) {
|
||||
return axios.delete(`/api/workspace/${workspaceId}`);
|
||||
}
|
||||
|
||||
export function getShortcutList(shortcutFind?: ShortcutFind) {
|
||||
const queryList = [];
|
||||
if (shortcutFind?.creatorId) {
|
||||
queryList.push(`creatorId=${shortcutFind.creatorId}`);
|
||||
}
|
||||
if (shortcutFind?.workspaceId) {
|
||||
queryList.push(`workspaceId=${shortcutFind.workspaceId}`);
|
||||
}
|
||||
return axios.get<ResponseObject<Shortcut[]>>(`/api/shortcut?${queryList.join("&")}`);
|
||||
}
|
||||
|
||||
export function createShortcut(shortcutCreate: ShortcutCreate) {
|
||||
return axios.post<ResponseObject<Shortcut>>("/api/shortcut", shortcutCreate);
|
||||
}
|
||||
|
||||
export function patchShortcut(shortcutPatch: ShortcutPatch) {
|
||||
return axios.patch<ResponseObject<Shortcut>>(`/api/shortcut/${shortcutPatch.id}`, shortcutPatch);
|
||||
}
|
||||
|
||||
export function deleteShortcutById(shortcutId: ShortcutId) {
|
||||
return axios.delete(`/api/shortcut/${shortcutId}`);
|
||||
}
|
8
web/src/helpers/consts.ts
Normal file
8
web/src/helpers/consts.ts
Normal file
@ -0,0 +1,8 @@
|
||||
// UNKNOWN_ID is the symbol for unknown id
|
||||
export const UNKNOWN_ID = -1;
|
||||
|
||||
// default animation duration
|
||||
export const ANIMATION_DURATION = 200;
|
||||
|
||||
// millisecond in a day
|
||||
export const DAILY_TIMESTAMP = 3600 * 24 * 1000;
|
15
web/src/helpers/polyfill.ts
Normal file
15
web/src/helpers/polyfill.ts
Normal file
@ -0,0 +1,15 @@
|
||||
(() => {
|
||||
if (!String.prototype.replaceAll) {
|
||||
String.prototype.replaceAll = function (str: any, newStr: any) {
|
||||
// If a regex pattern
|
||||
if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") {
|
||||
return this.replace(str, newStr);
|
||||
}
|
||||
|
||||
// If a string
|
||||
return this.replace(new RegExp(str, "g"), newStr);
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
export default null;
|
59
web/src/helpers/storage.ts
Normal file
59
web/src/helpers/storage.ts
Normal file
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Define storage data type
|
||||
*/
|
||||
interface StorageData {
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
type StorageKey = keyof StorageData;
|
||||
|
||||
/**
|
||||
* storage helper
|
||||
*/
|
||||
export function get(keys: StorageKey[]): Partial<StorageData> {
|
||||
const data: Partial<StorageData> = {};
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const stringifyValue = localStorage.getItem(key);
|
||||
if (stringifyValue !== null) {
|
||||
const val = JSON.parse(stringifyValue);
|
||||
data[key] = val;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Get storage failed in ", key, error);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export function set(data: Partial<StorageData>) {
|
||||
for (const key in data) {
|
||||
try {
|
||||
const stringifyValue = JSON.stringify(data[key as StorageKey]);
|
||||
localStorage.setItem(key, stringifyValue);
|
||||
} catch (error: any) {
|
||||
console.error("Save storage failed in ", key, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function remove(keys: StorageKey[]) {
|
||||
for (const key of keys) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch (error: any) {
|
||||
console.error("Remove storage failed in ", key, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function emitStorageChangedEvent() {
|
||||
const iframeEl = document.createElement("iframe");
|
||||
iframeEl.style.display = "none";
|
||||
document.body.appendChild(iframeEl);
|
||||
|
||||
iframeEl.contentWindow?.localStorage.setItem("t", Date.now().toString());
|
||||
iframeEl.remove();
|
||||
}
|
58
web/src/helpers/utils.ts
Normal file
58
web/src/helpers/utils.ts
Normal file
@ -0,0 +1,58 @@
|
||||
export function getNowTimeStamp(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
export function getOSVersion(): "Windows" | "MacOS" | "Linux" | "Unknown" {
|
||||
const appVersion = navigator.userAgent;
|
||||
let detectedOS: "Windows" | "MacOS" | "Linux" | "Unknown" = "Unknown";
|
||||
|
||||
if (appVersion.indexOf("Win") != -1) {
|
||||
detectedOS = "Windows";
|
||||
} else if (appVersion.indexOf("Mac") != -1) {
|
||||
detectedOS = "MacOS";
|
||||
} else if (appVersion.indexOf("Linux") != -1) {
|
||||
detectedOS = "Linux";
|
||||
}
|
||||
|
||||
return detectedOS;
|
||||
}
|
||||
|
||||
export function debounce(fn: FunctionType, delay: number) {
|
||||
let timer: number | null = null;
|
||||
|
||||
return () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(fn, delay);
|
||||
} else {
|
||||
timer = setTimeout(fn, delay);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function throttle(fn: FunctionType, delay: number) {
|
||||
let valid = true;
|
||||
|
||||
return () => {
|
||||
if (!valid) {
|
||||
return false;
|
||||
}
|
||||
valid = false;
|
||||
setTimeout(() => {
|
||||
fn();
|
||||
valid = true;
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
|
||||
export async function copyTextToClipboard(text: string) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (error: unknown) {
|
||||
console.warn("Copy to clipboard failed.", error);
|
||||
}
|
||||
} else {
|
||||
console.warn("Copy to clipboard failed, methods not supports.");
|
||||
}
|
||||
}
|
52
web/src/helpers/validator.ts
Normal file
52
web/src/helpers/validator.ts
Normal file
@ -0,0 +1,52 @@
|
||||
// Validator
|
||||
// * use for validating form data
|
||||
const chineseReg = /[\u3000\u3400-\u4DBF\u4E00-\u9FFF]/;
|
||||
|
||||
export interface ValidatorConfig {
|
||||
// min length
|
||||
minLength: number;
|
||||
// max length
|
||||
maxLength: number;
|
||||
// no space
|
||||
noSpace: boolean;
|
||||
// no chinese
|
||||
noChinese: boolean;
|
||||
}
|
||||
|
||||
export function validate(text: string, config: Partial<ValidatorConfig>): { result: boolean; reason?: string } {
|
||||
if (config.minLength !== undefined) {
|
||||
if (text.length < config.minLength) {
|
||||
return {
|
||||
result: false,
|
||||
reason: "Too short",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (config.maxLength !== undefined) {
|
||||
if (text.length > config.maxLength) {
|
||||
return {
|
||||
result: false,
|
||||
reason: "Too long",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (config.noSpace && text.includes(" ")) {
|
||||
return {
|
||||
result: false,
|
||||
reason: "Don't allow space",
|
||||
};
|
||||
}
|
||||
|
||||
if (config.noChinese && chineseReg.test(text)) {
|
||||
return {
|
||||
result: false,
|
||||
reason: "Don't allow chinese",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
result: true,
|
||||
};
|
||||
}
|
35
web/src/hooks/useLoading.ts
Normal file
35
web/src/hooks/useLoading.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { useState } from "react";
|
||||
|
||||
const useLoading = (initialState = true) => {
|
||||
const [state, setState] = useState({ isLoading: initialState, isFailed: false, isSucceed: false });
|
||||
|
||||
return {
|
||||
...state,
|
||||
setLoading: () => {
|
||||
setState({
|
||||
...state,
|
||||
isLoading: true,
|
||||
isFailed: false,
|
||||
isSucceed: false,
|
||||
});
|
||||
},
|
||||
setFinish: () => {
|
||||
setState({
|
||||
...state,
|
||||
isLoading: false,
|
||||
isFailed: false,
|
||||
isSucceed: true,
|
||||
});
|
||||
},
|
||||
setError: () => {
|
||||
setState({
|
||||
...state,
|
||||
isLoading: false,
|
||||
isFailed: true,
|
||||
isSucceed: false,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default useLoading;
|
15
web/src/hooks/useRefresh.ts
Normal file
15
web/src/hooks/useRefresh.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
const useRefresh = () => {
|
||||
const [, setBoolean] = useState<boolean>(false);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setBoolean((ps) => {
|
||||
return !ps;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return refresh;
|
||||
};
|
||||
|
||||
export default useRefresh;
|
21
web/src/hooks/useToggle.ts
Normal file
21
web/src/hooks/useToggle.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
// Parameter is the boolean, with default "false" value
|
||||
const useToggle = (initialState = false): [boolean, (nextState?: boolean) => void] => {
|
||||
// Initialize the state
|
||||
const [state, setState] = useState(initialState);
|
||||
|
||||
// Define and memorize toggler function in case we pass down the comopnent,
|
||||
// This function change the boolean value to it's opposite value
|
||||
const toggle = useCallback((nextState?: boolean) => {
|
||||
if (nextState !== undefined) {
|
||||
setState(nextState);
|
||||
} else {
|
||||
setState((state) => !state);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return [state, toggle];
|
||||
};
|
||||
|
||||
export default useToggle;
|
103
web/src/less/auth.less
Normal file
103
web/src/less/auth.less
Normal file
@ -0,0 +1,103 @@
|
||||
.page-wrapper.auth {
|
||||
@apply flex flex-row justify-center items-center w-full h-screen bg-white;
|
||||
|
||||
> .page-container {
|
||||
@apply w-80 max-w-full h-full py-4 flex flex-col justify-start items-center;
|
||||
|
||||
> .auth-form-wrapper {
|
||||
@apply w-full py-4 grow flex flex-col justify-center items-center;
|
||||
|
||||
> .page-header-container {
|
||||
@apply flex flex-col justify-start items-start w-full mb-4;
|
||||
|
||||
> .title-container {
|
||||
@apply w-full flex flex-row justify-between items-center;
|
||||
|
||||
> .logo-img {
|
||||
@apply h-20 w-auto;
|
||||
}
|
||||
}
|
||||
|
||||
> .slogan-text {
|
||||
@apply text-sm text-gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
> .page-content-container {
|
||||
@apply flex flex-col justify-start items-start w-full;
|
||||
|
||||
> .form-item-container {
|
||||
@apply flex flex-col justify-start items-start relative w-full text-base mt-2;
|
||||
|
||||
> .normal-text {
|
||||
@apply absolute top-3 left-3 px-1 leading-10 flex-shrink-0 text-base cursor-text text-gray-400 bg-transparent transition-all select-none;
|
||||
|
||||
&.not-null {
|
||||
@apply text-sm top-0 z-10 leading-4 bg-white rounded;
|
||||
}
|
||||
}
|
||||
|
||||
&.input-form-container {
|
||||
@apply py-2;
|
||||
|
||||
> input {
|
||||
@apply w-full py-3 px-3 text-base shadow-inner rounded-lg border border-solid border-gray-400 hover:opacity-80;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.requesting {
|
||||
@apply opacity-80;
|
||||
}
|
||||
}
|
||||
|
||||
> .action-btns-container {
|
||||
@apply flex flex-row justify-end items-center w-full mt-2;
|
||||
|
||||
> .btn {
|
||||
@apply flex flex-row justify-center items-center px-1 py-2 text-sm rounded hover:opacity-80;
|
||||
|
||||
&.signin-btn {
|
||||
@apply bg-green-600 text-white px-3 shadow;
|
||||
}
|
||||
|
||||
&.requesting {
|
||||
@apply cursor-wait opacity-80;
|
||||
}
|
||||
|
||||
> .img-icon {
|
||||
@apply w-4 h-auto mr-1 animate-spin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .tip-text {
|
||||
@apply w-full inline-block float-right text-sm mt-4 text-gray-500 text-right whitespace-pre-wrap;
|
||||
|
||||
&.host-tip {
|
||||
@apply text-blue-600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .footer-container {
|
||||
@apply w-full flex flex-col justify-start items-center;
|
||||
|
||||
> .language-container {
|
||||
@apply mt-2 w-full flex flex-row justify-center items-center text-sm text-gray-400;
|
||||
|
||||
> .locale-item {
|
||||
@apply px-2 cursor-pointer;
|
||||
|
||||
&.active {
|
||||
@apply text-blue-600 font-bold;
|
||||
}
|
||||
}
|
||||
|
||||
> .split-line {
|
||||
@apply font-mono text-gray-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
37
web/src/less/base-dialog.less
Normal file
37
web/src/less/base-dialog.less
Normal file
@ -0,0 +1,37 @@
|
||||
.dialog-wrapper {
|
||||
@apply fixed top-0 left-0 flex flex-col justify-start items-center w-full h-full pt-16 z-100 overflow-x-hidden overflow-y-scroll bg-transparent transition-all;
|
||||
|
||||
&.showup {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
&.showoff {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> .dialog-container {
|
||||
@apply flex flex-col justify-start items-start bg-white p-4 rounded-lg;
|
||||
|
||||
> .dialog-header-container {
|
||||
@apply flex flex-row justify-between items-center w-full mb-4;
|
||||
|
||||
> .title-text {
|
||||
> .icon-text {
|
||||
@apply mr-2 text-base;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply flex flex-col justify-center items-center w-6 h-6 rounded hover:bg-gray-100 hover:shadow;
|
||||
}
|
||||
}
|
||||
|
||||
> .dialog-content-container {
|
||||
@apply flex flex-col justify-start items-start w-full;
|
||||
}
|
||||
|
||||
> .dialog-footer-container {
|
||||
@apply flex flex-row justify-end items-center w-full mt-4;
|
||||
}
|
||||
}
|
||||
}
|
65
web/src/less/common/date-picker.less
Normal file
65
web/src/less/common/date-picker.less
Normal file
@ -0,0 +1,65 @@
|
||||
@import "../mixin.less";
|
||||
|
||||
.date-picker-wrapper {
|
||||
@apply flex flex-col justify-start items-start p-4;
|
||||
|
||||
> .date-picker-header {
|
||||
@apply flex flex-row justify-center items-center w-full mb-2;
|
||||
|
||||
> .btn-text {
|
||||
@apply w-6 h-6 rounded cursor-pointer select-none flex flex-col justify-center items-center opacity-40 hover:bg-gray-200;
|
||||
}
|
||||
|
||||
> .normal-text {
|
||||
@apply mx-1 leading-6 font-mono;
|
||||
}
|
||||
}
|
||||
|
||||
> .date-picker-day-container {
|
||||
.flex(row, flex-start, flex-start);
|
||||
width: 280px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
> .date-picker-day-header {
|
||||
.flex(row, space-around, center);
|
||||
width: 100%;
|
||||
|
||||
> .day-item {
|
||||
.flex(column, center, center);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
user-select: none;
|
||||
color: gray;
|
||||
font-size: 13px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
> .day-item {
|
||||
.flex(column, center, center);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
margin: 2px;
|
||||
|
||||
&:hover {
|
||||
background-color: @bg-whitegray;
|
||||
}
|
||||
|
||||
&.current {
|
||||
background-color: @bg-light-blue;
|
||||
font-size: 16px;
|
||||
color: @text-blue;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.null {
|
||||
background-color: unset;
|
||||
cursor: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
21
web/src/less/common/dropdown.less
Normal file
21
web/src/less/common/dropdown.less
Normal file
@ -0,0 +1,21 @@
|
||||
@import "../mixin.less";
|
||||
|
||||
.dropdown-wrapper {
|
||||
@apply relative flex flex-col justify-start items-start select-none;
|
||||
|
||||
> .trigger-button {
|
||||
@apply flex flex-row justify-center items-center border p-1 rounded shadow text-gray-600 cursor-pointer hover:opacity-80;
|
||||
|
||||
> .icon-img {
|
||||
@apply w-4 h-auto;
|
||||
}
|
||||
}
|
||||
|
||||
> .action-buttons-container {
|
||||
@apply w-28 mt-1 absolute top-full right-0 flex flex-col justify-start items-start bg-white z-1 border p-1 rounded shadow;
|
||||
|
||||
> button {
|
||||
@apply w-full text-left px-2 text-sm leading-7 rounded hover:bg-gray-100;
|
||||
}
|
||||
}
|
||||
}
|
47
web/src/less/common/selector.less
Normal file
47
web/src/less/common/selector.less
Normal file
@ -0,0 +1,47 @@
|
||||
@import "../mixin.less";
|
||||
|
||||
.selector-wrapper {
|
||||
@apply flex flex-col justify-start items-start relative h-8;
|
||||
|
||||
> .current-value-container {
|
||||
@apply flex flex-row justify-between items-center w-full h-full rounded px-2 pr-1 bg-white border cursor-pointer select-none;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
> .value-text {
|
||||
@apply text-sm mr-0 truncate;
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
|
||||
> .arrow-text {
|
||||
@apply flex flex-row justify-center items-center w-4 shrink-0;
|
||||
|
||||
> .icon-img {
|
||||
@apply w-4 h-auto opacity-40;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .items-wrapper {
|
||||
@apply flex flex-col justify-start items-start absolute top-full left-0 w-auto p-1 mt-1 -ml-2 bg-white rounded-md overflow-y-auto z-1;
|
||||
min-width: calc(100% + 16px);
|
||||
max-height: 256px;
|
||||
box-shadow: 0 0 8px 0 rgb(0 0 0 / 20%);
|
||||
.hide-scroll-bar();
|
||||
|
||||
> .item-container {
|
||||
@apply flex flex-col justify-start items-start w-full px-3 text-sm select-none leading-8 cursor-pointer rounded whitespace-nowrap hover:bg-gray-100;
|
||||
|
||||
&.selected {
|
||||
color: @text-green;
|
||||
}
|
||||
}
|
||||
|
||||
> .tip-text {
|
||||
@apply px-3 py-1 text-sm text-gray-600;
|
||||
}
|
||||
}
|
||||
}
|
3
web/src/less/header.module.less
Normal file
3
web/src/less/header.module.less
Normal file
@ -0,0 +1,3 @@
|
||||
.header {
|
||||
@apply w-full bg-amber-50;
|
||||
}
|
25
web/src/less/toast.less
Normal file
25
web/src/less/toast.less
Normal file
@ -0,0 +1,25 @@
|
||||
.toast-list-container {
|
||||
@apply flex flex-col justify-start items-end fixed top-2 right-4 z-1000 max-h-full;
|
||||
|
||||
> .toast-wrapper {
|
||||
@apply flex flex-col justify-start items-start relative left-full invisible text-base cursor-pointer shadow-lg rounded bg-white mt-6 py-2 px-4;
|
||||
min-width: 6em;
|
||||
left: calc(100% + 32px);
|
||||
transition: all 0.4s ease;
|
||||
|
||||
&.showup {
|
||||
@apply left-0 visible;
|
||||
}
|
||||
|
||||
&.destory {
|
||||
@apply invisible;
|
||||
left: calc(100% + 32px);
|
||||
}
|
||||
|
||||
> .toast-container {
|
||||
> .content-text {
|
||||
@apply text-sm whitespace-pre-wrap break-words leading-6 max-w-xs;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
17
web/src/main.tsx
Normal file
17
web/src/main.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Provider } from "react-redux";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import store from "./store";
|
||||
import App from "./App";
|
||||
import "./helpers/polyfill";
|
||||
import "./css/index.css";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
const root = createRoot(container as HTMLElement);
|
||||
root.render(
|
||||
<BrowserRouter>
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
</BrowserRouter>
|
||||
);
|
122
web/src/pages/Auth.tsx
Normal file
122
web/src/pages/Auth.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import * as api from "../helpers/api";
|
||||
import { validate, ValidatorConfig } from "../helpers/validator";
|
||||
import { userService } from "../services";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import Icon from "../components/Icon";
|
||||
import Only from "../components/common/OnlyWhen";
|
||||
import toastHelper from "../components/Toast";
|
||||
import "../less/auth.less";
|
||||
|
||||
const validateConfig: ValidatorConfig = {
|
||||
minLength: 4,
|
||||
maxLength: 24,
|
||||
noSpace: true,
|
||||
noChinese: true,
|
||||
};
|
||||
|
||||
const Auth: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const actionBtnLoadingState = useLoading(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (userService.getState().user) {
|
||||
navigate("/");
|
||||
return;
|
||||
}
|
||||
|
||||
api.getSystemStatus().then(({ data }) => {
|
||||
const { data: status } = data;
|
||||
if (status.profile.mode === "dev") {
|
||||
setEmail("demo@iamcorgi.com");
|
||||
setPassword("secret");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleEmailInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setEmail(text);
|
||||
};
|
||||
|
||||
const handlePasswordInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setPassword(text);
|
||||
};
|
||||
|
||||
const handleSigninBtnsClick = async () => {
|
||||
if (actionBtnLoadingState.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emailValidResult = validate(email, validateConfig);
|
||||
if (!emailValidResult.result) {
|
||||
toastHelper.error("Email: " + emailValidResult.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordValidResult = validate(password, validateConfig);
|
||||
if (!passwordValidResult.result) {
|
||||
toastHelper.error("Password: " + passwordValidResult.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
actionBtnLoadingState.setLoading();
|
||||
await api.signin(email, password);
|
||||
const user = await userService.doSignIn();
|
||||
if (user) {
|
||||
navigate("/", {
|
||||
replace: true,
|
||||
});
|
||||
} else {
|
||||
toastHelper.error("Login failed");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
}
|
||||
actionBtnLoadingState.setFinish();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-wrapper auth">
|
||||
<div className="page-container">
|
||||
<div className="auth-form-wrapper">
|
||||
<div className="page-header-container">
|
||||
<div className="title-container">
|
||||
<img className="logo-img" src="/logo-full.webp" alt="" />
|
||||
</div>
|
||||
<p className="slogan-text">Corgi</p>
|
||||
</div>
|
||||
<div className={`page-content-container ${actionBtnLoadingState.isLoading ? "requesting" : ""}`}>
|
||||
<div className="form-item-container input-form-container">
|
||||
<span className={`normal-text ${email ? "not-null" : ""}`}>Email</span>
|
||||
<input type="email" value={email} onChange={handleEmailInputChanged} />
|
||||
</div>
|
||||
<div className="form-item-container input-form-container">
|
||||
<span className={`normal-text ${password ? "not-null" : ""}`}>Password</span>
|
||||
<input type="password" value={password} onChange={handlePasswordInputChanged} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="action-btns-container">
|
||||
<button
|
||||
className={`btn signin-btn ${actionBtnLoadingState.isLoading ? "requesting" : ""}`}
|
||||
onClick={() => handleSigninBtnsClick()}
|
||||
>
|
||||
<Only when={actionBtnLoadingState.isLoading}>
|
||||
<Icon.Loader className="img-icon" />
|
||||
</Only>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Auth;
|
39
web/src/pages/Home.tsx
Normal file
39
web/src/pages/Home.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { useEffect } from "react";
|
||||
import { workspaceService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import Icon from "../components/Icon";
|
||||
import Header from "../components/Header";
|
||||
import WorkspaceListView from "../components/WorkspaceListView";
|
||||
import showCreateWorkspaceDialog from "../components/CreateWorkspaceDialog";
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const { workspaceList } = useAppSelector((state) => state.workspace);
|
||||
const loadingState = useLoading();
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([workspaceService.fetchWorkspaceList()]).finally(() => {
|
||||
loadingState.setFinish();
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col justify-start items-start">
|
||||
<Header />
|
||||
{loadingState.isLoading ? null : (
|
||||
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start">
|
||||
<p className="font-mono mb-2 text-gray-400">Workspace List</p>
|
||||
<WorkspaceListView workspaceList={workspaceList} />
|
||||
<div
|
||||
className="flex flex-row justify-start items-center border px-3 py-3 rounded-lg mt-4 cursor-pointer"
|
||||
onClick={() => showCreateWorkspaceDialog()}
|
||||
>
|
||||
<Icon.Plus className="w-5 h-auto mr-1" /> Create Workspace
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
71
web/src/pages/WorkspaceDetail.tsx
Normal file
71
web/src/pages/WorkspaceDetail.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { shortcutService, workspaceService } from "../services";
|
||||
import { useAppSelector } from "../store";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import Icon from "../components/Icon";
|
||||
import toastHelper from "../components/Toast";
|
||||
import Header from "../components/Header";
|
||||
import ShortcutListView from "../components/ShortcutListView";
|
||||
import { unknownWorkspace } from "../store/modules/workspace";
|
||||
import showCreateShortcutDialog from "../components/CreateShortcutDialog";
|
||||
|
||||
interface State {
|
||||
workspace: Workspace;
|
||||
}
|
||||
|
||||
const WorkspaceDetail: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
||||
const [state, setState] = useState<State>({
|
||||
workspace: unknownWorkspace,
|
||||
});
|
||||
const loadingState = useLoading();
|
||||
|
||||
useEffect(() => {
|
||||
const workspace = workspaceService.getWorkspaceById(Number(params.workspaceId));
|
||||
if (!workspace) {
|
||||
toastHelper.error("workspace not found");
|
||||
return;
|
||||
}
|
||||
|
||||
setState({
|
||||
...state,
|
||||
workspace,
|
||||
});
|
||||
Promise.all([shortcutService.fetchWorkspaceShortcuts(workspace.id)]).finally(() => {
|
||||
loadingState.setFinish();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleBackToHome = () => {
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col justify-start items-start">
|
||||
<Header />
|
||||
{loadingState.isLoading ? null : (
|
||||
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start">
|
||||
<div
|
||||
className="flex flex-row justify-start items-center mb-4 text-gray-600 border rounded px-2 py-1 cursor-pointer"
|
||||
onClick={() => handleBackToHome()}
|
||||
>
|
||||
<Icon.ChevronLeft className="w-5 h-auto mr-1" /> Back to Home
|
||||
</div>
|
||||
<p className="font-mono mb-2 text-gray-600">Workspace: {state?.workspace.name}</p>
|
||||
<ShortcutListView shortcutList={shortcutList} />
|
||||
<div
|
||||
className="flex flex-row justify-start items-center border px-3 py-3 rounded-lg mt-4 cursor-pointer"
|
||||
onClick={() => showCreateShortcutDialog(state.workspace.id)}
|
||||
>
|
||||
<Icon.Plus className="w-5 h-auto mr-1" /> Create Shortcut
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceDetail;
|
6
web/src/services/README.md
Normal file
6
web/src/services/README.md
Normal file
@ -0,0 +1,6 @@
|
||||
# Services
|
||||
|
||||
What should service do?
|
||||
|
||||
- request data api and throw error;
|
||||
- dispatch state actions;
|
23
web/src/services/globalService.ts
Normal file
23
web/src/services/globalService.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import store from "../store";
|
||||
import { setGlobalState } from "../store/modules/global";
|
||||
import userService from "./userService";
|
||||
|
||||
const globalService = {
|
||||
getState: () => {
|
||||
return store.getState().global;
|
||||
},
|
||||
|
||||
initialState: async () => {
|
||||
const defaultGlobalState = {};
|
||||
|
||||
try {
|
||||
await userService.initialState();
|
||||
} catch (error) {
|
||||
// do nth
|
||||
}
|
||||
|
||||
store.dispatch(setGlobalState(defaultGlobalState));
|
||||
},
|
||||
};
|
||||
|
||||
export default globalService;
|
6
web/src/services/index.ts
Normal file
6
web/src/services/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import globalService from "./globalService";
|
||||
import shortcutService from "./shortcutService";
|
||||
import userService from "./userService";
|
||||
import workspaceService from "./workspaceService";
|
||||
|
||||
export { globalService, shortcutService, userService, workspaceService };
|
63
web/src/services/shortcutService.ts
Normal file
63
web/src/services/shortcutService.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import * as api from "../helpers/api";
|
||||
import store from "../store/";
|
||||
import { createShortcut, deleteShortcut, patchShortcut, setShortcuts } from "../store/modules/shortcut";
|
||||
|
||||
const convertResponseModelShortcut = (shortcut: Shortcut): Shortcut => {
|
||||
return {
|
||||
...shortcut,
|
||||
createdTs: shortcut.createdTs * 1000,
|
||||
updatedTs: shortcut.updatedTs * 1000,
|
||||
};
|
||||
};
|
||||
|
||||
const shortcutService = {
|
||||
getState: () => {
|
||||
return store.getState().shortcut;
|
||||
},
|
||||
|
||||
fetchWorkspaceShortcuts: async (workspaceId: WorkspaceId) => {
|
||||
const { data } = (
|
||||
await api.getShortcutList({
|
||||
workspaceId,
|
||||
})
|
||||
).data;
|
||||
const shortcuts = data.map((s) => convertResponseModelShortcut(s));
|
||||
store.dispatch(setShortcuts(shortcuts));
|
||||
return shortcuts;
|
||||
},
|
||||
|
||||
getMyAllShortcuts: async () => {
|
||||
const { data } = (await api.getShortcutList()).data;
|
||||
const shortcuts = data.map((s) => convertResponseModelShortcut(s));
|
||||
store.dispatch(setShortcuts(shortcuts));
|
||||
},
|
||||
|
||||
getShortcutById: (id: ShortcutId) => {
|
||||
for (const s of shortcutService.getState().shortcutList) {
|
||||
if (s.id === id) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
createShortcut: async (shortcutCreate: ShortcutCreate) => {
|
||||
const { data } = (await api.createShortcut(shortcutCreate)).data;
|
||||
const shortcut = convertResponseModelShortcut(data);
|
||||
store.dispatch(createShortcut(shortcut));
|
||||
},
|
||||
|
||||
patchShortcut: async (shortcutPatch: ShortcutPatch) => {
|
||||
const { data } = (await api.patchShortcut(shortcutPatch)).data;
|
||||
const shortcut = convertResponseModelShortcut(data);
|
||||
store.dispatch(patchShortcut(shortcut));
|
||||
},
|
||||
|
||||
deleteShortcutById: async (shortcutId: ShortcutId) => {
|
||||
await api.deleteShortcutById(shortcutId);
|
||||
store.dispatch(deleteShortcut(shortcutId));
|
||||
},
|
||||
};
|
||||
|
||||
export default shortcutService;
|
66
web/src/services/userService.ts
Normal file
66
web/src/services/userService.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import * as api from "../helpers/api";
|
||||
import store from "../store";
|
||||
import { setUser, patchUser } from "../store/modules/user";
|
||||
|
||||
export const convertResponseModelUser = (user: User): User => {
|
||||
return {
|
||||
...user,
|
||||
createdTs: user.createdTs * 1000,
|
||||
updatedTs: user.updatedTs * 1000,
|
||||
};
|
||||
};
|
||||
|
||||
const userService = {
|
||||
getState: () => {
|
||||
return store.getState().user;
|
||||
},
|
||||
|
||||
initialState: async () => {
|
||||
try {
|
||||
const { data: user } = (await api.getMyselfUser()).data;
|
||||
if (user) {
|
||||
store.dispatch(setUser(convertResponseModelUser(user)));
|
||||
}
|
||||
} catch (error) {
|
||||
// do nth
|
||||
}
|
||||
},
|
||||
|
||||
doSignIn: async () => {
|
||||
const { data: user } = (await api.getMyselfUser()).data;
|
||||
if (user) {
|
||||
store.dispatch(setUser(convertResponseModelUser(user)));
|
||||
} else {
|
||||
userService.doSignOut();
|
||||
}
|
||||
return user;
|
||||
},
|
||||
|
||||
doSignOut: async () => {
|
||||
store.dispatch(setUser());
|
||||
await api.signout();
|
||||
},
|
||||
|
||||
getUserById: async (userId: UserId) => {
|
||||
const { data: user } = (await api.getUserById(userId)).data;
|
||||
if (user) {
|
||||
return convertResponseModelUser(user);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
patchUser: async (userPatch: UserPatch): Promise<void> => {
|
||||
const { data } = (await api.patchUser(userPatch)).data;
|
||||
if (userPatch.id === store.getState().user.user?.id) {
|
||||
const user = convertResponseModelUser(data);
|
||||
store.dispatch(patchUser(user));
|
||||
}
|
||||
},
|
||||
|
||||
deleteUser: async (userDelete: UserDelete) => {
|
||||
await api.deleteUser(userDelete);
|
||||
},
|
||||
};
|
||||
|
||||
export default userService;
|
55
web/src/services/workspaceService.ts
Normal file
55
web/src/services/workspaceService.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import * as api from "../helpers/api";
|
||||
import store from "../store";
|
||||
import { createWorkspace, deleteWorkspace, patchWorkspace, setWorkspaceList } from "../store/modules/workspace";
|
||||
|
||||
const convertResponseModelWorkspace = (workspace: Workspace): Workspace => {
|
||||
return {
|
||||
...workspace,
|
||||
createdTs: workspace.createdTs * 1000,
|
||||
updatedTs: workspace.updatedTs * 1000,
|
||||
};
|
||||
};
|
||||
|
||||
const workspaceService = {
|
||||
getState: () => {
|
||||
return store.getState().workspace;
|
||||
},
|
||||
|
||||
fetchWorkspaceList: async () => {
|
||||
const { data } = (await api.getWorkspaceList()).data;
|
||||
const workspaces = data.map((w) => convertResponseModelWorkspace(w));
|
||||
store.dispatch(setWorkspaceList(workspaces));
|
||||
return workspaces;
|
||||
},
|
||||
|
||||
getWorkspaceById: (id: WorkspaceId) => {
|
||||
const workspaceList = workspaceService.getState().workspaceList;
|
||||
for (const workspace of workspaceList) {
|
||||
if (workspace.id === id) {
|
||||
return workspace;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
createWorkspace: async (create: WorkspaceCreate) => {
|
||||
const { data } = (await api.createWorkspace(create)).data;
|
||||
const workspace = convertResponseModelWorkspace(data);
|
||||
store.dispatch(createWorkspace(workspace));
|
||||
return workspace;
|
||||
},
|
||||
|
||||
patchWorkspace: async (patch: WorkspacePatch) => {
|
||||
const { data } = (await api.patchWorkspace(patch)).data;
|
||||
const workspace = convertResponseModelWorkspace(data);
|
||||
store.dispatch(patchWorkspace(workspace));
|
||||
return workspace;
|
||||
},
|
||||
|
||||
deleteWorkspaceById: async (id: WorkspaceId) => {
|
||||
await api.deleteWorkspaceById(id);
|
||||
store.dispatch(deleteWorkspace(id));
|
||||
},
|
||||
};
|
||||
|
||||
export default workspaceService;
|
21
web/src/store/index.ts
Normal file
21
web/src/store/index.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { TypedUseSelectorHook, useSelector } from "react-redux";
|
||||
import globalReducer from "./modules/global";
|
||||
import userReducer from "./modules/user";
|
||||
import workspaceReducer from "./modules/workspace";
|
||||
import shortcutReducer from "./modules/shortcut";
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
global: globalReducer,
|
||||
user: userReducer,
|
||||
workspace: workspaceReducer,
|
||||
shortcut: shortcutReducer,
|
||||
},
|
||||
});
|
||||
|
||||
type AppState = ReturnType<typeof store.getState>;
|
||||
|
||||
export const useAppSelector: TypedUseSelectorHook<AppState> = useSelector;
|
||||
|
||||
export default store;
|
19
web/src/store/modules/global.ts
Normal file
19
web/src/store/modules/global.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
type State = {
|
||||
// do nth
|
||||
};
|
||||
|
||||
const globalSlice = createSlice({
|
||||
name: "global",
|
||||
initialState: {} as State,
|
||||
reducers: {
|
||||
setGlobalState: (_, action: PayloadAction<State>) => {
|
||||
return action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setGlobalState } = globalSlice.actions;
|
||||
|
||||
export default globalSlice.reducer;
|
51
web/src/store/modules/shortcut.ts
Normal file
51
web/src/store/modules/shortcut.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
interface State {
|
||||
shortcutList: Shortcut[];
|
||||
}
|
||||
|
||||
const shortcutSlice = createSlice({
|
||||
name: "shortcut",
|
||||
initialState: {
|
||||
shortcutList: [],
|
||||
} as State,
|
||||
reducers: {
|
||||
setShortcuts: (state, action: PayloadAction<Shortcut[]>) => {
|
||||
return {
|
||||
...state,
|
||||
shortcutList: action.payload,
|
||||
};
|
||||
},
|
||||
createShortcut: (state, action: PayloadAction<Shortcut>) => {
|
||||
return {
|
||||
...state,
|
||||
shortcutList: state.shortcutList.concat(action.payload).sort((a, b) => b.createdTs - a.createdTs),
|
||||
};
|
||||
},
|
||||
patchShortcut: (state, action: PayloadAction<Partial<Shortcut>>) => {
|
||||
return {
|
||||
...state,
|
||||
shortcutList: state.shortcutList.map((s) => {
|
||||
if (s.id === action.payload.id) {
|
||||
return {
|
||||
...s,
|
||||
...action.payload,
|
||||
};
|
||||
} else {
|
||||
return s;
|
||||
}
|
||||
}),
|
||||
};
|
||||
},
|
||||
deleteShortcut: (state, action: PayloadAction<ShortcutId>) => {
|
||||
return {
|
||||
...state,
|
||||
shortcutList: [...state.shortcutList].filter((shortcut) => shortcut.id !== action.payload),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setShortcuts, createShortcut, patchShortcut, deleteShortcut } = shortcutSlice.actions;
|
||||
|
||||
export default shortcutSlice.reducer;
|
32
web/src/store/modules/user.ts
Normal file
32
web/src/store/modules/user.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
interface State {
|
||||
// user is the user who is currently logged in
|
||||
user?: User;
|
||||
}
|
||||
|
||||
const userSlice = createSlice({
|
||||
name: "user",
|
||||
initialState: {} as State,
|
||||
reducers: {
|
||||
setUser: (state, action: PayloadAction<User | undefined>) => {
|
||||
return {
|
||||
...state,
|
||||
user: action.payload,
|
||||
};
|
||||
},
|
||||
patchUser: (state, action: PayloadAction<Partial<User>>) => {
|
||||
return {
|
||||
...state,
|
||||
user: {
|
||||
...state.user,
|
||||
...action.payload,
|
||||
} as User,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setUser, patchUser } = userSlice.actions;
|
||||
|
||||
export default userSlice.reducer;
|
56
web/src/store/modules/workspace.ts
Normal file
56
web/src/store/modules/workspace.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
import { UNKNOWN_ID } from "../../helpers/consts";
|
||||
|
||||
export const unknownWorkspace = {
|
||||
id: UNKNOWN_ID,
|
||||
} as Workspace;
|
||||
|
||||
interface State {
|
||||
workspaceList: Workspace[];
|
||||
}
|
||||
|
||||
const workspaceSlice = createSlice({
|
||||
name: "workspace",
|
||||
initialState: {
|
||||
workspaceList: [],
|
||||
} as State,
|
||||
reducers: {
|
||||
setWorkspaceList: (state, action: PayloadAction<Workspace[]>) => {
|
||||
return {
|
||||
...state,
|
||||
workspaceList: action.payload,
|
||||
};
|
||||
},
|
||||
createWorkspace: (state, action: PayloadAction<Workspace>) => {
|
||||
return {
|
||||
...state,
|
||||
workspaceList: state.workspaceList.concat(action.payload).sort((a, b) => b.createdTs - a.createdTs),
|
||||
};
|
||||
},
|
||||
patchWorkspace: (state, action: PayloadAction<Partial<Workspace>>) => {
|
||||
return {
|
||||
...state,
|
||||
workspaceList: state.workspaceList.map((s) => {
|
||||
if (s.id === action.payload.id) {
|
||||
return {
|
||||
...s,
|
||||
...action.payload,
|
||||
};
|
||||
} else {
|
||||
return s;
|
||||
}
|
||||
}),
|
||||
};
|
||||
},
|
||||
deleteWorkspace: (state, action: PayloadAction<WorkspaceId>) => {
|
||||
return {
|
||||
...state,
|
||||
workspaceList: [...state.workspaceList].filter((workspace) => workspace.id !== action.payload),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setWorkspaceList, createWorkspace, patchWorkspace, deleteWorkspace } = workspaceSlice.actions;
|
||||
|
||||
export default workspaceSlice.reducer;
|
13
web/src/types/common.d.ts
vendored
Normal file
13
web/src/types/common.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
type BasicType = undefined | null | boolean | number | string | Record<string, unknown> | Array<BasicType>;
|
||||
|
||||
type DateStamp = number;
|
||||
|
||||
type TimeStamp = number;
|
||||
|
||||
type FunctionType = (...args: unknown[]) => unknown;
|
||||
|
||||
interface KVObject<T = any> {
|
||||
[key: string]: T;
|
||||
}
|
||||
|
||||
type Option<T> = T | undefined;
|
1
web/src/types/modules/common.d.ts
vendored
Normal file
1
web/src/types/modules/common.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
type RowStatus = "NORMAL" | "ARCHIVED";
|
38
web/src/types/modules/shortcut.d.ts
vendored
Normal file
38
web/src/types/modules/shortcut.d.ts
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
type ShortcutId = number;
|
||||
|
||||
type Visibility = "PRIVATE" | "WORKSPACE";
|
||||
|
||||
interface Shortcut {
|
||||
id: ShortcutId;
|
||||
|
||||
creatorId: UserId;
|
||||
createdTs: TimeStamp;
|
||||
updatedTs: TimeStamp;
|
||||
workspaceId: WorkspaceId;
|
||||
rowStatus: RowStatus;
|
||||
|
||||
name: string;
|
||||
link: string;
|
||||
visibility: Visibility;
|
||||
}
|
||||
|
||||
interface ShortcutCreate {
|
||||
workspaceId: WorkspaceId;
|
||||
|
||||
name: string;
|
||||
link: string;
|
||||
visibility: Visibility;
|
||||
}
|
||||
|
||||
interface ShortcutPatch {
|
||||
id: ShortcutId;
|
||||
|
||||
name?: string;
|
||||
link?: string;
|
||||
visibility?: Visibility;
|
||||
}
|
||||
|
||||
interface ShortcutFind {
|
||||
creatorId?: UserId;
|
||||
workspaceId?: WorkspaceId;
|
||||
}
|
8
web/src/types/modules/system.d.ts
vendored
Normal file
8
web/src/types/modules/system.d.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
interface Profile {
|
||||
mode: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
interface SystemStatus {
|
||||
profile: Profile;
|
||||
}
|
31
web/src/types/modules/user.d.ts
vendored
Normal file
31
web/src/types/modules/user.d.ts
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
type UserId = number;
|
||||
|
||||
interface User {
|
||||
id: UserId;
|
||||
|
||||
createdTs: TimeStamp;
|
||||
updatedTs: TimeStamp;
|
||||
rowStatus: RowStatus;
|
||||
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface UserCreate {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface UserPatch {
|
||||
id: UserId;
|
||||
|
||||
rowStatus?: RowStatus;
|
||||
|
||||
name?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface UserDelete {
|
||||
id: UserId;
|
||||
}
|
30
web/src/types/modules/workspace.d.ts
vendored
Normal file
30
web/src/types/modules/workspace.d.ts
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
type WorkspaceId = number;
|
||||
|
||||
interface Workspace {
|
||||
id: WorkspaceId;
|
||||
|
||||
creatorId: UserId;
|
||||
createdTs: TimeStamp;
|
||||
updatedTs: TimeStamp;
|
||||
rowStatus: RowStatus;
|
||||
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface WorkspaceCreate {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface WorkspacePatch {
|
||||
id: WorkspaceId;
|
||||
rowStatus?: RowStatus;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface WorkspaceFind {
|
||||
creatorId?: UserId;
|
||||
memberId?: UserId;
|
||||
}
|
7
web/src/types/view.d.ts
vendored
Normal file
7
web/src/types/view.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
interface DialogProps {
|
||||
destroy: FunctionType;
|
||||
}
|
||||
|
||||
interface DialogCallback {
|
||||
destroy: FunctionType;
|
||||
}
|
29
web/tailwind.config.js
Normal file
29
web/tailwind.config.js
Normal file
@ -0,0 +1,29 @@
|
||||
/* eslint-disable no-undef */
|
||||
module.exports = {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,tsx}"],
|
||||
theme: {
|
||||
fontSize: {
|
||||
xs: ".75rem",
|
||||
sm: ".875rem",
|
||||
base: "1rem",
|
||||
lg: "1.125rem",
|
||||
xl: "1.25rem",
|
||||
"2xl": "1.5rem",
|
||||
"3xl": "1.875rem",
|
||||
"4xl": "2.25rem",
|
||||
},
|
||||
extend: {
|
||||
spacing: {
|
||||
112: "28rem",
|
||||
128: "32rem",
|
||||
180: "45rem",
|
||||
},
|
||||
zIndex: {
|
||||
1: "1",
|
||||
20: "20",
|
||||
100: "100",
|
||||
1000: "1000",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
20
web/tsconfig.json
Normal file
20
web/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"types": ["vite/client"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": false,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["./src"]
|
||||
}
|
16
web/vite.config.ts
Normal file
16
web/vite.config.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8081/",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
2559
web/yarn.lock
Normal file
2559
web/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user