first commit

This commit is contained in:
理想三旬 2026-05-31 22:21:37 +08:00
commit a7439069a2
13 changed files with 2309 additions and 0 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
__pycache__/
*.pyc
data/
server.err
server.log
server.out
.git/
.DS_Store

29
DEPLOY.md Normal file
View File

@ -0,0 +1,29 @@
# Cici 的个人网站 Docker 部署
## 启动
```bash
docker compose up -d --build
```
访问:
```text
http://localhost:8001
```
## 停止
```bash
docker compose down
```
## 数据保存
网站数据会保存在 Docker volume `cici_data` 里,重启容器不会丢失。
## 默认主人密码
```text
cici123
```

16
Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV HOST=0.0.0.0
ENV PORT=8001
WORKDIR /app
COPY app.py index.html script.js styles.css ./
RUN mkdir -p /app/data
EXPOSE 8001
CMD ["python", "app.py"]

Binary file not shown.

414
app.py Normal file
View File

@ -0,0 +1,414 @@
from __future__ import annotations
import hashlib
import hmac
import json
import os
import secrets
import sqlite3
import sys
from datetime import datetime
from http import HTTPStatus
from http.cookies import SimpleCookie
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "data" / "cici.sqlite"
SESSION_COOKIE = "cici_session"
SESSIONS: set[str] = set()
def db() -> sqlite3.Connection:
DB_PATH.parent.mkdir(exist_ok=True)
connection = sqlite3.connect(DB_PATH)
connection.row_factory = sqlite3.Row
return connection
def hash_password(password: str, salt: bytes | None = None) -> str:
salt = salt or secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 120_000)
return f"{salt.hex()}:{digest.hex()}"
def verify_password(password: str, stored: str) -> bool:
salt_hex, digest_hex = stored.split(":", 1)
salt = bytes.fromhex(salt_hex)
expected = bytes.fromhex(digest_hex)
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 120_000)
return hmac.compare_digest(actual, expected)
def has_column(connection: sqlite3.Connection, table: str, column: str) -> bool:
rows = connection.execute(f"PRAGMA table_info({table})").fetchall()
return any(row["name"] == column for row in rows)
def init_db() -> None:
with db() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS owner (
id INTEGER PRIMARY KEY CHECK (id = 1),
password_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS profile (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT NOT NULL,
bio TEXT NOT NULL,
avatar TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
folder_id INTEGER NOT NULL DEFAULT 1,
name TEXT NOT NULL,
src TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS diary (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
text TEXT NOT NULL,
image TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
"""
)
if not has_column(connection, "photos", "folder_id"):
connection.execute("ALTER TABLE photos ADD COLUMN folder_id INTEGER NOT NULL DEFAULT 1")
if not has_column(connection, "diary", "image"):
connection.execute("ALTER TABLE diary ADD COLUMN image TEXT NOT NULL DEFAULT ''")
now = datetime.now().strftime("%Y-%m-%d %H:%M")
connection.execute(
"INSERT OR IGNORE INTO folders (id, name, created_at) VALUES (1, ?, ?)",
("默认相册", now),
)
connection.execute(
"INSERT OR IGNORE INTO owner (id, password_hash) VALUES (1, ?)",
(hash_password("cici123"),),
)
connection.execute(
"""
INSERT OR IGNORE INTO profile (id, name, bio, avatar)
VALUES (1, ?, ?, '')
""",
(
"CICI",
"我是一名小学生女孩,喜欢画画、看书、拍照、记录生活。我的风格是甜甜的、酷酷的,心里装着亮晶晶的快乐。",
),
)
class CiciHandler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(BASE_DIR), **kwargs)
def log_message(self, format: str, *args) -> None:
if sys.stderr:
super().log_message(format, *args)
def end_headers(self) -> None:
self.send_header("Cache-Control", "no-store")
super().end_headers()
def do_GET(self) -> None:
path = urlparse(self.path).path
if path.startswith("/data/") or path.endswith(".py") or path.endswith(".sqlite"):
self.send_error(HTTPStatus.FORBIDDEN)
return
if path == "/api/session":
self.send_json({"authenticated": self.is_authenticated()})
return
if path == "/api/profile":
self.get_profile()
return
if path == "/api/folders":
self.get_folders()
return
if path == "/api/photos":
self.get_photos()
return
if path == "/api/diary":
self.get_diary()
return
if path == "/":
self.path = "/index.html"
super().do_GET()
def do_POST(self) -> None:
path = urlparse(self.path).path
if path == "/api/login":
self.login()
return
if path == "/api/logout":
self.logout()
return
if path == "/api/folders":
if not self.require_owner():
return
self.create_folder()
return
if path == "/api/photos":
if not self.require_owner():
return
self.create_photos()
return
if path == "/api/diary":
if not self.require_owner():
return
self.create_diary()
return
self.send_error(HTTPStatus.NOT_FOUND)
def do_PUT(self) -> None:
path = urlparse(self.path).path
if path == "/api/profile":
if not self.require_owner():
return
self.update_profile()
return
if path.startswith("/api/folders/"):
if not self.require_owner():
return
self.update_folder(int(path.rsplit("/", 1)[-1]))
return
self.send_error(HTTPStatus.NOT_FOUND)
def do_DELETE(self) -> None:
path = urlparse(self.path).path
if path.startswith("/api/folders/"):
if not self.require_owner():
return
self.delete_folder(int(path.rsplit("/", 1)[-1]))
return
if path.startswith("/api/photos/"):
if not self.require_owner():
return
photo_id = int(path.rsplit("/", 1)[-1])
with db() as connection:
connection.execute("DELETE FROM photos WHERE id = ?", (photo_id,))
self.send_json({"ok": True})
return
if path.startswith("/api/diary/"):
if not self.require_owner():
return
entry_id = int(path.rsplit("/", 1)[-1])
with db() as connection:
connection.execute("DELETE FROM diary WHERE id = ?", (entry_id,))
self.send_json({"ok": True})
return
self.send_error(HTTPStatus.NOT_FOUND)
def read_json(self) -> dict:
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length).decode("utf-8") if length else "{}"
return json.loads(raw or "{}")
def send_json(self, data: dict, status: int = 200) -> None:
payload = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def send_json_error(self, message: str, status: int) -> None:
self.send_json({"error": message}, status)
def session_token(self) -> str:
cookie = SimpleCookie(self.headers.get("Cookie"))
morsel = cookie.get(SESSION_COOKIE)
return morsel.value if morsel else ""
def is_authenticated(self) -> bool:
return self.session_token() in SESSIONS
def require_owner(self) -> bool:
if not self.is_authenticated():
self.send_json_error("需要主人登录后才能编辑。", HTTPStatus.UNAUTHORIZED)
return False
return True
def login(self) -> None:
data = self.read_json()
password = data.get("password", "")
with db() as connection:
owner = connection.execute("SELECT password_hash FROM owner WHERE id = 1").fetchone()
if not owner or not verify_password(password, owner["password_hash"]):
self.send_json_error("密码不正确。", HTTPStatus.UNAUTHORIZED)
return
token = secrets.token_urlsafe(32)
SESSIONS.add(token)
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Set-Cookie", f"{SESSION_COOKIE}={token}; HttpOnly; SameSite=Lax; Path=/")
payload = b'{"ok": true}'
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def logout(self) -> None:
SESSIONS.discard(self.session_token())
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Set-Cookie", f"{SESSION_COOKIE}=; Max-Age=0; Path=/")
payload = b'{"ok": true}'
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def get_profile(self) -> None:
with db() as connection:
profile = connection.execute("SELECT name, bio, avatar FROM profile WHERE id = 1").fetchone()
self.send_json(dict(profile))
def update_profile(self) -> None:
data = self.read_json()
name = str(data.get("name") or "CICI").strip()[:18]
bio = str(data.get("bio") or "").strip()[:220]
avatar = data.get("avatar")
with db() as connection:
if avatar:
connection.execute(
"UPDATE profile SET name = ?, bio = ?, avatar = ? WHERE id = 1",
(name, bio, avatar),
)
else:
connection.execute(
"UPDATE profile SET name = ?, bio = ? WHERE id = 1",
(name, bio),
)
self.send_json({"ok": True})
def get_folders(self) -> None:
with db() as connection:
rows = connection.execute(
"""
SELECT folders.id, folders.name, folders.created_at, COUNT(photos.id) AS photo_count
FROM folders
LEFT JOIN photos ON photos.folder_id = folders.id
GROUP BY folders.id
ORDER BY folders.id ASC
"""
).fetchall()
self.send_json({"folders": [dict(row) for row in rows]})
def create_folder(self) -> None:
data = self.read_json()
name = str(data.get("name") or "").strip()[:24]
if not name:
self.send_json_error("文件夹名称不能为空。", HTTPStatus.BAD_REQUEST)
return
now = datetime.now().strftime("%Y-%m-%d %H:%M")
with db() as connection:
cursor = connection.execute(
"INSERT INTO folders (name, created_at) VALUES (?, ?)",
(name, now),
)
self.send_json({"id": cursor.lastrowid, "name": name})
def update_folder(self, folder_id: int) -> None:
if folder_id == 1:
self.send_json_error("默认相册不能改名。", HTTPStatus.BAD_REQUEST)
return
data = self.read_json()
name = str(data.get("name") or "").strip()[:24]
if not name:
self.send_json_error("文件夹名称不能为空。", HTTPStatus.BAD_REQUEST)
return
with db() as connection:
connection.execute("UPDATE folders SET name = ? WHERE id = ?", (name, folder_id))
self.send_json({"ok": True, "id": folder_id, "name": name})
def delete_folder(self, folder_id: int) -> None:
if folder_id == 1:
self.send_json_error("默认相册不能删除。", HTTPStatus.BAD_REQUEST)
return
with db() as connection:
connection.execute("UPDATE photos SET folder_id = 1 WHERE folder_id = ?", (folder_id,))
connection.execute("DELETE FROM folders WHERE id = ?", (folder_id,))
self.send_json({"ok": True})
def get_photos(self) -> None:
with db() as connection:
rows = connection.execute(
"SELECT id, folder_id, name, src, created_at FROM photos ORDER BY id DESC LIMIT 120"
).fetchall()
self.send_json({"photos": [dict(row) for row in rows]})
def create_photos(self) -> None:
data = self.read_json()
photos = data.get("photos") or []
folder_id = int(data.get("folder_id") or 1)
now = datetime.now().strftime("%Y-%m-%d %H:%M")
with db() as connection:
folder = connection.execute("SELECT id FROM folders WHERE id = ?", (folder_id,)).fetchone()
if not folder:
folder_id = 1
for photo in photos[:12]:
name = str(photo.get("name") or "CICI 的照片").strip()[:40]
src = str(photo.get("src") or "")
if src.startswith("data:image/"):
connection.execute(
"INSERT INTO photos (folder_id, name, src, created_at) VALUES (?, ?, ?, ?)",
(folder_id, name, src, now),
)
self.send_json({"ok": True})
def get_diary(self) -> None:
with db() as connection:
rows = connection.execute(
"SELECT id, title, text, image, created_at FROM diary ORDER BY id DESC LIMIT 30"
).fetchall()
self.send_json({"entries": [dict(row) for row in rows]})
def create_diary(self) -> None:
data = self.read_json()
title = str(data.get("title") or "今天的小日记").strip()[:24]
text = str(data.get("text") or "").strip()[:360]
image = str(data.get("image") or "")
if not text and not image:
self.send_json_error("日记内容或图片至少需要一个。", HTTPStatus.BAD_REQUEST)
return
if image and not image.startswith("data:image/"):
image = ""
now = datetime.now().strftime("%Y-%m-%d %H:%M")
with db() as connection:
connection.execute(
"INSERT INTO diary (title, text, image, created_at) VALUES (?, ?, ?, ?)",
(title, text, image, now),
)
self.send_json({"ok": True})
def main() -> None:
init_db()
port = int(os.environ.get("PORT", "8001"))
host = os.environ.get("HOST", "127.0.0.1")
server = ThreadingHTTPServer((host, port), CiciHandler)
if sys.stdout:
print(f"CICI website running at http://{host}:{port}")
server.serve_forever()
if __name__ == "__main__":
try:
main()
except Exception as exc:
(BASE_DIR / "server.err").write_text(f"{type(exc).__name__}: {exc}\n", encoding="utf-8")
raise

BIN
data/cici.sqlite Normal file

Binary file not shown.

15
docker-compose.yml Normal file
View File

@ -0,0 +1,15 @@
services:
cici-website:
build: .
container_name: cici-website
restart: unless-stopped
ports:
- "8001:8001"
environment:
HOST: 0.0.0.0
PORT: 8001
volumes:
- cici_data:/app/data
volumes:
cici_data:

162
index.html Normal file
View File

@ -0,0 +1,162 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cici 的个人网站</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<header class="topbar">
<a class="brand" href="#home" data-route="home" aria-label="回到首页">
<span class="brand-mark">C</span>
<span>Cici 的个人网站</span>
</a>
<nav class="nav" aria-label="主要导航">
<a href="#home" data-route="home">自我介绍</a>
<a href="#gallery" data-route="gallery">个人相册</a>
<a href="#diary" data-route="diary">日记本</a>
<button class="text-button" id="loginOpenButton" type="button">主人登录</button>
<button class="text-button hidden" id="logoutButton" type="button">退出</button>
</nav>
</header>
<main>
<section class="page active" id="homePage" data-page="home">
<div class="hero">
<article class="profile-card">
<p class="eyebrow">About Me</p>
<div class="profile-main">
<div class="avatar-preview" id="avatarPreview" aria-label="头像预览">
<span>C</span>
</div>
<div>
<h1 id="profileName">你好,我是 CICI</h1>
<p id="profileBio">
我是一名小学生女孩,喜欢画画、看书、拍照、记录生活。我的风格是甜甜的、
酷酷的,心里装着亮晶晶的快乐。
</p>
</div>
</div>
</article>
</div>
<form class="owner-panel hidden" id="profileForm">
<div class="section-title compact">
<p class="eyebrow">Owner</p>
<h2>编辑自我介绍</h2>
</div>
<label>
昵称
<input id="nameInput" type="text" maxlength="18" placeholder="比如CICI" />
</label>
<label>
自我介绍
<textarea id="bioInput" maxlength="220" placeholder="写一段介绍自己的话"></textarea>
</label>
<label class="file-picker">
上传头像
<input id="avatarInput" type="file" accept="image/*" />
</label>
<button class="button primary" type="submit">保存自我介绍</button>
</form>
<section class="home-gallery">
<div class="home-gallery-header">
<div>
<p class="eyebrow">Gallery</p>
<h2>相册精选</h2>
</div>
<a class="button secondary" href="#gallery" data-route="gallery">查看全部</a>
</div>
<div class="gallery-grid home-gallery-grid" id="homeGalleryGrid" aria-live="polite"></div>
</section>
</section>
<section class="page band gallery-band" id="galleryPage" data-page="gallery">
<div class="section-title">
<p class="eyebrow">Gallery</p>
<h2>个人相册</h2>
</div>
<form class="owner-panel hidden folder-form" id="folderForm">
<label>
新建文件夹
<input id="folderNameInput" type="text" maxlength="24" placeholder="比如:旅行、学校、画画作品" />
</label>
<button class="button primary" type="submit">创建文件夹</button>
</form>
<div class="folder-tabs" id="folderTabs" aria-label="相册文件夹"></div>
<form class="owner-panel hidden" id="galleryForm">
<label>
上传到文件夹
<select id="uploadFolderSelect"></select>
</label>
<label class="file-picker">
选择照片
<input id="galleryInput" type="file" accept="image/*" multiple />
</label>
<button class="button primary" type="submit">上传到相册</button>
<p class="upload-status" id="galleryStatus" aria-live="polite"></p>
</form>
<div class="gallery-grid" id="galleryGrid" aria-live="polite"></div>
</section>
<section class="page band diary-band" id="diaryPage" data-page="diary">
<div class="section-title">
<p class="eyebrow">Diary</p>
<h2>日记本</h2>
</div>
<form class="owner-panel hidden diary-form" id="diaryForm">
<label>
今天的标题
<input id="diaryTitle" type="text" maxlength="24" placeholder="比如:开心的一天" />
</label>
<label>
今天发生了什么
<textarea id="diaryText" maxlength="360" placeholder="把今天的小心情写下来吧"></textarea>
</label>
<label class="file-picker">
上传日记图片
<input id="diaryImageInput" type="file" accept="image/*" />
</label>
<button class="button primary" type="submit">保存日记</button>
</form>
<div class="diary-list" id="diaryList" aria-live="polite"></div>
</section>
</main>
<dialog class="login-dialog" id="loginDialog">
<form id="loginForm" method="dialog">
<h2>主人登录</h2>
<p>登录之后才能编辑自我介绍、管理相册和写日记。</p>
<label>
主人密码
<input id="passwordInput" type="password" autocomplete="current-password" />
</label>
<p class="form-message" id="loginMessage" aria-live="polite"></p>
<div class="dialog-actions">
<button class="button secondary" id="loginCancelButton" type="button">取消</button>
<button class="button primary" type="submit">登录</button>
</div>
</form>
</dialog>
<dialog class="image-dialog" id="imageDialog">
<button class="dialog-close" id="imageCloseButton" type="button" aria-label="关闭大图">×</button>
<img id="imagePreview" alt="相册大图" />
</dialog>
<footer class="footer">
<span>Cici 的个人网站</span>
<span>欢迎来到我的小世界</span>
</footer>
<script src="/script.js"></script>
</body>
</html>

538
script.js Normal file
View File

@ -0,0 +1,538 @@
const state = {
authenticated: false,
folders: [],
photos: [],
selectedFolderId: 1,
};
const pages = document.querySelectorAll("[data-page]");
const routeLinks = document.querySelectorAll("[data-route]");
const loginOpenButton = document.querySelector("#loginOpenButton");
const logoutButton = document.querySelector("#logoutButton");
const loginDialog = document.querySelector("#loginDialog");
const loginForm = document.querySelector("#loginForm");
const loginCancelButton = document.querySelector("#loginCancelButton");
const passwordInput = document.querySelector("#passwordInput");
const loginMessage = document.querySelector("#loginMessage");
const ownerPanels = document.querySelectorAll(".owner-panel");
const profileForm = document.querySelector("#profileForm");
const nameInput = document.querySelector("#nameInput");
const bioInput = document.querySelector("#bioInput");
const avatarInput = document.querySelector("#avatarInput");
const profileName = document.querySelector("#profileName");
const profileBio = document.querySelector("#profileBio");
const avatarPreview = document.querySelector("#avatarPreview");
const folderForm = document.querySelector("#folderForm");
const folderNameInput = document.querySelector("#folderNameInput");
const folderTabs = document.querySelector("#folderTabs");
const uploadFolderSelect = document.querySelector("#uploadFolderSelect");
const galleryForm = document.querySelector("#galleryForm");
const galleryInput = document.querySelector("#galleryInput");
const galleryGrid = document.querySelector("#galleryGrid");
const homeGalleryGrid = document.querySelector("#homeGalleryGrid");
const galleryStatus = document.querySelector("#galleryStatus");
const imageDialog = document.querySelector("#imageDialog");
const imagePreview = document.querySelector("#imagePreview");
const imageCloseButton = document.querySelector("#imageCloseButton");
const diaryForm = document.querySelector("#diaryForm");
const titleInput = document.querySelector("#diaryTitle");
const textInput = document.querySelector("#diaryText");
const diaryImageInput = document.querySelector("#diaryImageInput");
const diaryList = document.querySelector("#diaryList");
async function api(path, options = {}) {
const response = await fetch(path, {
headers: {
"Content-Type": "application/json",
...(options.headers || {}),
},
credentials: "same-origin",
...options,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || "请求失败");
}
return data;
}
function showPage(route) {
const nextRoute = route || "home";
pages.forEach((page) => {
page.classList.toggle("active", page.dataset.page === nextRoute);
});
routeLinks.forEach((link) => {
link.classList.toggle("active", link.dataset.route === nextRoute);
});
}
function syncRoute() {
const route = (location.hash || "#home").replace("#", "");
showPage(["home", "gallery", "diary"].includes(route) ? route : "home");
}
function updateAuthUi() {
ownerPanels.forEach((panel) => panel.classList.toggle("hidden", !state.authenticated));
loginOpenButton.classList.toggle("hidden", state.authenticated);
logoutButton.classList.toggle("hidden", !state.authenticated);
document.querySelectorAll(".delete-button").forEach((button) => {
button.classList.toggle("hidden", !state.authenticated);
});
}
function dataUrlBytes(dataUrl) {
const base64 = dataUrl.split(",")[1] || "";
return Math.ceil((base64.length * 3) / 4);
}
function drawImageToDataUrl(image, maxSize, quality) {
const scale = Math.min(1, maxSize / Math.max(image.width, image.height));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(image.width * scale));
canvas.height = Math.max(1, Math.round(image.height * scale));
const context = canvas.getContext("2d");
context.fillStyle = "#fff8fc";
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL("image/jpeg", quality);
}
function readImage(file, options = {}) {
const maxSize = options.maxSize || 900;
const quality = options.quality || 0.78;
const maxBytes = options.maxBytes || 120000;
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const image = new Image();
image.onload = () => {
const sizes = [maxSize, 640, 520, 420, 320, 240, 180].filter((size) => size <= maxSize);
const qualities = [quality, 0.62, 0.5, 0.42, 0.34, 0.28];
let bestImage = drawImageToDataUrl(image, sizes[0], qualities[0]);
for (const size of sizes) {
for (const nextQuality of qualities) {
bestImage = drawImageToDataUrl(image, size, nextQuality);
if (dataUrlBytes(bestImage) <= maxBytes) {
resolve(bestImage);
return;
}
}
}
resolve(bestImage);
};
image.onerror = () => reject(new Error("图片读取失败"));
image.src = reader.result;
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
async function loadSession() {
const data = await api("/api/session");
state.authenticated = data.authenticated;
updateAuthUi();
}
async function loadProfile() {
const profile = await api("/api/profile");
const name = profile.name || "CICI";
profileName.textContent = `你好,我是 ${name}`;
profileBio.textContent = profile.bio || "";
nameInput.value = name;
bioInput.value = profile.bio || "";
if (profile.avatar) {
avatarPreview.innerHTML = "";
const image = document.createElement("img");
image.src = profile.avatar;
image.alt = `${name} 的头像`;
avatarPreview.append(image);
} else {
avatarPreview.innerHTML = "<span>C</span>";
}
}
function openImagePreview(src, alt = "相册大图") {
imagePreview.src = src;
imagePreview.alt = alt;
imageDialog.showModal();
}
function createPhotoCard(photo) {
const figure = document.createElement("figure");
figure.className = "photo-card uploaded-photo";
const previewButton = document.createElement("button");
previewButton.className = "photo-preview-button";
previewButton.type = "button";
previewButton.setAttribute("aria-label", "查看大图");
const image = document.createElement("img");
image.src = photo.src;
image.alt = photo.name || "CICI 的照片";
previewButton.append(image);
previewButton.addEventListener("click", () => openImagePreview(photo.src, photo.name || "相册大图"));
const deleteButton = document.createElement("button");
deleteButton.className = "delete-button";
deleteButton.type = "button";
deleteButton.textContent = "删除";
deleteButton.addEventListener("click", async () => {
await api(`/api/photos/${photo.id}`, { method: "DELETE" });
await loadGallery();
updateAuthUi();
});
figure.append(previewButton, deleteButton);
return figure;
}
function renderSampleGallery(target) {
target.innerHTML = `
<figure class="photo-card"><div class="photo-illustration night"></div><figcaption></figcaption></figure>
<figure class="photo-card"><div class="photo-illustration doodle"></div><figcaption></figcaption></figure>
<figure class="photo-card"><div class="photo-illustration stage"></div><figcaption></figcaption></figure>
`;
}
function renderEmptyFolder(target) {
target.innerHTML = "";
const empty = document.createElement("div");
empty.className = "empty-gallery";
empty.textContent = "这个文件夹还没有照片。";
target.append(empty);
}
function renderPhotoGrid(target, photos, options = {}) {
target.innerHTML = "";
const visiblePhotos = typeof options.limit === "number" ? photos.slice(0, options.limit) : photos;
if (visiblePhotos.length === 0) {
if (options.showSamples) {
renderSampleGallery(target);
} else {
renderEmptyFolder(target);
}
return;
}
visiblePhotos.forEach((photo) => {
target.append(createPhotoCard(photo));
});
}
function renderFolders() {
folderTabs.innerHTML = "";
uploadFolderSelect.innerHTML = "";
state.folders.forEach((folder) => {
const tab = document.createElement("button");
tab.className = "folder-tab";
tab.type = "button";
tab.classList.toggle("active", folder.id === state.selectedFolderId);
tab.textContent = `${folder.name} (${folder.photo_count})`;
tab.addEventListener("click", () => {
state.selectedFolderId = folder.id;
renderFolders();
renderSelectedFolderPhotos();
});
if (state.authenticated && folder.id !== 1) {
const renameButton = document.createElement("span");
renameButton.className = "folder-action";
renameButton.textContent = "重命名";
renameButton.addEventListener("click", async (event) => {
event.stopPropagation();
const nextName = window.prompt("请输入新的文件夹名称", folder.name);
if (!nextName || nextName.trim() === folder.name) {
return;
}
await api(`/api/folders/${folder.id}`, {
method: "PUT",
body: JSON.stringify({ name: nextName.trim() }),
});
await loadGallery();
});
const deleteButton = document.createElement("span");
deleteButton.className = "folder-action folder-delete";
deleteButton.textContent = "删除";
deleteButton.addEventListener("click", async (event) => {
event.stopPropagation();
await api(`/api/folders/${folder.id}`, { method: "DELETE" });
state.selectedFolderId = 1;
await loadGallery();
});
tab.append(renameButton);
tab.append(deleteButton);
}
folderTabs.append(tab);
const option = document.createElement("option");
option.value = folder.id;
option.textContent = folder.name;
option.selected = folder.id === state.selectedFolderId;
uploadFolderSelect.append(option);
});
}
function renderSelectedFolderPhotos() {
const photos = state.photos.filter((photo) => photo.folder_id === state.selectedFolderId);
renderPhotoGrid(galleryGrid, photos);
updateAuthUi();
}
async function loadGallery() {
const [foldersData, photosData] = await Promise.all([api("/api/folders"), api("/api/photos")]);
state.folders = foldersData.folders;
state.photos = photosData.photos;
if (!state.folders.some((folder) => folder.id === state.selectedFolderId)) {
state.selectedFolderId = state.folders[0]?.id || 1;
}
renderFolders();
renderSelectedFolderPhotos();
renderPhotoGrid(homeGalleryGrid, state.photos, { limit: 3, showSamples: true });
updateAuthUi();
}
async function loadDiary() {
const data = await api("/api/diary");
diaryList.innerHTML = "";
if (data.entries.length === 0) {
const empty = document.createElement("div");
empty.className = "empty-diary";
empty.textContent = "还没有日记。";
diaryList.append(empty);
return;
}
data.entries.forEach((entry) => {
const item = document.createElement("article");
item.className = "diary-entry";
const time = document.createElement("time");
time.textContent = entry.created_at;
const title = document.createElement("h3");
title.textContent = entry.title;
const text = document.createElement("p");
text.textContent = entry.text;
item.append(time, title);
if (entry.image) {
const imageButton = document.createElement("button");
imageButton.className = "diary-image-button";
imageButton.type = "button";
imageButton.setAttribute("aria-label", "查看日记图片");
const image = document.createElement("img");
image.src = entry.image;
image.alt = entry.title || "日记图片";
imageButton.append(image);
imageButton.addEventListener("click", () => openImagePreview(entry.image, entry.title || "日记图片"));
item.append(imageButton);
}
if (entry.text) {
item.append(text);
}
const deleteButton = document.createElement("button");
deleteButton.className = "delete-button";
deleteButton.type = "button";
deleteButton.textContent = "删除";
deleteButton.addEventListener("click", async () => {
await api(`/api/diary/${entry.id}`, { method: "DELETE" });
await loadDiary();
updateAuthUi();
});
item.append(deleteButton);
diaryList.append(item);
});
updateAuthUi();
}
profileForm.addEventListener("submit", async (event) => {
event.preventDefault();
let avatar = null;
if (avatarInput.files[0]) {
avatar = await readImage(avatarInput.files[0], {
maxSize: 360,
quality: 0.72,
maxBytes: 80000,
});
}
await api("/api/profile", {
method: "PUT",
body: JSON.stringify({
name: nameInput.value.trim() || "CICI",
bio: bioInput.value.trim(),
avatar,
}),
});
avatarInput.value = "";
await loadProfile();
});
folderForm.addEventListener("submit", async (event) => {
event.preventDefault();
const name = folderNameInput.value.trim();
if (!name) {
folderNameInput.focus();
return;
}
const folder = await api("/api/folders", {
method: "POST",
body: JSON.stringify({ name }),
});
state.selectedFolderId = folder.id;
folderForm.reset();
await loadGallery();
});
galleryForm.addEventListener("submit", async (event) => {
event.preventDefault();
const files = Array.from(galleryInput.files).slice(0, 12);
if (files.length === 0) {
galleryInput.focus();
return;
}
galleryStatus.textContent = "正在自动缩小照片,请稍等一下...";
const photos = await Promise.all(
files.map(async (file) => ({
name: file.name.replace(/\.[^.]+$/, ""),
src: await readImage(file, {
maxSize: 520,
quality: 0.68,
maxBytes: 90000,
}),
})),
);
const folderId = Number(uploadFolderSelect.value) || state.selectedFolderId || 1;
await api("/api/photos", {
method: "POST",
body: JSON.stringify({ folder_id: folderId, photos }),
});
state.selectedFolderId = folderId;
galleryInput.value = "";
galleryStatus.textContent = `已上传 ${photos.length} 张照片。`;
await loadGallery();
});
diaryForm.addEventListener("submit", async (event) => {
event.preventDefault();
const text = textInput.value.trim();
let image = "";
if (diaryImageInput.files[0]) {
image = await readImage(diaryImageInput.files[0], {
maxSize: 620,
quality: 0.72,
maxBytes: 120000,
});
}
if (!text && !image) {
textInput.focus();
return;
}
await api("/api/diary", {
method: "POST",
body: JSON.stringify({
title: titleInput.value.trim() || "今天的小日记",
text,
image,
}),
});
diaryForm.reset();
await loadDiary();
});
loginOpenButton.addEventListener("click", () => {
loginMessage.textContent = "";
passwordInput.value = "";
loginDialog.showModal();
passwordInput.focus();
});
loginCancelButton.addEventListener("click", () => {
loginDialog.close();
});
loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await api("/api/login", {
method: "POST",
body: JSON.stringify({ password: passwordInput.value }),
});
loginDialog.close();
await loadSession();
await Promise.all([loadProfile(), loadGallery(), loadDiary()]);
} catch (error) {
loginMessage.textContent = error.message;
}
});
logoutButton.addEventListener("click", async () => {
await api("/api/logout", { method: "POST", body: "{}" });
await loadSession();
await loadGallery();
});
imageCloseButton.addEventListener("click", () => {
imageDialog.close();
});
imageDialog.addEventListener("click", (event) => {
if (event.target === imageDialog) {
imageDialog.close();
}
});
window.addEventListener("hashchange", syncRoute);
async function start() {
syncRoute();
await loadSession();
await Promise.all([loadProfile(), loadGallery(), loadDiary()]);
}
start().catch((error) => {
console.error(error);
});

0
server.err Normal file
View File

0
server.log Normal file
View File

0
server.out Normal file
View File

1127
styles.css Normal file

File diff suppressed because it is too large Load Diff