/** * my-editor.js — VvvebJS 儲存與新增頁面橋接腳本 * 覆蓋 Vvveb.Builder.saveAjax 與 Vvveb.FileManager 行為,無縫串接到 Flask 後端,並引入精美網頁型暗黑系模態框 */ (function () { "use strict"; const SLUG = window.VVVEB_PROJECT_SLUG || ""; // ── Toast 通知 ────────────────────────────────────────────────── function showToast(msg, type) { const existing = document.getElementById("vvveb-save-toast"); if (existing) existing.remove(); const toast = document.createElement("div"); toast.id = "vvveb-save-toast"; const bg = type === "error" ? "#7f1d1d" : "#14532d"; const border = type === "error" ? "#ef444460" : "#22c55e60"; Object.assign(toast.style, { position: "fixed", top: "1rem", right: "1rem", zIndex: "99999", background: bg, border: "1px solid " + border, color: "#fff", borderRadius: "8px", padding: "0.65rem 1.1rem", fontSize: "0.85rem", fontFamily: "Inter, system-ui, sans-serif", fontWeight: "500", boxShadow: "0 4px 20px rgba(0,0,0,0.5)", opacity: "0", transform: "translateY(-8px)", transition: "opacity 0.3s ease, transform 0.3s ease", pointerEvents: "none", }); toast.textContent = msg; document.body.appendChild(toast); requestAnimationFrame(() => { toast.style.opacity = "1"; toast.style.transform = "translateY(0)"; }); setTimeout(() => { toast.style.opacity = "0"; toast.style.transform = "translateY(-8px)"; setTimeout(() => toast.remove(), 350); }, 3000); } // ── 頂級網頁型模態框 (Web-based Custom Dialogs) ────────────────────── function showModalPrompt(title, defaultValue, 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)", webkitBackdropFilter: "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.95)", 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: "420px", 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.25rem" }); card.innerHTML = `
${title}
`; overlay.appendChild(card); document.body.appendChild(overlay); const input = card.querySelector("#vvveb-modal-input"); input.value = defaultValue; input.addEventListener("focus", () => { input.style.borderColor = "#818cf8"; input.style.boxShadow = "0 0 12px rgba(129, 140, 248, 0.25)"; }); input.addEventListener("blur", () => { input.style.borderColor = "rgba(255, 255, 255, 0.1)"; input.style.boxShadow = "none"; }); const cancelBtn = card.querySelector("#vvveb-modal-cancel"); cancelBtn.addEventListener("mouseover", () => { cancelBtn.style.background = "rgba(255, 255, 255, 0.05)"; cancelBtn.style.color = "#fff"; }); cancelBtn.addEventListener("mouseout", () => { cancelBtn.style.background = "transparent"; cancelBtn.style.color = "#94a3b8"; }); const confirmBtn = card.querySelector("#vvveb-modal-confirm"); confirmBtn.addEventListener("mouseover", () => { confirmBtn.style.background = "#4338ca"; }); confirmBtn.addEventListener("mouseout", () => { confirmBtn.style.background = "#4f46e5"; }); // Fade in setTimeout(() => { overlay.style.opacity = "1"; card.style.transform = "scale(1)"; }, 10); input.focus(); input.select(); function close(value) { overlay.style.opacity = "0"; card.style.transform = "scale(0.95)"; setTimeout(() => { overlay.remove(); if (value !== undefined) { const finalVal = value.trim ? value.trim() : value; callback(finalVal); } }, 250); } cancelBtn.onclick = () => close(); confirmBtn.onclick = () => close(input.value); input.onkeydown = (e) => { if (e.key === "Enter") { close(input.value); } else if (e.key === "Escape") { close(); } }; } function showModalConfirm(title, message, 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)", webkitBackdropFilter: "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.95)", border: "1px solid rgba(255, 255, 255, 0.08)", boxShadow: "0 20px 50px rgba(0, 0, 0, 0.6), 0 0 40px rgba(239, 68, 68, 0.1)", borderRadius: "16px", width: "420px", 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.25rem" }); card.innerHTML = `
${title}
${message}
`; overlay.appendChild(card); document.body.appendChild(overlay); const cancelBtn = card.querySelector("#vvveb-modal-cancel"); cancelBtn.addEventListener("mouseover", () => { cancelBtn.style.background = "rgba(255, 255, 255, 0.05)"; cancelBtn.style.color = "#fff"; }); cancelBtn.addEventListener("mouseout", () => { cancelBtn.style.background = "transparent"; cancelBtn.style.color = "#94a3b8"; }); const confirmBtn = card.querySelector("#vvveb-modal-confirm"); confirmBtn.addEventListener("mouseover", () => { confirmBtn.style.background = "#dc2626"; }); confirmBtn.addEventListener("mouseout", () => { confirmBtn.style.background = "#ef4444"; }); // Fade in setTimeout(() => { overlay.style.opacity = "1"; card.style.transform = "scale(1)"; }, 10); confirmBtn.focus(); function close(confirmed) { overlay.style.opacity = "0"; card.style.transform = "scale(0.95)"; setTimeout(() => { overlay.remove(); if (confirmed) callback(); }, 250); } cancelBtn.onclick = () => close(false); confirmBtn.onclick = () => close(true); const keyHandler = (e) => { if (e.key === "Escape") { close(false); window.removeEventListener("keydown", keyHandler); } }; window.addEventListener("keydown", keyHandler); } // ── 覆蓋 Vvveb.Builder.saveAjax ────────────────────────────────── function patchSaveAjax() { if (typeof Vvveb === "undefined" || !Vvveb.Builder) { setTimeout(patchSaveAjax, 200); return; } /** * VvvebJS 原始簽名: saveAjax(data, saveUrl, callback, error) */ Vvveb.Builder.saveAjax = function (data, saveUrl, callback, error) { if (!SLUG) { showToast("錯誤:找不到專案識別碼 (slug)", "error"); if (error) error(new Error("Missing slug")); return; } // 確保 data 為物件並填入專案識別碼 if (typeof data !== "object" || data === null) { data = {}; } data.slug = SLUG; // 扁平化處理新檔案檔名 if (data.file) { const parts = data.file.split("/"); data.file = parts[parts.length - 1]; } // 如果不是新增頁面(即一般的儲存頁面),且未帶有 HTML 內容,則動態獲取目前 HTML const isNewPage = !!data.startTemplateUrl; if (!isNewPage && !data.html) { try { data.html = Vvveb.Builder.getHtml ? Vvveb.Builder.getHtml() : ""; } catch (e) { console.error("[my-editor] getHtml failed:", e); } } // 顯示儲存中狀態 const saveBtn = document.querySelector(".save-btn"); if (saveBtn && !isNewPage) { saveBtn.querySelector(".loading")?.classList.remove("d-none"); saveBtn.querySelector(".button-text")?.classList.add("d-none"); } // 發送 POST 請求至 Flask API fetch("/api/save", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(data).toString(), }) .then((res) => { if (!res.ok) { return res.json().then((errData) => { 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 { // 一般儲存成功 showToast("✓ 已儲存:" + (resData.saved || data.file)); if (callback) callback(resData); // 停用儲存按鈕(直至下一次變更) document.querySelectorAll("#top-panel .save-btn").forEach((e) => e.setAttribute("disabled", "true") ); } }) .catch((err) => { showToast("儲存失敗:" + err.message, "error"); if (error) error(err); }) .finally(() => { if (saveBtn && !isNewPage) { saveBtn.querySelector(".loading")?.classList.add("d-none"); saveBtn.querySelector(".button-text")?.classList.remove("d-none"); } }); }; console.log("[my-editor] Robust Vvveb.Builder.saveAjax patched for slug:", SLUG); } // ── 覆蓋 Vvveb.FileManager 頁面操作 ────────────────────────────── function patchFileManager() { if (typeof Vvveb === "undefined" || !Vvveb.FileManager) { setTimeout(patchFileManager, 200); return; } // 覆蓋 init 方法以修正 VvvebJS 使用全球廢棄的 `event.target` 導致在某些瀏覽器/嚴格模式下點擊無效的 Bug const originalInit = Vvveb.FileManager.init; Vvveb.FileManager.init = function (allowedComponents = {}) { originalInit.call(this, allowedComponents); // 利用 Clone 移除舊有包含 Bug 的匿名字選區接聽器 const newTree = this.tree.cloneNode(true); this.tree.parentNode.replaceChild(newTree, this.tree); this.tree = newTree; // 綁定符合標準且高度相容 (e.target) 的新接聽器 this.tree.addEventListener("click", function (e) { let element = e.target.closest("a"); if (element) { e.stopImmediatePropagation(); if (element.classList.contains('view')) return; e.preventDefault(); return false; } }); this.tree.addEventListener("click", function (e) { let element = e.target.closest(".delete"); if (element) { Vvveb.FileManager.deletePage(element.closest("li"), e); e.stopImmediatePropagation(); e.preventDefault(); return false; } }); this.tree.addEventListener("click", function (e) { let element = e.target.closest(".rename"); if (element) { Vvveb.FileManager.renamePage(element.closest("li"), e, false); e.stopImmediatePropagation(); e.preventDefault(); return false; } }); this.tree.addEventListener("click", function (e) { let element = e.target.closest(".duplicate"); if (element) { Vvveb.FileManager.renamePage(element.closest("li"), e, true); e.stopImmediatePropagation(); e.preventDefault(); return false; } }); this.tree.addEventListener("click", function (e) { let element = e.target.closest("li[data-page] label"); if (element) { let page = element.parentNode.dataset.page; if (page) { Vvveb.FileManager.loadPage(page); e.stopImmediatePropagation(); e.preventDefault(); return false; } } }); }; // 覆蓋重新命名 / 複製頁面 Vvveb.FileManager.renamePage = function (element, e, duplicate = false) { let page = element.dataset; showModalPrompt(`請輸入 "${page.file}" 的新檔名:`, page.file, function (newfile) { if (!newfile) return; // 確保副檔名為 .html if (!newfile.endsWith(".html")) { newfile += ".html"; } const bodyData = { slug: SLUG, file: page.file, newfile: newfile, duplicate: duplicate ? "true" : "false" }; fetch(`/api/save?action=rename`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(bodyData).toString() }) .then((res) => { if (!res.ok) { return res.json().then((err) => { throw new Error(err.error || "操作失敗"); }); } return res.json(); }) .then((data) => { showToast(`✓ ${data.message}`); let baseName = data.newfile.replace('.html', ''); let newName = friendlyName(data.newfile.replace(/.*[\/\\]+/, '')).replace('.html', ''); if (duplicate) { // 複製頁面:在 FileManager 中加入新頁面 let pageData = Object.assign({}, Vvveb.FileManager.pages[page.page]); pageData["file"] = data.newfile; pageData["title"] = newName; 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]["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; } 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]; } } }) .catch((err) => { showToast(`操作失敗:${err.message}`, "error"); }); }); }; // 覆蓋刪除頁面 Vvveb.FileManager.deletePage = function (element, e) { let page = element.dataset; if (page.file.toLowerCase() === "index.html") { showToast("錯誤:無法刪除主頁 index.html", "error"); return; } showModalConfirm("確認刪除頁面", `確定要刪除 "${page.file}" 頁面嗎?此動作將無法復原。`, function () { const bodyData = { slug: SLUG, file: page.file }; fetch(`/api/save?action=delete`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(bodyData).toString() }) .then((res) => { if (!res.ok) { return res.json().then((err) => { throw new Error(err.error || "刪除失敗"); }); } return res.json(); }) .then((data) => { showToast(`✓ ${data.message}`); // 從內部 pages 列表中移除 delete Vvveb.FileManager.pages[page.page]; element.remove(); // 如果刪除的是目前正在編輯的頁面,切換回第一個頁面 if (Vvveb.FileManager.currentPage === page.page) { let firstPage = Object.keys(Vvveb.FileManager.pages)[0]; if (firstPage) { Vvveb.FileManager.loadPage(firstPage); } } }) .catch((err) => { showToast(`刪除失敗:${err.message}`, "error"); }); }); }; console.log("[my-editor] Vvveb.FileManager operations patched."); } // ── 覆蓋 Vvveb.NewSection 防止佈局崩潰 ───────────────────────────── function patchNewSection() { if (typeof Vvveb === "undefined" || !Vvveb.NewSection) { setTimeout(patchNewSection, 200); return; } Vvveb.NewSection.insert = function () { let position = 'before'; let lastSection = Vvveb.Builder.frameBody.querySelector(':scope > footer:last-of-type'); if (!lastSection) { position = 'after'; lastSection = Vvveb.Builder.frameBody.querySelector(':scope > main:last-of-type') || Vvveb.Builder.frameBody.querySelector(':scope > section:last-of-type') || Vvveb.Builder.frameBody.querySelector(':scope > div:last-of-type') || Vvveb.Builder.frameBody; // 最安全的回退點:整個 body 節點 } this.container = generateElements(this.template)[0]; if (lastSection === Vvveb.Builder.frameBody) { Vvveb.Builder.frameBody.appendChild(this.container); } else { if (position == 'after') { lastSection.after(this.container); } else { lastSection.before(this.container); } } return this.container; }; console.log("[my-editor] Vvveb.NewSection.insert patched to prevent layout crashes."); } // ── 動態中文化 VvvebJS 元件與區塊 ───────────────────────────────── function patchI18n() { if (typeof Vvveb === "undefined" || !Vvveb.ComponentsGroup || Object.keys(Vvveb.ComponentsGroup).length === 0) { setTimeout(patchI18n, 500); return; } const translateMap = { // 分類 (Groups) "Grid": "網格系統", "Components": "基礎元件", "Basic": "基本", "Typography": "排版", "Media": "媒體", "Forms": "表單", "Widgets": "小工具", "Advanced": "進階", "Content": "內容區塊", "Layout": "佈局", "Blocks": "區塊", "Features": "功能特色", "Headers": "頁首", "Footers": "頁尾", "Pricing": "價格表", "Testimonials": "客戶評價", "Cards": "卡片", "Services": "服務", "Team": "團隊", "About": "關於我們", // 元件 (Components) "Grid Row": "網格列 (Row)", "Grid Column": "網格欄 (Column)", "Container": "容器 (Container)", "Heading": "標題 (Heading)", "Link": "超連結 (Link)", "Image": "圖片 (Image)", "Video": "影片 (Video)", "Paragraph": "段落文字", "List": "列表", "List item": "列表項目", "Button": "按鈕", "Button Group": "按鈕群組", "Button Toolbar": "按鈕工具列", "Form": "表單", "Text Input": "文字輸入框", "Textarea": "多行文字框", "Checkbox": "核取方塊", "Radio": "單選按鈕", "Select": "下拉選單", "Label": "標籤文字", "Alert": "警告提示", "Badge": "徽章 (Badge)", "Card": "卡片 (Card)", "Table": "表格", "Progress": "進度條", "Navbar": "導覽列 (Navbar)", "Breadcrumb": "麵包屑 (Breadcrumb)", "Pagination": "分頁", "Jumbotron": "大型看板", "Panel": "面板", "Icon": "圖示", "Divider": "分隔線", "Map": "地圖", "Audio": "音訊", "File Upload": "檔案上傳", "Date Input": "日期輸入框", "Password Input": "密碼輸入框", "Email Input": "Email 輸入框" }; // 1. 翻譯 Vvveb 物件內的資料 (供重新渲染時使用) if (Vvveb.ComponentsGroup) { const newGroups = {}; for (let group in Vvveb.ComponentsGroup) { let tGroup = translateMap[group] || group; newGroups[tGroup] = Vvveb.ComponentsGroup[group]; newGroups[tGroup].forEach(compName => { let comp = Vvveb.Components.get(compName); if (comp && comp.name) { comp.name = translateMap[comp.name] || comp.name; } }); } Vvveb.ComponentsGroup = newGroups; } if (Vvveb.BlocksGroup) { const newBlockGroups = {}; for (let group in Vvveb.BlocksGroup) { let tGroup = translateMap[group] || group; newBlockGroups[tGroup] = Vvveb.BlocksGroup[group]; } Vvveb.BlocksGroup = newBlockGroups; } // 2. 直接修改當前已渲染的 DOM 文字 document.querySelectorAll(".components-list .header span, .blocks-list .header span, .sections-list .header span").forEach(el => { const txt = el.textContent.trim(); if (translateMap[txt]) { el.textContent = translateMap[txt]; } }); document.querySelectorAll(".components-list li .name, .blocks-list li .name, .sections-list li .name").forEach(el => { const txt = el.textContent.trim(); if (translateMap[txt]) { // 如果有子元素,只替換文本節點 if (el.childNodes.length > 0) { for (let i = 0; i < el.childNodes.length; i++) { if (el.childNodes[i].nodeType === 3 && el.childNodes[i].textContent.trim() === txt) { el.childNodes[i].textContent = translateMap[txt]; break; } } } else { el.textContent = translateMap[txt]; } } }); console.log("[my-editor] VvvebJS components and blocks localized."); } // ── 啟用 Save 按鈕(vvvebjs 預設 disabled)────────────────────── function enableSaveBtn() { const btn = document.querySelector(".save-btn"); if (btn) { btn.removeAttribute("disabled"); } else { setTimeout(enableSaveBtn, 300); } } // ── Ctrl+S 快捷鍵 ──────────────────────────────────────────────── document.addEventListener("keydown", function (e) { if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); if (typeof Vvveb !== "undefined" && Vvveb.Builder && Vvveb.Builder.saveAjax) { // 觸發原生儲存按鈕的 click const btn = document.querySelector(".save-btn"); if (btn) { btn.click(); } else { Vvveb.Builder.saveAjax({}); } } } }); // ── 初始化 ──────────────────────────────────────────────────────── if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => { patchSaveAjax(); patchFileManager(); patchNewSection(); patchI18n(); enableSaveBtn(); }); } else { patchSaveAjax(); patchFileManager(); patchNewSection(); patchI18n(); enableSaveBtn(); } })();