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 的个人网站 + + + +
+ + C + Cici 的个人网站 + + +
+ +
+
+
+
+

About Me

+
+
+ C +
+
+

你好,我是 CICI

+

+ 我是一名小学生女孩,喜欢画画、看书、拍照、记录生活。我的风格是甜甜的、 + 酷酷的,心里装着亮晶晶的快乐。 +

+
+
+
+
+ + + + +
+ + + +
+
+

Diary

+

日记本

+
+ + + +
+
+
+ + +
+

主人登录

+

登录之后才能编辑自我介绍、管理相册和写日记。

+ +

+
+ + +
+
+
+ + + + 相册大图 + + + + + + + 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; + } +}