diff --git a/README.md b/README.md
index e69de29..c6f4e1d 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,27 @@
+# 網頁管理編輯器
+
+# Fork
+This project is forked from [vvvebjs](https://github.com/givanz/VvvebJs)
+
+# 目的
+為了替代Google site可以提供社團或者一些使用者來進行簡易架站(如Publii之類的)
+以及希望可以增加Google site不支援的模組如Blog等
+但是由於本人對Python比較熟悉,所以不考慮使用現有的vvvebjs cms套組,而是另外重新撰寫dashboard等
+~~我就是死不用node的傢伙~~
+(其實相當於是預期重製一套更好用一點CMS套組)
+
+# requirement
+uv(python 3.13)
+
+# 預計功能
+- [x] 中文化
+- [ ] 部落格模組
+- [ ] 帳號管理(註冊登入網站管理器、網站成員管理)
+- [ ] 網站管理(頁面管理、模組管理、檔案管理、版本控制)
+- [ ] 部署功能(網域配置:dns與tunnel設置?)
+- [ ] 網站模板(提供一些預設模板)
+- [ ] 網站設定(如網站名稱、網站描述、網站logo等)
+- [ ] 自動導覽列模組(根據頁面名稱自動生成導覽列)
+
+# 招募貢獻
+目前還在開發中,歡迎有興趣的開發者一起參與開發,可以[來信](mailto:hi@l.nudoragon.com)跟我說!
\ No newline at end of file
diff --git a/main.py b/main.py
index e45b286..4a4f092 100644
--- a/main.py
+++ b/main.py
@@ -62,18 +62,23 @@ def _save_project(slug: str, data: dict[str, Any]) -> None:
def _list_pages(slug: str) -> list[str]:
- """列出專案目錄下所有 .html 頁面."""
+ """列出專案目錄下所有 .html 頁面(包含子資料夾)並回傳相對路徑."""
proj_dir = _project_dir(slug)
if not proj_dir.exists():
return []
- return sorted(p.name for p in proj_dir.glob("*.html"))
+ # 排除專案設定檔、寮本檔等非頁面檔
+ return sorted(
+ str(p.relative_to(proj_dir)).replace("\\", "/")
+ for p in proj_dir.rglob("*.html")
+ if p.name != "project.json"
+ )
def _project_summary(slug: str) -> dict[str, Any]:
data = _load_project(slug)
pages = _list_pages(slug)
proj_dir = _project_dir(slug)
- html_files = list(proj_dir.glob("*.html"))
+ html_files = list(proj_dir.rglob("*.html"))
if html_files:
last_mod = max(f.stat().st_mtime for f in html_files)
last_mod_str = datetime.fromtimestamp(last_mod, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
@@ -88,10 +93,27 @@ def _project_summary(slug: str) -> dict[str, Any]:
}
-def _sanitize_file_path(slug: str, filename: str) -> Path | None:
- """驗證並回傳安全的檔案路徑(防止路徑遍歷)."""
+def _sanitize_file_path(slug: str, filename: str, folder: str = "") -> Path | None:
+ """驗證並回傳安全的檔案路徑,支援子資料夾(防止路徑遍歷)."""
proj_dir = _project_dir(slug).resolve()
- target = (proj_dir / Path(filename).name).resolve()
+ safe_filename = Path(filename).name # 只取最後的檔名,防止路徑注入
+ if folder:
+ safe_folder = str(Path(folder)).lstrip("/\\").replace("..", "")
+ target = (proj_dir / safe_folder / safe_filename).resolve()
+ else:
+ target = (proj_dir / safe_filename).resolve()
+ if not str(target).startswith(str(proj_dir)):
+ return None
+ if target.suffix.lower() != ".html":
+ return None
+ return target
+
+
+def _sanitize_rel_path(slug: str, rel_path: str) -> Path | None:
+ """驗證並回傳安全的相對路徑(支援子資料夾)."""
+ proj_dir = _project_dir(slug).resolve()
+ clean = rel_path.lstrip("/\\").replace("..", "")
+ target = (proj_dir / clean).resolve()
if not str(target).startswith(str(proj_dir)):
return None
if target.suffix.lower() != ".html":
@@ -128,18 +150,21 @@ def editor(slug: str) -> str:
proj_dir = _project_dir(slug)
if not proj_dir.exists():
abort(404)
- pages = _list_pages(slug)
+ pages = _list_pages(slug) # 現在回傳相對路徑列表,如 'index.html', 'sub/about.html'
project = _load_project(slug)
pages_obj: dict[str, Any] = {}
- for page in pages:
- name = page.replace(".html", "")
+ for rel_path in pages:
+ name = rel_path.replace(".html", "")
+ stem = Path(rel_path).stem.replace("-", " ").title()
+ folder_part = name.rsplit("/", 1)[0] if "/" in name else ""
pages_obj[name] = {
"name": name,
- "title": name.replace("-", " ").title(),
- "filename": page,
- "file": page,
- "url": f"/sites/{slug}/{page}",
+ "title": stem,
+ "folder": folder_part,
+ "filename": rel_path,
+ "file": rel_path,
+ "url": f"/sites/{slug}/{rel_path}",
}
if not pages_obj:
@@ -279,14 +304,16 @@ def api_save() -> tuple[Response, int] | Response:
if action == "rename":
file = str(body.get("file", "")).strip()
newfile = str(body.get("newfile", "")).strip()
+ new_title = str(body.get("title", "")).strip()
+ new_folder = str(body.get("folder", "")).strip()
duplicate_str = str(body.get("duplicate", "")).strip().lower()
is_duplicate = (duplicate_str == "true")
if not file or not newfile:
return jsonify({"error": "缺少參數 file 或 newfile"}), 400
- old_path = _sanitize_file_path(slug, file)
- new_path = _sanitize_file_path(slug, newfile)
+ old_path = _sanitize_rel_path(slug, file)
+ new_path = _sanitize_file_path(slug, newfile, new_folder)
if old_path is None or new_path is None:
return jsonify({"error": "不合法的頁面名稱"}), 400
@@ -297,6 +324,9 @@ def api_save() -> tuple[Response, int] | Response:
if new_path.exists() and old_path != new_path:
return jsonify({"error": "目標頁面已存在"}), 409
+ # 建立子資料夾(若需要)
+ new_path.parent.mkdir(parents=True, exist_ok=True)
+
if is_duplicate:
shutil.copy(old_path, new_path)
msg = "頁面複製成功"
@@ -304,12 +334,19 @@ def api_save() -> tuple[Response, int] | Response:
old_path.rename(new_path)
msg = "頁面重新命名成功"
+ # 計算相對於專案目錄的路徑,用於 URL
+ proj_dir = _project_dir(slug)
+ rel_path = new_path.relative_to(proj_dir)
+ page_name = str(rel_path).replace("\\", "/").replace(".html", "")
+
return jsonify({
"success": True,
"ok": True,
"message": msg,
- "newfile": new_path.name,
- "url": f"/sites/{slug}/{new_path.name}"
+ "newfile": str(rel_path).replace("\\", "/"),
+ "title": new_title or new_path.stem.replace("-", " ").title(),
+ "name": page_name,
+ "url": f"/sites/{slug}/{str(rel_path).replace(chr(92), '/')}"
})
# ── 處理刪除頁面 ──
@@ -339,19 +376,23 @@ def api_save() -> tuple[Response, int] | Response:
start_template_url = str(body.get("startTemplateUrl", "")).strip()
if start_template_url:
title = str(body.get("title", "")).strip() or "New Page"
- # 取得安全的檔名 (只取檔名部分,例如 'about.html')
+ folder = str(body.get("folder", "")).strip()
+ # 取得安全的檔名
filename = Path(str(body.get("file", "untitled.html")).strip()).name
if not filename.endswith(".html"):
filename += ".html"
- safe_path = _sanitize_file_path(slug, filename)
+ safe_path = _sanitize_file_path(slug, filename, folder)
if safe_path is None:
return jsonify({"error": "不合法的頁面名稱"}), 400
if safe_path.exists():
return jsonify({"error": "頁面已存在"}), 409
- # 解析並複製樣板 (定位在 static/Vvvebjs 目錄下)
+ # 建立子資料夾(若需要)
+ safe_path.parent.mkdir(parents=True, exist_ok=True)
+
+ # 解析並複製樣板
template_source = BASE_DIR / "static" / "Vvvebjs" / start_template_url
if template_source.exists() and template_source.is_file():
shutil.copy(template_source, safe_path)
@@ -359,28 +400,32 @@ def api_save() -> tuple[Response, int] | Response:
_copy_blank_template(safe_path)
# 回傳 VvvebJS FileManager 所期待的 JSON 格式
- page_name = safe_path.stem
+ proj_dir = _project_dir(slug)
+ rel_path = safe_path.relative_to(proj_dir)
+ page_name = str(rel_path).replace("\\", "/").replace(".html", "")
+
return jsonify({
"ok": True,
"name": page_name,
"title": title,
- "file": safe_path.name,
- "url": f"/sites/{slug}/{safe_path.name}"
+ "file": str(rel_path).replace("\\", "/"),
+ "url": f"/sites/{slug}/{str(rel_path).replace(chr(92), '/')}"
})
- # 2. 一般儲存頁面請求
+ # 2. 一般儲存頁面請求(支援子資料夾路徑)
filename = str(body.get("file", "")).strip()
html: str = str(body.get("html", "")).strip()
if not filename or not html:
return jsonify({"error": "缺少必要參數 file / html"}), 400
- safe_path = _sanitize_file_path(slug, filename)
+ safe_path = _sanitize_rel_path(slug, filename)
if safe_path is None:
return jsonify({"error": "不合法的檔案路徑"}), 400
+ safe_path.parent.mkdir(parents=True, exist_ok=True)
safe_path.write_text(html, encoding="utf-8")
- return jsonify({"ok": True, "saved": safe_path.name})
+ return jsonify({"ok": True, "saved": str(safe_path.relative_to(_project_dir(slug))).replace("\\", "/")})
diff --git a/static/js/my-editor.js b/static/js/my-editor.js
index 083d700..cd1439c 100644
--- a/static/js/my-editor.js
+++ b/static/js/my-editor.js
@@ -332,6 +332,116 @@
window.addEventListener("keydown", keyHandler);
}
+ // ── 三欄位頁面表單對話框 (頁面名稱 / 檔案名稱 / 資料夾) ────────────
+ function showModalPageForm(actionTitle, defaultTitle, defaultFile, defaultFolder, callback) {
+ const existing = document.getElementById("vvveb-custom-modal");
+ if (existing) existing.remove();
+
+ const overlay = document.createElement("div");
+ overlay.id = "vvveb-custom-modal";
+ Object.assign(overlay.style, {
+ position: "fixed", top: "0", left: "0", width: "100%", height: "100%",
+ zIndex: "100000", background: "rgba(10,11,18,0.65)",
+ backdropFilter: "blur(12px)", display: "flex",
+ alignItems: "center", justifyContent: "center",
+ opacity: "0", transition: "opacity 0.25s ease-out"
+ });
+
+ const card = document.createElement("div");
+ Object.assign(card.style, {
+ background: "rgba(22,24,38,0.97)",
+ border: "1px solid rgba(255,255,255,0.08)",
+ boxShadow: "0 20px 50px rgba(0,0,0,0.6), 0 0 40px rgba(99,102,241,0.1)",
+ borderRadius: "16px", width: "460px", padding: "2rem",
+ color: "#f8fafc", fontFamily: "Inter, system-ui, sans-serif",
+ transform: "scale(0.95)",
+ transition: "transform 0.25s cubic-bezier(0.34,1.56,0.64,1)",
+ display: "flex", flexDirection: "column", gap: "1.1rem"
+ });
+
+ const fieldStyle = `
+ width:100%; background:rgba(13,14,24,0.8);
+ border:1px solid rgba(255,255,255,0.1); border-radius:8px;
+ padding:0.6rem 0.85rem; color:#fff; font-size:0.88rem;
+ outline:none; box-sizing:border-box; transition:border-color 0.2s, box-shadow 0.2s;`;
+ const labelStyle = `display:block; font-size:0.78rem; color:#94a3b8; margin-bottom:0.3rem; font-weight:500;`;
+
+ card.innerHTML = `
+
+
+ ${actionTitle}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+
+ overlay.appendChild(card);
+ document.body.appendChild(overlay);
+
+ const titleInput = card.querySelector("#pf-title");
+ const fileInput = card.querySelector("#pf-file");
+ const folderInput = card.querySelector("#pf-folder");
+ const cancelBtn = card.querySelector("#pf-cancel");
+ const confirmBtn = card.querySelector("#pf-confirm");
+
+ // Focus style
+ [titleInput, fileInput, folderInput].forEach(inp => {
+ inp.addEventListener("focus", () => { inp.style.borderColor = "#818cf8"; inp.style.boxShadow = "0 0 10px rgba(129,140,248,0.2)"; });
+ inp.addEventListener("blur", () => { inp.style.borderColor = "rgba(255,255,255,0.1)"; inp.style.boxShadow = "none"; });
+ });
+ cancelBtn.addEventListener("mouseover", () => { cancelBtn.style.background = "rgba(255,255,255,0.06)"; });
+ cancelBtn.addEventListener("mouseout", () => { cancelBtn.style.background = "transparent"; });
+ confirmBtn.addEventListener("mouseover", () => { confirmBtn.style.background = "#4338ca"; });
+ confirmBtn.addEventListener("mouseout", () => { confirmBtn.style.background = "#4f46e5"; });
+
+ // Auto-fill filename from title
+ titleInput.addEventListener("input", () => {
+ const slug = titleInput.value.trim().toLowerCase()
+ .replace(/[^\w\s-]/g, "").replace(/[\s_]+/g, "-").replace(/^-+|-+$/g, "");
+ if (slug) fileInput.value = slug;
+ });
+
+ setTimeout(() => { overlay.style.opacity = "1"; card.style.transform = "scale(1)"; }, 10);
+ titleInput.focus();
+ titleInput.select();
+
+ function close(confirmed) {
+ overlay.style.opacity = "0";
+ card.style.transform = "scale(0.95)";
+ setTimeout(() => {
+ overlay.remove();
+ if (confirmed) {
+ callback({ title: titleInput.value.trim(), filename: fileInput.value.trim(), folder: folderInput.value.trim() });
+ } else {
+ callback(null);
+ }
+ }, 220);
+ }
+
+ cancelBtn.onclick = () => close(false);
+ confirmBtn.onclick = () => close(true);
+ fileInput.onkeydown = titleInput.onkeydown = folderInput.onkeydown = (e) => {
+ if (e.key === "Enter") close(true);
+ if (e.key === "Escape") close(false);
+ };
+ }
+
// ── 覆蓋 Vvveb.Builder.saveAjax ──────────────────────────────────
function patchSaveAjax() {
if (typeof Vvveb === "undefined" || !Vvveb.Builder) {
@@ -501,21 +611,25 @@
});
};
- // 覆蓋重新命名 / 複製頁面
+ // 覆蓋重新命名 / 複製頁面(三欄位對話框:頁面名稱、檔案名稱、儲存至資料夾)
Vvveb.FileManager.renamePage = function (element, e, duplicate = false) {
let page = element.dataset;
- showModalPrompt(`請輸入 "${page.file}" 的新檔名:`, page.file, function (newfile) {
- if (!newfile) return;
+ const currentTitle = element.querySelector("label > span")?.textContent?.trim() || page.file.replace(".html","");
+ const currentFile = page.file.replace(".html","");
+ const action = duplicate ? "複製頁面" : "重新命名頁面";
- // 確保副檔名為 .html
- if (!newfile.endsWith(".html")) {
- newfile += ".html";
- }
+ showModalPageForm(action, currentTitle, currentFile, "", function (formData) {
+ if (!formData) return;
+ let { title, filename, folder } = formData;
+ if (!filename) return;
+ if (!filename.endsWith(".html")) filename += ".html";
const bodyData = {
slug: SLUG,
file: page.file,
- newfile: newfile,
+ newfile: filename,
+ title: title,
+ folder: folder,
duplicate: duplicate ? "true" : "false"
};
@@ -534,40 +648,32 @@
})
.then((data) => {
showToast(`✓ ${data.message}`);
- let baseName = data.newfile.replace('.html', '');
- let newName = friendlyName(data.newfile.replace(/.*[\/\\]+/, '')).replace('.html', '');
+ const baseName = data.name || data.newfile.replace('.html', '');
+ const newTitle = data.title || title || baseName;
if (duplicate) {
- // 複製頁面:在 FileManager 中加入新頁面
let pageData = Object.assign({}, Vvveb.FileManager.pages[page.page]);
pageData["file"] = data.newfile;
- pageData["title"] = newName;
+ pageData["title"] = newTitle;
pageData["url"] = data.url;
pageData["name"] = baseName;
Vvveb.FileManager.addPage(baseName, pageData);
} else {
- // 重新命名:更新現有節點資訊
const oldPageKey = page.page;
Vvveb.FileManager.pages[oldPageKey]["file"] = data.newfile;
- Vvveb.FileManager.pages[oldPageKey]["title"] = newName;
+ Vvveb.FileManager.pages[oldPageKey]["title"] = newTitle;
Vvveb.FileManager.pages[oldPageKey]["url"] = data.url;
Vvveb.FileManager.pages[oldPageKey]["name"] = baseName;
let link = element.querySelector("a.view");
- if (link) {
- link.setAttribute("href", data.url);
- }
- let span = element.querySelector("label > span");
- if (!span) {
- span = element.querySelector("span");
- }
- if (span) {
- span.textContent = newName;
- }
+ if (link) link.setAttribute("href", data.url);
+
+ let span = element.querySelector("label > span") || element.querySelector("span");
+ if (span) span.textContent = newTitle;
+
element.dataset.file = data.newfile;
element.dataset.page = baseName;
-
- // 將 key 重新綁定
+
Vvveb.FileManager.pages[baseName] = Vvveb.FileManager.pages[oldPageKey];
if (baseName !== oldPageKey) {
delete Vvveb.FileManager.pages[oldPageKey];
@@ -666,6 +772,129 @@
console.log("[my-editor] Vvveb.NewSection.insert patched to prevent layout crashes.");
}
+ // ── 頁面清單樹狀結構 ────────────────────────────────────────────────
+ function patchPageTree() {
+ if (typeof Vvveb === "undefined" || !Vvveb.FileManager) {
+ setTimeout(patchPageTree, 300);
+ return;
+ }
+
+ // 攔截 addPages — 批次新增後重組樹狀結構
+ const origAddPages = Vvveb.FileManager.addPages;
+ if (origAddPages) {
+ Vvveb.FileManager.addPages = function (pages) {
+ origAddPages.call(this, pages);
+ setTimeout(buildPageTree, 80);
+ };
+ }
+
+ // 攔截 addPage — 單筆新增(Rename/Duplicate)後重組
+ const origAddPage = Vvveb.FileManager.addPage;
+ if (origAddPage) {
+ Vvveb.FileManager.addPage = function (name, page, ...rest) {
+ const result = origAddPage.call(this, name, page, ...rest);
+ setTimeout(buildPageTree, 80);
+ return result;
+ };
+ }
+
+ console.log("[my-editor] Page tree patch ready.");
+ }
+
+ function buildPageTree() {
+ // 找到頁面清單容器(支援多種可能的 selector)
+ const list = (
+ document.querySelector("#file-manager .files") ||
+ document.querySelector(".file-manager .files") ||
+ document.querySelector("#file-manager ul") ||
+ (() => {
+ const li = document.querySelector("li[data-file]");
+ return li ? li.closest("ul") : null;
+ })()
+ );
+ if (!list) return;
+
+ // 取得所有頁面 li(不包含我們建立的 folder-node)
+ const allPageItems = [...list.querySelectorAll("li[data-file]:not(.folder-node-item)")];
+ if (allPageItems.length === 0) return;
+
+ // 移除舊的資料夾節點(避免重複)
+ list.querySelectorAll(".folder-node").forEach(n => n.remove());
+
+ // 分組:根頁面 vs 子資料夾頁面
+ const rootItems = [];
+ const folderMap = {}; // folderName -> [li]
+
+ allPageItems.forEach(li => {
+ const pageKey = li.dataset.page || "";
+ if (pageKey.includes("/")) {
+ const folderName = pageKey.split("/").slice(0, -1).join("/");
+ if (!folderMap[folderName]) folderMap[folderName] = [];
+ folderMap[folderName].push(li);
+ li.remove(); // 先從扁平清單移除,稍後放進資料夾節點
+ } else {
+ rootItems.push(li);
+ }
+ });
+
+ // 每個資料夾建一個折疊節點並加到清單尾端
+ for (const folderName in folderMap) {
+ const folderNode = createFolderNode(folderName, folderMap[folderName]);
+ list.appendChild(folderNode);
+ }
+ }
+
+ function createFolderNode(folderPath, childItems) {
+ const displayName = folderPath.split("/").pop()
+ .replace(/-/g, " ")
+ .replace(/\b\w/g, c => c.toUpperCase());
+
+ const li = document.createElement("li");
+ li.className = "folder-node";
+ li.dataset.folder = folderPath;
+ li.style.cssText = "list-style:none;";
+
+ li.innerHTML = `
+
+ `;
+
+ const childrenUl = li.querySelector(".folder-children");
+ childItems.forEach(child => {
+ child.classList.add("folder-node-item");
+ childrenUl.appendChild(child);
+ });
+
+ // 折疊切換
+ const header = li.querySelector(".folder-header");
+ const chevron = li.querySelector(".chevron");
+ let collapsed = false;
+
+ header.addEventListener("mouseenter", () => { header.style.background = "rgba(99,102,241,0.08)"; });
+ header.addEventListener("mouseleave", () => { header.style.background = ""; });
+ header.addEventListener("click", (e) => {
+ if (e.target.closest("li[data-file]")) return;
+ collapsed = !collapsed;
+ childrenUl.style.display = collapsed ? "none" : "";
+ chevron.style.transform = collapsed ? "rotate(-90deg)" : "";
+ });
+
+ return li;
+ }
+
// ── 動態中文化 VvvebJS 元件與區塊 ─────────────────────────────────
function patchI18n() {
if (typeof Vvveb === "undefined" || !Vvveb.ComponentsGroup || Object.keys(Vvveb.ComponentsGroup).length === 0) {
@@ -979,6 +1208,7 @@
patchSaveAjax();
patchFileManager();
patchNewSection();
+ patchPageTree();
patchI18n();
enableSaveBtn();
});
@@ -986,6 +1216,7 @@
patchSaveAjax();
patchFileManager();
patchNewSection();
+ patchPageTree();
patchI18n();
enableSaveBtn();
}
diff --git a/templates/editor.html b/templates/editor.html
index 880e2d1..de87321 100644
--- a/templates/editor.html
+++ b/templates/editor.html
@@ -2052,8 +2052,8 @@
@@ -2062,7 +2062,7 @@
@@ -2083,8 +2083,8 @@
@@ -2287,6 +2287,21 @@ Vvveb.FileManager.addPages(pages);
Vvveb.FileManager.loadPage(pages[firstPage]["name"]);
Vvveb.Gui.toggleRightColumn(false);
Vvveb.Breadcrumb.init();
+
+// ── 新增頁面對話框:頁面名稱自動同步至檔案名稱 ──
+document.addEventListener("DOMContentLoaded", function () {
+ var titleInput = document.getElementById("new-page-title");
+ var fileInput = document.getElementById("new-page-file");
+ if (titleInput && fileInput) {
+ titleInput.addEventListener("input", function () {
+ var slug = titleInput.value.trim().toLowerCase()
+ .replace(/[^\w\s-]/g, "")
+ .replace(/[\s_]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+ if (slug) fileInput.value = slug + ".html";
+ });
+ }
+});
+
+
+
+
+
+
+
+
+
+
+
Bootstrap 5 start page
+
Start by dragging components to page or double click to edit text
+
+
+
+
+
+