feat: website info and edit

This commit is contained in:
moonrailgun 2023-09-05 15:32:16 +08:00
parent 6441230df4
commit a4c4a6d9e9
15 changed files with 514 additions and 160 deletions

View File

@ -10,38 +10,51 @@ import { useUserStore } from './store/user';
import { Register } from './pages/Register';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './api/cache';
function App() {
import { TokenLoginContainer } from './components/TokenLoginContainer';
import React from 'react';
export const AppRoutes: React.FC = React.memo(() => {
const { info } = useUserStore();
return (
<Routes>
{info ? (
<Route element={<Layout />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/monitor" element={<Monitor />} />
<Route path="/website" element={<Website />} />
<Route path="/servers" element={<Servers />} />
<Route path="/settings/*" element={<Settings />} />
</Route>
) : (
<Route>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
</Route>
)}
<Route
path="*"
element={
<Navigate to={info ? '/dashboard' : '/login'} replace={true} />
}
/>
</Routes>
);
});
AppRoutes.displayName = 'AppRoutes';
export const App: React.FC = React.memo(() => {
return (
<div className="App">
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
{info && (
<Route element={<Layout />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/monitor" element={<Monitor />} />
<Route path="/website" element={<Website />} />
<Route path="/servers" element={<Servers />} />
<Route path="/settings" element={<Settings />} />
</Route>
)}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route
path="*"
element={
<Navigate to={info ? '/dashboard' : '/login'} replace={true} />
}
/>
</Routes>
<TokenLoginContainer>
<AppRoutes />
</TokenLoginContainer>
</BrowserRouter>
</QueryClientProvider>
</div>
);
}
export default App;
});
App.displayName = 'App';

View File

@ -26,6 +26,33 @@ export async function getWorkspaceWebsites(
return data.websites;
}
export async function getWorkspaceWebsiteInfo(
workspaceId: string,
websiteId: string
): Promise<WebsiteInfo | null> {
const { data } = await request.get(`/api/workspace/website/${websiteId}`, {
params: {
workspaceId,
},
});
return data.website;
}
export async function updateWorkspaceWebsiteInfo(
workspaceId: string,
websiteId: string,
info: { name: string; domain: string }
) {
await request.post(`/api/workspace/website/${websiteId}`, {
workspaceId,
name: info.name,
domain: info.domain,
});
queryClient.resetQueries(['websites', workspaceId]);
}
export function useWorspaceWebsites(workspaceId: string) {
const { data: websites = [], isLoading } = useQuery(
['websites', workspaceId],
@ -37,6 +64,21 @@ export function useWorspaceWebsites(workspaceId: string) {
return { websites, isLoading };
}
export function useWorkspaceWebsiteInfo(
workspaceId: string,
websiteId: string
) {
const { data: website = null, isLoading } = useQuery(
['website', workspaceId, websiteId],
() => {
return getWorkspaceWebsiteInfo(workspaceId, websiteId);
},
{ cacheTime: 0 }
);
return { website, isLoading };
}
export function refreshWorkspaceWebsites(workspaceId: string) {
queryClient.refetchQueries(['websites', workspaceId]);
}

View File

@ -0,0 +1,6 @@
import React from 'react';
export const ErrorTip: React.FC = React.memo(() => {
return <div>An unexpected error has occurred</div>;
});
ErrorTip.displayName = 'ErrorTip';

View File

@ -0,0 +1,27 @@
import React, { useEffect, useState } from 'react';
import { getJWT } from '../api/auth';
import { loginWithToken } from '../api/model/user';
import { Loading } from './Loading';
export const TokenLoginContainer: React.FC<React.PropsWithChildren> =
React.memo((props) => {
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = getJWT();
if (token) {
loginWithToken().then(() => {
setLoading(false);
});
} else {
setLoading(false);
}
}, []);
if (loading) {
return <Loading />;
}
return <>{props.children}</>;
});
TokenLoginContainer.displayName = 'TokenLoginContainer';

View File

@ -0,0 +1,87 @@
import { Button, Form, Input, message } from 'antd';
import React from 'react';
import { useParams } from 'react-router';
import {
updateWorkspaceWebsiteInfo,
useWorkspaceWebsiteInfo,
} from '../api/model/website';
import { useRequest } from '../hooks/useRequest';
import { useCurrentWorkspaceId } from '../store/user';
import { ErrorTip } from './ErrorTip';
import { Loading } from './Loading';
import { NoWorkspaceTip } from './NoWorkspaceTip';
export const WebsiteInfo: React.FC = React.memo(() => {
const { websiteId } = useParams<{
websiteId: string;
}>();
const currentWorkspaceId = useCurrentWorkspaceId();
const { website, isLoading } = useWorkspaceWebsiteInfo(
currentWorkspaceId!,
websiteId!
);
const [, handleSave] = useRequest(
async (values: { name: string; domain: string }) => {
await updateWorkspaceWebsiteInfo(currentWorkspaceId!, websiteId!, {
name: values.name,
domain: values.domain,
});
message.success('Save Success');
}
);
if (!currentWorkspaceId) {
return <NoWorkspaceTip />;
}
if (!websiteId) {
return <ErrorTip />;
}
if (isLoading) {
return <Loading />;
}
if (!website) {
return <ErrorTip />;
}
return (
<div>
<div className="h-24 flex items-center">
<div className="text-2xl flex-1">Website Info</div>
</div>
<div>
<Form
layout="vertical"
initialValues={{
id: website.id,
name: website.name,
domain: website.domain,
}}
onFinish={handleSave}
>
<Form.Item label="Website ID" name="id">
<Input size="large" disabled={true} />
</Form.Item>
<Form.Item label="Name" name="name" rules={[{ required: true }]}>
<Input size="large" />
</Form.Item>
<Form.Item label="Domain" name="domain" rules={[{ required: true }]}>
<Input size="large" />
</Form.Item>
<Form.Item>
<Button size="large" htmlType="submit">
Save
</Button>
</Form.Item>
</Form>
</div>
</div>
);
});
WebsiteInfo.displayName = 'WebsiteInfo';

View File

@ -0,0 +1,135 @@
import {
BarChartOutlined,
EditOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { Button, Form, Input, Modal, Table } from 'antd';
import { ColumnsType } from 'antd/es/table';
import React, { useMemo, useState } from 'react';
import {
addWorkspaceWebsite,
refreshWorkspaceWebsites,
useWorspaceWebsites,
WebsiteInfo,
} from '../api/model/website';
import { Loading } from './Loading';
import { NoWorkspaceTip } from './NoWorkspaceTip';
import { useRequest } from '../hooks/useRequest';
import { useUserStore } from '../store/user';
import { useEvent } from '../hooks/useEvent';
import { useNavigate } from 'react-router';
export const WebsiteList: React.FC = React.memo(() => {
const [isModalOpen, setIsModalOpen] = useState(false);
const currentWorkspace = useUserStore(
(state) => state.info?.currentWorkspace
);
const [form] = Form.useForm();
const [{ loading }, handleAddWebsite] = useRequest(async () => {
await form.validateFields();
const values = form.getFieldsValue();
await addWorkspaceWebsite(currentWorkspace!.id, values.name, values.domain);
refreshWorkspaceWebsites(currentWorkspace!.id);
setIsModalOpen(false);
form.resetFields();
});
if (!currentWorkspace) {
return <NoWorkspaceTip />;
}
return (
<div>
<div className="h-24 flex items-center">
<div className="text-2xl flex-1">Websites</div>
<div>
<Button
type="primary"
icon={<PlusOutlined />}
size="large"
onClick={() => setIsModalOpen(true)}
>
Add Website
</Button>
</div>
</div>
<WebsiteListTable workspaceId={currentWorkspace.id} />
<Modal
title="Add Server"
open={isModalOpen}
okButtonProps={{
loading,
}}
onOk={() => handleAddWebsite()}
onCancel={() => setIsModalOpen(false)}
>
<Form layout="vertical" form={form}>
<Form.Item
label="Server Name"
name="name"
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item label="Domain" name="domain" rules={[{ required: true }]}>
<Input />
</Form.Item>
</Form>
</Modal>
</div>
);
});
WebsiteList.displayName = 'WebsiteList';
const WebsiteListTable: React.FC<{ workspaceId: string }> = React.memo(
(props) => {
const { websites, isLoading } = useWorspaceWebsites(props.workspaceId);
const navigate = useNavigate();
const handleEdit = useEvent((websiteId) => {
console.log(`/settings/website/${websiteId}`);
navigate(`/settings/website/${websiteId}`);
});
const columns = useMemo((): ColumnsType<WebsiteInfo> => {
return [
{
dataIndex: 'name',
title: 'Name',
},
{
dataIndex: 'domain',
title: 'Domain',
},
{
key: 'action',
render: (record) => {
return (
<div className="flex gap-2 justify-end">
<Button
icon={<EditOutlined />}
onClick={() => handleEdit(record.id)}
>
Edit
</Button>
<Button icon={<BarChartOutlined />}>View</Button>
</div>
);
},
},
] as ColumnsType<WebsiteInfo>;
}, []);
if (isLoading) {
return <Loading />;
}
return <Table columns={columns} dataSource={websites} pagination={false} />;
}
);
WebsiteListTable.displayName = 'WebsiteListTable';

View File

@ -3,7 +3,7 @@ import './index.css';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { App } from './App';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>

View File

@ -1,9 +1,7 @@
import { Button, Form, Input, Typography } from 'antd';
import React, { useEffect } from 'react';
import React from 'react';
import { model } from '../api/model';
import { useNavigate } from 'react-router';
import { loginWithToken } from '../api/model/user';
import { getJWT } from '../api/auth';
import { useRequest } from '../hooks/useRequest';
export const Login: React.FC = React.memo(() => {
@ -14,15 +12,6 @@ export const Login: React.FC = React.memo(() => {
navigate('/dashboard');
});
useEffect(() => {
const token = getJWT();
if (token) {
loginWithToken().then(() => {
navigate('/dashboard');
});
}
}, []);
return (
<div className="w-full h-full flex justify-center items-center">
<div className="w-80 -translate-y-1/4">

View File

@ -1,6 +1,49 @@
import { Menu, MenuProps } from 'antd';
import React from 'react';
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
import { WebsiteInfo } from '../components/WebsiteInfo';
import { WebsiteList } from '../components/WebsiteList';
import { useEvent } from '../hooks/useEvent';
export const Settings: React.FC = React.memo(() => {
return <div>Settings</div>;
const navigate = useNavigate();
const { pathname } = useLocation();
const items: MenuProps['items'] = [
{
key: 'websites',
label: 'Websites',
},
];
const onClick: MenuProps['onClick'] = useEvent((e) => {
navigate(`/settings/${e.key}`);
});
const selectedKey =
(items.find((item) => pathname.startsWith(`/settings/${item?.key}`))
?.key as string) ?? 'websites';
return (
<div className="flex h-full">
<div className="w-full md:w-1/6 pt-10">
<Menu
className="h-full"
onClick={onClick}
selectedKeys={[selectedKey]}
mode="vertical"
items={items}
/>
{pathname}
</div>
<div className="w-full md:w-5/6 py-2 px-4">
<Routes>
<Route path="/" element={<WebsiteList />} />
<Route path="/websites" element={<WebsiteList />} />
<Route path="/website/:websiteId" element={<WebsiteInfo />} />
</Routes>
</div>
</div>
);
});
Settings.displayName = 'Settings';

View File

@ -1,120 +1,7 @@
import {
BarChartOutlined,
EditOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { Button, Form, Input, Modal, Table } from 'antd';
import { ColumnsType } from 'antd/es/table';
import React, { useMemo, useState } from 'react';
import {
addWorkspaceWebsite,
refreshWorkspaceWebsites,
useWorspaceWebsites,
WebsiteInfo,
} from '../api/model/website';
import { Loading } from '../components/Loading';
import { NoWorkspaceTip } from '../components/NoWorkspaceTip';
import { useRequest } from '../hooks/useRequest';
import { useUserStore } from '../store/user';
import React from 'react';
import { WebsiteList } from '../components/WebsiteList';
export const Website: React.FC = React.memo(() => {
const [isModalOpen, setIsModalOpen] = useState(false);
const currentWorkspace = useUserStore(
(state) => state.info?.currentWorkspace
);
const [form] = Form.useForm();
const [{ loading }, handleAddWebsite] = useRequest(async () => {
await form.validateFields();
const values = form.getFieldsValue();
await addWorkspaceWebsite(currentWorkspace!.id, values.name, values.domain);
refreshWorkspaceWebsites(currentWorkspace!.id);
setIsModalOpen(false);
form.resetFields();
});
if (!currentWorkspace) {
return <NoWorkspaceTip />;
}
return (
<div>
<div className="h-24 flex items-center">
<div className="text-2xl flex-1">Websites</div>
<div>
<Button
type="primary"
icon={<PlusOutlined />}
size="large"
onClick={() => setIsModalOpen(true)}
>
Add Website
</Button>
</div>
</div>
<WebsiteList workspaceId={currentWorkspace.id} />
<Modal
title="Add Server"
open={isModalOpen}
okButtonProps={{
loading,
}}
onOk={() => handleAddWebsite()}
onCancel={() => setIsModalOpen(false)}
>
<Form layout="vertical" form={form}>
<Form.Item
label="Server Name"
name="name"
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item label="Domain" name="domain" rules={[{ required: true }]}>
<Input />
</Form.Item>
</Form>
</Modal>
</div>
);
return <WebsiteList />;
});
Website.displayName = 'Website';
const WebsiteList: React.FC<{ workspaceId: string }> = React.memo((props) => {
const { websites, isLoading } = useWorspaceWebsites(props.workspaceId);
const columns = useMemo((): ColumnsType<WebsiteInfo> => {
return [
{
dataIndex: 'name',
title: 'Name',
},
{
dataIndex: 'domain',
title: 'Domain',
},
{
key: 'action',
render: () => {
return (
<div className="flex gap-2 justify-end">
<Button icon={<EditOutlined />}>Edit</Button>
<Button icon={<BarChartOutlined />}>View</Button>
</div>
);
},
},
];
}, []);
if (isLoading) {
return <Loading />;
}
return <Table columns={columns} dataSource={websites} pagination={false} />;
});
WebsiteList.displayName = 'WebsiteList';

View File

@ -22,3 +22,11 @@ export function setUserInfo(info: UserLoginInfo) {
info,
});
}
export function useCurrentWorkspaceId() {
const currentWorkspaceId = useUserStore(
(state) => state.info?.currentWorkspace?.id
);
return currentWorkspaceId;
}

View File

@ -3,7 +3,8 @@ import { checkIsWorkspaceUser } from '../model/workspace';
export function workspacePermission(): Handler {
return async (req, res, next) => {
const workspaceId = req.body.workspaceId ?? req.query.workspaceId;
const workspaceId =
req.body.workspaceId ?? req.query.workspaceId ?? req.params.workspaceId;
if (!workspaceId) {
throw new Error('Cannot find workspace id');

View File

@ -35,6 +35,40 @@ export async function getWorkspaceWebsites(workspaceId: string) {
return workspace?.websites ?? [];
}
export async function getWorkspaceWebsiteInfo(
workspaceId: string,
websiteId: string
) {
const websiteInfo = await prisma.website.findUnique({
where: {
id: websiteId,
workspaceId,
},
});
return websiteInfo;
}
export async function updateWorkspaceWebsiteInfo(
workspaceId: string,
websiteId: string,
name: string,
domain: string
) {
const websiteInfo = await prisma.website.update({
where: {
id: websiteId,
workspaceId,
},
data: {
name,
domain,
},
});
return websiteInfo;
}
export async function addWorkspaceWebsite(
workspaceId: string,
name: string,

View File

@ -1,8 +1,13 @@
import { Router } from 'express';
import { auth } from '../middleware/auth';
import { body, query, validate } from '../middleware/validate';
import { body, param, query, validate } from '../middleware/validate';
import { workspacePermission } from '../middleware/workspace';
import { addWorkspaceWebsite, getWorkspaceWebsites } from '../model/workspace';
import {
addWorkspaceWebsite,
getWorkspaceWebsiteInfo,
getWorkspaceWebsites,
updateWorkspaceWebsiteInfo,
} from '../model/workspace';
export const workspaceRouter = Router();
@ -34,8 +39,16 @@ workspaceRouter.post(
.withMessage('workspaceId should be string')
.isUUID()
.withMessage('workspaceId should be UUID'),
body('name').isString().withMessage('name should be a string'),
body('domain').isURL().withMessage('domain should be URL')
body('name')
.isString()
.withMessage('name should be string')
.isLength({ max: 100 })
.withMessage('length should be under 100'),
body('domain')
.isURL()
.withMessage('domain should be URL')
.isLength({ max: 500 })
.withMessage('length should be under 500')
),
auth(),
workspacePermission(),
@ -47,3 +60,71 @@ workspaceRouter.post(
res.json({ website });
}
);
workspaceRouter.get(
'/website/:websiteId',
validate(
query('workspaceId')
.isString()
.withMessage('workspaceId should be string')
.isUUID()
.withMessage('workspaceId should be UUID'),
param('websiteId')
.isString()
.withMessage('workspaceId should be string')
.isUUID()
.withMessage('workspaceId should be UUID')
),
auth(),
workspacePermission(),
async (req, res) => {
const workspaceId = req.query.workspaceId as string;
const websiteId = req.params.websiteId;
const website = await getWorkspaceWebsiteInfo(workspaceId, websiteId);
res.json({ website });
}
);
workspaceRouter.post(
'/website/:websiteId',
validate(
body('workspaceId')
.isString()
.withMessage('workspaceId should be string')
.isUUID()
.withMessage('workspaceId should be UUID'),
param('websiteId')
.isString()
.withMessage('workspaceId should be string')
.isUUID()
.withMessage('workspaceId should be UUID'),
body('name')
.isString()
.withMessage('name should be string')
.isLength({ max: 100 })
.withMessage('length should be under 100'),
body('domain')
.isURL()
.withMessage('domain should be URL')
.isLength({ max: 500 })
.withMessage('length should be under 500')
),
auth(),
workspacePermission(),
async (req, res) => {
const workspaceId = req.query.workspaceId as string;
const websiteId = req.params.websiteId;
const { name, domain } = req.body;
const website = await updateWorkspaceWebsiteInfo(
workspaceId,
websiteId,
name,
domain
);
res.json({ website });
}
);

View File

@ -1,3 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["./**/*"]
}