commit a7439069a2c9e085c66cb24053439919162b88ed
Author: 理想三旬 <2435040480@qq.com>
Date: Sun May 31 22:21:37 2026 +0800
first commit
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..34ff113
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,8 @@
+__pycache__/
+*.pyc
+data/
+server.err
+server.log
+server.out
+.git/
+.DS_Store
diff --git a/DEPLOY.md b/DEPLOY.md
new file mode 100644
index 0000000..117c773
--- /dev/null
+++ b/DEPLOY.md
@@ -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
+```
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..d366f84
--- /dev/null
+++ b/Dockerfile
@@ -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"]
diff --git a/__pycache__/app.cpython-314.pyc b/__pycache__/app.cpython-314.pyc
new file mode 100644
index 0000000..6fd91b8
Binary files /dev/null and b/__pycache__/app.cpython-314.pyc differ
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..2d46db7
--- /dev/null
+++ b/app.py
@@ -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
diff --git a/data/cici.sqlite b/data/cici.sqlite
new file mode 100644
index 0000000..6b651d4
Binary files /dev/null and b/data/cici.sqlite differ
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..7beab41
--- /dev/null
+++ b/docker-compose.yml
@@ -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:
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..88b6cbd
--- /dev/null
+++ b/index.html
@@ -0,0 +1,162 @@
+
+
+
+
+
+ Cici 的个人网站
+
+
+
+
+
+
+
+
+
+ About Me
+
+
+ C
+
+
+
你好,我是 CICI
+
+ 我是一名小学生女孩,喜欢画画、看书、拍照、记录生活。我的风格是甜甜的、
+ 酷酷的,心里装着亮晶晶的快乐。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/script.js b/script.js
new file mode 100644
index 0000000..a818541
--- /dev/null
+++ b/script.js
@@ -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 = "C";
+ }
+}
+
+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 = `
+ 我的酷酷自拍
+ 甜酷手账
+ 闪亮表演视频
+ `;
+}
+
+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);
+});
diff --git a/server.err b/server.err
new file mode 100644
index 0000000..e69de29
diff --git a/server.log b/server.log
new file mode 100644
index 0000000..e69de29
diff --git a/server.out b/server.out
new file mode 100644
index 0000000..e69de29
diff --git a/styles.css b/styles.css
new file mode 100644
index 0000000..e5ef5aa
--- /dev/null
+++ b/styles.css
@@ -0,0 +1,1127 @@
+:root {
+ --ink: #20172b;
+ --paper: #fff8fc;
+ --pink: #ff6fb1;
+ --hot-pink: #ff2f87;
+ --purple: #8b5cf6;
+ --lilac: #eadcff;
+ --mint: #84f0de;
+ --butter: #fff09a;
+ --sky: #b9e9ff;
+ --peach: #ffc7a8;
+ --shadow: 7px 7px 0 var(--ink);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ margin: 0;
+ color: var(--ink);
+ font-family: "Trebuchet MS", "Microsoft YaHei", sans-serif;
+ background:
+ radial-gradient(circle at 18px 18px, rgba(255, 111, 177, 0.28) 2px, transparent 3px),
+ radial-gradient(circle at 44px 44px, rgba(132, 240, 222, 0.3) 2px, transparent 3px),
+ linear-gradient(135deg, #fff8fc 0%, #fff8fc 52%, #f0e5ff 52%, #fff3c9 100%);
+ background-size: 52px 52px, 52px 52px, auto;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+.topbar {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ padding: 14px clamp(16px, 4vw, 56px);
+ border-bottom: 4px solid var(--ink);
+ background: rgba(255, 248, 252, 0.94);
+ backdrop-filter: blur(12px);
+}
+
+.brand,
+.nav,
+.hero-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.brand {
+ font-weight: 900;
+}
+
+.brand-mark {
+ display: grid;
+ width: 38px;
+ height: 38px;
+ place-items: center;
+ border: 3px solid var(--ink);
+ border-radius: 50%;
+ background: var(--pink);
+ box-shadow: 4px 4px 0 var(--ink);
+ color: white;
+}
+
+.nav {
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ font-size: 15px;
+ font-weight: 800;
+}
+
+.nav a {
+ border-bottom: 3px solid transparent;
+}
+
+.nav a:hover {
+ border-color: var(--hot-pink);
+}
+
+.hero {
+ display: block;
+ min-height: auto;
+ padding: clamp(28px, 5vw, 72px);
+}
+
+.hero-art {
+ position: relative;
+ min-height: 420px;
+ border: 5px solid var(--ink);
+ border-radius: 8px;
+ overflow: hidden;
+ background:
+ radial-gradient(circle at 22% 22%, var(--butter) 0 34px, transparent 35px),
+ radial-gradient(circle at 74% 32%, var(--mint) 0 18px, transparent 19px),
+ radial-gradient(circle at 82% 76%, var(--pink) 0 28px, transparent 29px),
+ linear-gradient(145deg, #c6f4ff 0%, #f7dcff 54%, #ffd4ea 100%);
+ box-shadow: var(--shadow);
+}
+
+.moon {
+ position: absolute;
+ right: 34px;
+ top: 28px;
+ width: 84px;
+ height: 84px;
+ border: 4px solid var(--ink);
+ border-radius: 50%;
+ background: var(--butter);
+ box-shadow: 5px 5px 0 var(--ink);
+}
+
+.moon::after {
+ position: absolute;
+ right: -2px;
+ top: -4px;
+ width: 70px;
+ height: 80px;
+ border-radius: 50%;
+ background: #f7dcff;
+ content: "";
+}
+
+.star {
+ position: absolute;
+ width: 20px;
+ height: 20px;
+ background: var(--hot-pink);
+ clip-path: polygon(50% 0, 62% 34%, 98% 35%, 69% 56%, 79% 92%, 50% 70%, 21% 92%, 31% 56%, 2% 35%, 38% 34%);
+}
+
+.star-one {
+ left: 48px;
+ top: 56px;
+}
+
+.star-two {
+ right: 126px;
+ bottom: 118px;
+ background: var(--purple);
+}
+
+.star-three {
+ left: 88px;
+ bottom: 78px;
+ background: var(--mint);
+}
+
+.comic-girl {
+ position: absolute;
+ left: 50%;
+ bottom: 32px;
+ width: 236px;
+ height: 322px;
+ transform: translateX(-50%);
+}
+
+.hood,
+.face,
+.shirt,
+.collar,
+.pop,
+.profile-card,
+.edit-card,
+.upload-card,
+.photo-card,
+.video-box,
+.diary-form,
+.diary-entry {
+ border: 4px solid var(--ink);
+}
+
+.hood {
+ position: absolute;
+ left: 24px;
+ top: 22px;
+ width: 188px;
+ height: 180px;
+ border-radius: 50% 50% 38% 38%;
+ background: #2c1c3c;
+ box-shadow: inset 0 -18px 0 var(--purple);
+}
+
+.ear {
+ position: absolute;
+ top: -35px;
+ width: 58px;
+ height: 94px;
+ border: 4px solid var(--ink);
+ background: #2c1c3c;
+}
+
+.ear.left {
+ left: 12px;
+ border-radius: 90% 16% 70% 20%;
+ transform: rotate(-18deg);
+}
+
+.ear.right {
+ right: 12px;
+ border-radius: 16% 90% 20% 70%;
+ transform: rotate(18deg);
+}
+
+.ear::after {
+ position: absolute;
+ inset: 18px 13px 12px;
+ border-radius: inherit;
+ background: var(--pink);
+ content: "";
+}
+
+.skull {
+ position: absolute;
+ left: 50%;
+ top: 24px;
+ display: grid;
+ width: 46px;
+ height: 40px;
+ place-items: center;
+ border: 4px solid var(--ink);
+ border-radius: 50% 50% 42% 42%;
+ background: white;
+ font-size: 20px;
+ font-weight: 900;
+ transform: translateX(-50%);
+}
+
+.face {
+ position: absolute;
+ left: 47px;
+ top: 76px;
+ width: 142px;
+ height: 126px;
+ border-radius: 46% 46% 42% 42%;
+ background: #ffd7c4;
+}
+
+.eye {
+ position: absolute;
+ top: 52px;
+ width: 16px;
+ height: 24px;
+ border-radius: 50%;
+ background: var(--ink);
+}
+
+.eye.left {
+ left: 36px;
+}
+
+.eye.right {
+ right: 36px;
+}
+
+.cheek {
+ position: absolute;
+ top: 80px;
+ width: 18px;
+ height: 8px;
+ border-radius: 999px;
+ background: var(--pink);
+}
+
+.cheek.left {
+ left: 22px;
+}
+
+.cheek.right {
+ right: 22px;
+}
+
+.smile {
+ position: absolute;
+ left: 55px;
+ bottom: 28px;
+ width: 32px;
+ height: 16px;
+ border-bottom: 4px solid var(--ink);
+ border-radius: 0 0 40px 40px;
+}
+
+.collar {
+ position: absolute;
+ left: 70px;
+ bottom: 94px;
+ z-index: 2;
+ width: 96px;
+ height: 32px;
+ border-radius: 999px;
+ background: var(--pink);
+}
+
+.shirt {
+ position: absolute;
+ left: 44px;
+ bottom: 0;
+ width: 148px;
+ height: 126px;
+ border-radius: 34px 34px 10px 10px;
+ background:
+ linear-gradient(90deg, transparent 0 43%, var(--mint) 43% 57%, transparent 57%),
+ var(--purple);
+}
+
+.pop {
+ position: absolute;
+ z-index: 2;
+ padding: 10px 16px;
+ border-radius: 999px;
+ background: white;
+ color: var(--ink);
+ font-weight: 900;
+ box-shadow: 5px 5px 0 var(--ink);
+}
+
+.pop-one {
+ left: 24px;
+ top: 28px;
+ background: var(--mint);
+}
+
+.pop-two {
+ right: 24px;
+ bottom: 34px;
+ background: var(--pink);
+ color: white;
+}
+
+.hero-copy {
+ max-width: 620px;
+}
+
+.eyebrow {
+ margin: 0 0 10px;
+ color: var(--hot-pink);
+ font-size: 14px;
+ font-weight: 900;
+ letter-spacing: 0;
+ text-transform: uppercase;
+}
+
+h1,
+h2,
+h3,
+p,
+figure {
+ margin-top: 0;
+}
+
+h1 {
+ margin-bottom: 18px;
+ font-size: clamp(42px, 8vw, 88px);
+ line-height: 0.98;
+}
+
+h2 {
+ margin-bottom: 0;
+ font-size: clamp(30px, 5vw, 52px);
+}
+
+h3 {
+ margin-bottom: 10px;
+ font-size: 24px;
+}
+
+p {
+ font-size: 18px;
+ line-height: 1.7;
+}
+
+.button,
+.delete-button {
+ display: inline-flex;
+ min-height: 46px;
+ align-items: center;
+ justify-content: center;
+ padding: 11px 18px;
+ border: 4px solid var(--ink);
+ border-radius: 8px;
+ box-shadow: 5px 5px 0 var(--ink);
+ font: inherit;
+ font-weight: 900;
+ cursor: pointer;
+ transition: transform 160ms ease, box-shadow 160ms ease;
+}
+
+.button:hover,
+.delete-button:hover,
+.play-button:hover {
+ transform: translate(3px, 3px);
+ box-shadow: 2px 2px 0 var(--ink);
+}
+
+.primary {
+ background: var(--pink);
+ color: white;
+}
+
+.secondary {
+ background: white;
+}
+
+.delete-button {
+ min-height: 36px;
+ padding: 6px 12px;
+ background: var(--butter);
+ font-size: 14px;
+}
+
+.band {
+ padding: clamp(44px, 7vw, 86px) clamp(16px, 5vw, 72px);
+ border-top: 5px solid var(--ink);
+}
+
+.section-title,
+.about-layout,
+.info-grid,
+.upload-card,
+.gallery-grid,
+.video-box,
+.diary-layout {
+ max-width: 1040px;
+ margin-right: auto;
+ margin-left: auto;
+}
+
+.section-title {
+ margin-bottom: 28px;
+}
+
+.about-band {
+ background:
+ linear-gradient(90deg, rgba(255, 240, 154, 0.7) 0 20%, transparent 20% 100%) 0 0 / 38px 38px,
+ #fff8fc;
+}
+
+.about-layout,
+.diary-layout {
+ display: grid;
+ grid-template-columns: minmax(260px, 0.95fr) minmax(280px, 1.05fr);
+ gap: 26px;
+}
+
+.profile-card,
+.edit-card,
+.upload-card,
+.photo-card,
+.diary-form,
+.diary-entry {
+ border-radius: 8px;
+ background: white;
+ box-shadow: var(--shadow);
+}
+
+.profile-card {
+ display: grid;
+ grid-template-columns: 150px 1fr;
+ gap: 22px;
+ align-items: center;
+ padding: 22px;
+}
+
+.avatar-preview {
+ display: grid;
+ width: 150px;
+ height: 150px;
+ place-items: center;
+ overflow: hidden;
+ border: 4px solid var(--ink);
+ border-radius: 50%;
+ background:
+ radial-gradient(circle at 50% 44%, #ffd7c4 0 36%, transparent 37%),
+ linear-gradient(145deg, #2c1c3c 0 52%, var(--pink) 52% 100%);
+ box-shadow: 5px 5px 0 var(--ink);
+ color: white;
+ font-size: 54px;
+ font-weight: 900;
+}
+
+.avatar-preview img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.edit-card,
+.diary-form {
+ padding: 20px;
+}
+
+.info-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 16px;
+ margin-top: 26px;
+}
+
+.info-grid div {
+ min-height: 116px;
+ padding: 18px;
+ border: 4px solid var(--ink);
+ border-radius: 8px;
+ background: var(--lilac);
+ box-shadow: 5px 5px 0 var(--ink);
+}
+
+.info-grid div:nth-child(2) {
+ background: #ffd1e8;
+}
+
+.info-grid div:nth-child(3) {
+ background: #d9f9ff;
+}
+
+.info-grid div:nth-child(4) {
+ background: var(--mint);
+}
+
+.info-grid span,
+label {
+ display: block;
+ margin-bottom: 8px;
+ font-size: 14px;
+ font-weight: 900;
+}
+
+.info-grid strong {
+ font-size: 20px;
+ line-height: 1.35;
+}
+
+input,
+select,
+textarea {
+ width: 100%;
+ margin-bottom: 16px;
+ padding: 12px;
+ border: 3px solid var(--ink);
+ border-radius: 6px;
+ color: var(--ink);
+ font: inherit;
+ background: #fffafd;
+}
+
+input[type="file"] {
+ padding: 10px;
+ background: white;
+}
+
+textarea {
+ min-height: 126px;
+ resize: vertical;
+}
+
+.file-picker {
+ margin-bottom: 16px;
+}
+
+.gallery-band {
+ background:
+ radial-gradient(circle at 12% 20%, rgba(132, 240, 222, 0.55) 0 58px, transparent 59px),
+ radial-gradient(circle at 88% 16%, rgba(255, 111, 177, 0.38) 0 64px, transparent 65px),
+ #f9f1ff;
+ color: var(--ink);
+}
+
+.upload-card {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 14px;
+ margin-bottom: 24px;
+ padding: 18px;
+}
+
+.upload-card .file-picker {
+ flex: 1;
+ margin-bottom: 0;
+}
+
+.upload-card input {
+ margin-bottom: 0;
+}
+
+.folder-form {
+ display: flex;
+ align-items: flex-end;
+ gap: 14px;
+}
+
+.folder-form label {
+ flex: 1;
+}
+
+.folder-tabs {
+ display: flex;
+ max-width: 1040px;
+ margin: 0 auto 22px;
+ gap: 12px;
+ overflow-x: auto;
+ padding: 4px 4px 10px;
+}
+
+.folder-tab {
+ display: inline-flex;
+ min-height: 42px;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 14px;
+ border: 4px solid var(--ink);
+ border-radius: 8px;
+ background: white;
+ box-shadow: 4px 4px 0 var(--ink);
+ color: var(--ink);
+ font: inherit;
+ font-weight: 900;
+ cursor: pointer;
+}
+
+.folder-tab.active {
+ background: var(--mint);
+}
+
+.folder-action {
+ padding: 3px 7px;
+ border: 2px solid var(--ink);
+ border-radius: 999px;
+ background: var(--butter);
+ font-size: 12px;
+}
+
+.folder-delete {
+ background: #ffd1e8;
+}
+
+.upload-status {
+ width: 100%;
+ margin: 0;
+ color: #6a5176;
+ font-size: 14px;
+ font-weight: 900;
+}
+
+.gallery-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 20px;
+ margin-bottom: 22px;
+}
+
+.photo-card {
+ position: relative;
+ min-height: 250px;
+ margin: 0;
+ padding: 14px;
+}
+
+.uploaded-photo {
+ min-height: auto;
+}
+
+.photo-illustration,
+.uploaded-photo img {
+ width: 100%;
+ height: 170px;
+ border: 4px solid var(--ink);
+ border-radius: 6px;
+ object-fit: cover;
+ overflow: hidden;
+}
+
+.photo-preview-button {
+ display: block;
+ width: 100%;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ cursor: zoom-in;
+}
+
+.night {
+ background:
+ radial-gradient(circle at 26% 28%, var(--butter) 0 26px, transparent 27px),
+ radial-gradient(circle at 72% 36%, var(--pink) 0 11px, transparent 12px),
+ linear-gradient(145deg, #b9e9ff, #eadcff);
+}
+
+.doodle {
+ background:
+ radial-gradient(circle at 24% 34%, var(--mint) 0 12px, transparent 13px),
+ radial-gradient(circle at 72% 25%, white 0 10px, transparent 11px),
+ radial-gradient(circle at 62% 72%, var(--purple) 0 15px, transparent 16px),
+ #ffabd0;
+}
+
+.stage {
+ background:
+ linear-gradient(90deg, transparent 0 34%, rgba(32, 23, 43, 0.18) 34% 36%, transparent 36% 64%, rgba(32, 23, 43, 0.18) 64% 66%, transparent 66%),
+ linear-gradient(#d9f9ff 0 55%, #ff8ec2 55% 100%);
+}
+
+.photo-card figcaption {
+ padding-top: 13px;
+ font-size: 18px;
+ font-weight: 900;
+}
+
+.uploaded-photo .delete-button {
+ margin-top: 10px;
+}
+
+.image-dialog {
+ width: min(94vw, 980px);
+ max-height: 92vh;
+ padding: 18px;
+ border: 4px solid var(--ink);
+ border-radius: 8px;
+ background: #fff8fc;
+ box-shadow: var(--shadow);
+}
+
+.image-dialog::backdrop {
+ background: rgba(32, 23, 43, 0.55);
+}
+
+.image-dialog img {
+ display: block;
+ max-width: 100%;
+ max-height: 78vh;
+ margin: 0 auto;
+ border: 4px solid var(--ink);
+ border-radius: 8px;
+ object-fit: contain;
+}
+
+.dialog-close {
+ display: grid;
+ width: 40px;
+ height: 40px;
+ margin: 0 0 12px auto;
+ place-items: center;
+ border: 3px solid var(--ink);
+ border-radius: 50%;
+ background: var(--pink);
+ box-shadow: 3px 3px 0 var(--ink);
+ color: white;
+ font-size: 24px;
+ font-weight: 900;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.video-box {
+ display: flex;
+ align-items: center;
+ gap: 18px;
+ padding: 20px;
+ border-radius: 8px;
+ background: var(--mint);
+ box-shadow: var(--shadow);
+}
+
+.play-button {
+ display: grid;
+ width: 74px;
+ height: 74px;
+ flex: 0 0 auto;
+ place-items: center;
+ border: 4px solid var(--ink);
+ border-radius: 50%;
+ background: white;
+ box-shadow: 5px 5px 0 var(--ink);
+ color: var(--ink);
+ font-size: 28px;
+ cursor: pointer;
+}
+
+.diary-band {
+ background:
+ linear-gradient(135deg, rgba(255, 111, 177, 0.18) 25%, transparent 25%) 0 0 / 28px 28px,
+ #fff8fc;
+}
+
+.diary-list {
+ display: grid;
+ gap: 14px;
+ align-content: start;
+}
+
+.diary-entry {
+ padding: 20px;
+ box-shadow: 5px 5px 0 var(--ink);
+}
+
+.diary-entry time {
+ display: block;
+ margin-bottom: 8px;
+ color: #695474;
+ font-size: 13px;
+ font-weight: 900;
+}
+
+.diary-entry .delete-button {
+ margin-top: 4px;
+}
+
+.diary-image-button {
+ display: block;
+ width: 100%;
+ margin: 10px 0 14px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ cursor: zoom-in;
+}
+
+.diary-image-button img {
+ display: block;
+ width: 100%;
+ max-height: 360px;
+ border: 4px solid var(--ink);
+ border-radius: 8px;
+ object-fit: cover;
+}
+
+.empty-diary {
+ padding: 22px;
+ border: 4px dashed var(--ink);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.78);
+ font-weight: 900;
+}
+
+.empty-gallery {
+ grid-column: 1 / -1;
+ padding: 26px;
+ border: 4px dashed var(--ink);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.78);
+ box-shadow: 5px 5px 0 var(--ink);
+ font-weight: 900;
+}
+
+.footer {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 22px clamp(16px, 5vw, 72px);
+ border-top: 5px solid var(--ink);
+ background: var(--ink);
+ color: white;
+ font-weight: 800;
+}
+
+@media (max-width: 920px) {
+ .info-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 820px) {
+ .topbar,
+ .footer {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .hero,
+ .about-layout,
+ .diary-layout,
+ .gallery-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .hero {
+ min-height: auto;
+ }
+
+ .hero-art {
+ min-height: 360px;
+ order: -1;
+ }
+
+ .profile-card {
+ grid-template-columns: 1fr;
+ }
+
+ .upload-card {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .folder-form {
+ align-items: stretch;
+ flex-direction: column;
+ }
+}
+
+@media (max-width: 520px) {
+ .hero-actions,
+ .video-box {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .button,
+ .delete-button {
+ width: 100%;
+ }
+
+ .info-grid {
+ grid-template-columns: 1fr;
+ }
+
+ h1 {
+ font-size: 42px;
+ }
+}
+
+.page {
+ display: none;
+}
+
+.page.active {
+ display: block;
+}
+
+.hidden {
+ display: none !important;
+}
+
+.nav a.active {
+ border-color: var(--hot-pink);
+}
+
+.text-button {
+ display: inline-flex;
+ min-height: 34px;
+ align-items: center;
+ justify-content: center;
+ padding: 5px 12px;
+ border: 3px solid var(--ink);
+ border-radius: 8px;
+ background: white;
+ box-shadow: 3px 3px 0 var(--ink);
+ color: var(--ink);
+ font: inherit;
+ font-weight: 900;
+ cursor: pointer;
+}
+
+.profile-main {
+ display: grid;
+ grid-template-columns: 150px 1fr;
+ gap: 22px;
+ align-items: center;
+}
+
+.hero .profile-card {
+ display: block;
+ max-width: 1040px;
+ margin: 0 auto;
+ padding: 28px;
+}
+
+.hero .profile-card h1 {
+ font-size: clamp(36px, 6vw, 68px);
+}
+
+.owner-panel {
+ max-width: 1040px;
+ margin: 0 auto 28px;
+ padding: 20px;
+ border: 4px solid var(--ink);
+ border-radius: 8px;
+ background: white;
+ box-shadow: var(--shadow);
+}
+
+.owner-panel.compact,
+.section-title.compact {
+ margin-bottom: 14px;
+}
+
+.login-dialog {
+ width: min(92vw, 420px);
+ border: 4px solid var(--ink);
+ border-radius: 8px;
+ background: #fff8fc;
+ box-shadow: var(--shadow);
+ color: var(--ink);
+}
+
+.login-dialog::backdrop {
+ background: rgba(32, 23, 43, 0.45);
+}
+
+.login-dialog form {
+ display: grid;
+ gap: 10px;
+}
+
+.dialog-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+}
+
+.form-message,
+.upload-status {
+ min-height: 20px;
+ margin: 0;
+ color: #6a5176;
+ font-size: 14px;
+ font-weight: 900;
+}
+
+.home-gallery {
+ max-width: 1040px;
+ margin: 0 auto;
+ padding: 0 clamp(16px, 5vw, 72px) clamp(44px, 7vw, 72px);
+}
+
+.home-gallery-header {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 20px;
+}
+
+.home-gallery-header h2 {
+ margin-bottom: 0;
+}
+
+.home-gallery-grid {
+ margin-bottom: 0;
+}
+
+@media (max-width: 820px) {
+ .profile-main {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 920px) {
+ .gallery-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 640px) {
+ .topbar {
+ position: static;
+ }
+
+ .brand {
+ width: 100%;
+ }
+
+ .nav {
+ width: 100%;
+ justify-content: flex-start;
+ }
+
+ .hero {
+ padding: 22px 16px;
+ }
+
+ .home-gallery {
+ padding: 0 16px 42px;
+ }
+
+ .home-gallery-header {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .home-gallery-header .button {
+ width: 100%;
+ }
+
+ .gallery-grid {
+ grid-template-columns: 1fr;
+ gap: 16px;
+ }
+
+ .photo-card {
+ padding: 12px;
+ }
+
+ .photo-illustration,
+ .uploaded-photo img {
+ height: 220px;
+ }
+
+ .image-dialog {
+ width: 94vw;
+ padding: 12px;
+ }
+
+ .image-dialog img {
+ max-height: 72vh;
+ }
+
+ .dialog-actions {
+ flex-direction: column;
+ }
+
+ .folder-tabs {
+ margin-right: 16px;
+ margin-left: 16px;
+ }
+}