Initial Commit
This commit is contained in:
parent
5cae6fec49
commit
6a032196e7
57
README.md
57
README.md
@ -1,2 +1,59 @@
|
||||
# ComicViewer
|
||||
|
||||
## I. Project Description
|
||||
|
||||
- Automatically read comic directories, support jpg/png/webp/gif/mp4/html files
|
||||
- Front-end responsive, compatible with all device
|
||||
- Thumbnails, quick page switching, folder display
|
||||
|
||||
---
|
||||
|
||||
## II. Project Structure
|
||||
|
||||
- backend/ Node.js+Viewers API
|
||||
- frontend/ React+Vite+Tailwind Frontend
|
||||
- README.md
|
||||
|
||||
---
|
||||
|
||||
## III. Deployment
|
||||
|
||||
### 1. Backend
|
||||
|
||||
1. Install Node.js、PM2
|
||||
2. Upload backend folder, execute following codes via ssh:
|
||||
`npm install`
|
||||
3. Modify `comicsRoot` in **server.js**
|
||||
4. Set up at least one username and password in **users.txt**
|
||||
5. Start PM2 service:
|
||||
`pm2 start server.js --name comic-backend`,
|
||||
`pm2 save`,
|
||||
`pm2 startup`
|
||||
|
||||
### 2. Frontend
|
||||
|
||||
1. Redirect to the **frontend/** folder and execute:
|
||||
`npm install`,
|
||||
`npm run build`
|
||||
2. Copy contents inside **dist/** to the root folder of your website
|
||||
3. Set up Reverse Proxy:
|
||||
>Nginx Example:
|
||||
```
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /www/wwwroot/你的站点目录;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Miscellaneous
|
||||
|
||||
- Default port is 3001, It is recommended to open only locally and use a reverse proxy for the external network
|
||||
- Suggested to put each comic in a separate folder
|
||||
|
||||
|
||||
15
backend/package.json
Normal file
15
backend/package.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "comicviewer-backend",
|
||||
"version": "1.1.0",
|
||||
"main": "server.js",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"express-session": "^1.18.0",
|
||||
"sharp": "^0.33.4"
|
||||
}
|
||||
}
|
||||
153
backend/server.js
Normal file
153
backend/server.js
Normal file
@ -0,0 +1,153 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const sharp = require('sharp');
|
||||
const session = require('express-session');
|
||||
const app = express();
|
||||
const port = 3001;
|
||||
|
||||
const comicsRoot = '/www/comics'; // 修改为你的漫画主目录
|
||||
|
||||
app.use(require('cors')({
|
||||
origin: true,
|
||||
credentials: true // 允许跨域携带cookie(前端用 credentials: 'include')
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(session({
|
||||
secret: 'comic-viewer-2024', // 建议改成强随机字符串
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: { httpOnly: true, maxAge: 30 * 24 * 3600 * 1000 }
|
||||
}));
|
||||
|
||||
// ==========================
|
||||
// 用户管理
|
||||
// ==========================
|
||||
|
||||
// users.txt 格式: 每行 username:password
|
||||
function loadUsers() {
|
||||
const file = path.join(__dirname, 'users.txt');
|
||||
if (!fs.existsSync(file)) return {};
|
||||
const users = {};
|
||||
fs.readFileSync(file, 'utf8').split(/\r?\n/).forEach(line => {
|
||||
if (!line.trim() || line.trim().startsWith('#')) return;
|
||||
const [u, p] = line.split(':');
|
||||
if (u && p) users[u.trim()] = p.trim();
|
||||
});
|
||||
return users;
|
||||
}
|
||||
|
||||
// 登录
|
||||
app.post('/api/login', (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
const users = loadUsers();
|
||||
if (users[username] && users[username] === password) {
|
||||
req.session.login = true;
|
||||
req.session.username = username;
|
||||
res.json({ ok: true });
|
||||
} else {
|
||||
res.status(401).json({ ok: false, msg: '用户名或密码错误' });
|
||||
}
|
||||
});
|
||||
// 登出
|
||||
app.post('/api/logout', (req, res) => {
|
||||
req.session.destroy(() => res.json({ ok: true }));
|
||||
});
|
||||
// 检查登录状态
|
||||
app.get('/api/check', (req, res) => {
|
||||
res.json({ login: !!req.session.login, username: req.session.username });
|
||||
});
|
||||
|
||||
// 认证中间件
|
||||
function auth(req, res, next) {
|
||||
if (req.session.login) return next();
|
||||
res.status(401).json({ ok: false, msg: '请先登录' });
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// 业务API(所有加auth保护)
|
||||
// ==========================
|
||||
|
||||
// 文件类型判定
|
||||
function isImage(fn) { return /\.(jpe?g|png|webp|gif)$/i.test(fn); }
|
||||
function isHtml(fn) { return /\.html?$/i.test(fn); }
|
||||
function isVideo(fn) { return /\.(mp4|webm|mov|avi|mkv|ogg)$/i.test(fn); }
|
||||
|
||||
// 只列出非空文件夹
|
||||
app.get('/api/folders', auth, (req, res) => {
|
||||
fs.readdir(comicsRoot, { withFileTypes: true }, (err, files) => {
|
||||
if (err) return res.status(500).json([]);
|
||||
const dirs = files.filter(f => f.isDirectory()).map(f => f.name);
|
||||
Promise.all(
|
||||
dirs.map(d =>
|
||||
new Promise(resolve => {
|
||||
fs.readdir(path.join(comicsRoot, d), (e, fls) => {
|
||||
const nonHidden = (fls || []).filter(f => !f.startsWith('.'));
|
||||
resolve(nonHidden.length > 0 ? d : null);
|
||||
});
|
||||
})
|
||||
)
|
||||
).then(results => {
|
||||
res.json(results.filter(Boolean));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 文件夹下所有文件(图片/html/视频/其它)
|
||||
app.get('/api/list/:folder', auth, (req, res) => {
|
||||
const folder = path.basename(req.params.folder);
|
||||
const dir = path.join(comicsRoot, folder);
|
||||
fs.readdir(dir, (err, files) => {
|
||||
if (err) return res.status(404).json([]);
|
||||
const filtered = files.filter(f => !f.startsWith('.'));
|
||||
res.json(filtered.sort((a, b) => a.localeCompare(b, 'zh', { numeric: true })));
|
||||
});
|
||||
});
|
||||
|
||||
// 文件夹下所有图片(按文件名排序)
|
||||
app.get('/api/images/:folder', auth, (req, res) => {
|
||||
const folder = path.basename(req.params.folder);
|
||||
const dir = path.join(comicsRoot, folder);
|
||||
fs.readdir(dir, (err, files) => {
|
||||
if (err) return res.status(404).json([]);
|
||||
const imgs = files.filter(isImage).sort((a, b) => a.localeCompare(b, 'zh', { numeric: true }));
|
||||
res.json(imgs);
|
||||
});
|
||||
});
|
||||
|
||||
// 图片/视频/其它原始文件
|
||||
app.get('/api/image/:folder/:img', auth, (req, res) => {
|
||||
const folder = path.basename(req.params.folder);
|
||||
const img = path.basename(req.params.img);
|
||||
const file = path.join(comicsRoot, folder, img);
|
||||
fs.access(file, fs.constants.R_OK, (err) => {
|
||||
if (err) return res.status(404).end();
|
||||
res.sendFile(file);
|
||||
});
|
||||
});
|
||||
|
||||
// 图片缩略图(只对图片调用)
|
||||
app.get('/api/image-thumb/:folder/:img', auth, (req, res) => {
|
||||
const folder = path.basename(req.params.folder);
|
||||
const img = path.basename(req.params.img);
|
||||
const file = path.join(comicsRoot, folder, img);
|
||||
fs.access(file, fs.constants.R_OK, (err) => {
|
||||
if (err) return res.status(404).end();
|
||||
sharp(file).resize({ height: 128 }).toBuffer()
|
||||
.then(buf => res.type('image/jpeg').send(buf))
|
||||
.catch(() => res.status(404).end());
|
||||
});
|
||||
});
|
||||
|
||||
// HTML 文件(iframe方式)
|
||||
app.get('/api/html/:folder/:file', auth, (req, res) => {
|
||||
const folder = path.basename(req.params.folder);
|
||||
const file = path.basename(req.params.file);
|
||||
const filePath = path.join(comicsRoot, folder, file);
|
||||
fs.access(filePath, fs.constants.R_OK, (err) => {
|
||||
if (err) return res.status(404).end();
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(port, () => console.log('Comic backend running at port', port));
|
||||
1
backend/users.txt
Normal file
1
backend/users.txt
Normal file
@ -0,0 +1 @@
|
||||
username:password
|
||||
21
frontend/package.json
Normal file
21
frontend/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "comicviewer-frontend",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.0.4",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"vite": "^4.4.9"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
}
|
||||
}
|
||||
221
frontend/src/App.jsx
Normal file
221
frontend/src/App.jsx
Normal file
@ -0,0 +1,221 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Login from "./Login";
|
||||
|
||||
const apiBase = "/api";
|
||||
const isImage = (name) => /\.(jpe?g|png|webp|gif)$/i.test(name);
|
||||
const isHtml = (name) => /\.html?$/i.test(name);
|
||||
const isVideo = (name) => /\.(mp4|webm|mov|avi|mkv|ogg)$/i.test(name);
|
||||
|
||||
function FolderList({ onOpen }) {
|
||||
const [folders, setFolders] = useState([]);
|
||||
useEffect(() => {
|
||||
fetch(`${apiBase}/folders`).then((r) => r.json()).then(setFolders);
|
||||
}, []);
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-indigo-100 to-white p-8">
|
||||
<h1 className="text-3xl font-bold mb-8">Comics Library</h1>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6 w-full max-w-4xl">
|
||||
{folders.length === 0 && <div className="text-gray-400 col-span-full">暂无可用漫画</div>}
|
||||
{folders.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
className="bg-white hover:bg-indigo-50 shadow-xl rounded-2xl p-8 text-xl font-semibold transition border border-indigo-100"
|
||||
onClick={() => onOpen(name)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderContent({ folder, onBack }) {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [view, setView] = useState(null); // {type: 'image'|'html'|'video', file: string, index?: number}
|
||||
const [allImages, setAllImages] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${apiBase}/list/${encodeURIComponent(folder)}`)
|
||||
.then(r => r.json())
|
||||
.then(fls => {
|
||||
setFiles(fls);
|
||||
const imgs = fls.filter(isImage);
|
||||
setAllImages(imgs);
|
||||
if (imgs.length > 0 && imgs.length === fls.length) {
|
||||
setView({ type: 'image', file: imgs[0], index: 0 });
|
||||
}
|
||||
});
|
||||
}, [folder]);
|
||||
|
||||
// 三种文件类型的预览模式
|
||||
if (view && view.type === "image") {
|
||||
return (
|
||||
<ComicViewer
|
||||
files={allImages}
|
||||
folder={folder}
|
||||
index={view.index}
|
||||
onBack={() => setView(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (view && view.type === "html") {
|
||||
return (
|
||||
<div className="w-full min-h-screen flex flex-col items-center">
|
||||
<div className="flex w-full max-w-3xl justify-between items-center p-2">
|
||||
<button className="p-2 m-2" onClick={() => setView(null)}>⬅ 返回</button>
|
||||
<span className="font-semibold text-lg">{view.file}</span>
|
||||
</div>
|
||||
<iframe
|
||||
src={`${apiBase}/html/${encodeURIComponent(folder)}/${encodeURIComponent(view.file)}`}
|
||||
className="w-full max-w-3xl min-h-[80vh] border rounded-lg shadow-lg"
|
||||
title="Comic HTML"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (view && view.type === "video") {
|
||||
return (
|
||||
<div className="w-full min-h-screen flex flex-col items-center bg-neutral-100">
|
||||
<div className="flex w-full max-w-3xl justify-between items-center p-2">
|
||||
<button className="p-2 m-2" onClick={() => setView(null)}>⬅ 返回</button>
|
||||
<span className="font-semibold text-lg">{view.file}</span>
|
||||
</div>
|
||||
<video
|
||||
controls
|
||||
autoPlay
|
||||
style={{ width: '100%', maxWidth: '900px', maxHeight: '70vh', background: '#000', borderRadius: '1rem', boxShadow: '0 2px 16px rgba(0,0,0,0.18)' }}
|
||||
src={`${apiBase}/image/${encodeURIComponent(folder)}/${encodeURIComponent(view.file)}`}
|
||||
poster=""
|
||||
>
|
||||
抱歉,您的浏览器不支持 video 标签。
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 列表展示
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center bg-gradient-to-br from-indigo-50 to-white p-8">
|
||||
<div className="flex w-full max-w-3xl justify-between items-center p-2 mb-4">
|
||||
<button className="p-2" onClick={onBack}>⬅ 返回</button>
|
||||
<span className="font-semibold text-lg">{folder}</span>
|
||||
</div>
|
||||
<div className="w-full max-w-3xl grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{files.length === 0 && <div className="text-gray-400 col-span-full">空文件夹</div>}
|
||||
{files.map((f, i) => (
|
||||
<div key={f} className="bg-white shadow-md rounded-xl flex flex-col items-center p-3">
|
||||
{isImage(f) ? (
|
||||
<img
|
||||
src={`${apiBase}/image-thumb/${encodeURIComponent(folder)}/${encodeURIComponent(f)}`}
|
||||
alt={f}
|
||||
className="h-24 w-auto rounded mb-2 cursor-pointer hover:scale-105 transition"
|
||||
onClick={() => setView({ type: 'image', file: f, index: allImages.indexOf(f) })}
|
||||
/>
|
||||
) : isVideo(f) ? (
|
||||
<button
|
||||
className="flex flex-col items-center text-indigo-700 cursor-pointer"
|
||||
style={{ width: '100%' }}
|
||||
onClick={() => setView({ type: 'video', file: f })}
|
||||
>
|
||||
<div className="flex items-center justify-center h-20">
|
||||
<svg width="48" height="48" fill="none"><circle cx="24" cy="24" r="20" fill="#6366f1" opacity="0.15"/><polygon points="19,16 36,24 19,32" fill="#6366f1"/></svg>
|
||||
</div>
|
||||
<div className="mt-1">{f}</div>
|
||||
</button>
|
||||
) : isHtml(f) ? (
|
||||
<button
|
||||
className="text-indigo-700 underline cursor-pointer"
|
||||
onClick={() => setView({ type: 'html', file: f })}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-gray-500">{f}</span>
|
||||
)}
|
||||
<div className="text-sm text-gray-600 truncate">{f}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComicViewer({ files, folder, index = 0, onBack }) {
|
||||
const [curIdx, setCurIdx] = useState(index);
|
||||
|
||||
useEffect(() => {
|
||||
setCurIdx(index);
|
||||
}, [index]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (e.key === "ArrowLeft" || e.key === "ArrowUp") setCurIdx(i => Math.max(i - 1, 0));
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") setCurIdx(i => Math.min(i + 1, files.length - 1));
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [files.length]);
|
||||
|
||||
if (!files.length) return <div className="text-center p-16">无图片</div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-100 flex flex-col items-center">
|
||||
<div className="flex w-full items-center justify-between max-w-3xl p-2">
|
||||
<button
|
||||
className="p-2 text-lg hover:bg-indigo-100 rounded-xl"
|
||||
onClick={onBack}
|
||||
>⬅ 返回</button>
|
||||
<span className="text-xl font-semibold">{folder}</span>
|
||||
<span className="text-gray-500">
|
||||
{curIdx + 1} / {files.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center w-full max-w-3xl grow">
|
||||
<button onClick={() => setCurIdx(i => Math.max(i - 1, 0))} disabled={curIdx === 0} className="p-3 text-2xl hover:bg-indigo-200 rounded-xl disabled:opacity-30">◀</button>
|
||||
<div className="flex-grow flex flex-col items-center">
|
||||
<img
|
||||
src={`${apiBase}/image/${encodeURIComponent(folder)}/${encodeURIComponent(files[curIdx])}`}
|
||||
alt={files[curIdx]}
|
||||
className="max-h-[70vh] w-auto max-w-full rounded-2xl shadow-lg object-contain transition-all duration-300"
|
||||
draggable="false"
|
||||
/>
|
||||
<div className="text-base mt-2 text-gray-500">{files[curIdx]}</div>
|
||||
</div>
|
||||
<button onClick={() => setCurIdx(i => Math.min(i + 1, files.length - 1))} disabled={curIdx === files.length - 1} className="p-3 text-2xl hover:bg-indigo-200 rounded-xl disabled:opacity-30">▶</button>
|
||||
</div>
|
||||
<div className="w-full max-w-3xl flex gap-2 mt-4 flex-wrap justify-center">
|
||||
{files.map((f, i) => (
|
||||
<img
|
||||
key={f}
|
||||
src={`${apiBase}/image-thumb/${encodeURIComponent(folder)}/${encodeURIComponent(f)}`}
|
||||
alt={f}
|
||||
onClick={() => setCurIdx(i)}
|
||||
className={`h-16 w-auto rounded border-2 ${i === curIdx ? "border-indigo-500" : "border-gray-200"} cursor-pointer hover:scale-105 transition`}
|
||||
draggable="false"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [login, setLogin] = useState(null);
|
||||
|
||||
function checkLogin() {
|
||||
fetch("/api/check", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setLogin(!!d.login));
|
||||
}
|
||||
useEffect(() => { checkLogin(); }, []);
|
||||
|
||||
if (login === null) return <div>加载中...</div>;
|
||||
if (!login) return <Login onLogin={checkLogin} />;
|
||||
|
||||
// 已登录,显示主界面
|
||||
const [folder, setFolder] = useState(null);
|
||||
return folder
|
||||
? <FolderContent folder={folder} onBack={() => setFolder(null)} />
|
||||
: <FolderList onOpen={setFolder} />;
|
||||
}
|
||||
56
frontend/src/Login.jsx
Normal file
56
frontend/src/Login.jsx
Normal file
@ -0,0 +1,56 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
export default function Login({ onLogin }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error("用户名或密码错误");
|
||||
return r.json();
|
||||
})
|
||||
.then((d) => {
|
||||
if (d.ok) onLogin();
|
||||
else setErr("用户名或密码错误");
|
||||
})
|
||||
.catch(() => setErr("用户名或密码错误"));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col justify-center items-center bg-gradient-to-br from-indigo-100 to-white">
|
||||
<form
|
||||
className="w-80 bg-white shadow-xl rounded-2xl px-8 py-10 flex flex-col gap-6"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-center mb-4">登录</h2>
|
||||
<input
|
||||
className="border px-3 py-2 rounded"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
className="border px-3 py-2 rounded"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{err && <div className="text-red-500 text-center">{err}</div>}
|
||||
<button className="bg-indigo-600 hover:bg-indigo-700 text-white py-2 rounded-xl font-semibold text-lg transition" type="submit">
|
||||
登录
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
frontend/src/index.css
Normal file
7
frontend/src/index.css
Normal file
@ -0,0 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #eef2ff, #fff);
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
10
frontend/tailwind.config.js
Normal file
10
frontend/tailwind.config.js
Normal file
@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,jsx,ts,tsx}"
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
12
frontend/vite.config.js
Normal file
12
frontend/vite.config.js
Normal file
@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3001'
|
||||
}
|
||||
},
|
||||
base: './'
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user