diff --git a/README.md b/README.md index 281325b..04916e4 100644 --- a/README.md +++ b/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 + diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..df999d9 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..bb4c987 --- /dev/null +++ b/backend/server.js @@ -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)); diff --git a/backend/users.txt b/backend/users.txt new file mode 100644 index 0000000..c18e3a8 --- /dev/null +++ b/backend/users.txt @@ -0,0 +1 @@ +username:password \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8224e47 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..f1c8dac --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..2143dfd --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ( +
+

Comics Library

+
+ {folders.length === 0 &&
暂无可用漫画
} + {folders.map((name) => ( + + ))} +
+
+ ); +} + +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 ( + setView(null)} + /> + ); + } + if (view && view.type === "html") { + return ( +
+
+ + {view.file} +
+