first commit
This commit is contained in:
commit
a7439069a2
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
data/
|
||||
server.err
|
||||
server.log
|
||||
server.out
|
||||
.git/
|
||||
.DS_Store
|
||||
29
DEPLOY.md
Normal file
29
DEPLOY.md
Normal file
@ -0,0 +1,29 @@
|
||||
# Cici 的个人网站 Docker 部署
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
访问:
|
||||
|
||||
```text
|
||||
http://localhost:8001
|
||||
```
|
||||
|
||||
## 停止
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## 数据保存
|
||||
|
||||
网站数据会保存在 Docker volume `cici_data` 里,重启容器不会丢失。
|
||||
|
||||
## 默认主人密码
|
||||
|
||||
```text
|
||||
cici123
|
||||
```
|
||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=8001
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY app.py index.html script.js styles.css ./
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 8001
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
BIN
__pycache__/app.cpython-314.pyc
Normal file
BIN
__pycache__/app.cpython-314.pyc
Normal file
Binary file not shown.
414
app.py
Normal file
414
app.py
Normal file
@ -0,0 +1,414 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
from http.cookies import SimpleCookie
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
DB_PATH = BASE_DIR / "data" / "cici.sqlite"
|
||||
SESSION_COOKIE = "cici_session"
|
||||
SESSIONS: set[str] = set()
|
||||
|
||||
|
||||
def db() -> sqlite3.Connection:
|
||||
DB_PATH.parent.mkdir(exist_ok=True)
|
||||
connection = sqlite3.connect(DB_PATH)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
|
||||
def hash_password(password: str, salt: bytes | None = None) -> str:
|
||||
salt = salt or secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 120_000)
|
||||
return f"{salt.hex()}:{digest.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
salt_hex, digest_hex = stored.split(":", 1)
|
||||
salt = bytes.fromhex(salt_hex)
|
||||
expected = bytes.fromhex(digest_hex)
|
||||
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 120_000)
|
||||
return hmac.compare_digest(actual, expected)
|
||||
|
||||
|
||||
def has_column(connection: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
rows = connection.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
return any(row["name"] == column for row in rows)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with db() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS owner (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
name TEXT NOT NULL,
|
||||
bio TEXT NOT NULL,
|
||||
avatar TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
folder_id INTEGER NOT NULL DEFAULT 1,
|
||||
name TEXT NOT NULL,
|
||||
src TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS diary (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
image TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
if not has_column(connection, "photos", "folder_id"):
|
||||
connection.execute("ALTER TABLE photos ADD COLUMN folder_id INTEGER NOT NULL DEFAULT 1")
|
||||
if not has_column(connection, "diary", "image"):
|
||||
connection.execute("ALTER TABLE diary ADD COLUMN image TEXT NOT NULL DEFAULT ''")
|
||||
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO folders (id, name, created_at) VALUES (1, ?, ?)",
|
||||
("默认相册", now),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO owner (id, password_hash) VALUES (1, ?)",
|
||||
(hash_password("cici123"),),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO profile (id, name, bio, avatar)
|
||||
VALUES (1, ?, ?, '')
|
||||
""",
|
||||
(
|
||||
"CICI",
|
||||
"我是一名小学生女孩,喜欢画画、看书、拍照、记录生活。我的风格是甜甜的、酷酷的,心里装着亮晶晶的快乐。",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CiciHandler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=str(BASE_DIR), **kwargs)
|
||||
|
||||
def log_message(self, format: str, *args) -> None:
|
||||
if sys.stderr:
|
||||
super().log_message(format, *args)
|
||||
|
||||
def end_headers(self) -> None:
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
super().end_headers()
|
||||
|
||||
def do_GET(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
if path.startswith("/data/") or path.endswith(".py") or path.endswith(".sqlite"):
|
||||
self.send_error(HTTPStatus.FORBIDDEN)
|
||||
return
|
||||
if path == "/api/session":
|
||||
self.send_json({"authenticated": self.is_authenticated()})
|
||||
return
|
||||
if path == "/api/profile":
|
||||
self.get_profile()
|
||||
return
|
||||
if path == "/api/folders":
|
||||
self.get_folders()
|
||||
return
|
||||
if path == "/api/photos":
|
||||
self.get_photos()
|
||||
return
|
||||
if path == "/api/diary":
|
||||
self.get_diary()
|
||||
return
|
||||
if path == "/":
|
||||
self.path = "/index.html"
|
||||
super().do_GET()
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
if path == "/api/login":
|
||||
self.login()
|
||||
return
|
||||
if path == "/api/logout":
|
||||
self.logout()
|
||||
return
|
||||
if path == "/api/folders":
|
||||
if not self.require_owner():
|
||||
return
|
||||
self.create_folder()
|
||||
return
|
||||
if path == "/api/photos":
|
||||
if not self.require_owner():
|
||||
return
|
||||
self.create_photos()
|
||||
return
|
||||
if path == "/api/diary":
|
||||
if not self.require_owner():
|
||||
return
|
||||
self.create_diary()
|
||||
return
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
|
||||
def do_PUT(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
if path == "/api/profile":
|
||||
if not self.require_owner():
|
||||
return
|
||||
self.update_profile()
|
||||
return
|
||||
if path.startswith("/api/folders/"):
|
||||
if not self.require_owner():
|
||||
return
|
||||
self.update_folder(int(path.rsplit("/", 1)[-1]))
|
||||
return
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
|
||||
def do_DELETE(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
if path.startswith("/api/folders/"):
|
||||
if not self.require_owner():
|
||||
return
|
||||
self.delete_folder(int(path.rsplit("/", 1)[-1]))
|
||||
return
|
||||
if path.startswith("/api/photos/"):
|
||||
if not self.require_owner():
|
||||
return
|
||||
photo_id = int(path.rsplit("/", 1)[-1])
|
||||
with db() as connection:
|
||||
connection.execute("DELETE FROM photos WHERE id = ?", (photo_id,))
|
||||
self.send_json({"ok": True})
|
||||
return
|
||||
if path.startswith("/api/diary/"):
|
||||
if not self.require_owner():
|
||||
return
|
||||
entry_id = int(path.rsplit("/", 1)[-1])
|
||||
with db() as connection:
|
||||
connection.execute("DELETE FROM diary WHERE id = ?", (entry_id,))
|
||||
self.send_json({"ok": True})
|
||||
return
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
|
||||
def read_json(self) -> dict:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
raw = self.rfile.read(length).decode("utf-8") if length else "{}"
|
||||
return json.loads(raw or "{}")
|
||||
|
||||
def send_json(self, data: dict, status: int = 200) -> None:
|
||||
payload = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def send_json_error(self, message: str, status: int) -> None:
|
||||
self.send_json({"error": message}, status)
|
||||
|
||||
def session_token(self) -> str:
|
||||
cookie = SimpleCookie(self.headers.get("Cookie"))
|
||||
morsel = cookie.get(SESSION_COOKIE)
|
||||
return morsel.value if morsel else ""
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
return self.session_token() in SESSIONS
|
||||
|
||||
def require_owner(self) -> bool:
|
||||
if not self.is_authenticated():
|
||||
self.send_json_error("需要主人登录后才能编辑。", HTTPStatus.UNAUTHORIZED)
|
||||
return False
|
||||
return True
|
||||
|
||||
def login(self) -> None:
|
||||
data = self.read_json()
|
||||
password = data.get("password", "")
|
||||
with db() as connection:
|
||||
owner = connection.execute("SELECT password_hash FROM owner WHERE id = 1").fetchone()
|
||||
if not owner or not verify_password(password, owner["password_hash"]):
|
||||
self.send_json_error("密码不正确。", HTTPStatus.UNAUTHORIZED)
|
||||
return
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
SESSIONS.add(token)
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Set-Cookie", f"{SESSION_COOKIE}={token}; HttpOnly; SameSite=Lax; Path=/")
|
||||
payload = b'{"ok": true}'
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def logout(self) -> None:
|
||||
SESSIONS.discard(self.session_token())
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Set-Cookie", f"{SESSION_COOKIE}=; Max-Age=0; Path=/")
|
||||
payload = b'{"ok": true}'
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def get_profile(self) -> None:
|
||||
with db() as connection:
|
||||
profile = connection.execute("SELECT name, bio, avatar FROM profile WHERE id = 1").fetchone()
|
||||
self.send_json(dict(profile))
|
||||
|
||||
def update_profile(self) -> None:
|
||||
data = self.read_json()
|
||||
name = str(data.get("name") or "CICI").strip()[:18]
|
||||
bio = str(data.get("bio") or "").strip()[:220]
|
||||
avatar = data.get("avatar")
|
||||
with db() as connection:
|
||||
if avatar:
|
||||
connection.execute(
|
||||
"UPDATE profile SET name = ?, bio = ?, avatar = ? WHERE id = 1",
|
||||
(name, bio, avatar),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE profile SET name = ?, bio = ? WHERE id = 1",
|
||||
(name, bio),
|
||||
)
|
||||
self.send_json({"ok": True})
|
||||
|
||||
def get_folders(self) -> None:
|
||||
with db() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT folders.id, folders.name, folders.created_at, COUNT(photos.id) AS photo_count
|
||||
FROM folders
|
||||
LEFT JOIN photos ON photos.folder_id = folders.id
|
||||
GROUP BY folders.id
|
||||
ORDER BY folders.id ASC
|
||||
"""
|
||||
).fetchall()
|
||||
self.send_json({"folders": [dict(row) for row in rows]})
|
||||
|
||||
def create_folder(self) -> None:
|
||||
data = self.read_json()
|
||||
name = str(data.get("name") or "").strip()[:24]
|
||||
if not name:
|
||||
self.send_json_error("文件夹名称不能为空。", HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
with db() as connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO folders (name, created_at) VALUES (?, ?)",
|
||||
(name, now),
|
||||
)
|
||||
self.send_json({"id": cursor.lastrowid, "name": name})
|
||||
|
||||
def update_folder(self, folder_id: int) -> None:
|
||||
if folder_id == 1:
|
||||
self.send_json_error("默认相册不能改名。", HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
data = self.read_json()
|
||||
name = str(data.get("name") or "").strip()[:24]
|
||||
if not name:
|
||||
self.send_json_error("文件夹名称不能为空。", HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
with db() as connection:
|
||||
connection.execute("UPDATE folders SET name = ? WHERE id = ?", (name, folder_id))
|
||||
self.send_json({"ok": True, "id": folder_id, "name": name})
|
||||
|
||||
def delete_folder(self, folder_id: int) -> None:
|
||||
if folder_id == 1:
|
||||
self.send_json_error("默认相册不能删除。", HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
with db() as connection:
|
||||
connection.execute("UPDATE photos SET folder_id = 1 WHERE folder_id = ?", (folder_id,))
|
||||
connection.execute("DELETE FROM folders WHERE id = ?", (folder_id,))
|
||||
self.send_json({"ok": True})
|
||||
|
||||
def get_photos(self) -> None:
|
||||
with db() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT id, folder_id, name, src, created_at FROM photos ORDER BY id DESC LIMIT 120"
|
||||
).fetchall()
|
||||
self.send_json({"photos": [dict(row) for row in rows]})
|
||||
|
||||
def create_photos(self) -> None:
|
||||
data = self.read_json()
|
||||
photos = data.get("photos") or []
|
||||
folder_id = int(data.get("folder_id") or 1)
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
with db() as connection:
|
||||
folder = connection.execute("SELECT id FROM folders WHERE id = ?", (folder_id,)).fetchone()
|
||||
if not folder:
|
||||
folder_id = 1
|
||||
for photo in photos[:12]:
|
||||
name = str(photo.get("name") or "CICI 的照片").strip()[:40]
|
||||
src = str(photo.get("src") or "")
|
||||
if src.startswith("data:image/"):
|
||||
connection.execute(
|
||||
"INSERT INTO photos (folder_id, name, src, created_at) VALUES (?, ?, ?, ?)",
|
||||
(folder_id, name, src, now),
|
||||
)
|
||||
self.send_json({"ok": True})
|
||||
|
||||
def get_diary(self) -> None:
|
||||
with db() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT id, title, text, image, created_at FROM diary ORDER BY id DESC LIMIT 30"
|
||||
).fetchall()
|
||||
self.send_json({"entries": [dict(row) for row in rows]})
|
||||
|
||||
def create_diary(self) -> None:
|
||||
data = self.read_json()
|
||||
title = str(data.get("title") or "今天的小日记").strip()[:24]
|
||||
text = str(data.get("text") or "").strip()[:360]
|
||||
image = str(data.get("image") or "")
|
||||
if not text and not image:
|
||||
self.send_json_error("日记内容或图片至少需要一个。", HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
if image and not image.startswith("data:image/"):
|
||||
image = ""
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
with db() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO diary (title, text, image, created_at) VALUES (?, ?, ?, ?)",
|
||||
(title, text, image, now),
|
||||
)
|
||||
self.send_json({"ok": True})
|
||||
|
||||
|
||||
def main() -> None:
|
||||
init_db()
|
||||
port = int(os.environ.get("PORT", "8001"))
|
||||
host = os.environ.get("HOST", "127.0.0.1")
|
||||
server = ThreadingHTTPServer((host, port), CiciHandler)
|
||||
if sys.stdout:
|
||||
print(f"CICI website running at http://{host}:{port}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as exc:
|
||||
(BASE_DIR / "server.err").write_text(f"{type(exc).__name__}: {exc}\n", encoding="utf-8")
|
||||
raise
|
||||
BIN
data/cici.sqlite
Normal file
BIN
data/cici.sqlite
Normal file
Binary file not shown.
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@ -0,0 +1,15 @@
|
||||
services:
|
||||
cici-website:
|
||||
build: .
|
||||
container_name: cici-website
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8001:8001"
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: 8001
|
||||
volumes:
|
||||
- cici_data:/app/data
|
||||
|
||||
volumes:
|
||||
cici_data:
|
||||
162
index.html
Normal file
162
index.html
Normal file
@ -0,0 +1,162 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cici 的个人网站</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="#home" data-route="home" aria-label="回到首页">
|
||||
<span class="brand-mark">C</span>
|
||||
<span>Cici 的个人网站</span>
|
||||
</a>
|
||||
<nav class="nav" aria-label="主要导航">
|
||||
<a href="#home" data-route="home">自我介绍</a>
|
||||
<a href="#gallery" data-route="gallery">个人相册</a>
|
||||
<a href="#diary" data-route="diary">日记本</a>
|
||||
<button class="text-button" id="loginOpenButton" type="button">主人登录</button>
|
||||
<button class="text-button hidden" id="logoutButton" type="button">退出</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="page active" id="homePage" data-page="home">
|
||||
<div class="hero">
|
||||
<article class="profile-card">
|
||||
<p class="eyebrow">About Me</p>
|
||||
<div class="profile-main">
|
||||
<div class="avatar-preview" id="avatarPreview" aria-label="头像预览">
|
||||
<span>C</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 id="profileName">你好,我是 CICI</h1>
|
||||
<p id="profileBio">
|
||||
我是一名小学生女孩,喜欢画画、看书、拍照、记录生活。我的风格是甜甜的、
|
||||
酷酷的,心里装着亮晶晶的快乐。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<form class="owner-panel hidden" id="profileForm">
|
||||
<div class="section-title compact">
|
||||
<p class="eyebrow">Owner</p>
|
||||
<h2>编辑自我介绍</h2>
|
||||
</div>
|
||||
<label>
|
||||
昵称
|
||||
<input id="nameInput" type="text" maxlength="18" placeholder="比如:CICI" />
|
||||
</label>
|
||||
<label>
|
||||
自我介绍
|
||||
<textarea id="bioInput" maxlength="220" placeholder="写一段介绍自己的话"></textarea>
|
||||
</label>
|
||||
<label class="file-picker">
|
||||
上传头像
|
||||
<input id="avatarInput" type="file" accept="image/*" />
|
||||
</label>
|
||||
<button class="button primary" type="submit">保存自我介绍</button>
|
||||
</form>
|
||||
|
||||
<section class="home-gallery">
|
||||
<div class="home-gallery-header">
|
||||
<div>
|
||||
<p class="eyebrow">Gallery</p>
|
||||
<h2>相册精选</h2>
|
||||
</div>
|
||||
<a class="button secondary" href="#gallery" data-route="gallery">查看全部</a>
|
||||
</div>
|
||||
<div class="gallery-grid home-gallery-grid" id="homeGalleryGrid" aria-live="polite"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="page band gallery-band" id="galleryPage" data-page="gallery">
|
||||
<div class="section-title">
|
||||
<p class="eyebrow">Gallery</p>
|
||||
<h2>个人相册</h2>
|
||||
</div>
|
||||
|
||||
<form class="owner-panel hidden folder-form" id="folderForm">
|
||||
<label>
|
||||
新建文件夹
|
||||
<input id="folderNameInput" type="text" maxlength="24" placeholder="比如:旅行、学校、画画作品" />
|
||||
</label>
|
||||
<button class="button primary" type="submit">创建文件夹</button>
|
||||
</form>
|
||||
|
||||
<div class="folder-tabs" id="folderTabs" aria-label="相册文件夹"></div>
|
||||
|
||||
<form class="owner-panel hidden" id="galleryForm">
|
||||
<label>
|
||||
上传到文件夹
|
||||
<select id="uploadFolderSelect"></select>
|
||||
</label>
|
||||
<label class="file-picker">
|
||||
选择照片
|
||||
<input id="galleryInput" type="file" accept="image/*" multiple />
|
||||
</label>
|
||||
<button class="button primary" type="submit">上传到相册</button>
|
||||
<p class="upload-status" id="galleryStatus" aria-live="polite"></p>
|
||||
</form>
|
||||
|
||||
<div class="gallery-grid" id="galleryGrid" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section class="page band diary-band" id="diaryPage" data-page="diary">
|
||||
<div class="section-title">
|
||||
<p class="eyebrow">Diary</p>
|
||||
<h2>日记本</h2>
|
||||
</div>
|
||||
|
||||
<form class="owner-panel hidden diary-form" id="diaryForm">
|
||||
<label>
|
||||
今天的标题
|
||||
<input id="diaryTitle" type="text" maxlength="24" placeholder="比如:开心的一天" />
|
||||
</label>
|
||||
<label>
|
||||
今天发生了什么
|
||||
<textarea id="diaryText" maxlength="360" placeholder="把今天的小心情写下来吧"></textarea>
|
||||
</label>
|
||||
<label class="file-picker">
|
||||
上传日记图片
|
||||
<input id="diaryImageInput" type="file" accept="image/*" />
|
||||
</label>
|
||||
<button class="button primary" type="submit">保存日记</button>
|
||||
</form>
|
||||
|
||||
<div class="diary-list" id="diaryList" aria-live="polite"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog class="login-dialog" id="loginDialog">
|
||||
<form id="loginForm" method="dialog">
|
||||
<h2>主人登录</h2>
|
||||
<p>登录之后才能编辑自我介绍、管理相册和写日记。</p>
|
||||
<label>
|
||||
主人密码
|
||||
<input id="passwordInput" type="password" autocomplete="current-password" />
|
||||
</label>
|
||||
<p class="form-message" id="loginMessage" aria-live="polite"></p>
|
||||
<div class="dialog-actions">
|
||||
<button class="button secondary" id="loginCancelButton" type="button">取消</button>
|
||||
<button class="button primary" type="submit">登录</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog class="image-dialog" id="imageDialog">
|
||||
<button class="dialog-close" id="imageCloseButton" type="button" aria-label="关闭大图">×</button>
|
||||
<img id="imagePreview" alt="相册大图" />
|
||||
</dialog>
|
||||
|
||||
<footer class="footer">
|
||||
<span>Cici 的个人网站</span>
|
||||
<span>欢迎来到我的小世界</span>
|
||||
</footer>
|
||||
|
||||
<script src="/script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
538
script.js
Normal file
538
script.js
Normal file
@ -0,0 +1,538 @@
|
||||
const state = {
|
||||
authenticated: false,
|
||||
folders: [],
|
||||
photos: [],
|
||||
selectedFolderId: 1,
|
||||
};
|
||||
|
||||
const pages = document.querySelectorAll("[data-page]");
|
||||
const routeLinks = document.querySelectorAll("[data-route]");
|
||||
const loginOpenButton = document.querySelector("#loginOpenButton");
|
||||
const logoutButton = document.querySelector("#logoutButton");
|
||||
const loginDialog = document.querySelector("#loginDialog");
|
||||
const loginForm = document.querySelector("#loginForm");
|
||||
const loginCancelButton = document.querySelector("#loginCancelButton");
|
||||
const passwordInput = document.querySelector("#passwordInput");
|
||||
const loginMessage = document.querySelector("#loginMessage");
|
||||
const ownerPanels = document.querySelectorAll(".owner-panel");
|
||||
|
||||
const profileForm = document.querySelector("#profileForm");
|
||||
const nameInput = document.querySelector("#nameInput");
|
||||
const bioInput = document.querySelector("#bioInput");
|
||||
const avatarInput = document.querySelector("#avatarInput");
|
||||
const profileName = document.querySelector("#profileName");
|
||||
const profileBio = document.querySelector("#profileBio");
|
||||
const avatarPreview = document.querySelector("#avatarPreview");
|
||||
|
||||
const folderForm = document.querySelector("#folderForm");
|
||||
const folderNameInput = document.querySelector("#folderNameInput");
|
||||
const folderTabs = document.querySelector("#folderTabs");
|
||||
const uploadFolderSelect = document.querySelector("#uploadFolderSelect");
|
||||
|
||||
const galleryForm = document.querySelector("#galleryForm");
|
||||
const galleryInput = document.querySelector("#galleryInput");
|
||||
const galleryGrid = document.querySelector("#galleryGrid");
|
||||
const homeGalleryGrid = document.querySelector("#homeGalleryGrid");
|
||||
const galleryStatus = document.querySelector("#galleryStatus");
|
||||
const imageDialog = document.querySelector("#imageDialog");
|
||||
const imagePreview = document.querySelector("#imagePreview");
|
||||
const imageCloseButton = document.querySelector("#imageCloseButton");
|
||||
|
||||
const diaryForm = document.querySelector("#diaryForm");
|
||||
const titleInput = document.querySelector("#diaryTitle");
|
||||
const textInput = document.querySelector("#diaryText");
|
||||
const diaryImageInput = document.querySelector("#diaryImageInput");
|
||||
const diaryList = document.querySelector("#diaryList");
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
credentials: "same-origin",
|
||||
...options,
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "请求失败");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function showPage(route) {
|
||||
const nextRoute = route || "home";
|
||||
pages.forEach((page) => {
|
||||
page.classList.toggle("active", page.dataset.page === nextRoute);
|
||||
});
|
||||
routeLinks.forEach((link) => {
|
||||
link.classList.toggle("active", link.dataset.route === nextRoute);
|
||||
});
|
||||
}
|
||||
|
||||
function syncRoute() {
|
||||
const route = (location.hash || "#home").replace("#", "");
|
||||
showPage(["home", "gallery", "diary"].includes(route) ? route : "home");
|
||||
}
|
||||
|
||||
function updateAuthUi() {
|
||||
ownerPanels.forEach((panel) => panel.classList.toggle("hidden", !state.authenticated));
|
||||
loginOpenButton.classList.toggle("hidden", state.authenticated);
|
||||
logoutButton.classList.toggle("hidden", !state.authenticated);
|
||||
|
||||
document.querySelectorAll(".delete-button").forEach((button) => {
|
||||
button.classList.toggle("hidden", !state.authenticated);
|
||||
});
|
||||
}
|
||||
|
||||
function dataUrlBytes(dataUrl) {
|
||||
const base64 = dataUrl.split(",")[1] || "";
|
||||
return Math.ceil((base64.length * 3) / 4);
|
||||
}
|
||||
|
||||
function drawImageToDataUrl(image, maxSize, quality) {
|
||||
const scale = Math.min(1, maxSize / Math.max(image.width, image.height));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, Math.round(image.width * scale));
|
||||
canvas.height = Math.max(1, Math.round(image.height * scale));
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#fff8fc";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
return canvas.toDataURL("image/jpeg", quality);
|
||||
}
|
||||
|
||||
function readImage(file, options = {}) {
|
||||
const maxSize = options.maxSize || 900;
|
||||
const quality = options.quality || 0.78;
|
||||
const maxBytes = options.maxBytes || 120000;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
const sizes = [maxSize, 640, 520, 420, 320, 240, 180].filter((size) => size <= maxSize);
|
||||
const qualities = [quality, 0.62, 0.5, 0.42, 0.34, 0.28];
|
||||
let bestImage = drawImageToDataUrl(image, sizes[0], qualities[0]);
|
||||
|
||||
for (const size of sizes) {
|
||||
for (const nextQuality of qualities) {
|
||||
bestImage = drawImageToDataUrl(image, size, nextQuality);
|
||||
if (dataUrlBytes(bestImage) <= maxBytes) {
|
||||
resolve(bestImage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolve(bestImage);
|
||||
};
|
||||
image.onerror = () => reject(new Error("图片读取失败"));
|
||||
image.src = reader.result;
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSession() {
|
||||
const data = await api("/api/session");
|
||||
state.authenticated = data.authenticated;
|
||||
updateAuthUi();
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
const profile = await api("/api/profile");
|
||||
const name = profile.name || "CICI";
|
||||
|
||||
profileName.textContent = `你好,我是 ${name}`;
|
||||
profileBio.textContent = profile.bio || "";
|
||||
nameInput.value = name;
|
||||
bioInput.value = profile.bio || "";
|
||||
|
||||
if (profile.avatar) {
|
||||
avatarPreview.innerHTML = "";
|
||||
const image = document.createElement("img");
|
||||
image.src = profile.avatar;
|
||||
image.alt = `${name} 的头像`;
|
||||
avatarPreview.append(image);
|
||||
} else {
|
||||
avatarPreview.innerHTML = "<span>C</span>";
|
||||
}
|
||||
}
|
||||
|
||||
function openImagePreview(src, alt = "相册大图") {
|
||||
imagePreview.src = src;
|
||||
imagePreview.alt = alt;
|
||||
imageDialog.showModal();
|
||||
}
|
||||
|
||||
function createPhotoCard(photo) {
|
||||
const figure = document.createElement("figure");
|
||||
figure.className = "photo-card uploaded-photo";
|
||||
|
||||
const previewButton = document.createElement("button");
|
||||
previewButton.className = "photo-preview-button";
|
||||
previewButton.type = "button";
|
||||
previewButton.setAttribute("aria-label", "查看大图");
|
||||
|
||||
const image = document.createElement("img");
|
||||
image.src = photo.src;
|
||||
image.alt = photo.name || "CICI 的照片";
|
||||
|
||||
previewButton.append(image);
|
||||
previewButton.addEventListener("click", () => openImagePreview(photo.src, photo.name || "相册大图"));
|
||||
|
||||
const deleteButton = document.createElement("button");
|
||||
deleteButton.className = "delete-button";
|
||||
deleteButton.type = "button";
|
||||
deleteButton.textContent = "删除";
|
||||
deleteButton.addEventListener("click", async () => {
|
||||
await api(`/api/photos/${photo.id}`, { method: "DELETE" });
|
||||
await loadGallery();
|
||||
updateAuthUi();
|
||||
});
|
||||
|
||||
figure.append(previewButton, deleteButton);
|
||||
return figure;
|
||||
}
|
||||
|
||||
function renderSampleGallery(target) {
|
||||
target.innerHTML = `
|
||||
<figure class="photo-card"><div class="photo-illustration night"></div><figcaption>我的酷酷自拍</figcaption></figure>
|
||||
<figure class="photo-card"><div class="photo-illustration doodle"></div><figcaption>甜酷手账</figcaption></figure>
|
||||
<figure class="photo-card"><div class="photo-illustration stage"></div><figcaption>闪亮表演视频</figcaption></figure>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderEmptyFolder(target) {
|
||||
target.innerHTML = "";
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "empty-gallery";
|
||||
empty.textContent = "这个文件夹还没有照片。";
|
||||
target.append(empty);
|
||||
}
|
||||
|
||||
function renderPhotoGrid(target, photos, options = {}) {
|
||||
target.innerHTML = "";
|
||||
const visiblePhotos = typeof options.limit === "number" ? photos.slice(0, options.limit) : photos;
|
||||
|
||||
if (visiblePhotos.length === 0) {
|
||||
if (options.showSamples) {
|
||||
renderSampleGallery(target);
|
||||
} else {
|
||||
renderEmptyFolder(target);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
visiblePhotos.forEach((photo) => {
|
||||
target.append(createPhotoCard(photo));
|
||||
});
|
||||
}
|
||||
|
||||
function renderFolders() {
|
||||
folderTabs.innerHTML = "";
|
||||
uploadFolderSelect.innerHTML = "";
|
||||
|
||||
state.folders.forEach((folder) => {
|
||||
const tab = document.createElement("button");
|
||||
tab.className = "folder-tab";
|
||||
tab.type = "button";
|
||||
tab.classList.toggle("active", folder.id === state.selectedFolderId);
|
||||
tab.textContent = `${folder.name} (${folder.photo_count})`;
|
||||
tab.addEventListener("click", () => {
|
||||
state.selectedFolderId = folder.id;
|
||||
renderFolders();
|
||||
renderSelectedFolderPhotos();
|
||||
});
|
||||
|
||||
if (state.authenticated && folder.id !== 1) {
|
||||
const renameButton = document.createElement("span");
|
||||
renameButton.className = "folder-action";
|
||||
renameButton.textContent = "重命名";
|
||||
renameButton.addEventListener("click", async (event) => {
|
||||
event.stopPropagation();
|
||||
const nextName = window.prompt("请输入新的文件夹名称", folder.name);
|
||||
if (!nextName || nextName.trim() === folder.name) {
|
||||
return;
|
||||
}
|
||||
await api(`/api/folders/${folder.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ name: nextName.trim() }),
|
||||
});
|
||||
await loadGallery();
|
||||
});
|
||||
|
||||
const deleteButton = document.createElement("span");
|
||||
deleteButton.className = "folder-action folder-delete";
|
||||
deleteButton.textContent = "删除";
|
||||
deleteButton.addEventListener("click", async (event) => {
|
||||
event.stopPropagation();
|
||||
await api(`/api/folders/${folder.id}`, { method: "DELETE" });
|
||||
state.selectedFolderId = 1;
|
||||
await loadGallery();
|
||||
});
|
||||
tab.append(renameButton);
|
||||
tab.append(deleteButton);
|
||||
}
|
||||
|
||||
folderTabs.append(tab);
|
||||
|
||||
const option = document.createElement("option");
|
||||
option.value = folder.id;
|
||||
option.textContent = folder.name;
|
||||
option.selected = folder.id === state.selectedFolderId;
|
||||
uploadFolderSelect.append(option);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSelectedFolderPhotos() {
|
||||
const photos = state.photos.filter((photo) => photo.folder_id === state.selectedFolderId);
|
||||
renderPhotoGrid(galleryGrid, photos);
|
||||
updateAuthUi();
|
||||
}
|
||||
|
||||
async function loadGallery() {
|
||||
const [foldersData, photosData] = await Promise.all([api("/api/folders"), api("/api/photos")]);
|
||||
state.folders = foldersData.folders;
|
||||
state.photos = photosData.photos;
|
||||
|
||||
if (!state.folders.some((folder) => folder.id === state.selectedFolderId)) {
|
||||
state.selectedFolderId = state.folders[0]?.id || 1;
|
||||
}
|
||||
|
||||
renderFolders();
|
||||
renderSelectedFolderPhotos();
|
||||
renderPhotoGrid(homeGalleryGrid, state.photos, { limit: 3, showSamples: true });
|
||||
updateAuthUi();
|
||||
}
|
||||
|
||||
async function loadDiary() {
|
||||
const data = await api("/api/diary");
|
||||
diaryList.innerHTML = "";
|
||||
|
||||
if (data.entries.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "empty-diary";
|
||||
empty.textContent = "还没有日记。";
|
||||
diaryList.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
data.entries.forEach((entry) => {
|
||||
const item = document.createElement("article");
|
||||
item.className = "diary-entry";
|
||||
|
||||
const time = document.createElement("time");
|
||||
time.textContent = entry.created_at;
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = entry.title;
|
||||
|
||||
const text = document.createElement("p");
|
||||
text.textContent = entry.text;
|
||||
|
||||
item.append(time, title);
|
||||
|
||||
if (entry.image) {
|
||||
const imageButton = document.createElement("button");
|
||||
imageButton.className = "diary-image-button";
|
||||
imageButton.type = "button";
|
||||
imageButton.setAttribute("aria-label", "查看日记图片");
|
||||
|
||||
const image = document.createElement("img");
|
||||
image.src = entry.image;
|
||||
image.alt = entry.title || "日记图片";
|
||||
imageButton.append(image);
|
||||
imageButton.addEventListener("click", () => openImagePreview(entry.image, entry.title || "日记图片"));
|
||||
item.append(imageButton);
|
||||
}
|
||||
|
||||
if (entry.text) {
|
||||
item.append(text);
|
||||
}
|
||||
|
||||
const deleteButton = document.createElement("button");
|
||||
deleteButton.className = "delete-button";
|
||||
deleteButton.type = "button";
|
||||
deleteButton.textContent = "删除";
|
||||
deleteButton.addEventListener("click", async () => {
|
||||
await api(`/api/diary/${entry.id}`, { method: "DELETE" });
|
||||
await loadDiary();
|
||||
updateAuthUi();
|
||||
});
|
||||
|
||||
item.append(deleteButton);
|
||||
diaryList.append(item);
|
||||
});
|
||||
|
||||
updateAuthUi();
|
||||
}
|
||||
|
||||
profileForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
let avatar = null;
|
||||
if (avatarInput.files[0]) {
|
||||
avatar = await readImage(avatarInput.files[0], {
|
||||
maxSize: 360,
|
||||
quality: 0.72,
|
||||
maxBytes: 80000,
|
||||
});
|
||||
}
|
||||
|
||||
await api("/api/profile", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
name: nameInput.value.trim() || "CICI",
|
||||
bio: bioInput.value.trim(),
|
||||
avatar,
|
||||
}),
|
||||
});
|
||||
|
||||
avatarInput.value = "";
|
||||
await loadProfile();
|
||||
});
|
||||
|
||||
folderForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const name = folderNameInput.value.trim();
|
||||
if (!name) {
|
||||
folderNameInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const folder = await api("/api/folders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
state.selectedFolderId = folder.id;
|
||||
folderForm.reset();
|
||||
await loadGallery();
|
||||
});
|
||||
|
||||
galleryForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const files = Array.from(galleryInput.files).slice(0, 12);
|
||||
if (files.length === 0) {
|
||||
galleryInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
galleryStatus.textContent = "正在自动缩小照片,请稍等一下...";
|
||||
const photos = await Promise.all(
|
||||
files.map(async (file) => ({
|
||||
name: file.name.replace(/\.[^.]+$/, ""),
|
||||
src: await readImage(file, {
|
||||
maxSize: 520,
|
||||
quality: 0.68,
|
||||
maxBytes: 90000,
|
||||
}),
|
||||
})),
|
||||
);
|
||||
|
||||
const folderId = Number(uploadFolderSelect.value) || state.selectedFolderId || 1;
|
||||
await api("/api/photos", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ folder_id: folderId, photos }),
|
||||
});
|
||||
|
||||
state.selectedFolderId = folderId;
|
||||
galleryInput.value = "";
|
||||
galleryStatus.textContent = `已上传 ${photos.length} 张照片。`;
|
||||
await loadGallery();
|
||||
});
|
||||
|
||||
diaryForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const text = textInput.value.trim();
|
||||
let image = "";
|
||||
|
||||
if (diaryImageInput.files[0]) {
|
||||
image = await readImage(diaryImageInput.files[0], {
|
||||
maxSize: 620,
|
||||
quality: 0.72,
|
||||
maxBytes: 120000,
|
||||
});
|
||||
}
|
||||
|
||||
if (!text && !image) {
|
||||
textInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
await api("/api/diary", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: titleInput.value.trim() || "今天的小日记",
|
||||
text,
|
||||
image,
|
||||
}),
|
||||
});
|
||||
|
||||
diaryForm.reset();
|
||||
await loadDiary();
|
||||
});
|
||||
|
||||
loginOpenButton.addEventListener("click", () => {
|
||||
loginMessage.textContent = "";
|
||||
passwordInput.value = "";
|
||||
loginDialog.showModal();
|
||||
passwordInput.focus();
|
||||
});
|
||||
|
||||
loginCancelButton.addEventListener("click", () => {
|
||||
loginDialog.close();
|
||||
});
|
||||
|
||||
loginForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
await api("/api/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password: passwordInput.value }),
|
||||
});
|
||||
loginDialog.close();
|
||||
await loadSession();
|
||||
await Promise.all([loadProfile(), loadGallery(), loadDiary()]);
|
||||
} catch (error) {
|
||||
loginMessage.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
logoutButton.addEventListener("click", async () => {
|
||||
await api("/api/logout", { method: "POST", body: "{}" });
|
||||
await loadSession();
|
||||
await loadGallery();
|
||||
});
|
||||
|
||||
imageCloseButton.addEventListener("click", () => {
|
||||
imageDialog.close();
|
||||
});
|
||||
|
||||
imageDialog.addEventListener("click", (event) => {
|
||||
if (event.target === imageDialog) {
|
||||
imageDialog.close();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("hashchange", syncRoute);
|
||||
|
||||
async function start() {
|
||||
syncRoute();
|
||||
await loadSession();
|
||||
await Promise.all([loadProfile(), loadGallery(), loadDiary()]);
|
||||
}
|
||||
|
||||
start().catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
0
server.err
Normal file
0
server.err
Normal file
0
server.log
Normal file
0
server.log
Normal file
0
server.out
Normal file
0
server.out
Normal file
1127
styles.css
Normal file
1127
styles.css
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user