Add Page
This commit is contained in:
50
main.py
50
main.py
@@ -254,7 +254,7 @@ def api_create_page(slug: str) -> tuple[Response, int]:
|
|||||||
|
|
||||||
@app.route("/api/save", methods=["POST"]) # type: ignore[untyped-decorator]
|
@app.route("/api/save", methods=["POST"]) # type: ignore[untyped-decorator]
|
||||||
def api_save() -> tuple[Response, int] | Response:
|
def api_save() -> tuple[Response, int] | Response:
|
||||||
"""接受 vvvebjs 的儲存請求,寫入對應專案目錄."""
|
"""接受 vvvebjs 的儲存或新增頁面請求,寫入對應專案目錄."""
|
||||||
if request.is_json:
|
if request.is_json:
|
||||||
raw: dict[str, Any] = request.get_json(force=True) or {}
|
raw: dict[str, Any] = request.get_json(force=True) or {}
|
||||||
body: dict[str, Any] = raw
|
body: dict[str, Any] = raw
|
||||||
@@ -263,15 +263,52 @@ def api_save() -> tuple[Response, int] | Response:
|
|||||||
body = {k: (v[0] if isinstance(v, list) else v) for k, v in form.items()}
|
body = {k: (v[0] if isinstance(v, list) else v) for k, v in form.items()}
|
||||||
|
|
||||||
slug: str = str(body.get("slug", "")).strip()
|
slug: str = str(body.get("slug", "")).strip()
|
||||||
filename: str = str(body.get("file", "")).strip()
|
if not slug:
|
||||||
html: str = str(body.get("html", "")).strip()
|
return jsonify({"error": "缺少專案識別碼 (slug)"}), 400
|
||||||
|
|
||||||
if not slug or not filename or not html:
|
|
||||||
return jsonify({"error": "缺少必要參數 slug / file / html"}), 400
|
|
||||||
|
|
||||||
if not _project_dir(slug).exists():
|
if not _project_dir(slug).exists():
|
||||||
return jsonify({"error": "專案不存在"}), 404
|
return jsonify({"error": "專案不存在"}), 404
|
||||||
|
|
||||||
|
# 1. 判斷是否為新增頁面請求 (含有 startTemplateUrl)
|
||||||
|
start_template_url = str(body.get("startTemplateUrl", "")).strip()
|
||||||
|
if start_template_url:
|
||||||
|
title = str(body.get("title", "")).strip() or "New Page"
|
||||||
|
# 取得安全的檔名 (只取檔名部分,例如 'about.html')
|
||||||
|
filename = Path(str(body.get("file", "untitled.html")).strip()).name
|
||||||
|
if not filename.endswith(".html"):
|
||||||
|
filename += ".html"
|
||||||
|
|
||||||
|
safe_path = _sanitize_file_path(slug, filename)
|
||||||
|
if safe_path is None:
|
||||||
|
return jsonify({"error": "不合法的頁面名稱"}), 400
|
||||||
|
|
||||||
|
if safe_path.exists():
|
||||||
|
return jsonify({"error": "頁面已存在"}), 409
|
||||||
|
|
||||||
|
# 解析並複製樣板 (定位在 static/Vvvebjs 目錄下)
|
||||||
|
template_source = BASE_DIR / "static" / "Vvvebjs" / start_template_url
|
||||||
|
if template_source.exists() and template_source.is_file():
|
||||||
|
shutil.copy(template_source, safe_path)
|
||||||
|
else:
|
||||||
|
_copy_blank_template(safe_path)
|
||||||
|
|
||||||
|
# 回傳 VvvebJS FileManager 所期待的 JSON 格式
|
||||||
|
page_name = safe_path.stem
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"name": page_name,
|
||||||
|
"title": title,
|
||||||
|
"file": safe_path.name,
|
||||||
|
"url": f"/sites/{slug}/{safe_path.name}"
|
||||||
|
})
|
||||||
|
|
||||||
|
# 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_file_path(slug, filename)
|
||||||
if safe_path is None:
|
if safe_path is None:
|
||||||
return jsonify({"error": "不合法的檔案路徑"}), 400
|
return jsonify({"error": "不合法的檔案路徑"}), 400
|
||||||
@@ -280,6 +317,7 @@ def api_save() -> tuple[Response, int] | Response:
|
|||||||
return jsonify({"ok": True, "saved": safe_path.name})
|
return jsonify({"ok": True, "saved": safe_path.name})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/sites/<slug>/<path:filename>") # type: ignore[untyped-decorator]
|
@app.route("/sites/<slug>/<path:filename>") # type: ignore[untyped-decorator]
|
||||||
def serve_site_file(slug: str, filename: str) -> Response:
|
def serve_site_file(slug: str, filename: str) -> Response:
|
||||||
proj_dir = _project_dir(slug)
|
proj_dir = _project_dir(slug)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* my-editor.js — VvvebJS 儲存橋接腳本
|
* my-editor.js — VvvebJS 儲存與新增頁面橋接腳本
|
||||||
* 覆蓋 vvvebjs 的 saveAjax 行為,附加 project slug 後送至 Flask /api/save
|
* 覆蓋 Vvveb.Builder.saveAjax 行為,使儲存與新增頁面功能無縫串接至 Flask 後端
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 覆蓋 saveAjax ───────────────────────────────────────────────
|
// ── 覆蓋 Vvveb.Builder.saveAjax ──────────────────────────────────
|
||||||
function patchSaveAjax() {
|
function patchSaveAjax() {
|
||||||
if (typeof Vvveb === "undefined" || !Vvveb.Builder) {
|
if (typeof Vvveb === "undefined" || !Vvveb.Builder) {
|
||||||
// 等待 Vvveb 載入
|
// 等待 Vvveb 載入
|
||||||
@@ -57,82 +57,94 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalSaveAjax = Vvveb.Builder.saveAjax
|
/**
|
||||||
? Vvveb.Builder.saveAjax.bind(Vvveb.Builder)
|
* VvvebJS 原始簽名: saveAjax(data, saveUrl, callback, error)
|
||||||
: null;
|
*/
|
||||||
|
Vvveb.Builder.saveAjax = function (data, saveUrl, callback, error) {
|
||||||
Vvveb.Builder.saveAjax = function (saveUrl) {
|
|
||||||
if (!SLUG) {
|
if (!SLUG) {
|
||||||
showToast("錯誤:找不到專案識別碼 (slug)", "error");
|
showToast("錯誤:找不到專案識別碼 (slug)", "error");
|
||||||
|
if (error) error(new Error("Missing slug"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取得目前編輯的頁面檔名
|
// 確保 data 為物件並填入專案識別碼
|
||||||
const currentPage = Vvveb.FileManager
|
if (typeof data !== "object" || data === null) {
|
||||||
? Vvveb.FileManager.getCurrentPage
|
data = {};
|
||||||
? Vvveb.FileManager.getCurrentPage()
|
}
|
||||||
: null
|
data.slug = SLUG;
|
||||||
: null;
|
|
||||||
|
|
||||||
let filename = "index.html";
|
// 扁平化處理新檔案檔名(確保所有頁面皆存於專案根目錄下)
|
||||||
if (currentPage && currentPage.filename) {
|
if (data.file) {
|
||||||
filename = currentPage.filename;
|
const parts = data.file.split("/");
|
||||||
} else if (currentPage && currentPage.file) {
|
data.file = parts[parts.length - 1];
|
||||||
// 只取檔名部分
|
|
||||||
filename = currentPage.file.split("/").pop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取得 HTML 內容
|
// 如果不是新增頁面(即一般的儲存頁面),且未帶有 HTML 內容,則動態獲取目前 HTML
|
||||||
let html = "";
|
const isNewPage = !!data.startTemplateUrl;
|
||||||
|
if (!isNewPage && !data.html) {
|
||||||
try {
|
try {
|
||||||
html = Vvveb.Builder.getHtml ? Vvveb.Builder.getHtml() : "";
|
data.html = Vvveb.Builder.getHtml ? Vvveb.Builder.getHtml() : "";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[my-editor] getHtml failed:", e);
|
console.error("[my-editor] getHtml failed:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!html) {
|
|
||||||
showToast("無法取得頁面內容", "error");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 顯示儲存中狀態
|
// 顯示儲存中狀態(一般儲存時觸發)
|
||||||
const saveBtn = document.querySelector(".save-btn");
|
const saveBtn = document.querySelector(".save-btn");
|
||||||
if (saveBtn) {
|
if (saveBtn && !isNewPage) {
|
||||||
saveBtn.querySelector(".loading")?.classList.remove("d-none");
|
saveBtn.querySelector(".loading")?.classList.remove("d-none");
|
||||||
saveBtn.querySelector(".button-text")?.classList.add("d-none");
|
saveBtn.querySelector(".button-text")?.classList.add("d-none");
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = new URLSearchParams({
|
// 發送 POST 請求至 Flask API
|
||||||
slug: SLUG,
|
|
||||||
file: filename,
|
|
||||||
html: html,
|
|
||||||
});
|
|
||||||
|
|
||||||
fetch("/api/save", {
|
fetch("/api/save", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
body: body.toString(),
|
body: new URLSearchParams(data).toString(),
|
||||||
})
|
})
|
||||||
.then((res) => res.json())
|
.then((res) => {
|
||||||
.then((data) => {
|
if (!res.ok) {
|
||||||
if (data.ok) {
|
return res.json().then((errData) => {
|
||||||
showToast("✓ 已儲存:" + (data.saved || filename));
|
throw new Error(errData.error || "伺服器儲存錯誤");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((resData) => {
|
||||||
|
if (isNewPage) {
|
||||||
|
// 新增頁面成功:秀出通知並執行 Vvveb 原生 callback 以載入新頁面
|
||||||
|
showToast("✓ 成功建立新頁面:" + resData.file);
|
||||||
|
if (callback) {
|
||||||
|
callback({
|
||||||
|
name: resData.name,
|
||||||
|
title: resData.title,
|
||||||
|
file: resData.file,
|
||||||
|
url: resData.url
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showToast("儲存失敗:" + (data.error || "未知錯誤"), "error");
|
// 一般儲存成功
|
||||||
|
showToast("✓ 已儲存:" + (resData.saved || data.file));
|
||||||
|
if (callback) callback(resData);
|
||||||
|
// 停用儲存按鈕(直至下一次變更)
|
||||||
|
document.querySelectorAll("#top-panel .save-btn").forEach((e) =>
|
||||||
|
e.setAttribute("disabled", "true")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast("網路錯誤:" + err.message, "error");
|
showToast("儲存失敗:" + err.message, "error");
|
||||||
|
if (error) error(err);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (saveBtn) {
|
if (saveBtn && !isNewPage) {
|
||||||
saveBtn.querySelector(".loading")?.classList.add("d-none");
|
saveBtn.querySelector(".loading")?.classList.add("d-none");
|
||||||
saveBtn.querySelector(".button-text")?.classList.remove("d-none");
|
saveBtn.querySelector(".button-text")?.classList.remove("d-none");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("[my-editor] saveAjax patched for slug:", SLUG);
|
console.log("[my-editor] Robust Vvveb.Builder.saveAjax patched for slug:", SLUG);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 啟用 Save 按鈕(vvvebjs 預設 disabled)──────────────────────
|
// ── 啟用 Save 按鈕(vvvebjs 預設 disabled)──────────────────────
|
||||||
@@ -150,7 +162,13 @@
|
|||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (typeof Vvveb !== "undefined" && Vvveb.Builder && Vvveb.Builder.saveAjax) {
|
if (typeof Vvveb !== "undefined" && Vvveb.Builder && Vvveb.Builder.saveAjax) {
|
||||||
Vvveb.Builder.saveAjax("/api/save");
|
// 觸發原生儲存按鈕的 click
|
||||||
|
const btn = document.querySelector(".save-btn");
|
||||||
|
if (btn) {
|
||||||
|
btn.click();
|
||||||
|
} else {
|
||||||
|
Vvveb.Builder.saveAjax({});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
33
websites/my-website/about-us.html
Normal file
33
websites/my-website/about-us.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" =""><head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta name="description" content="">
|
||||||
|
<meta name="author" content="">
|
||||||
|
<title>My page</title>
|
||||||
|
<!-- Bootstrap core CSS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.min.js"></script>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
|
||||||
|
<style>
|
||||||
|
html, body
|
||||||
|
{
|
||||||
|
width:100%;
|
||||||
|
height:100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style id="vvvebjs-styles"></style></head>
|
||||||
|
<body>
|
||||||
|
<!-- Page Content -->
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-12 text-center">
|
||||||
|
<h1 class="mt-5">Bootstrap 5 start page</h1>
|
||||||
|
<p class="lead">Start by dragging components to page or double click to edit text</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</body></html>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" =""><head>
|
<html lang="en" =""="" =""><head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<meta name="description" content="">
|
<meta name="description" content="">
|
||||||
@@ -22,7 +22,31 @@
|
|||||||
<!-- Page Content -->
|
<!-- Page Content -->
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-12 text-center">
|
<nav class="navbar navbar-expand-lg bg-body-secondary bg-body-tertiary">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="#">Navbar</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarTogglerDemo02" aria-controls="navbarTogglerDemo02" aria-expanded="true" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="navbar-collapse collapse show" id="navbarTogglerDemo02" style="">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" aria-current="page" href="#">Home</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="#">Link</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link disabled">Disabled</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<form class="d-flex" role="search">
|
||||||
|
<input class="form-control me-2" type="search" placeholder="Search" aria-label="Search">
|
||||||
|
<button class="btn btn-outline-success" type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav><div class="col-lg-12 text-center">
|
||||||
<h1 class="mt-5">Bootstrap 5 start page</h1>
|
<h1 class="mt-5">Bootstrap 5 start page</h1>
|
||||||
<p class="lead">Start by dragging components to page or double click to edit text</p>
|
<p class="lead">Start by dragging components to page or double click to edit text</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
33
websites/my-website/my-page.html
Normal file
33
websites/my-website/my-page.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta name="description" content="">
|
||||||
|
<meta name="author" content="">
|
||||||
|
<title>My page</title>
|
||||||
|
<!-- Bootstrap core CSS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.min.js"></script>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
|
||||||
|
<style>
|
||||||
|
html, body
|
||||||
|
{
|
||||||
|
width:100%;
|
||||||
|
height:100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Page Content -->
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-12 text-center">
|
||||||
|
<h1 class="mt-5">Bootstrap 5 start page</h1>
|
||||||
|
<p class="lead">Start by dragging components to page or double click to edit text</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user