setVisible(true)} className={`${isEdit ? 'visible op-100' : 'invisible op-0'} transition-all i-carbon-task-add text-5 mr-3 cursor-pointer opacity-animation-3`} />
30 |
toggleEditMode()} className={`${isEdit ? 'i-carbon-edit-off' : 'i-carbon-edit'} text-5 cursor-pointer icon-tap-color mr-3 opacity-animation-3 `} />
31 |
32 | 添加卡片
33 | 所有选项都必填
34 |
35 | handleChange(e, 'name')} />
36 |
37 | handleChange(e, 'description')} />
38 |
39 | handleChange(e, 'path')} />
40 |
41 | handleChange(e, 'icon')} />
42 |
43 | Tip:
44 | 跳转到选择图标的页面
45 |
46 |
47 | setVisible(false)}>取消
48 | {
49 | handlerAddService(service, () => setVisible(false));
50 | }}
51 | >提交
52 |
53 | >
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/pages/api/services/[action].ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @eslint-react/naming-convention/filename -- ignore
2 | import type { NextApiHandler } from 'next';
3 | import type { Action } from 'src/types/next-handler';
4 | import type { Service } from 'src/types/services';
5 |
6 | import { generatorRespError } from 'src/lib/utils';
7 | import { addServicesData, deleteServicesData, editServiceData, updateServiceData } from 'src/lib/services';
8 |
9 | const addHandler: NextApiHandler = async (req, res) => {
10 | if (!req.body) {
11 | res.status(400).json(generatorRespError('数据不能为空'));
12 | return;
13 | }
14 |
15 | const data = req.body as Service;
16 |
17 | try {
18 | await addServicesData(data);
19 | res.status(200).json({ msg: `添加 ${data.name} 成功` });
20 | } catch (e) {
21 | if (e instanceof Error)
22 | res.status(500).json(generatorRespError(e.message));
23 | }
24 | };
25 |
26 | const deleteHandler: NextApiHandler = async (req, res) => {
27 | if (!req.body?.name) {
28 | res.status(400).json(generatorRespError('删除的目标卡片名为空'));
29 | return;
30 | }
31 |
32 | const { name } = req.body as { name: string };
33 |
34 | try {
35 | await deleteServicesData(name);
36 | res.status(200).json({ msg: `删除 ${name} 成功` });
37 | } catch (e) {
38 | if (e instanceof Error)
39 | res.status(500).json(generatorRespError(e.message));
40 | }
41 | };
42 |
43 | const editHandler: NextApiHandler = async (req, res) => {
44 | if (!req.body) {
45 | res.status(400).json(generatorRespError('数据不能为空'));
46 | return;
47 | }
48 |
49 | const data = req.body as { newData: Service, id: string };
50 |
51 | try {
52 | await editServiceData(data.newData, data.id);
53 | res.status(200).json({ msg: `编辑 ${data.id} 成功` });
54 | } catch (e) {
55 | if (e instanceof Error)
56 | res.status(500).json(generatorRespError(e.message));
57 | }
58 | };
59 |
60 | const updateHandler: NextApiHandler = async (req, res) => {
61 | if (!req.body) {
62 | res.status(400).json(generatorRespError('数据不能为空'));
63 | return;
64 | }
65 |
66 | const data = req.body as Service[];
67 |
68 | try {
69 | await updateServiceData(data);
70 | res.status(200).json({ msg: '更新成功' });
71 | } catch (e) {
72 | if (e instanceof Error)
73 | res.status(500).json(generatorRespError(e.message));
74 | }
75 | };
76 |
77 | const handler: NextApiHandler = async (req, res) => {
78 | if (req.method !== 'POST') {
79 | res.status(405).json(generatorRespError(`请求方法 ${req.method ?? ''} 不支持`));
80 | return;
81 | }
82 |
83 | const action = req.query.action as Action;
84 | switch (action) {
85 | case 'add':
86 | await addHandler(req, res);
87 | break;
88 | case 'delete':
89 | await deleteHandler(req, res);
90 | break;
91 | case 'edit':
92 | await editHandler(req, res);
93 | break;
94 | case 'update':
95 | await updateHandler(req, res);
96 | break;
97 | default:
98 | res.status(400).json(generatorRespError('未知操作'));
99 | }
100 | };
101 |
102 | export default handler;
103 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "home-page",
3 | "version": "0.8.0",
4 | "private": true,
5 | "description": "一个简洁的 NAS 导航页 & 主页",
6 | "scripts": {
7 | "dev": "npm run dev:css & npm run dev:next",
8 | "dev:css": "unocss 'src/**/*.tsx' --out-file=src/styles/uno.css --watch",
9 | "dev:next": "next dev",
10 | "css": "unocss 'src/**/*.tsx' --out-file=src/styles/uno.css",
11 | "build": "npm run css && next build",
12 | "start": "next start",
13 | "lint": "eslint --format=sukka ."
14 | },
15 | "license": "MIT",
16 | "dependencies": {
17 | "@geist-ui/core": "2.3.8",
18 | "clsx": "1.2.1",
19 | "date-fns": "^2.30.0",
20 | "jotai": "2.12.0",
21 | "next": "13.5.3",
22 | "react": "18.2.0",
23 | "react-dom": "18.2.0",
24 | "swr": "^2.2.4",
25 | "systeminformation": "^5.21.9"
26 | },
27 | "devDependencies": {
28 | "@eslint-sukka/react": "^6.18.0",
29 | "@iconify-json/carbon": "^1.1.21",
30 | "@types/node": "18.0.3",
31 | "@types/react": "18.0.15",
32 | "@types/react-dom": "18.0.6",
33 | "@unocss/cli": "^0.49.8",
34 | "eslint": "9.23.0",
35 | "eslint-config-kaho": "3.6.0",
36 | "typescript": "5.3.3",
37 | "unocss": "65.4.3"
38 | },
39 | "pnpm": {
40 | "overrides": {
41 | "array-buffer-byte-length": "npm:@nolyfill/array-buffer-byte-length@latest",
42 | "array-includes": "npm:@nolyfill/array-includes@latest",
43 | "array.prototype.flat": "npm:@nolyfill/array.prototype.flat@latest",
44 | "array.prototype.flatmap": "npm:@nolyfill/array.prototype.flatmap@latest",
45 | "array.prototype.tosorted": "npm:@nolyfill/array.prototype.tosorted@latest",
46 | "define-properties": "npm:@nolyfill/define-properties@latest",
47 | "function-bind": "npm:@nolyfill/function-bind@latest",
48 | "has": "npm:@nolyfill/has@latest",
49 | "has-proto": "npm:@nolyfill/has-proto@latest",
50 | "has-symbols": "npm:@nolyfill/has-symbols@latest",
51 | "has-tostringtag": "npm:@nolyfill/has-tostringtag@latest",
52 | "is-array-buffer": "npm:@nolyfill/is-array-buffer@latest",
53 | "is-date-object": "npm:@nolyfill/is-date-object@latest",
54 | "is-regex": "npm:@nolyfill/is-regex@latest",
55 | "is-shared-array-buffer": "npm:@nolyfill/is-shared-array-buffer@latest",
56 | "is-string": "npm:@nolyfill/is-string@latest",
57 | "object-keys": "npm:@nolyfill/object-keys@latest",
58 | "object.assign": "npm:@nolyfill/object.assign@latest",
59 | "object.entries": "npm:@nolyfill/object.entries@latest",
60 | "object.fromentries": "npm:@nolyfill/object.fromentries@latest",
61 | "object.hasown": "npm:@nolyfill/object.hasown@latest",
62 | "object.values": "npm:@nolyfill/object.values@latest",
63 | "regexp.prototype.flags": "npm:@nolyfill/regexp.prototype.flags@latest",
64 | "string.prototype.matchall": "npm:@nolyfill/string.prototype.matchall@latest",
65 | "which-boxed-primitive": "npm:@nolyfill/which-boxed-primitive@latest",
66 | "which-typed-array": "npm:@nolyfill/which-typed-array@latest",
67 | "array.prototype.findlastindex": "npm:@nolyfill/array.prototype.findlastindex@latest",
68 | "es-iterator-helpers": "npm:@nolyfill/es-iterator-helpers@latest",
69 | "object.groupby": "npm:@nolyfill/object.groupby@latest"
70 | },
71 | "onlyBuiltDependencies": [
72 | "esbuild"
73 | ]
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/hooks/use-services.ts:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr';
2 | import { fetcher, fetcherWithJSON } from 'src/lib/fetcher';
3 |
4 | import { useToasts } from '@geist-ui/core';
5 |
6 | import { validateFormDataForService } from 'src/lib/utils';
7 |
8 | import type { ActionsResponse, Service } from 'src/types/services';
9 |
10 | export function useServices() {
11 | const { setToast } = useToasts();
12 | const handleError = (message: string) => {
13 | setToast({
14 | text: message,
15 | type: 'error',
16 | delay: 3000
17 | });
18 | };
19 |
20 | const { data: servicesData, mutate } = useSWR
('/api/services', fetcher, {
21 | onError(e) {
22 | if (e instanceof Error) {
23 | const message = `获取数据出错: ${e.message}`;
24 |
25 | handleError(message);
26 | console.error(message);
27 | }
28 | }
29 | });
30 |
31 | const handlerAddService = async (service: Service, closeModal: () => void) => {
32 | // validate data
33 | const error = validateFormDataForService(service);
34 | if (error) {
35 | handleError(error);
36 | return;
37 | }
38 |
39 | try {
40 | const data = await fetcherWithJSON('/api/services/add', { method: 'POST', body: JSON.stringify(service) });
41 |
42 | closeModal();
43 | // refetch data
44 | mutate();
45 | setToast({ text: data.msg });
46 | } catch (e) {
47 | if (e instanceof Error) {
48 | const message = `添加服务出错: ${e.message}`;
49 | handleError(message);
50 | }
51 | }
52 | };
53 |
54 | const handleDeleteService = async (name: string) => {
55 | try {
56 | const data = await fetcherWithJSON('/api/services/delete', { method: 'POST', body: JSON.stringify({ name }) });
57 |
58 | // refetch data
59 | mutate();
60 | setToast({ text: data.msg });
61 | } catch (e) {
62 | if (e instanceof Error) {
63 | const message = `删除服务出错: ${e.message}`;
64 | handleError(message);
65 | }
66 | }
67 | };
68 |
69 | const handleEditService = async (service: Service, id: string, closeModal: () => void) => {
70 | // validate data
71 | const error = validateFormDataForService(service);
72 | if (error) {
73 | handleError(error);
74 | return;
75 | }
76 |
77 | try {
78 | const data = await fetcherWithJSON('/api/services/edit', { method: 'POST', body: JSON.stringify({ newData: service, id }) });
79 |
80 | closeModal();
81 | // refetch data
82 | mutate();
83 | setToast({ text: data.msg });
84 | } catch (e) {
85 | if (e instanceof Error) {
86 | const message = `编辑服务出错: ${e.message}`;
87 | handleError(message);
88 | }
89 | }
90 | };
91 |
92 | const handleUpdateServices = async (services: Service[]) => {
93 | try {
94 | await fetcherWithJSON('/api/services/update', { method: 'POST', body: JSON.stringify(services) });
95 |
96 | // refetch data
97 | mutate();
98 | } catch (e) {
99 | if (e instanceof Error) {
100 | const message = `更新服务出错: ${e.message}`;
101 | handleError(message);
102 | }
103 | }
104 | };
105 |
106 | return {
107 | servicesData: Array.isArray(servicesData) ? servicesData : [],
108 | handlerAddService,
109 | handleDeleteService,
110 | handleEditService,
111 | handleUpdateServices
112 | };
113 | }
114 |
--------------------------------------------------------------------------------
/src/components/settings-option/sync-data.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Input, Note, Tabs, Text, useToasts } from '@geist-ui/core';
2 | import { useState } from 'react';
3 |
4 | import { useOnedrive } from 'src/hooks/use-onedrive';
5 | import { useOnedriveData } from 'src/hooks/use-onedrive-data';
6 | import { useServices } from 'src/hooks/use-services';
7 | import { getAuthCode } from 'src/lib/onedrive-auth';
8 |
9 | export type DataSource = 'onedrive' | 'googledrive';
10 | export default function SyncData() {
11 | const { setToast } = useToasts();
12 | const { servicesData } = useServices();
13 | const [onedriveData, setOnedriveData] = useOnedriveData();
14 |
15 | const [codeText, setCodeText] = useState('');
16 |
17 | const { isSyncing, isUploading, handleUpload, handleSync } = useOnedrive();
18 |
19 | const handleSetCode = () => {
20 | if (!codeText) {
21 | setToast({ text: '请输入 Code', type: 'error', delay: 3000 });
22 | return;
23 | }
24 | const code = codeText.replaceAll(/.*\?code=/g, '').trim();
25 | setOnedriveData({ ...onedriveData, authCode: code });
26 | setToast({ text: 'code 设置成功,请点击想操作的按钮', delay: 3000 });
27 | };
28 |
29 | return (
30 |
31 |
32 |
33 |
34 | setCodeText(e.target.value)}
39 | onKeyUp={e => e.key === 'Enter' && handleSetCode()}
40 | initialValue={onedriveData.authCode}
41 | />
42 |
43 |
44 |
}
48 | auto
49 | scale={0.7}
50 | >
51 | 设置 Code
52 |
53 |
}
57 | auto
58 | scale={0.7}
59 | >
60 | 获取 Code
61 |
62 |
}
66 | auto
67 | scale={0.7}
68 | loading={isSyncing}
69 | >
70 | 从 OneDrive 同步
71 |
72 |
82 |
83 |
84 |
85 | 获取 Token 仅用于更新、同步 Home-Page 的数据。并且 Token 等数据都只会存在本地浏览器中
86 |
87 | 点击「获取 Code」按钮,授权 OneDrive 权限。然后将跳转后的地址栏链接复制到输入框并点击「设置 Code」
88 |
89 |
90 |
91 |
92 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
92 |
--------------------------------------------------------------------------------
/src/hooks/use-onedrive.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useToasts } from '@geist-ui/core';
3 |
4 | import { calcAccessTokenExpires, useOnedriveData } from './use-onedrive-data';
5 | import type { Service } from 'src/types/services';
6 |
7 | import { HTTPError, fetcherWithAuthorization } from 'src/lib/fetcher';
8 | import { CLIENT_ID, CLIENT_SECRET } from 'src/lib/constant';
9 |
10 | import type { RequestTokenResponse, ResourceError, UploadResponse } from 'src/types/onedrive';
11 | import { useServices } from './use-services';
12 |
13 | export function useOnedrive() {
14 | const { setToast } = useToasts();
15 | const handleError = (message: string) => {
16 | setToast({
17 | text: message,
18 | type: 'error',
19 | delay: 3000
20 | });
21 | };
22 |
23 | const [onedriveData, setOnedriveData] = useOnedriveData();
24 | const { handleUpdateServices } = useServices();
25 |
26 | const [isSyncing, setIsSyncing] = useState(false);
27 | const [isUploading, setIsUploading] = useState(false);
28 |
29 | const requestTokenHandler = async (query: string): Promise => {
30 | try {
31 | const res = await fetch(`/api/onedrive?${query}`, { method: 'POST' });
32 | const data = await res.json() as RequestTokenResponse;
33 | if (!res.ok)
34 | // eslint-disable-next-line @typescript-eslint/only-throw-error -- ignore
35 | throw data;
36 |
37 | // 将 token 数据保存在本地
38 | setOnedriveData({
39 | ...onedriveData,
40 | accessToken: {
41 | token: data.access_token,
42 | expires: calcAccessTokenExpires(data.expires_in)
43 | },
44 | refreshToken: data.refresh_token
45 | });
46 |
47 | return data.access_token;
48 | } catch (e) {
49 | setOnedriveData({
50 | accessToken: {
51 | token: '',
52 | expires: 0
53 | },
54 | authCode: '',
55 | refreshToken: ''
56 | });
57 |
58 | handleError('获取 onedrive token 失败,请重新获取 code');
59 | console.error(e);
60 | }
61 | };
62 |
63 | const getToken = async () => {
64 | if (!CLIENT_ID || !CLIENT_SECRET) {
65 | handleError('client id 或 client secret 不存在');
66 | return;
67 | }
68 |
69 | if (onedriveData.accessToken.expires > Date.now())
70 | return onedriveData.accessToken.token;
71 |
72 | // 如果存在 refresh token 使用它来刷新 token
73 | if (onedriveData.refreshToken)
74 | return requestTokenHandler(`refresh_token=${onedriveData.refreshToken}`);
75 |
76 | if (!onedriveData.authCode) {
77 | handleError('auth code 不存在');
78 | return;
79 | }
80 |
81 | // 使用 code 获取令牌
82 | return requestTokenHandler(`code=${onedriveData.authCode}`);
83 | };
84 |
85 | const handleUpload = async (services: Service[] | undefined) => {
86 | setIsUploading(true);
87 | const token = await getToken();
88 |
89 | if (!token) {
90 | setIsUploading(false);
91 | return;
92 | }
93 |
94 | if (!services || services.length === 0) {
95 | setIsUploading(false);
96 | handleError('services 数据不存在');
97 | return;
98 | }
99 |
100 | const requestOptions: RequestInit = {
101 | method: 'PUT',
102 | body: JSON.stringify(services),
103 | headers: { 'Content-Type': 'application/json' }
104 | };
105 |
106 | try {
107 | await fetcherWithAuthorization([encodeURIComponent('root:/services.json:/content'), token], requestOptions);
108 | setIsUploading(false);
109 | setToast({ text: '更新成功' });
110 | } catch (e) {
111 | setIsUploading(false);
112 | if (e instanceof HTTPError) {
113 | const errorInfo = e.info as ResourceError;
114 | handleError(`更新失败: ${errorInfo.error.message}`);
115 | }
116 | }
117 | };
118 |
119 | const handleSync = async () => {
120 | setIsSyncing(true);
121 | const token = await getToken();
122 |
123 | if (!token) {
124 | setIsSyncing(false);
125 | return;
126 | }
127 |
128 | try {
129 | const data = await fetcherWithAuthorization([encodeURIComponent('root:/services.json:/content'), token], { method: 'GET' });
130 | handleUpdateServices(data);
131 |
132 | setIsSyncing(false);
133 | setToast({ text: '同步成功' });
134 | } catch (e) {
135 | setIsSyncing(false);
136 | if (e instanceof HTTPError) {
137 | const errorInfo = e.info as ResourceError;
138 | handleError(`同步失败: ${errorInfo.error.message}`);
139 | }
140 | }
141 | };
142 |
143 | return {
144 | isSyncing,
145 | isUploading,
146 | handleUpload,
147 | handleSync
148 | };
149 | }
150 |
--------------------------------------------------------------------------------