$wWS&hKO@Fpil?SXftH)37MFjm+uRXh}H_&!fX-sVDsb?MjpW
zr=ut=t@_|o83^u!$?zuAr37li8i+o1N4mt)xI)BqkPexoONxb9_6?ItvEtuQxxKb4
zMLROKK@2bSSGtbo$T|y{%M||Wp;PKmitDDwCfAp1D>8!vwL3Nq$CE0)gH2bXxv(7J
z?sxkxBlrBS-(^<$U50!Y3zLgALtiqMm`XC2ae#|ZbQEnaH{OR$K$oO2TW2*1oZ$Wy
zo)Zhpfk9qML8C6oL-h9^-4dMc(LIW;JXOnKO{J#5-OYqDI-n8=tsA()ECcSVh+
z^J7nsO?HV!J1HM33EMY_mW|W(VM}A!)Og)e5`6Uau`6Sur3M0anY_t!WSib~BiLdt
zS6`|QZhoomjrxeON`Uy;yn^87OJ^c^E&-f6)zd9v-c}G?JQK{UQoKpbDSv~F7+r$V
z6?GH^AH0$ganuS92)m!5MaOn1|KLQ1Wa6O(Yptkm<{V4S4kX%yOlS#ZGQl(wzB&(?
z)a>9-N0|~hs$!5w8Un6o0B=9ohderzZb=jss8wKASMLFkK~q3yt`W?HdPD`tn;=hU
z5x92^kSF-r;gY;C3G61(sF=PCQzXDcLn8MbXm>2LPMR(-g2R@sI?36J?$71S2uT4v
zt%Dyan&UF$V%mX`B!eMgOA-W=og@DR#U4fYeFif32*0yt1TeF6y!BU^)_^w38ueS9YG`=_UmUCq9_>FVhZc7%6#g&k~QcVP6w{!jIk#dga;
znXQwXFYE}@`4U^JxdU5k#yzXskIl{eLB>EnJ5Wm0=%Qrd6Jpg6J6%EAF!Xr<*iD&t}(%#{76A$sSIyv*ur;%Mp3^2A>F?naT|p)rGAXeFoBq>ggFvBeWd%zW2eP
zl?#P3g49tUw1Cvgi!M-2k}nw*yiOp>%F9$?=67-odBF2{=%~v46Iw#?Bk7XSlqeTy
zTTQxThJoSl570dL&q)A0K+Y(P;A{g#Td8UV-IFO-msy1}CIA``qIC#6hcq6#SKCpc
z1aIh;C>NTP%0)upwA|-Z8-3;eVq~5C?S8NnC(J^g+(-5IZeLvWmyn$*t{se*OCSX%
zLPQGXuO`Iu!M1?pOCEK=3zvj51VtLAdjn^sRx^k>krYGY#
z&^}LQUo!04lH2uUbp0_nkekpB!2wU^YTEIFdE)3tWYcVN&+-ylv?EqQi$JH6uP(F?
zBwfUX>>w^=vpl0-n9UyfhU!AUK+**pbv}}3(n+!Te#;>%%tmceF}U?)%D4pO3jCG?
z=dcnL?9YP!)}^SMCD-+5CqP}c{IqN1@;YoGTO{`^<@;h4I{i5bR1aL~9?Ku|6;E11
z6b;Ok6K8+Z@}8yaO$ob3>8%X(3037so*dSRfdy>QYJ1qzBsasB%JZep42g3ta~tJ1
z05Jx(oE*^icYh8_sp9T3Y^_LuU4pG1rf73ZxU>ngC?x*BX;
zHjb)<9!OYi?b<4@f%+@2s56n{Vi-p?>ryvQLGxK)h`$MP!Pk>A@qHW{UXAJVH>
z<$L5c30MF!7GV22f9_pJaj)Dbujf8_F7MiQ2D@Iyt<}tsQU)C^NtfU5v3KoEdI+~{
zv0O`@+kTW7?QAg#^0BK>2>T4|o;-!i{0SkEsDO$5uQmY^C2nB7cW%&gzdTyjjS4AS
zhpER#lv9Vtu^Jrl0zZI~d*qhXUl|MJS@VJ-c{h&Zad@oqdOE2?Oi!!a9z54bdBCa^
zk}B{Q>;HfL;>qzO;=`)B*q?#&Z*VKTcNLW8Ugcu@T`_axJ<_gxk6nQjl>PGMyJb{(
zQv&QK$$p4|GW_^c;a`1nU4WWqrzA_a9dlP98O87t$-gAk
z_ykT_47_>c>+d_O(MsczOhq4H`ENr4dv5#435o^?^24(gFXBTZoNkNWBPlZd96n0x
z2S4gSA0D_1KHgE^D2n6p4_$nA{*@UgR&c^lobk%UVueQ8$-w-(ufSjvtyiPDQ2qK(
zzc~M+N$Ig-y(MqpbiDbgD@k@N?l#onB9M^a#rR~ALmJ>1*}3OmPq2~QJpUq2>7}25
zu}pfY?p2odZy_0%wr-(|;pQ<&;P_!9Kib#l<9Xs;!;LaLj|M9p5It!!NOU5)9%2T`
zEIf+R!?KAW;gQlDJ@+*j{v5Gb!x_oYYp1%iYZQd5xK(fI1Smt7Ixkg+eO>5XlRSSW
z$i=eAaXH=bqmVt^;Fzh?_c%FgVk|>G9aq|1_6PV92YL;~;nagY?_V%ahVvrkaN&uo
zF-GG-6vWORAJ*S~;gfGZJ(sY`b>yInWec2maFBYAMch>hNArU1tKU^YqD9HhZW8((
zhVR1Z6$7wqm4-m_A?`AEghyy`e?s&5|L^5F18iVoaJ7|
zN?*jJ4H8!YY#{eOE(LB`a6vR@Vw%S!oYdgw$gCpt#L@sQdZkVD>6ne5PL!eG6`mp%
zGviix1=CD$f)TN(#!Og+oKmVA;!jFW3*x@`-#CCYa^4YnokJi(Iq(CwkEmEId2}uE
zVb#6-@F+&^GtfW202)G*ZR$&kvNxe27nhMMhbm}R<~6`1#wl=JnJni&fBy5cC6$p9
z_lG6!>29H9TcqUvnd1RnbWPa=ecfVxdEe!Omkz=qhOnWorR{&Vf6)H-2d`O<{N2I#
zT4r`lYF=v(J^I~>NKu_oR5!V6()*>B=_4OzHGXg~Kns>5*Y6gkd)<I3w=634qUY`WWB%@f!C=Q;2
zFJ!EXWNZ{NHi{Yb<9lIGiz#!`aSd(75T0^*%cU)_4RE(;tr9KO5sO=}xM5o|g#YH`
z!bZYVmrezr0DE;_t(fDE5J7(6Gesf$FzM*w#_s4S#!?C1J54_
zny#7GMRV3p?D;r;ic#A&b7eGVeP9oOq|F(Mhgn+-Bi1UxS~X>oMg((gdZ{B~a|t%r
zR0Ztzv^7L*&4R6Y=E1ASMcaNjQ`3@%Taa=fEx@qHEiXM4v9A~G3TL~{3s
zs=rnHwc1EolTg+)qnlyHvR#p~eL~qjv21_TRxI!2EPZ3ol<6BCk&3NC#n$)BXO924
z>T11Mu`gWS7PjvH)M9d^FH)u~W`Q!LF$;NAdKT>BODnuH7=2{1#^-HIxNxgz-8Qo)
zY~2|)?^Mn%4?<{PRlH2{>e{NNXwg<5NQ*KyI7c*IYT05sBy@^cN(D=4yijAfut~IH
zY=(7v*u4E?Q+CAU5KNA1rUG!1$g}2#V;yi`h{JL%?v|YJW`^$AJf?dc~sciJ&i9_eHD+1?$21Mqq@|5Nrs>
zv)$krte{aauL-nW$GL&P4ufdhd6)TNY!Rjq5JEa7KK1+{80Sbc6fuD={xIh5
z0~jqV4;>W>YNykLg011^PNASPV2LsX((!K*rX0?qow7u#cM8=zBh|Zv>RmGATc+}V
zX0E?(c82l+2bMy6QN&s;SgWTTqIDDC@9Ll(Kpi2rAXV{I0y1O36*u6D8(_tAC|J&w
z=pe-9D2ttH-^Dy*{c1yMPx!G5d=4ZE@mLJiXu
zuw77~o1*x_A%O4BHf4k$LV?~uVt2l4o%&3qcDGQw`|q2>hr7i+9^MK`B#kD1l}K_*2f6i77(V(1tc^pEhPV!&YHs36
zEa~#ASmJf17y3={MNNkN?rnA?L%J0?AtAh{r#j->N^5pP3~zyagKqYyLq
zEqw4MCO^dFr&L|3;<#8F8_y4G)-P&x
znqn+d3nzeSis8&HsYsZqnf6SRGR=!xi>46EY+u0KLPg3l>6)f^8OSXZsh@fv)P|p`
zUDP@>8?cCb0dotDN;9QXty4`{rXIVq4$CxPch)6zC$S8pl!2V`sW4MI)i$*a+i6_X
z>NTaWFZgMttr`)g
y4@Ogu1=i!Z>KAh0fd0UksLPKg9|01jjpz40y>GH2*gd}QL%Iyk-;AGh@P7dp<64FQ
literal 0
HcmV?d00001
diff --git a/main.py b/main.py
index 4a4f092..f4d1383 100644
--- a/main.py
+++ b/main.py
@@ -8,8 +8,13 @@ import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
+import os
+import uuid
from flask import Flask, Response, abort, jsonify, render_template, request, send_from_directory
+from werkzeug.utils import secure_filename
+
+from utils.media_manager import save_upload, list_media, delete_media
app = Flask(__name__)
@@ -132,6 +137,43 @@ def _copy_blank_template(dest: Path) -> None:
)
+def build_page_tree(slug: str, max_depth: int = 3) -> dict[str, Any]:
+ """遞歸建構專案的頁面樹狀結構(根據資料夾結構)。"""
+ proj_dir = _project_dir(slug)
+
+ def scan_folder(folder: Path, current_depth: int = 0):
+ items: list[dict[str, Any]] = []
+ if current_depth > max_depth:
+ return items
+ try:
+ names = sorted([p.name for p in folder.iterdir()])
+ except Exception:
+ return items
+
+ for name in names:
+ if name.startswith("."):
+ continue
+ full = folder / name
+ if full.is_dir():
+ children = scan_folder(full, current_depth + 1)
+ items.append({
+ "type": "folder",
+ "name": name,
+ "title": name.replace("-", " ").title(),
+ "children": children,
+ })
+ elif full.is_file() and full.suffix.lower() == ".html":
+ rel = str(full.relative_to(proj_dir)).replace("\\", "/")
+ items.append({
+ "type": "file",
+ "name": rel,
+ "title": Path(name).stem.replace("-", " ").title(),
+ })
+ return items
+
+ return {"root": scan_folder(proj_dir, 0)}
+
+
# ── 路由:一般頁面 ──────────────────────────────────────────────────────────
@app.route("/") # type: ignore[untyped-decorator]
@@ -195,6 +237,40 @@ def api_list_projects() -> Response:
return jsonify(projects)
+@app.route("/api/projects//settings", methods=["GET"]) # type: ignore[untyped-decorator]
+def api_get_settings(slug: str) -> tuple[Response, int] | Response:
+ if not _project_dir(slug).exists():
+ return jsonify({"error": "專案不存在"}), 404
+ data = _load_project(slug)
+ settings = data.get("settings", {})
+ # provide sensible defaults
+ defaults = {
+ "title": data.get("name", slug),
+ "description": data.get("description", ""),
+ "logo_url": None,
+ "favicon_url": None,
+ "primary_color": "#007bff",
+ "secondary_color": "#6c757d",
+ }
+ merged = {**defaults, **settings}
+ return jsonify(merged)
+
+
+@app.route("/api/projects//settings", methods=["PUT"]) # type: ignore[untyped-decorator]
+def api_put_settings(slug: str) -> tuple[Response, int] | Response:
+ if not _project_dir(slug).exists():
+ return jsonify({"error": "專案不存在"}), 404
+ body: dict[str, Any] = request.get_json(force=True) or {}
+ data = _load_project(slug)
+ settings = data.get("settings", {})
+ settings.update(body)
+ data["settings"] = settings
+ data["updated_at"] = _now_iso()
+ _save_project(slug, data)
+ return jsonify({"ok": True, "settings": settings})
+
+
+
@app.route("/api/projects", methods=["POST"]) # type: ignore[untyped-decorator]
def api_create_project() -> tuple[Response, int]:
body: dict[str, Any] = request.get_json(force=True) or {}
@@ -261,6 +337,59 @@ def api_list_pages(slug: str) -> tuple[Response, int] | Response:
return jsonify(_list_pages(slug))
+@app.route("/api/projects//pages-tree", methods=["GET"]) # type: ignore[untyped-decorator]
+def api_pages_tree(slug: str) -> tuple[Response, int] | Response:
+ if not _project_dir(slug).exists():
+ return jsonify({"error": "專案不存在"}), 404
+ tree = build_page_tree(slug, max_depth=5)
+ return jsonify(tree)
+
+
+@app.route("/api/projects//media/upload", methods=["POST"]) # type: ignore[untyped-decorator]
+def api_media_upload(slug: str) -> tuple[Response, int] | Response:
+ proj_dir = _project_dir(slug)
+ if not proj_dir.exists():
+ return jsonify({"error": "專案不存在"}), 404
+ if "file" not in request.files:
+ return jsonify({"error": "缺少檔案 (file)"}), 400
+ f = request.files["file"]
+ if f.filename == "":
+ return jsonify({"error": "檔名為空"}), 400
+ # basic validation
+ filename = secure_filename(f.filename)
+ meta = save_upload(proj_dir, f)
+ # substitute slug in returned urls
+ if isinstance(meta.get("url"), str):
+ meta["url"] = meta["url"].replace("{slug}", slug)
+ if isinstance(meta.get("thumb"), str):
+ meta["thumb"] = meta["thumb"].replace("{slug}", slug)
+ return jsonify({"ok": True, "file": meta})
+
+
+@app.route("/api/projects//media/list", methods=["GET"]) # type: ignore[untyped-decorator]
+def api_media_list(slug: str) -> tuple[Response, int] | Response:
+ proj_dir = _project_dir(slug)
+ if not proj_dir.exists():
+ return jsonify({"error": "專案不存在"}), 404
+ items = list_media(proj_dir)
+ # patch urls
+ for it in items:
+ if "filename" in it and "date" in it:
+ it["url"] = f"/sites/{slug}/media/images/{it['date']}/{it['filename']}"
+ return jsonify(items)
+
+
+@app.route("/api/projects//media/", methods=["DELETE"]) # type: ignore[untyped-decorator]
+def api_media_delete(slug: str, rel_path: str) -> tuple[Response, int] | Response:
+ proj_dir = _project_dir(slug)
+ if not proj_dir.exists():
+ return jsonify({"error": "專案不存在"}), 404
+ ok = delete_media(proj_dir, rel_path)
+ if not ok:
+ return jsonify({"error": "刪除失敗或檔案不存在"}), 400
+ return jsonify({"ok": True})
+
+
@app.route("/api/projects//pages", methods=["POST"]) # type: ignore[untyped-decorator]
def api_create_page(slug: str) -> tuple[Response, int]:
if not _project_dir(slug).exists():
diff --git a/pyproject.toml b/pyproject.toml
index aad68eb..857afac 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,4 +6,10 @@ readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"flask>=3.1",
+]
+
+[tool.pip]
+# Image processing for media manager
+dependencies = [
+ "Pillow>=10.0"
]
\ No newline at end of file
diff --git a/templates/dashboard.html b/templates/dashboard.html
index acc3d10..8bdc7df 100644
--- a/templates/dashboard.html
+++ b/templates/dashboard.html
@@ -1,3 +1,136 @@
+
+
+
+
+
+ 管理器 - Dashboard
+
+
+
+
+
+
+
+
概覽
+
網站設定
+
頁面管理
+
媒體庫
+
+
+
+
選擇一個專案在右側編輯。
+
+
+
網站設定
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/editor.html b/templates/editor.html
index f4a09b3..742f65a 100644
--- a/templates/editor.html
+++ b/templates/editor.html
@@ -108,6 +108,9 @@