Codex commited on
Commit
b7fa969
·
1 Parent(s): ff4153a

Deploy Daily Reminder Master

Browse files
Files changed (13) hide show
  1. .gitattributes +0 -35
  2. .gitignore +11 -0
  3. Dockerfile +18 -0
  4. README.md +12 -3
  5. app.py +249 -0
  6. requirements.txt +2 -0
  7. static/admin.js +86 -0
  8. static/app.js +567 -0
  9. static/styles.css +727 -0
  10. storage.py +179 -0
  11. templates/admin.html +47 -0
  12. templates/base.html +21 -0
  13. templates/index.html +176 -0
.gitattributes DELETED
@@ -1,35 +0,0 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .env
4
+ .venv/
5
+ data/store.json
6
+ data/preview_store.json
7
+ preview_artifacts/
8
+ server.out
9
+ server.err
10
+ test_artifacts/
11
+ test_artifacts_round3/
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.13-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1
4
+ ENV PYTHONUNBUFFERED=1
5
+ ENV PORT=7860
6
+
7
+ WORKDIR /app
8
+
9
+ COPY requirements.txt requirements.txt
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY . .
13
+
14
+ RUN mkdir -p /app/data
15
+
16
+ EXPOSE 7860
17
+
18
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md CHANGED
@@ -1,10 +1,19 @@
1
  ---
2
  title: DRM
3
- emoji: 🐠
4
- colorFrom: gray
5
  colorTo: blue
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: DRM
3
+ emoji:
4
+ colorFrom: cyan
5
  colorTo: blue
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # Daily Reminder Master
12
+
13
+ A polished personal reminder board built with Flask for Hugging Face Spaces.
14
+
15
+ ## Environment variables
16
+
17
+ - `PASSWORD`: login password for task operations and `/admin`
18
+ - `SECRET_KEY`: Flask session secret
19
+ - `PORT`: server port, defaults to `7860`
app.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hmac
2
+ import os
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from urllib.parse import urlparse
6
+ from zoneinfo import ZoneInfo
7
+
8
+ from flask import Flask, jsonify, redirect, render_template, request, session, url_for
9
+
10
+ from storage import ReminderStore, beijing_now
11
+
12
+ BASE_DIR = Path(__file__).resolve().parent
13
+ STORE_PATH = BASE_DIR / "data" / "store.json"
14
+ TZ = ZoneInfo("Asia/Shanghai")
15
+ DEFAULT_PASSWORD = "123456"
16
+
17
+ app = Flask(__name__)
18
+ app.config["JSON_AS_ASCII"] = False
19
+ app.secret_key = os.getenv("SECRET_KEY", "daily-reminder-master-secret")
20
+ store = ReminderStore(STORE_PATH)
21
+
22
+
23
+ MSG_LOGIN_REQUIRED = "\u8bf7\u5148\u767b\u5f55"
24
+ MSG_WRONG_PASSWORD = "\u5bc6\u7801\u4e0d\u6b63\u786e"
25
+ MSG_ENTER_DUE = "\u8bf7\u8f93\u5165\u622a\u6b62\u65e5\u671f"
26
+ MSG_DUE_AFTER_NOW = "\u622a\u6b62\u65e5\u671f\u9700\u8981\u665a\u4e8e\u5f53\u524d\u5317\u4eac\u65f6\u95f4"
27
+ MSG_CATEGORY_NAME = "\u5206\u7c7b\u540d\u79f0\u81f3\u5c11 2 \u4e2a\u5b57\u7b26"
28
+ MSG_TASK_NAME = "\u4efb\u52a1\u540d\u79f0\u81f3\u5c11 2 \u4e2a\u5b57\u7b26"
29
+ MSG_CATEGORY_NOT_FOUND = "\u5206\u7c7b\u4e0d\u5b58\u5728"
30
+ MSG_TASK_NOT_FOUND = "\u4efb\u52a1\u4e0d\u5b58\u5728"
31
+ WEEKDAYS = [
32
+ "\u661f\u671f\u4e00",
33
+ "\u661f\u671f\u4e8c",
34
+ "\u661f\u671f\u4e09",
35
+ "\u661f\u671f\u56db",
36
+ "\u661f\u671f\u4e94",
37
+ "\u661f\u671f\u516d",
38
+ "\u661f\u671f\u65e5",
39
+ ]
40
+
41
+
42
+ def get_password() -> str:
43
+ return os.getenv("PASSWORD", DEFAULT_PASSWORD)
44
+
45
+
46
+ def is_authed() -> bool:
47
+ return bool(session.get("authenticated"))
48
+
49
+
50
+ def safe_next_path(raw_next: str | None) -> str:
51
+ if not raw_next:
52
+ return url_for("index")
53
+ parsed = urlparse(raw_next)
54
+ if parsed.scheme or parsed.netloc:
55
+ return url_for("index")
56
+ if not raw_next.startswith("/"):
57
+ return url_for("index")
58
+ return raw_next
59
+
60
+
61
+ def require_auth():
62
+ if not is_authed():
63
+ return jsonify({"ok": False, "error": MSG_LOGIN_REQUIRED}), 401
64
+ return None
65
+
66
+
67
+ def parse_due_at(raw_value: str) -> datetime:
68
+ if not raw_value:
69
+ raise ValueError(MSG_ENTER_DUE)
70
+
71
+ normalized = raw_value.strip()
72
+ if normalized.endswith("Z"):
73
+ normalized = normalized[:-1] + "+00:00"
74
+
75
+ due_at = datetime.fromisoformat(normalized)
76
+ if due_at.tzinfo is None:
77
+ due_at = due_at.replace(tzinfo=TZ)
78
+
79
+ due_at = due_at.astimezone(TZ).replace(minute=0, second=0, microsecond=0)
80
+
81
+ if due_at <= beijing_now():
82
+ raise ValueError(MSG_DUE_AFTER_NOW)
83
+
84
+ return due_at
85
+
86
+
87
+ def current_date_payload() -> dict[str, str]:
88
+ now = beijing_now()
89
+ return {
90
+ "iso": now.isoformat(),
91
+ "weekday": WEEKDAYS[now.weekday()],
92
+ }
93
+
94
+
95
+ @app.context_processor
96
+ def inject_globals():
97
+ return {
98
+ "authenticated": is_authed(),
99
+ }
100
+
101
+
102
+ @app.get("/")
103
+ def index():
104
+ login_required = request.args.get("login") == "required"
105
+ next_path = safe_next_path(request.args.get("next"))
106
+ return render_template(
107
+ "index.html",
108
+ categories=store.list_categories(),
109
+ clock_meta=current_date_payload(),
110
+ login_required=login_required,
111
+ next_path=next_path,
112
+ )
113
+
114
+
115
+ @app.get("/admin")
116
+ def admin():
117
+ if not is_authed():
118
+ return redirect(url_for("index", login="required", next=url_for("admin")))
119
+ return render_template(
120
+ "admin.html",
121
+ categories=store.list_categories(),
122
+ )
123
+
124
+
125
+ @app.post("/api/login")
126
+ def login():
127
+ payload = request.get_json(silent=True) or {}
128
+ password = str(payload.get("password", ""))
129
+ next_path = safe_next_path(payload.get("next"))
130
+
131
+ if hmac.compare_digest(password, get_password()):
132
+ session["authenticated"] = True
133
+ return jsonify({"ok": True, "next": next_path})
134
+
135
+ return jsonify({"ok": False, "error": MSG_WRONG_PASSWORD}), 401
136
+
137
+
138
+ @app.post("/api/logout")
139
+ def logout():
140
+ session.clear()
141
+ return jsonify({"ok": True})
142
+
143
+
144
+ @app.post("/api/categories")
145
+ def create_category():
146
+ auth_error = require_auth()
147
+ if auth_error:
148
+ return auth_error
149
+
150
+ payload = request.get_json(silent=True) or {}
151
+ name = str(payload.get("name", "")).strip()
152
+ if len(name) < 2:
153
+ return jsonify({"ok": False, "error": MSG_CATEGORY_NAME}), 400
154
+
155
+ category = store.create_category(name)
156
+ return jsonify({"ok": True, "category": category})
157
+
158
+
159
+ @app.patch("/api/categories/<category_id>")
160
+ def rename_category(category_id: str):
161
+ auth_error = require_auth()
162
+ if auth_error:
163
+ return auth_error
164
+
165
+ payload = request.get_json(silent=True) or {}
166
+ name = str(payload.get("name", "")).strip()
167
+ if len(name) < 2:
168
+ return jsonify({"ok": False, "error": MSG_CATEGORY_NAME}), 400
169
+
170
+ try:
171
+ category = store.rename_category(category_id, name)
172
+ except KeyError:
173
+ return jsonify({"ok": False, "error": MSG_CATEGORY_NOT_FOUND}), 404
174
+
175
+ return jsonify({"ok": True, "category": category})
176
+
177
+
178
+ @app.delete("/api/categories/<category_id>")
179
+ def delete_category(category_id: str):
180
+ auth_error = require_auth()
181
+ if auth_error:
182
+ return auth_error
183
+
184
+ try:
185
+ store.delete_category(category_id)
186
+ except KeyError:
187
+ return jsonify({"ok": False, "error": MSG_CATEGORY_NOT_FOUND}), 404
188
+
189
+ return jsonify({"ok": True})
190
+
191
+
192
+ @app.post("/api/categories/<category_id>/tasks")
193
+ def create_task(category_id: str):
194
+ auth_error = require_auth()
195
+ if auth_error:
196
+ return auth_error
197
+
198
+ payload = request.get_json(silent=True) or {}
199
+ title = str(payload.get("title", "")).strip()
200
+ if len(title) < 2:
201
+ return jsonify({"ok": False, "error": MSG_TASK_NAME}), 400
202
+
203
+ try:
204
+ due_at = parse_due_at(str(payload.get("due_at", "")))
205
+ except ValueError as exc:
206
+ return jsonify({"ok": False, "error": str(exc)}), 400
207
+
208
+ try:
209
+ task = store.add_task(category_id, title, due_at.isoformat())
210
+ except KeyError:
211
+ return jsonify({"ok": False, "error": MSG_CATEGORY_NOT_FOUND}), 404
212
+
213
+ return jsonify({"ok": True, "task": task})
214
+
215
+
216
+ @app.patch("/api/tasks/<task_id>")
217
+ def update_task(task_id: str):
218
+ auth_error = require_auth()
219
+ if auth_error:
220
+ return auth_error
221
+
222
+ payload = request.get_json(silent=True) or {}
223
+ completed = bool(payload.get("completed"))
224
+
225
+ try:
226
+ task = store.toggle_task(task_id, completed)
227
+ except KeyError:
228
+ return jsonify({"ok": False, "error": MSG_TASK_NOT_FOUND}), 404
229
+
230
+ return jsonify({"ok": True, "task": task})
231
+
232
+
233
+ @app.delete("/api/tasks/<task_id>")
234
+ def delete_task(task_id: str):
235
+ auth_error = require_auth()
236
+ if auth_error:
237
+ return auth_error
238
+
239
+ try:
240
+ store.delete_task(task_id)
241
+ except KeyError:
242
+ return jsonify({"ok": False, "error": MSG_TASK_NOT_FOUND}), 404
243
+
244
+ return jsonify({"ok": True})
245
+
246
+
247
+ if __name__ == "__main__":
248
+ port = int(os.getenv("PORT", "7860"))
249
+ app.run(host="0.0.0.0", port=port, debug=False)
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ Flask==3.1.2
2
+ gunicorn==23.0.0
static/admin.js ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function () {
2
+ const form = document.getElementById("createCategoryForm");
3
+ const toastStack = document.getElementById("toastStack");
4
+ const adminGrid = document.getElementById("adminGrid");
5
+
6
+ function showToast(message, kind = "success") {
7
+ const toast = document.createElement("div");
8
+ toast.className = `toast ${kind}`;
9
+ toast.textContent = message;
10
+ toastStack.appendChild(toast);
11
+ window.setTimeout(() => toast.remove(), 2600);
12
+ }
13
+
14
+ async function requestJSON(url, options = {}) {
15
+ const response = await fetch(url, {
16
+ headers: {
17
+ "Content-Type": "application/json",
18
+ },
19
+ ...options,
20
+ });
21
+
22
+ const payload = await response.json().catch(() => ({ ok: false, error: "请求失败" }));
23
+ if (!response.ok || !payload.ok) {
24
+ throw new Error(payload.error || "请求失败");
25
+ }
26
+ return payload;
27
+ }
28
+
29
+ function categoryCard(category) {
30
+ const card = document.createElement("article");
31
+ card.className = "admin-card";
32
+ card.dataset.categoryId = category.id;
33
+ card.innerHTML = `
34
+ <div class="admin-card-head">
35
+ <div>
36
+ <p class="column-label">Category</p>
37
+ <h2>${category.name}</h2>
38
+ </div>
39
+ <span class="task-count">0 项任务</span>
40
+ </div>
41
+ <p class="admin-card-copy">删除分类会同时移除其下全部任务,请谨慎操作。</p>
42
+ <button class="danger-button" type="button" data-delete-category="${category.id}">删除此清单</button>
43
+ `;
44
+ return card;
45
+ }
46
+
47
+ form.addEventListener("submit", async (event) => {
48
+ event.preventDefault();
49
+ const nameInput = document.getElementById("newCategoryName");
50
+ const name = nameInput.value.trim();
51
+
52
+ try {
53
+ const payload = await requestJSON("/api/categories", {
54
+ method: "POST",
55
+ body: JSON.stringify({ name }),
56
+ });
57
+ const createCard = adminGrid.querySelector(".create-card");
58
+ adminGrid.insertBefore(categoryCard(payload.category), createCard.nextSibling);
59
+ nameInput.value = "";
60
+ showToast("新分类已创建");
61
+ } catch (error) {
62
+ showToast(error.message, "error");
63
+ }
64
+ });
65
+
66
+ adminGrid.addEventListener("click", async (event) => {
67
+ const button = event.target.closest("[data-delete-category]");
68
+ if (!button) {
69
+ return;
70
+ }
71
+
72
+ try {
73
+ await requestJSON(`/api/categories/${button.dataset.deleteCategory}`, {
74
+ method: "DELETE",
75
+ body: JSON.stringify({}),
76
+ });
77
+ const card = button.closest(".admin-card");
78
+ if (card) {
79
+ card.remove();
80
+ }
81
+ showToast("分类已删除");
82
+ } catch (error) {
83
+ showToast(error.message, "error");
84
+ }
85
+ });
86
+ })();
static/app.js ADDED
@@ -0,0 +1,567 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function () {
2
+ const state = {
3
+ authenticated: document.body.dataset.authenticated === "true",
4
+ nextPath: (window.__DRM_BOOTSTRAP__ && window.__DRM_BOOTSTRAP__.nextPath) || "/",
5
+ loginRequired: !!(window.__DRM_BOOTSTRAP__ && window.__DRM_BOOTSTRAP__.loginRequired),
6
+ };
7
+
8
+ const loginModal = document.getElementById("loginModal");
9
+ const taskModal = document.getElementById("taskModal");
10
+ const renameModal = document.getElementById("renameModal");
11
+ const toastStack = document.getElementById("toastStack");
12
+ const boardGrid = document.getElementById("boardGrid");
13
+ const loginForm = document.getElementById("loginForm");
14
+ const taskForm = document.getElementById("taskForm");
15
+ const renameForm = document.getElementById("renameForm");
16
+ const openLoginButton = document.getElementById("openLoginButton");
17
+ const logoutButton = document.getElementById("logoutButton");
18
+
19
+ const clockDisplay = document.getElementById("clockDisplay");
20
+ const dateDisplay = document.getElementById("dateDisplay");
21
+ const weekdayDisplay = document.getElementById("weekdayDisplay");
22
+ const lunarDisplay = document.getElementById("lunarDisplay");
23
+
24
+ const lunarData = [
25
+ 0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2,
26
+ 0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977,
27
+ 0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970,
28
+ 0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950,
29
+ 0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557,
30
+ 0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5d0, 0x14573, 0x052d0, 0x0a9a8, 0x0e950, 0x06aa0,
31
+ 0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0,
32
+ 0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b5a0, 0x195a6,
33
+ 0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570,
34
+ 0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x05ac0, 0x0ab60, 0x096d5, 0x092e0,
35
+ 0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5,
36
+ 0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930,
37
+ 0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530,
38
+ 0x05aa0, 0x076a3, 0x096d0, 0x04bd7, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45,
39
+ 0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0,
40
+ 0x14b63,
41
+ ];
42
+
43
+ function showToast(message, kind = "success") {
44
+ const toast = document.createElement("div");
45
+ toast.className = `toast ${kind}`;
46
+ toast.textContent = message;
47
+ toastStack.appendChild(toast);
48
+ window.setTimeout(() => {
49
+ toast.remove();
50
+ }, 2600);
51
+ }
52
+
53
+ function openModal(modal) {
54
+ if (modal) {
55
+ modal.classList.add("is-open");
56
+ }
57
+ }
58
+
59
+ function closeModal(modal) {
60
+ if (modal) {
61
+ modal.classList.remove("is-open");
62
+ }
63
+ }
64
+
65
+ function requireAuth() {
66
+ if (!state.authenticated) {
67
+ openModal(loginModal);
68
+ return false;
69
+ }
70
+ return true;
71
+ }
72
+
73
+ async function requestJSON(url, options = {}) {
74
+ const response = await fetch(url, {
75
+ headers: {
76
+ "Content-Type": "application/json",
77
+ },
78
+ ...options,
79
+ });
80
+
81
+ const payload = await response.json().catch(() => ({ ok: false, error: "请求失败" }));
82
+ if (!response.ok || !payload.ok) {
83
+ throw new Error(payload.error || "请求失败");
84
+ }
85
+ return payload;
86
+ }
87
+
88
+ function formatLocalDateTime(isoString) {
89
+ const date = new Date(isoString);
90
+ return new Intl.DateTimeFormat("zh-CN", {
91
+ timeZone: "Asia/Shanghai",
92
+ month: "2-digit",
93
+ day: "2-digit",
94
+ hour: "2-digit",
95
+ minute: "2-digit",
96
+ hour12: false,
97
+ }).format(date).replace(",", "");
98
+ }
99
+
100
+ function clamp(number, min, max) {
101
+ return Math.min(Math.max(number, min), max);
102
+ }
103
+
104
+ function mixColor(percent) {
105
+ if (percent <= 50) {
106
+ return "#73d883";
107
+ }
108
+ if (percent <= 80) {
109
+ return "#ffc857";
110
+ }
111
+ return "#ff6b5c";
112
+ }
113
+
114
+ function renderProgress() {
115
+ const now = new Date();
116
+ document.querySelectorAll(".task-card").forEach((card) => {
117
+ const createdAt = new Date(card.dataset.createdAt);
118
+ const dueAt = new Date(card.dataset.dueAt);
119
+ const completed = card.dataset.completed === "true";
120
+ const total = dueAt.getTime() - createdAt.getTime();
121
+ const elapsed = now.getTime() - createdAt.getTime();
122
+ const progress = completed || total <= 0 ? 100 : clamp((elapsed / total) * 100, 0, 100);
123
+ const dueLabel = card.querySelector("[data-due-label]");
124
+ const progressText = card.querySelector("[data-progress-text]");
125
+ const progressBar = card.querySelector("[data-progress-bar]");
126
+
127
+ if (dueLabel) {
128
+ dueLabel.textContent = `截止 ${formatLocalDateTime(card.dataset.dueAt)}`;
129
+ }
130
+
131
+ if (progressText) {
132
+ progressText.textContent = completed ? "已完成" : `已过去 ${progress.toFixed(0)}%`;
133
+ }
134
+
135
+ if (progressBar) {
136
+ progressBar.style.width = `${progress}%`;
137
+ progressBar.style.background = completed
138
+ ? "linear-gradient(90deg, #66d0ff 0%, #7be7ea 100%)"
139
+ : mixColor(progress);
140
+ }
141
+ });
142
+ }
143
+
144
+ function getBit(year, month) {
145
+ return (lunarData[year - 1900] & (0x10000 >> month)) !== 0 ? 1 : 0;
146
+ }
147
+
148
+ function leapMonth(year) {
149
+ return lunarData[year - 1900] & 0xf;
150
+ }
151
+
152
+ function leapDays(year) {
153
+ if (leapMonth(year)) {
154
+ return (lunarData[year - 1900] & 0x10000) ? 30 : 29;
155
+ }
156
+ return 0;
157
+ }
158
+
159
+ function monthDays(year, month) {
160
+ return getBit(year, month) ? 30 : 29;
161
+ }
162
+
163
+ function lunarYearDays(year) {
164
+ let sum = 348;
165
+ for (let i = 0x8000; i > 0x8; i >>= 1) {
166
+ sum += (lunarData[year - 1900] & i) ? 1 : 0;
167
+ }
168
+ return sum + leapDays(year);
169
+ }
170
+
171
+ function solarToLunar(date) {
172
+ const baseDate = new Date(Date.UTC(1900, 0, 31));
173
+ const utcDate = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
174
+ let offset = Math.floor((utcDate - baseDate) / 86400000);
175
+ let year;
176
+
177
+ for (year = 1900; year < 2101 && offset > 0; year += 1) {
178
+ const temp = lunarYearDays(year);
179
+ if (offset < temp) {
180
+ break;
181
+ }
182
+ offset -= temp;
183
+ }
184
+
185
+ let month = 1;
186
+ let isLeap = false;
187
+ const leap = leapMonth(year);
188
+
189
+ while (month <= 12 && offset >= 0) {
190
+ let temp;
191
+ if (leap > 0 && month === leap + 1 && !isLeap) {
192
+ month -= 1;
193
+ isLeap = true;
194
+ temp = leapDays(year);
195
+ } else {
196
+ temp = monthDays(year, month);
197
+ }
198
+
199
+ if (offset < temp) {
200
+ break;
201
+ }
202
+
203
+ offset -= temp;
204
+ if (isLeap && month === leap) {
205
+ isLeap = false;
206
+ }
207
+ month += 1;
208
+ }
209
+
210
+ return {
211
+ year,
212
+ month,
213
+ day: offset + 1,
214
+ isLeap,
215
+ };
216
+ }
217
+
218
+ function formatLunar(date) {
219
+ const lunar = solarToLunar(date);
220
+ const monthNames = ["正", "二", "三", "四", "五", "六", "七", "八", "九", "十", "冬", "腊"];
221
+ const dayNames = [
222
+ "初一", "初二", "初三", "初四", "初五", "初六", "初七", "初八", "初九", "初十",
223
+ "十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十",
224
+ "廿一", "廿二", "廿三", "廿四", "廿五", "廿六", "廿七", "廿八", "廿九", "三十",
225
+ ];
226
+ const prefix = lunar.isLeap ? "闰" : "";
227
+ return `农历 ${prefix}${monthNames[lunar.month - 1]}月${dayNames[lunar.day - 1]}`;
228
+ }
229
+
230
+ function getBeijingDate() {
231
+ const parts = new Intl.DateTimeFormat("en-CA", {
232
+ timeZone: "Asia/Shanghai",
233
+ year: "numeric",
234
+ month: "2-digit",
235
+ day: "2-digit",
236
+ hour: "2-digit",
237
+ minute: "2-digit",
238
+ second: "2-digit",
239
+ hour12: false,
240
+ }).formatToParts(new Date());
241
+
242
+ const map = {};
243
+ parts.forEach((part) => {
244
+ if (part.type !== "literal") {
245
+ map[part.type] = part.value;
246
+ }
247
+ });
248
+
249
+ return {
250
+ year: Number(map.year),
251
+ month: Number(map.month),
252
+ day: Number(map.day),
253
+ hour: map.hour,
254
+ minute: map.minute,
255
+ second: map.second,
256
+ };
257
+ }
258
+
259
+ function renderClock() {
260
+ const now = getBeijingDate();
261
+ clockDisplay.textContent = `${now.hour}:${now.minute}:${now.second}`;
262
+ dateDisplay.textContent = `${now.year} 年 ${String(now.month).padStart(2, "0")} 月 ${String(now.day).padStart(2, "0")} 日`;
263
+ const currentDate = new Date(now.year, now.month - 1, now.day);
264
+ const weekdays = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
265
+ weekdayDisplay.textContent = weekdays[currentDate.getDay()];
266
+ lunarDisplay.textContent = formatLunar(currentDate);
267
+ }
268
+
269
+ function taskTemplate(task, authenticated) {
270
+ const card = document.createElement("article");
271
+ card.className = `task-card ${task.completed ? "task-complete" : ""}`;
272
+ card.dataset.taskId = task.id;
273
+ card.dataset.createdAt = task.created_at;
274
+ card.dataset.dueAt = task.due_at;
275
+ card.dataset.completed = task.completed ? "true" : "false";
276
+ card.innerHTML = `
277
+ <div class="task-main">
278
+ <label class="check-wrap">
279
+ <input class="task-check" type="checkbox" ${task.completed ? "checked" : ""} data-toggle-task="${task.id}">
280
+ <span class="custom-check"></span>
281
+ </label>
282
+ <div class="task-copy">
283
+ <h4>${task.title}</h4>
284
+ <p class="meta-line">
285
+ <span class="meta-badge due-time" data-due-label></span>
286
+ <span class="meta-badge progress-text" data-progress-text></span>
287
+ </p>
288
+ </div>
289
+ </div>
290
+ <div class="progress-shell">
291
+ <div class="progress-bar" data-progress-bar></div>
292
+ </div>
293
+ ${authenticated ? `<button class="task-delete" type="button" data-delete-task="${task.id}">删除</button>` : ""}
294
+ `;
295
+ return card;
296
+ }
297
+
298
+ function ensureEmptyState(column) {
299
+ const list = column.querySelector(".task-list");
300
+ if (!list) {
301
+ return;
302
+ }
303
+ const cards = list.querySelectorAll(".task-card");
304
+ const empty = list.querySelector(".empty-state");
305
+ if (!cards.length && !empty) {
306
+ const block = document.createElement("div");
307
+ block.className = "empty-state";
308
+ block.innerHTML = "<p>这里还没有任务</p><span>点击右上角的加号,给这个分类加上第一条提醒。</span>";
309
+ list.appendChild(block);
310
+ }
311
+ if (cards.length && empty) {
312
+ empty.remove();
313
+ }
314
+ }
315
+
316
+ function setDefaultDueAt() {
317
+ const input = document.getElementById("taskDueAt");
318
+ const now = getBeijingDate();
319
+ const rounded = new Date(now.year, now.month - 1, now.day, Number(now.hour) + 1, 0, 0);
320
+ const yyyy = rounded.getFullYear();
321
+ const mm = String(rounded.getMonth() + 1).padStart(2, "0");
322
+ const dd = String(rounded.getDate()).padStart(2, "0");
323
+ const hh = String(rounded.getHours()).padStart(2, "0");
324
+ input.value = `${yyyy}-${mm}-${dd}T${hh}:00`;
325
+ }
326
+
327
+ async function handleLogin(event) {
328
+ event.preventDefault();
329
+ const password = document.getElementById("loginPassword").value;
330
+ try {
331
+ await requestJSON("/api/login", {
332
+ method: "POST",
333
+ body: JSON.stringify({
334
+ password,
335
+ next: state.nextPath,
336
+ }),
337
+ });
338
+ showToast("登录成功,已解锁编辑权限");
339
+ window.location.href = state.nextPath || "/";
340
+ } catch (error) {
341
+ showToast(error.message, "error");
342
+ }
343
+ }
344
+
345
+ async function handleLogout() {
346
+ try {
347
+ await requestJSON("/api/logout", {
348
+ method: "POST",
349
+ body: JSON.stringify({}),
350
+ });
351
+ window.location.reload();
352
+ } catch (error) {
353
+ showToast(error.message, "error");
354
+ }
355
+ }
356
+
357
+ async function handleTaskSubmit(event) {
358
+ event.preventDefault();
359
+ if (!requireAuth()) {
360
+ return;
361
+ }
362
+ const categoryId = document.getElementById("taskCategoryId").value;
363
+ const title = document.getElementById("taskName").value.trim();
364
+ const dueAt = document.getElementById("taskDueAt").value;
365
+ try {
366
+ const payload = await requestJSON(`/api/categories/${categoryId}/tasks`, {
367
+ method: "POST",
368
+ body: JSON.stringify({
369
+ title,
370
+ due_at: dueAt,
371
+ }),
372
+ });
373
+ const column = document.querySelector(`[data-category-id="${categoryId}"]`);
374
+ const list = column.querySelector(".task-list");
375
+ const card = taskTemplate(payload.task, state.authenticated);
376
+ list.prepend(card);
377
+ ensureEmptyState(column);
378
+ renderProgress();
379
+ closeModal(taskModal);
380
+ taskForm.reset();
381
+ setDefaultDueAt();
382
+ showToast("任务已添加");
383
+ } catch (error) {
384
+ showToast(error.message, "error");
385
+ }
386
+ }
387
+
388
+ async function handleRenameSubmit(event) {
389
+ event.preventDefault();
390
+ if (!requireAuth()) {
391
+ return;
392
+ }
393
+ const categoryId = document.getElementById("renameCategoryId").value;
394
+ const name = document.getElementById("renameCategoryName").value.trim();
395
+ try {
396
+ await requestJSON(`/api/categories/${categoryId}`, {
397
+ method: "PATCH",
398
+ body: JSON.stringify({ name }),
399
+ });
400
+ const column = document.querySelector(`[data-category-id="${categoryId}"]`);
401
+ if (column) {
402
+ const title = column.querySelector(".column-title");
403
+ if (title) {
404
+ title.textContent = name;
405
+ }
406
+ }
407
+ closeModal(renameModal);
408
+ showToast("分类名称已更新");
409
+ } catch (error) {
410
+ showToast(error.message, "error");
411
+ }
412
+ }
413
+
414
+ async function toggleTask(taskId, checked, input) {
415
+ if (!requireAuth()) {
416
+ input.checked = !checked;
417
+ return;
418
+ }
419
+ try {
420
+ await requestJSON(`/api/tasks/${taskId}`, {
421
+ method: "PATCH",
422
+ body: JSON.stringify({ completed: checked }),
423
+ });
424
+ const card = document.querySelector(`[data-task-id="${taskId}"]`);
425
+ if (card) {
426
+ card.dataset.completed = checked ? "true" : "false";
427
+ card.classList.toggle("task-complete", checked);
428
+ renderProgress();
429
+ }
430
+ showToast(checked ? "任务已完成" : "任务已恢复");
431
+ } catch (error) {
432
+ input.checked = !checked;
433
+ showToast(error.message, "error");
434
+ }
435
+ }
436
+
437
+ async function deleteTask(taskId) {
438
+ if (!requireAuth()) {
439
+ return;
440
+ }
441
+ try {
442
+ await requestJSON(`/api/tasks/${taskId}`, {
443
+ method: "DELETE",
444
+ body: JSON.stringify({}),
445
+ });
446
+ const card = document.querySelector(`[data-task-id="${taskId}"]`);
447
+ const column = card && card.closest(".todo-column");
448
+ if (card) {
449
+ card.remove();
450
+ }
451
+ if (column) {
452
+ ensureEmptyState(column);
453
+ }
454
+ showToast("��务已删除");
455
+ } catch (error) {
456
+ showToast(error.message, "error");
457
+ }
458
+ }
459
+
460
+ function bindBoardEvents() {
461
+ boardGrid.addEventListener("click", (event) => {
462
+ const addButton = event.target.closest("[data-open-add]");
463
+ if (addButton) {
464
+ if (!requireAuth()) {
465
+ return;
466
+ }
467
+ document.getElementById("taskCategoryId").value = addButton.dataset.openAdd;
468
+ const column = addButton.closest(".todo-column");
469
+ const title = column ? column.querySelector(".column-title").textContent : "添加提醒";
470
+ document.getElementById("taskModalTitle").textContent = `添加到 ${title}`;
471
+ openModal(taskModal);
472
+ setDefaultDueAt();
473
+ return;
474
+ }
475
+
476
+ const menuTrigger = event.target.closest("[data-menu-trigger]");
477
+ if (menuTrigger) {
478
+ const panel = document.querySelector(`[data-menu-panel="${menuTrigger.dataset.menuTrigger}"]`);
479
+ document.querySelectorAll(".menu-panel").forEach((item) => {
480
+ if (item !== panel) {
481
+ item.classList.remove("is-open");
482
+ }
483
+ });
484
+ if (panel) {
485
+ panel.classList.toggle("is-open");
486
+ }
487
+ return;
488
+ }
489
+
490
+ const renameButton = event.target.closest("[data-rename-category]");
491
+ if (renameButton) {
492
+ if (!requireAuth()) {
493
+ return;
494
+ }
495
+ const categoryId = renameButton.dataset.renameCategory;
496
+ const column = document.querySelector(`[data-category-id="${categoryId}"]`);
497
+ const currentName = column ? column.querySelector(".column-title").textContent.trim() : "";
498
+ document.getElementById("renameCategoryId").value = categoryId;
499
+ document.getElementById("renameCategoryName").value = currentName;
500
+ closeMenus();
501
+ openModal(renameModal);
502
+ return;
503
+ }
504
+
505
+ const deleteButton = event.target.closest("[data-delete-task]");
506
+ if (deleteButton) {
507
+ deleteTask(deleteButton.dataset.deleteTask);
508
+ }
509
+ });
510
+
511
+ boardGrid.addEventListener("change", (event) => {
512
+ const checkbox = event.target.closest("[data-toggle-task]");
513
+ if (checkbox) {
514
+ toggleTask(checkbox.dataset.toggleTask, checkbox.checked, checkbox);
515
+ }
516
+ });
517
+ }
518
+
519
+ function closeMenus() {
520
+ document.querySelectorAll(".menu-panel.is-open").forEach((panel) => {
521
+ panel.classList.remove("is-open");
522
+ });
523
+ }
524
+
525
+ document.addEventListener("click", (event) => {
526
+ if (!event.target.closest(".menu-wrap")) {
527
+ closeMenus();
528
+ }
529
+
530
+ const closeTarget = event.target.closest("[data-close-modal]");
531
+ if (closeTarget) {
532
+ const modal = document.getElementById(closeTarget.dataset.closeModal);
533
+ closeModal(modal);
534
+ }
535
+
536
+ if (event.target.classList.contains("modal-backdrop")) {
537
+ closeModal(event.target);
538
+ }
539
+ });
540
+
541
+ loginForm.addEventListener("submit", handleLogin);
542
+ taskForm.addEventListener("submit", handleTaskSubmit);
543
+ renameForm.addEventListener("submit", handleRenameSubmit);
544
+ bindBoardEvents();
545
+
546
+ if (openLoginButton) {
547
+ openLoginButton.addEventListener("click", () => openModal(loginModal));
548
+ }
549
+
550
+ if (logoutButton) {
551
+ logoutButton.addEventListener("click", handleLogout);
552
+ }
553
+
554
+ if (!state.authenticated) {
555
+ const promptedKey = "drm-login-prompted";
556
+ if (state.loginRequired || !window.sessionStorage.getItem(promptedKey)) {
557
+ openModal(loginModal);
558
+ window.sessionStorage.setItem(promptedKey, "1");
559
+ }
560
+ }
561
+
562
+ renderClock();
563
+ renderProgress();
564
+ window.setInterval(renderClock, 1000);
565
+ window.setInterval(renderProgress, 30000);
566
+ setDefaultDueAt();
567
+ })();
static/styles.css ADDED
@@ -0,0 +1,727 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #081524;
3
+ --bg-soft: rgba(10, 23, 37, 0.75);
4
+ --surface: rgba(9, 22, 35, 0.76);
5
+ --surface-border: rgba(255, 255, 255, 0.08);
6
+ --surface-strong: rgba(255, 255, 255, 0.08);
7
+ --text: #eef4fb;
8
+ --muted: rgba(238, 244, 251, 0.68);
9
+ --muted-strong: rgba(238, 244, 251, 0.84);
10
+ --accent: #5ce1e6;
11
+ --accent-2: #73d883;
12
+ --warning: #ffc857;
13
+ --danger: #ff6b5c;
14
+ --shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
15
+ --radius-xl: 30px;
16
+ --radius-lg: 24px;
17
+ --radius-md: 18px;
18
+ --radius-sm: 14px;
19
+ }
20
+
21
+ * {
22
+ box-sizing: border-box;
23
+ }
24
+
25
+ html, body {
26
+ margin: 0;
27
+ min-height: 100%;
28
+ background:
29
+ radial-gradient(circle at top left, rgba(92, 225, 230, 0.18), transparent 28%),
30
+ radial-gradient(circle at 85% 15%, rgba(255, 200, 87, 0.16), transparent 24%),
31
+ linear-gradient(180deg, #0b1625 0%, #07111d 100%);
32
+ color: var(--text);
33
+ font-family: "Noto Sans SC", "PingFang SC", sans-serif;
34
+ }
35
+
36
+ body {
37
+ position: relative;
38
+ overflow-x: hidden;
39
+ }
40
+
41
+ button,
42
+ input,
43
+ textarea {
44
+ font: inherit;
45
+ }
46
+
47
+ button {
48
+ cursor: pointer;
49
+ }
50
+
51
+ a {
52
+ color: inherit;
53
+ text-decoration: none;
54
+ }
55
+
56
+ .page-shell {
57
+ position: relative;
58
+ min-height: 100vh;
59
+ isolation: isolate;
60
+ }
61
+
62
+ .ambient {
63
+ position: fixed;
64
+ inset: auto;
65
+ width: 42vw;
66
+ height: 42vw;
67
+ border-radius: 999px;
68
+ filter: blur(46px);
69
+ opacity: 0.34;
70
+ z-index: -1;
71
+ animation: floatGlow 18s ease-in-out infinite;
72
+ }
73
+
74
+ .ambient-one {
75
+ top: -10vw;
76
+ left: -12vw;
77
+ background: rgba(92, 225, 230, 0.3);
78
+ }
79
+
80
+ .ambient-two {
81
+ right: -8vw;
82
+ bottom: -10vw;
83
+ background: rgba(255, 200, 87, 0.22);
84
+ animation-delay: -6s;
85
+ }
86
+
87
+ .layout,
88
+ .admin-layout {
89
+ width: min(1340px, calc(100% - 32px));
90
+ margin: 0 auto;
91
+ padding: 28px 0 56px;
92
+ }
93
+
94
+ .hero-card,
95
+ .card-surface,
96
+ .admin-card,
97
+ .modal-card {
98
+ border: 1px solid var(--surface-border);
99
+ background: linear-gradient(180deg, rgba(13, 28, 44, 0.9) 0%, rgba(8, 20, 32, 0.86) 100%);
100
+ box-shadow: var(--shadow);
101
+ backdrop-filter: blur(18px);
102
+ }
103
+
104
+ .hero-card {
105
+ min-height: 31vh;
106
+ padding: 28px;
107
+ border-radius: var(--radius-xl);
108
+ display: flex;
109
+ flex-direction: column;
110
+ justify-content: space-between;
111
+ overflow: hidden;
112
+ position: relative;
113
+ }
114
+
115
+ .hero-card::after {
116
+ content: "";
117
+ position: absolute;
118
+ inset: auto -12% -28% auto;
119
+ width: 300px;
120
+ height: 300px;
121
+ border-radius: 999px;
122
+ background: radial-gradient(circle, rgba(92, 225, 230, 0.18), transparent 72%);
123
+ pointer-events: none;
124
+ }
125
+
126
+ .hero-topbar,
127
+ .column-header,
128
+ .section-header,
129
+ .admin-card-head,
130
+ .action-group,
131
+ .column-actions,
132
+ .task-main,
133
+ .form-actions {
134
+ display: flex;
135
+ align-items: center;
136
+ }
137
+
138
+ .hero-topbar,
139
+ .section-header,
140
+ .admin-card-head {
141
+ justify-content: space-between;
142
+ gap: 16px;
143
+ }
144
+
145
+ .brand-mark {
146
+ display: inline-flex;
147
+ align-items: center;
148
+ gap: 10px;
149
+ padding: 8px 12px;
150
+ border-radius: 999px;
151
+ background: rgba(255, 255, 255, 0.06);
152
+ color: var(--muted-strong);
153
+ }
154
+
155
+ .brand-dot {
156
+ width: 10px;
157
+ height: 10px;
158
+ border-radius: 999px;
159
+ background: linear-gradient(180deg, var(--accent) 0%, #7effa7 100%);
160
+ box-shadow: 0 0 18px rgba(92, 225, 230, 0.72);
161
+ }
162
+
163
+ .ghost-link,
164
+ .pill-button,
165
+ .primary-button,
166
+ .secondary-button,
167
+ .danger-button,
168
+ .icon-button,
169
+ .task-delete {
170
+ transition: transform 160ms ease, background 160ms ease, border-color 160ms ease, color 160ms ease, opacity 160ms ease;
171
+ }
172
+
173
+ .ghost-link,
174
+ .pill-button,
175
+ .primary-button,
176
+ .secondary-button,
177
+ .danger-button {
178
+ display: inline-flex;
179
+ align-items: center;
180
+ justify-content: center;
181
+ gap: 8px;
182
+ min-height: 44px;
183
+ border-radius: 999px;
184
+ padding: 0 18px;
185
+ border: 1px solid rgba(255, 255, 255, 0.08);
186
+ }
187
+
188
+ .ghost-link,
189
+ .pill-button {
190
+ background: rgba(255, 255, 255, 0.05);
191
+ color: var(--text);
192
+ }
193
+
194
+ .pill-button.accent,
195
+ .primary-button {
196
+ background: linear-gradient(135deg, #7be7ea 0%, #61d29f 100%);
197
+ border-color: transparent;
198
+ color: #082030;
199
+ font-weight: 700;
200
+ }
201
+
202
+ .secondary-button {
203
+ background: rgba(255, 255, 255, 0.06);
204
+ color: var(--text);
205
+ }
206
+
207
+ .danger-button {
208
+ background: rgba(255, 107, 92, 0.12);
209
+ border-color: rgba(255, 107, 92, 0.25);
210
+ color: #ff9388;
211
+ }
212
+
213
+ .ghost-link:hover,
214
+ .pill-button:hover,
215
+ .primary-button:hover,
216
+ .secondary-button:hover,
217
+ .danger-button:hover,
218
+ .icon-button:hover,
219
+ .task-delete:hover {
220
+ transform: translateY(-2px);
221
+ }
222
+
223
+ .clock-wrap {
224
+ display: grid;
225
+ place-items: center;
226
+ gap: 8px;
227
+ text-align: center;
228
+ padding: 10px 0 8px;
229
+ }
230
+
231
+ .eyebrow,
232
+ .section-kicker,
233
+ .column-label,
234
+ .modal-kicker {
235
+ margin: 0;
236
+ letter-spacing: 0.12em;
237
+ text-transform: uppercase;
238
+ color: var(--muted);
239
+ font-size: 0.82rem;
240
+ }
241
+
242
+ .clock-display {
243
+ margin: 0;
244
+ font-family: "Sora", "Noto Sans SC", sans-serif;
245
+ font-weight: 800;
246
+ font-size: clamp(2.8rem, 8vw, 5.2rem);
247
+ letter-spacing: 0.08em;
248
+ line-height: 1;
249
+ }
250
+
251
+ .clock-meta {
252
+ display: flex;
253
+ flex-wrap: wrap;
254
+ align-items: center;
255
+ justify-content: center;
256
+ gap: 10px 18px;
257
+ color: var(--muted-strong);
258
+ font-size: 1rem;
259
+ }
260
+
261
+ .clock-meta span {
262
+ padding: 8px 14px;
263
+ border-radius: 999px;
264
+ background: rgba(255, 255, 255, 0.06);
265
+ }
266
+
267
+ .board-section {
268
+ padding-top: 24px;
269
+ }
270
+
271
+ .section-header {
272
+ margin-bottom: 18px;
273
+ }
274
+
275
+ .section-header h2,
276
+ .admin-hero h1,
277
+ .admin-card h2,
278
+ .modal-head h3 {
279
+ margin: 6px 0 0;
280
+ font-family: "Sora", "Noto Sans SC", sans-serif;
281
+ }
282
+
283
+ .section-note,
284
+ .admin-copy,
285
+ .admin-card-copy {
286
+ color: var(--muted);
287
+ }
288
+
289
+ .board-grid {
290
+ display: grid;
291
+ gap: 18px;
292
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
293
+ align-items: start;
294
+ }
295
+
296
+ .todo-column {
297
+ padding: 20px;
298
+ border-radius: var(--radius-lg);
299
+ min-height: 460px;
300
+ animation: riseIn 360ms ease both;
301
+ }
302
+
303
+ .column-title {
304
+ margin: 6px 0 0;
305
+ font-size: 1.45rem;
306
+ }
307
+
308
+ .column-actions {
309
+ gap: 8px;
310
+ }
311
+
312
+ .menu-wrap {
313
+ position: relative;
314
+ }
315
+
316
+ .menu-panel {
317
+ position: absolute;
318
+ top: calc(100% + 8px);
319
+ right: 0;
320
+ min-width: 140px;
321
+ padding: 8px;
322
+ border-radius: 16px;
323
+ border: 1px solid rgba(255, 255, 255, 0.08);
324
+ background: rgba(8, 20, 32, 0.98);
325
+ box-shadow: 0 18px 32px rgba(0, 0, 0, 0.3);
326
+ opacity: 0;
327
+ pointer-events: none;
328
+ transform: translateY(-6px);
329
+ transition: opacity 160ms ease, transform 160ms ease;
330
+ }
331
+
332
+ .menu-panel.is-open {
333
+ opacity: 1;
334
+ pointer-events: auto;
335
+ transform: translateY(0);
336
+ }
337
+
338
+ .menu-panel button {
339
+ width: 100%;
340
+ border: 0;
341
+ background: transparent;
342
+ color: var(--text);
343
+ padding: 12px 14px;
344
+ border-radius: 12px;
345
+ text-align: left;
346
+ }
347
+
348
+ .menu-panel button:hover {
349
+ background: rgba(255, 255, 255, 0.06);
350
+ }
351
+
352
+ .icon-button {
353
+ width: 42px;
354
+ height: 42px;
355
+ border-radius: 14px;
356
+ border: 1px solid rgba(255, 255, 255, 0.09);
357
+ background: linear-gradient(180deg, rgba(123, 231, 234, 0.16) 0%, rgba(255, 255, 255, 0.04) 100%);
358
+ color: var(--text);
359
+ font-size: 1.4rem;
360
+ }
361
+
362
+ .icon-button.muted {
363
+ font-size: 1.55rem;
364
+ }
365
+
366
+ .task-list {
367
+ display: grid;
368
+ gap: 14px;
369
+ margin-top: 18px;
370
+ }
371
+
372
+ .task-card {
373
+ padding: 16px;
374
+ border-radius: 20px;
375
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.03) 100%);
376
+ border: 1px solid rgba(255, 255, 255, 0.06);
377
+ display: grid;
378
+ gap: 14px;
379
+ position: relative;
380
+ overflow: hidden;
381
+ }
382
+
383
+ .task-card::before {
384
+ content: "";
385
+ position: absolute;
386
+ inset: 0 0 auto;
387
+ height: 1px;
388
+ background: linear-gradient(90deg, rgba(123, 231, 234, 0.6), transparent);
389
+ }
390
+
391
+ .task-copy {
392
+ flex: 1;
393
+ }
394
+
395
+ .task-copy h4 {
396
+ margin: 0 0 10px;
397
+ font-size: 1.08rem;
398
+ }
399
+
400
+ .meta-line {
401
+ margin: 0;
402
+ display: flex;
403
+ flex-wrap: wrap;
404
+ gap: 8px;
405
+ }
406
+
407
+ .meta-badge {
408
+ display: inline-flex;
409
+ align-items: center;
410
+ gap: 6px;
411
+ padding: 7px 10px;
412
+ border-radius: 999px;
413
+ background: rgba(255, 255, 255, 0.06);
414
+ color: var(--muted-strong);
415
+ font-size: 0.88rem;
416
+ }
417
+
418
+ .progress-shell {
419
+ height: 10px;
420
+ border-radius: 999px;
421
+ background: rgba(255, 255, 255, 0.08);
422
+ overflow: hidden;
423
+ }
424
+
425
+ .progress-bar {
426
+ width: 0;
427
+ height: 100%;
428
+ border-radius: inherit;
429
+ background: linear-gradient(90deg, #73d883 0%, #ffd166 55%, #ff6b5c 100%);
430
+ transition: width 360ms ease, filter 360ms ease;
431
+ }
432
+
433
+ .task-card.task-complete .progress-bar {
434
+ background: linear-gradient(90deg, #66d0ff 0%, #7be7ea 100%);
435
+ }
436
+
437
+ .task-card.task-complete h4 {
438
+ color: rgba(238, 244, 251, 0.66);
439
+ text-decoration: line-through;
440
+ }
441
+
442
+ .check-wrap {
443
+ position: relative;
444
+ }
445
+
446
+ .task-check {
447
+ position: absolute;
448
+ opacity: 0;
449
+ pointer-events: none;
450
+ }
451
+
452
+ .custom-check {
453
+ display: inline-flex;
454
+ width: 24px;
455
+ height: 24px;
456
+ border-radius: 8px;
457
+ border: 1.5px solid rgba(255, 255, 255, 0.36);
458
+ background: rgba(255, 255, 255, 0.02);
459
+ position: relative;
460
+ flex-shrink: 0;
461
+ }
462
+
463
+ .task-check:checked + .custom-check {
464
+ background: linear-gradient(135deg, #7be7ea 0%, #61d29f 100%);
465
+ border-color: transparent;
466
+ }
467
+
468
+ .task-check:checked + .custom-check::after {
469
+ content: "";
470
+ position: absolute;
471
+ left: 7px;
472
+ top: 3px;
473
+ width: 6px;
474
+ height: 12px;
475
+ border-right: 2px solid #082030;
476
+ border-bottom: 2px solid #082030;
477
+ transform: rotate(45deg);
478
+ }
479
+
480
+ body[data-authenticated="false"] .task-check,
481
+ body[data-authenticated="false"] .icon-button,
482
+ body[data-authenticated="false"] [data-rename-category] {
483
+ cursor: pointer;
484
+ }
485
+
486
+ body[data-authenticated="false"] .task-delete {
487
+ display: none;
488
+ }
489
+
490
+ .task-delete {
491
+ justify-self: flex-end;
492
+ border: 0;
493
+ background: transparent;
494
+ color: rgba(255, 255, 255, 0.42);
495
+ padding: 0;
496
+ }
497
+
498
+ .empty-state {
499
+ min-height: 180px;
500
+ display: grid;
501
+ place-items: center;
502
+ text-align: center;
503
+ gap: 8px;
504
+ padding: 22px;
505
+ border-radius: 20px;
506
+ background: rgba(255, 255, 255, 0.03);
507
+ color: var(--muted);
508
+ }
509
+
510
+ .modal-backdrop {
511
+ position: fixed;
512
+ inset: 0;
513
+ background: rgba(3, 10, 18, 0.64);
514
+ backdrop-filter: blur(10px);
515
+ display: grid;
516
+ place-items: center;
517
+ padding: 24px;
518
+ opacity: 0;
519
+ pointer-events: none;
520
+ transition: opacity 180ms ease;
521
+ z-index: 30;
522
+ }
523
+
524
+ .modal-backdrop.is-open {
525
+ opacity: 1;
526
+ pointer-events: auto;
527
+ }
528
+
529
+ .modal-card {
530
+ width: min(500px, 100%);
531
+ padding: 28px;
532
+ border-radius: 28px;
533
+ }
534
+
535
+ .modal-form {
536
+ display: grid;
537
+ gap: 14px;
538
+ margin-top: 18px;
539
+ }
540
+
541
+ .modal-form.compact {
542
+ margin-top: 10px;
543
+ }
544
+
545
+ .modal-form label {
546
+ display: grid;
547
+ gap: 8px;
548
+ }
549
+
550
+ .modal-form span {
551
+ color: var(--muted-strong);
552
+ }
553
+
554
+ .modal-form input {
555
+ width: 100%;
556
+ min-height: 50px;
557
+ padding: 0 16px;
558
+ border-radius: 16px;
559
+ border: 1px solid rgba(255, 255, 255, 0.1);
560
+ background: rgba(255, 255, 255, 0.04);
561
+ color: var(--text);
562
+ outline: none;
563
+ }
564
+
565
+ .modal-form input:focus {
566
+ border-color: rgba(123, 231, 234, 0.7);
567
+ box-shadow: 0 0 0 3px rgba(123, 231, 234, 0.14);
568
+ }
569
+
570
+ .form-actions {
571
+ justify-content: flex-end;
572
+ gap: 10px;
573
+ padding-top: 6px;
574
+ }
575
+
576
+ .toast-stack {
577
+ position: fixed;
578
+ top: 20px;
579
+ right: 20px;
580
+ display: grid;
581
+ gap: 10px;
582
+ z-index: 40;
583
+ }
584
+
585
+ .toast {
586
+ min-width: 220px;
587
+ max-width: 320px;
588
+ padding: 14px 16px;
589
+ border-radius: 18px;
590
+ border: 1px solid rgba(255, 255, 255, 0.08);
591
+ background: rgba(7, 18, 30, 0.96);
592
+ box-shadow: var(--shadow);
593
+ color: var(--text);
594
+ animation: riseIn 220ms ease both;
595
+ }
596
+
597
+ .toast.error {
598
+ border-color: rgba(255, 107, 92, 0.26);
599
+ }
600
+
601
+ .toast.success {
602
+ border-color: rgba(115, 216, 131, 0.24);
603
+ }
604
+
605
+ .admin-hero {
606
+ border-radius: var(--radius-xl);
607
+ padding: 28px;
608
+ margin-bottom: 22px;
609
+ display: flex;
610
+ justify-content: space-between;
611
+ gap: 18px;
612
+ align-items: center;
613
+ }
614
+
615
+ .admin-grid {
616
+ display: grid;
617
+ gap: 18px;
618
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
619
+ }
620
+
621
+ .admin-card {
622
+ border-radius: 26px;
623
+ padding: 22px;
624
+ min-height: 240px;
625
+ display: flex;
626
+ flex-direction: column;
627
+ justify-content: space-between;
628
+ }
629
+
630
+ .create-card {
631
+ border-style: dashed;
632
+ align-items: flex-start;
633
+ }
634
+
635
+ .create-icon {
636
+ width: 56px;
637
+ height: 56px;
638
+ border-radius: 18px;
639
+ display: grid;
640
+ place-items: center;
641
+ font-size: 2rem;
642
+ background: linear-gradient(135deg, rgba(123, 231, 234, 0.18) 0%, rgba(97, 210, 159, 0.12) 100%);
643
+ }
644
+
645
+ .task-count {
646
+ padding: 8px 12px;
647
+ border-radius: 999px;
648
+ background: rgba(255, 255, 255, 0.06);
649
+ color: var(--muted-strong);
650
+ }
651
+
652
+ @keyframes floatGlow {
653
+ 0%, 100% {
654
+ transform: translate3d(0, 0, 0) scale(1);
655
+ }
656
+ 50% {
657
+ transform: translate3d(3vw, 2vw, 0) scale(1.05);
658
+ }
659
+ }
660
+
661
+ @keyframes riseIn {
662
+ from {
663
+ opacity: 0;
664
+ transform: translateY(8px);
665
+ }
666
+ to {
667
+ opacity: 1;
668
+ transform: translateY(0);
669
+ }
670
+ }
671
+
672
+ @media (max-width: 840px) {
673
+ .hero-topbar,
674
+ .section-header,
675
+ .admin-hero {
676
+ flex-direction: column;
677
+ align-items: flex-start;
678
+ }
679
+
680
+ .action-group {
681
+ width: 100%;
682
+ flex-wrap: wrap;
683
+ }
684
+
685
+ .clock-display {
686
+ letter-spacing: 0.03em;
687
+ }
688
+
689
+ .clock-meta {
690
+ gap: 10px;
691
+ }
692
+ }
693
+
694
+ @media (max-width: 560px) {
695
+ .layout,
696
+ .admin-layout {
697
+ width: min(100% - 18px, 1340px);
698
+ padding-top: 18px;
699
+ padding-bottom: 32px;
700
+ }
701
+
702
+ .hero-card,
703
+ .todo-column,
704
+ .modal-card,
705
+ .admin-card,
706
+ .admin-hero {
707
+ padding: 18px;
708
+ border-radius: 24px;
709
+ }
710
+
711
+ .clock-display {
712
+ font-size: clamp(2.4rem, 16vw, 4.2rem);
713
+ }
714
+
715
+ .form-actions {
716
+ flex-direction: column-reverse;
717
+ align-items: stretch;
718
+ }
719
+
720
+ .ghost-link,
721
+ .pill-button,
722
+ .primary-button,
723
+ .secondary-button,
724
+ .danger-button {
725
+ width: 100%;
726
+ }
727
+ }
storage.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import threading
3
+ import uuid
4
+ from copy import deepcopy
5
+ from datetime import datetime, timedelta
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from zoneinfo import ZoneInfo
9
+
10
+ TZ = ZoneInfo("Asia/Shanghai")
11
+
12
+
13
+ def beijing_now() -> datetime:
14
+ return datetime.now(TZ)
15
+
16
+
17
+ def iso_now() -> str:
18
+ return beijing_now().isoformat()
19
+
20
+
21
+ def make_id(prefix: str) -> str:
22
+ return f"{prefix}_{uuid.uuid4().hex[:10]}"
23
+
24
+
25
+ class ReminderStore:
26
+ def __init__(self, path: Path):
27
+ self.path = path
28
+ self.lock = threading.Lock()
29
+ self.path.parent.mkdir(parents=True, exist_ok=True)
30
+ if not self.path.exists():
31
+ self._write(self._seed_data())
32
+
33
+ def _seed_data(self) -> dict[str, Any]:
34
+ now = beijing_now().replace(minute=0, second=0, microsecond=0)
35
+
36
+ def task(title: str, hours_from_now: int) -> dict[str, Any]:
37
+ created = now - timedelta(hours=max(hours_from_now - 4, 1))
38
+ due = now + timedelta(hours=hours_from_now)
39
+ return {
40
+ "id": make_id("task"),
41
+ "title": title,
42
+ "created_at": created.isoformat(),
43
+ "due_at": due.isoformat(),
44
+ "completed": False,
45
+ "completed_at": None,
46
+ }
47
+
48
+ return {
49
+ "categories": [
50
+ {
51
+ "id": make_id("cat"),
52
+ "name": "\u4eca\u65e5\u8282\u594f",
53
+ "created_at": iso_now(),
54
+ "tasks": [
55
+ task("\u6574\u7406\u4eca\u5929\u7684\u91cd\u70b9\u4efb\u52a1", 10),
56
+ task("\u665a\u95f4\u590d\u76d8 15 \u5206\u949f", 14),
57
+ ],
58
+ },
59
+ {
60
+ "id": make_id("cat"),
61
+ "name": "\u5b66\u4e60\u63a8\u8fdb",
62
+ "created_at": iso_now(),
63
+ "tasks": [
64
+ task("\u5b8c\u6210\u4e00\u8282\u8bfe\u7a0b\u5e76\u8bb0\u7b14\u8bb0", 26),
65
+ ],
66
+ },
67
+ {
68
+ "id": make_id("cat"),
69
+ "name": "\u751f\u6d3b\u5b89\u6392",
70
+ "created_at": iso_now(),
71
+ "tasks": [
72
+ task("\u8865\u5145\u4e0b\u5468\u9700\u8981\u91c7\u8d2d\u7684\u6e05\u5355", 40),
73
+ ],
74
+ },
75
+ ]
76
+ }
77
+
78
+ def _read(self) -> dict[str, Any]:
79
+ with self.path.open("r", encoding="utf-8") as file:
80
+ return json.load(file)
81
+
82
+ def _write(self, data: dict[str, Any]) -> None:
83
+ temp_path = self.path.with_suffix(".tmp")
84
+ with temp_path.open("w", encoding="utf-8") as file:
85
+ json.dump(data, file, ensure_ascii=False, indent=2)
86
+ temp_path.replace(self.path)
87
+
88
+ def snapshot(self) -> dict[str, Any]:
89
+ with self.lock:
90
+ return deepcopy(self._read())
91
+
92
+ def list_categories(self) -> list[dict[str, Any]]:
93
+ data = self.snapshot()
94
+ categories = data.get("categories", [])
95
+ for category in categories:
96
+ category["tasks"] = sorted(
97
+ category.get("tasks", []),
98
+ key=lambda item: item.get("due_at", ""),
99
+ )
100
+ return categories
101
+
102
+ def create_category(self, name: str) -> dict[str, Any]:
103
+ with self.lock:
104
+ data = self._read()
105
+ category = {
106
+ "id": make_id("cat"),
107
+ "name": name,
108
+ "created_at": iso_now(),
109
+ "tasks": [],
110
+ }
111
+ data.setdefault("categories", []).append(category)
112
+ self._write(data)
113
+ return deepcopy(category)
114
+
115
+ def rename_category(self, category_id: str, name: str) -> dict[str, Any]:
116
+ with self.lock:
117
+ data = self._read()
118
+ for category in data.get("categories", []):
119
+ if category["id"] == category_id:
120
+ category["name"] = name
121
+ self._write(data)
122
+ return deepcopy(category)
123
+ raise KeyError("Category not found")
124
+
125
+ def delete_category(self, category_id: str) -> None:
126
+ with self.lock:
127
+ data = self._read()
128
+ original_count = len(data.get("categories", []))
129
+ data["categories"] = [
130
+ category
131
+ for category in data.get("categories", [])
132
+ if category["id"] != category_id
133
+ ]
134
+ if len(data["categories"]) == original_count:
135
+ raise KeyError("Category not found")
136
+ self._write(data)
137
+
138
+ def add_task(self, category_id: str, title: str, due_at: str) -> dict[str, Any]:
139
+ with self.lock:
140
+ data = self._read()
141
+ for category in data.get("categories", []):
142
+ if category["id"] == category_id:
143
+ item = {
144
+ "id": make_id("task"),
145
+ "title": title,
146
+ "created_at": iso_now(),
147
+ "due_at": due_at,
148
+ "completed": False,
149
+ "completed_at": None,
150
+ }
151
+ category.setdefault("tasks", []).append(item)
152
+ self._write(data)
153
+ return deepcopy(item)
154
+ raise KeyError("Category not found")
155
+
156
+ def toggle_task(self, task_id: str, completed: bool) -> dict[str, Any]:
157
+ with self.lock:
158
+ data = self._read()
159
+ for category in data.get("categories", []):
160
+ for task in category.get("tasks", []):
161
+ if task["id"] == task_id:
162
+ task["completed"] = completed
163
+ task["completed_at"] = iso_now() if completed else None
164
+ self._write(data)
165
+ return deepcopy(task)
166
+ raise KeyError("Task not found")
167
+
168
+ def delete_task(self, task_id: str) -> None:
169
+ with self.lock:
170
+ data = self._read()
171
+ for category in data.get("categories", []):
172
+ original_count = len(category.get("tasks", []))
173
+ category["tasks"] = [
174
+ task for task in category.get("tasks", []) if task["id"] != task_id
175
+ ]
176
+ if len(category["tasks"]) != original_count:
177
+ self._write(data)
178
+ return
179
+ raise KeyError("Task not found")
templates/admin.html ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}DRM | 后台管理{% endblock %}
3
+ {% block body %}
4
+ <main class="admin-layout">
5
+ <section class="admin-hero card-surface">
6
+ <div>
7
+ <p class="section-kicker">管理后台</p>
8
+ <h1>维护你的 todolist 分类</h1>
9
+ <p class="admin-copy">在这里可以新建或删除分类,首页会自动同步更新。</p>
10
+ </div>
11
+ <a class="pill-button accent" href="{{ url_for('index') }}">返回主页</a>
12
+ </section>
13
+
14
+ <section class="admin-grid" id="adminGrid">
15
+ <article class="admin-card create-card">
16
+ <div class="create-icon">+</div>
17
+ <h2>新建一个清单</h2>
18
+ <form id="createCategoryForm" class="modal-form compact">
19
+ <label>
20
+ <span>分类名称</span>
21
+ <input id="newCategoryName" name="name" type="text" maxlength="20" placeholder="例如:健康管理">
22
+ </label>
23
+ <button class="primary-button" type="submit">立即创建</button>
24
+ </form>
25
+ </article>
26
+
27
+ {% for category in categories %}
28
+ <article class="admin-card" data-category-id="{{ category.id }}">
29
+ <div class="admin-card-head">
30
+ <div>
31
+ <p class="column-label">Category</p>
32
+ <h2>{{ category.name }}</h2>
33
+ </div>
34
+ <span class="task-count">{{ category.tasks|length }} 项任务</span>
35
+ </div>
36
+ <p class="admin-card-copy">删除分类会同时移除其下全部任务,请谨慎操作。</p>
37
+ <button class="danger-button" type="button" data-delete-category="{{ category.id }}">删除此清单</button>
38
+ </article>
39
+ {% endfor %}
40
+ </section>
41
+ </main>
42
+
43
+ <div class="toast-stack" id="toastStack"></div>
44
+ {% endblock %}
45
+ {% block scripts %}
46
+ <script src="{{ url_for('static', filename='admin.js') }}"></script>
47
+ {% endblock %}
templates/base.html ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{% block title %}Daily Reminder Master{% endblock %}</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;800&family=Sora:wght@400;600;700;800&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
11
+ {% block head %}{% endblock %}
12
+ </head>
13
+ <body data-authenticated="{{ 'true' if authenticated else 'false' }}">
14
+ <div class="page-shell">
15
+ <div class="ambient ambient-one"></div>
16
+ <div class="ambient ambient-two"></div>
17
+ {% block body %}{% endblock %}
18
+ </div>
19
+ {% block scripts %}{% endblock %}
20
+ </body>
21
+ </html>
templates/index.html ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}DRM | 提醒主页{% endblock %}
3
+ {% block body %}
4
+ <main class="layout" data-next-path="{{ next_path }}">
5
+ <section class="hero-card">
6
+ <div class="hero-topbar">
7
+ <div class="brand-mark">
8
+ <span class="brand-dot"></span>
9
+ <span>Daily Reminder Master</span>
10
+ </div>
11
+ <div class="action-group">
12
+ <a class="ghost-link" href="{{ url_for('admin') }}">后台管理</a>
13
+ {% if authenticated %}
14
+ <button class="pill-button" id="logoutButton" type="button">退出登录</button>
15
+ {% else %}
16
+ <button class="pill-button accent" id="openLoginButton" type="button">登录后编辑</button>
17
+ {% endif %}
18
+ </div>
19
+ </div>
20
+ <div class="clock-wrap">
21
+ <p class="eyebrow">北京时间</p>
22
+ <h1 class="clock-display" id="clockDisplay">00:00:00</h1>
23
+ <div class="clock-meta">
24
+ <span id="dateDisplay">0000 年 00 月 00 日</span>
25
+ <span id="weekdayDisplay">{{ clock_meta.weekday }}</span>
26
+ <span id="lunarDisplay">农历加载中</span>
27
+ </div>
28
+ </div>
29
+ </section>
30
+
31
+ <section class="board-section">
32
+ <div class="section-header">
33
+ <div>
34
+ <p class="section-kicker">今日待办板</p>
35
+ <h2>每个分类一列,专注当前最重要的事情</h2>
36
+ </div>
37
+ <p class="section-note">
38
+ {% if authenticated %}
39
+ 已登录,可添加、勾选和重命名。
40
+ {% else %}
41
+ 当前为只读模式,登录后可进行编辑。
42
+ {% endif %}
43
+ </p>
44
+ </div>
45
+
46
+ <div class="board-grid" id="boardGrid">
47
+ {% for category in categories %}
48
+ <article class="todo-column card-surface" data-category-id="{{ category.id }}">
49
+ <header class="column-header">
50
+ <div>
51
+ <p class="column-label">Todolist</p>
52
+ <h3 class="column-title">{{ category.name }}</h3>
53
+ </div>
54
+ <div class="column-actions">
55
+ <button class="icon-button" type="button" data-open-add="{{ category.id }}" aria-label="添加任务">+</button>
56
+ <div class="menu-wrap">
57
+ <button class="icon-button muted" type="button" data-menu-trigger="{{ category.id }}" aria-label="分类设置">⋯</button>
58
+ <div class="menu-panel" data-menu-panel="{{ category.id }}">
59
+ <button type="button" data-rename-category="{{ category.id }}">重命名清单</button>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ </header>
64
+
65
+ <div class="task-list">
66
+ {% if category.tasks %}
67
+ {% for task in category.tasks %}
68
+ <article class="task-card {% if task.completed %}task-complete{% endif %}" data-task-id="{{ task.id }}" data-created-at="{{ task.created_at }}" data-due-at="{{ task.due_at }}" data-completed="{{ 'true' if task.completed else 'false' }}">
69
+ <div class="task-main">
70
+ <label class="check-wrap">
71
+ <input class="task-check" type="checkbox" {% if task.completed %}checked{% endif %} data-toggle-task="{{ task.id }}">
72
+ <span class="custom-check"></span>
73
+ </label>
74
+ <div class="task-copy">
75
+ <h4>{{ task.title }}</h4>
76
+ <p class="meta-line">
77
+ <span class="meta-badge due-time" data-due-label></span>
78
+ <span class="meta-badge progress-text" data-progress-text></span>
79
+ </p>
80
+ </div>
81
+ </div>
82
+ <div class="progress-shell">
83
+ <div class="progress-bar" data-progress-bar></div>
84
+ </div>
85
+ {% if authenticated %}
86
+ <button class="task-delete" type="button" data-delete-task="{{ task.id }}">删除</button>
87
+ {% endif %}
88
+ </article>
89
+ {% endfor %}
90
+ {% else %}
91
+ <div class="empty-state">
92
+ <p>这里还没有任务</p>
93
+ <span>点击右上角的加号,给这个分类加上第一条提醒。</span>
94
+ </div>
95
+ {% endif %}
96
+ </div>
97
+ </article>
98
+ {% endfor %}
99
+ </div>
100
+ </section>
101
+ </main>
102
+
103
+ <div class="toast-stack" id="toastStack"></div>
104
+
105
+ <div class="modal-backdrop {% if login_required %}is-open{% endif %}" id="loginModal">
106
+ <div class="modal-card">
107
+ <div class="modal-head">
108
+ <p class="modal-kicker">安全登录</p>
109
+ <h3>输入密码后即可编辑待办</h3>
110
+ </div>
111
+ <form class="modal-form" id="loginForm">
112
+ <label>
113
+ <span>访问密码</span>
114
+ <input id="loginPassword" name="password" type="password" autocomplete="current-password" placeholder="请输入 PASSWORD">
115
+ </label>
116
+ <button class="primary-button" type="submit">立即登录</button>
117
+ </form>
118
+ </div>
119
+ </div>
120
+
121
+ <div class="modal-backdrop" id="taskModal">
122
+ <div class="modal-card">
123
+ <div class="modal-head">
124
+ <p class="modal-kicker">新增任务</p>
125
+ <h3 id="taskModalTitle">添加提醒</h3>
126
+ </div>
127
+ <form class="modal-form" id="taskForm">
128
+ <input type="hidden" id="taskCategoryId" name="category_id">
129
+ <label>
130
+ <span>任务名称</span>
131
+ <input id="taskName" name="title" type="text" maxlength="40" placeholder="例如:准备明天的会议资料">
132
+ </label>
133
+ <label>
134
+ <span>截止日期(北京时间)</span>
135
+ <input id="taskDueAt" name="due_at" type="datetime-local" step="3600">
136
+ </label>
137
+ <div class="form-actions">
138
+ <button class="secondary-button" type="button" data-close-modal="taskModal">取消</button>
139
+ <button class="primary-button" type="submit">保存任务</button>
140
+ </div>
141
+ </form>
142
+ </div>
143
+ </div>
144
+
145
+ <div class="modal-backdrop" id="renameModal">
146
+ <div class="modal-card">
147
+ <div class="modal-head">
148
+ <p class="modal-kicker">清单设置</p>
149
+ <h3>重命名当前 todolist</h3>
150
+ </div>
151
+ <form class="modal-form" id="renameForm">
152
+ <input type="hidden" id="renameCategoryId" name="category_id">
153
+ <label>
154
+ <span>新的分类名称</span>
155
+ <input id="renameCategoryName" name="name" type="text" maxlength="20" placeholder="例如:项目推进">
156
+ </label>
157
+ <div class="form-actions">
158
+ <button class="secondary-button" type="button" data-close-modal="renameModal">取消</button>
159
+ <button class="primary-button" type="submit">确认修改</button>
160
+ </div>
161
+ </form>
162
+ </div>
163
+ </div>
164
+ {% endblock %}
165
+ {% block scripts %}
166
+ <script>
167
+ window.__DRM_BOOTSTRAP__ = {
168
+ currentDateIso: {{ clock_meta.iso|tojson }},
169
+ loginRequired: {{ login_required|tojson }},
170
+ authenticated: {{ authenticated|tojson }},
171
+ nextPath: {{ next_path|tojson }},
172
+ };
173
+ </script>
174
+ <script src="{{ url_for('static', filename='app.js') }}"></script>
175
+ {% endblock %}
176
+