Initial Commit
This commit is contained in:
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: './'
|
||||
});
|
||||
Reference in New Issue
Block a user