rename page
This commit is contained in:
@@ -7,6 +7,13 @@ OUT = BASE / "templates" / "editor.html"
|
||||
|
||||
content = SRC.read_text(encoding="utf-8")
|
||||
|
||||
# ── Normalize line endings to LF to avoid CRLF mismatch on Windows ──
|
||||
content = content.replace("\r\n", "\n")
|
||||
|
||||
# ── Correct themeBaseUrl and mediaPath in editor.html ──
|
||||
content = content.replace("Vvveb.themeBaseUrl = 'demo/landing/';", "Vvveb.themeBaseUrl = '/static/Vvvebjs/demo/landing/';")
|
||||
content = content.replace("window.mediaPath = '../../media';", "window.mediaPath = '/static/Vvvebjs/media/';")
|
||||
|
||||
# ── 1. Fix static asset paths ────────────────────────────────────
|
||||
VBASE = "/static/Vvvebjs/"
|
||||
replacements = [
|
||||
@@ -41,6 +48,20 @@ JS_VARS = [
|
||||
for old, new in JS_VARS:
|
||||
content = content.replace(old, new)
|
||||
|
||||
# ── Make editor load requested page from query parameter ──
|
||||
old_init = 'let firstPage = Object.keys(pages)[0];\nVvveb.Builder.init(pages[firstPage]["url"], function () {'
|
||||
new_init = """let firstPage = Object.keys(pages)[0];
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const requestedPage = urlParams.get('page');
|
||||
if (requestedPage) {
|
||||
\tlet pageKey = requestedPage.replace(".html", "");
|
||||
\tif (pages[pageKey]) {
|
||||
\t\tfirstPage = pageKey;
|
||||
\t}
|
||||
}
|
||||
Vvveb.Builder.init(pages[firstPage]["url"], function () {"""
|
||||
content = content.replace(old_init, new_init)
|
||||
|
||||
# ── 4. Wrap everything in {% raw %} ... {% endraw %} to avoid Jinja2 parsing conflicts ──
|
||||
# We do this first so Jinja2 ignores VvvebJS's frontend micro-templates ({%= %}, {% %}).
|
||||
# Then we selectively exit the {% raw %} block using {% endraw %} ... {% raw %} for our dynamic values.
|
||||
@@ -94,7 +115,7 @@ BACK_BTN = """
|
||||
// Inject project slug for save bridge
|
||||
window.VVVEB_PROJECT_SLUG = "{% endraw %}{{ slug | safe }}{% raw %}";
|
||||
</script>
|
||||
<script src="/static/js/my-editor.js"></script>
|
||||
<script src="/static/js/my-editor.js?v={% endraw %}{{ range(1, 100000) | random }}{% raw %}"></script>
|
||||
"""
|
||||
content = content.replace("</body>", BACK_BTN + "\n</body>")
|
||||
|
||||
|
||||
66
main.py
66
main.py
@@ -263,12 +263,78 @@ def api_save() -> tuple[Response, int] | Response:
|
||||
body = {k: (v[0] if isinstance(v, list) else v) for k, v in form.items()}
|
||||
|
||||
slug: str = str(body.get("slug", "")).strip()
|
||||
if not slug:
|
||||
# Fallback to query parameter if not in body (e.g. for some raw requests)
|
||||
slug = request.args.get("slug", "").strip()
|
||||
|
||||
if not slug:
|
||||
return jsonify({"error": "缺少專案識別碼 (slug)"}), 400
|
||||
|
||||
if not _project_dir(slug).exists():
|
||||
return jsonify({"error": "專案不存在"}), 404
|
||||
|
||||
action = request.args.get("action", "").strip()
|
||||
|
||||
# ── 處理重新命名 / 複製頁面 ──
|
||||
if action == "rename":
|
||||
file = str(body.get("file", "")).strip()
|
||||
newfile = str(body.get("newfile", "")).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)
|
||||
|
||||
if old_path is None or new_path is None:
|
||||
return jsonify({"error": "不合法的頁面名稱"}), 400
|
||||
|
||||
if not old_path.exists():
|
||||
return jsonify({"error": "來源頁面不存在"}), 404
|
||||
|
||||
if new_path.exists() and old_path != new_path:
|
||||
return jsonify({"error": "目標頁面已存在"}), 409
|
||||
|
||||
if is_duplicate:
|
||||
shutil.copy(old_path, new_path)
|
||||
msg = "頁面複製成功"
|
||||
else:
|
||||
old_path.rename(new_path)
|
||||
msg = "頁面重新命名成功"
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"ok": True,
|
||||
"message": msg,
|
||||
"newfile": new_path.name,
|
||||
"url": f"/sites/{slug}/{new_path.name}"
|
||||
})
|
||||
|
||||
# ── 處理刪除頁面 ──
|
||||
elif action == "delete":
|
||||
file = str(body.get("file", "")).strip()
|
||||
if not file:
|
||||
return jsonify({"error": "缺少參數 file"}), 400
|
||||
|
||||
if file.lower() == "index.html":
|
||||
return jsonify({"error": "無法刪除主頁 index.html"}), 400
|
||||
|
||||
safe_path = _sanitize_file_path(slug, file)
|
||||
if safe_path is None:
|
||||
return jsonify({"error": "不合法的頁面名稱"}), 400
|
||||
|
||||
if not safe_path.exists():
|
||||
return jsonify({"error": "頁面不存在"}), 404
|
||||
|
||||
safe_path.unlink()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"ok": True,
|
||||
"message": "頁面已成功刪除"
|
||||
})
|
||||
|
||||
# 1. 判斷是否為新增頁面請求 (含有 startTemplateUrl)
|
||||
start_template_url = str(body.get("startTemplateUrl", "")).strip()
|
||||
if start_template_url:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* my-editor.js — VvvebJS 儲存與新增頁面橋接腳本
|
||||
* 覆蓋 Vvveb.Builder.saveAjax 行為,使儲存與新增頁面功能無縫串接至 Flask 後端
|
||||
* 覆蓋 Vvveb.Builder.saveAjax 與 Vvveb.FileManager 行為,無縫串接到 Flask 後端,並引入精美網頁型暗黑系模態框
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
@@ -49,10 +49,292 @@
|
||||
}, 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 = `
|
||||
<div style="font-size: 1.15rem; font-weight: 600; color: #fff; display: flex; align-items: center; gap: 0.6rem;">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#818cf8" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
${title}
|
||||
</div>
|
||||
<div>
|
||||
<input type="text" id="vvveb-modal-input" style="
|
||||
width: 100%;
|
||||
background: rgba(13, 14, 24, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.9rem;
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
" />
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 0.5rem;">
|
||||
<button id="vvveb-modal-cancel" style="
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.1rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
">取消</button>
|
||||
<button id="vvveb-modal-confirm" style="
|
||||
background: #4f46e5;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.25rem;
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
|
||||
">確定</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div style="font-size: 1.15rem; font-weight: 600; color: #fff; display: flex; align-items: center; gap: 0.6rem;">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
${title}
|
||||
</div>
|
||||
<div style="font-size: 0.9rem; color: #cbd5e1; line-height: 1.5;">
|
||||
${message}
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 0.5rem;">
|
||||
<button id="vvveb-modal-cancel" style="
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.1rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
">取消</button>
|
||||
<button id="vvveb-modal-confirm" style="
|
||||
background: #ef4444;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.25rem;
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||
">確定</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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) {
|
||||
// 等待 Vvveb 載入
|
||||
setTimeout(patchSaveAjax, 200);
|
||||
return;
|
||||
}
|
||||
@@ -73,7 +355,7 @@
|
||||
}
|
||||
data.slug = SLUG;
|
||||
|
||||
// 扁平化處理新檔案檔名(確保所有頁面皆存於專案根目錄下)
|
||||
// 扁平化處理新檔案檔名
|
||||
if (data.file) {
|
||||
const parts = data.file.split("/");
|
||||
data.file = parts[parts.length - 1];
|
||||
@@ -89,7 +371,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 顯示儲存中狀態(一般儲存時觸發)
|
||||
// 顯示儲存中狀態
|
||||
const saveBtn = document.querySelector(".save-btn");
|
||||
if (saveBtn && !isNewPage) {
|
||||
saveBtn.querySelector(".loading")?.classList.remove("d-none");
|
||||
@@ -147,6 +429,243 @@
|
||||
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.");
|
||||
}
|
||||
|
||||
// ── 啟用 Save 按鈕(vvvebjs 預設 disabled)──────────────────────
|
||||
function enableSaveBtn() {
|
||||
const btn = document.querySelector(".save-btn");
|
||||
@@ -177,10 +696,14 @@
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
patchSaveAjax();
|
||||
patchFileManager();
|
||||
patchNewSection();
|
||||
enableSaveBtn();
|
||||
});
|
||||
} else {
|
||||
patchSaveAjax();
|
||||
patchFileManager();
|
||||
patchNewSection();
|
||||
enableSaveBtn();
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -402,12 +402,21 @@ async function openSettings(slug) {
|
||||
listEl.innerHTML = '<li class="no-pages">尚無頁面</li>';
|
||||
} else {
|
||||
listEl.innerHTML = pages.map(p => `
|
||||
<li class="page-item">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<li class="page-item" style="justify-content: space-between; display: flex; align-items: center; margin-bottom: 0.4rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: var(--accent);">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
${escHtml(p)}
|
||||
<span>${escHtml(p)}</span>
|
||||
</div>
|
||||
<a href="/editor/${slug}?page=${escHtml(p)}" class="btn btn-primary btn-sm" style="padding: 0.2rem 0.6rem; font-size: 0.75rem; gap: 0.25rem; border-radius: var(--radius-sm);">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
編輯
|
||||
</a>
|
||||
</li>`).join('');
|
||||
}
|
||||
} catch(e) {
|
||||
|
||||
@@ -2165,8 +2165,8 @@
|
||||
<!-- media gallery -->
|
||||
<link href="/static/Vvvebjs/libs/media/media.css" rel="stylesheet">
|
||||
<script>
|
||||
window.mediaPath = '../../media';
|
||||
Vvveb.themeBaseUrl = 'demo/landing/';
|
||||
window.mediaPath = '/static/Vvvebjs/media/';
|
||||
Vvveb.themeBaseUrl = '/static/Vvvebjs/demo/landing/';
|
||||
</script>
|
||||
<script src="/static/Vvvebjs/libs/media/media.js"></script>
|
||||
<!--
|
||||
@@ -2262,6 +2262,14 @@ let defaultPages = {% endraw %}{{ pages_json | safe }}{% raw %};
|
||||
let pages = defaultPages;
|
||||
|
||||
let firstPage = Object.keys(pages)[0];
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const requestedPage = urlParams.get('page');
|
||||
if (requestedPage) {
|
||||
let pageKey = requestedPage.replace(".html", "");
|
||||
if (pages[pageKey]) {
|
||||
firstPage = pageKey;
|
||||
}
|
||||
}
|
||||
Vvveb.Builder.init(pages[firstPage]["url"], function () {
|
||||
//load code after page is loaded here
|
||||
});
|
||||
@@ -2313,7 +2321,7 @@ Vvveb.Breadcrumb.init();
|
||||
// Inject project slug for save bridge
|
||||
window.VVVEB_PROJECT_SLUG = "{% endraw %}{{ slug | safe }}{% raw %}";
|
||||
</script>
|
||||
<script src="/static/js/my-editor.js"></script>
|
||||
<script src="/static/js/my-editor.js?v={% endraw %}{{ range(1, 100000) | random }}{% raw %}"></script>
|
||||
|
||||
</body>
|
||||
|
||||
|
||||
BIN
websites/linktree-test/avatar.png
Normal file
BIN
websites/linktree-test/avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
97
websites/linktree-test/config.js
Normal file
97
websites/linktree-test/config.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* nudoragon Linktree Configuration
|
||||
*
|
||||
* 只需要修改這裡的內容,頁面就會自動更新。
|
||||
*/
|
||||
const CONFIG = {
|
||||
profile: {
|
||||
name: "nudoragon",
|
||||
bio: "設計與開發的探索者 | 建立數位世界的連結",
|
||||
avatar: "avatar.png", // 已從部落格擷取
|
||||
favicon: "avatar.png", // 使用與 avatar 相同的圖片作為 favicon
|
||||
copyrightYear: 2026
|
||||
},
|
||||
links: [
|
||||
{
|
||||
title: "聯絡我",
|
||||
desc: "透過電子郵件與我聯繫",
|
||||
url: "mailto:hi@l.nudoragon.com",
|
||||
icon: "fas fa-envelope",
|
||||
delay: "0.1s",
|
||||
category: "contact",
|
||||
visible: true,
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
title: "Element (Matrix)",
|
||||
desc: "透過 Matrix 協議與我通訊(server:matrix.nudoragon.com)",
|
||||
url: "https://element.nudoragon.com",
|
||||
icon: "fas fa-comments",
|
||||
delay: "0.2s",
|
||||
category: "contact",
|
||||
visible: true,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
title: "個人部落格",
|
||||
desc: "分享技術開發與生活紀錄",
|
||||
url: "https://blog.nudoragon.com",
|
||||
icon: "fas fa-blog",
|
||||
delay: "0.3s",
|
||||
category: "content",
|
||||
visible: true,
|
||||
order: 3
|
||||
},
|
||||
{
|
||||
title: "Gitea",
|
||||
desc: "查看我的開源專案與代碼",
|
||||
url: "https://code.nudoragon.com",
|
||||
icon: "fas fa-code-branch",
|
||||
delay: "0.4s",
|
||||
category: "development",
|
||||
visible: true,
|
||||
order: 4
|
||||
},
|
||||
{
|
||||
title: "系統狀態",
|
||||
desc: "監控各項服務運行狀況",
|
||||
url: "https://status.nudoragon.com",
|
||||
icon: "fas fa-heartbeat",
|
||||
delay: "0.5s",
|
||||
category: "service",
|
||||
visible: true,
|
||||
order: 5
|
||||
},
|
||||
{
|
||||
title: "NudoAuth 透透龍女",
|
||||
desc: "統一身份認證與授權服務",
|
||||
url: "https://auth.nudoragon.com",
|
||||
icon: "fas fa-user-shield",
|
||||
delay: "0.6s",
|
||||
category: "service",
|
||||
visible: true,
|
||||
order: 6
|
||||
},
|
||||
{
|
||||
title: "Stickers 模組",
|
||||
desc: "我開發的貼圖服務系統",
|
||||
url: "https://stickers.example.com",
|
||||
icon: "fas fa-code",
|
||||
delay: "0.7s",
|
||||
category: "development",
|
||||
visible: false,
|
||||
order: 7
|
||||
}
|
||||
],
|
||||
socials: [
|
||||
{ name: "Instagram", url: "https://www.instagram.com/nudoragon/", icon: "fab fa-instagram", visible: true, order: 1 },
|
||||
{ name: "Threads", url: "https://www.threads.net/@nudoragon", icon: "fas fa-at", visible: true, order: 2, note: "使用 @ 符號替代" },
|
||||
{ name: "Facebook", url: "https://www.facebook.com/nudoragon/", icon: "fab fa-facebook", visible: true, order: 3 },
|
||||
{ name: "Twitter", url: "https://twitter.com/nudoragon", icon: "fab fa-twitter", visible: true, order: 4 },
|
||||
{ name: "Bluesky", url: "https://bsky.app/profile/nudoragon.bsky.social", icon: "fas fa-cloud", visible: true, order: 5, note: "使用雲朵圖標替代" },
|
||||
{ name: "Discord", url: "https://discord.gg/nHjUJJXGVG", icon: "fab fa-discord", visible: true, order: 6 },
|
||||
{ name: "Line", url: "#", icon: "fab fa-line", visible: false, order: 7 },
|
||||
{ name: "Telegram", url: "#", icon: "fab fa-telegram", visible: false, order: 8 },
|
||||
{ name: "Mail", url: "mailto:hi@l.nudoragon.com", icon: "fas fa-envelope", visible: true, order: 9 }
|
||||
]
|
||||
};
|
||||
168
websites/linktree-test/index.html
Normal file
168
websites/linktree-test/index.html
Normal file
@@ -0,0 +1,168 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW" =""="" =""><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>nudoragon | 個人連結</title>
|
||||
<meta name="description" content="nudoragon 的個人品牌與社交連結入口。包含部落格、專案與聯絡資訊。">
|
||||
<link rel="icon" id="favicon" href="avatar.png">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Outfit:wght@400;600;800&display=swap" rel="stylesheet">
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style id="vvvebjs-styles">#footer-section {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
#social-icons {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
</style></head>
|
||||
|
||||
<body>
|
||||
<div class="background-blobs">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
</div>
|
||||
|
||||
<main class="container">
|
||||
<header class="profile animate-fade-in" id="profile-header">
|
||||
<div class="avatar-container">
|
||||
<img src="avatar.png" alt="nudoragon Avatar" class="avatar">
|
||||
<div class="avatar-ring"></div>
|
||||
</div>
|
||||
<h1 class="name">nudoragon</h1>
|
||||
<p class="bio">設計與開發的探索者 | 建立數位世界的連結</p>
|
||||
</header>
|
||||
|
||||
<section class="links-container" id="links-container">
|
||||
<a href="mailto:hi@l.nudoragon.com" class="link-card animate-slide-up" style="animation-delay: 0.1s;" target="_blank" rel="noopener noreferrer">
|
||||
<div class="link-icon"><i class="fas fa-envelope"></i></div>
|
||||
<div class="link-content">
|
||||
<span class="link-title">聯絡我</span>
|
||||
<span class="link-desc">透過電子郵件與我聯繫</span>
|
||||
</div>
|
||||
<div class="link-arrow"><i class="fas fa-chevron-right"></i></div>
|
||||
</a>
|
||||
|
||||
<a href="https://element.nudoragon.com" class="link-card animate-slide-up" style="animation-delay: 0.2s;" target="_blank" rel="noopener noreferrer">
|
||||
<div class="link-icon"><i class="fas fa-comments"></i></div>
|
||||
<div class="link-content">
|
||||
<span class="link-title">Element (Matrix)</span>
|
||||
<span class="link-desc">透過 Matrix 協議與我通訊(server:matrix.nudoragon.com)</span>
|
||||
</div>
|
||||
<div class="link-arrow"><i class="fas fa-chevron-right"></i></div>
|
||||
</a>
|
||||
|
||||
<a href="https://blog.nudoragon.com" class="link-card animate-slide-up" style="animation-delay: 0.3s;" target="_blank" rel="noopener noreferrer">
|
||||
<div class="link-icon"><i class="fas fa-blog"></i></div>
|
||||
<div class="link-content">
|
||||
<span class="link-title">個人部落格</span>
|
||||
<span class="link-desc">分享技術開發與生活紀錄</span>
|
||||
</div>
|
||||
<div class="link-arrow"><i class="fas fa-chevron-right"></i></div>
|
||||
</a>
|
||||
|
||||
<a href="https://code.nudoragon.com" class="link-card animate-slide-up" style="animation-delay: 0.4s;" target="_blank" rel="noopener noreferrer">
|
||||
<div class="link-icon"><i class="fas fa-code-branch"></i></div>
|
||||
<div class="link-content">
|
||||
<span class="link-title">Gitea</span>
|
||||
<span class="link-desc">查看我的開源專案與代碼</span>
|
||||
</div>
|
||||
<div class="link-arrow"><i class="fas fa-chevron-right"></i></div>
|
||||
</a>
|
||||
|
||||
<a href="https://status.nudoragon.com" class="link-card animate-slide-up" style="animation-delay: 0.5s;" target="_blank" rel="noopener noreferrer">
|
||||
<div class="link-icon"><i class="fas fa-heartbeat"></i></div>
|
||||
<div class="link-content">
|
||||
<span class="link-title">系統狀態</span>
|
||||
<span class="link-desc">監控各項服務運行狀況</span>
|
||||
</div>
|
||||
<div class="link-arrow"><i class="fas fa-chevron-right"></i></div>
|
||||
</a>
|
||||
|
||||
<a href="https://auth.nudoragon.com" class="link-card animate-slide-up" style="animation-delay: 0.6s;" target="_blank" rel="noopener noreferrer">
|
||||
<div class="link-icon"><i class="fas fa-user-shield"></i></div>
|
||||
<div class="link-content">
|
||||
<span class="link-title">NudoAuth 透透龍女</span>
|
||||
<span class="link-desc">統一身份認證與授權服務</span>
|
||||
</div>
|
||||
<div class="link-arrow"><i class="fas fa-chevron-right"></i></div>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<footer class="footer animate-fade-in" id="footer-section">
|
||||
<div class="social-icons" id="social-icons">
|
||||
<a href="https://www.instagram.com/nudoragon/" title="Instagram" target="_blank" rel="noopener noreferrer"><i class="fab fa-instagram"></i></a>
|
||||
|
||||
<a href="https://www.threads.net/@nudoragon" title="Threads - 使用 @ 符號替代" target="_blank" rel="noopener noreferrer"><i class="fas fa-at"></i></a>
|
||||
|
||||
<a href="https://www.facebook.com/nudoragon/" title="Facebook" target="_blank" rel="noopener noreferrer"><i class="fab fa-facebook"></i></a>
|
||||
|
||||
<a href="https://twitter.com/nudoragon" title="Twitter" target="_blank" rel="noopener noreferrer"><i class="fab fa-twitter"></i></a>
|
||||
|
||||
<a href="https://bsky.app/profile/nudoragon.bsky.social" title="Bluesky - 使用雲朵圖標替代" target="_blank" rel="noopener noreferrer"><i class="fas fa-cloud"></i></a>
|
||||
|
||||
<a href="https://discord.gg/nHjUJJXGVG" title="Discord" target="_blank" rel="noopener noreferrer"><i class="fab fa-discord"></i></a>
|
||||
|
||||
<a href="mailto:hi@l.nudoragon.com" title="Mail" target="_blank" rel="noopener noreferrer"><i class="fas fa-envelope"></i></a>
|
||||
</div>
|
||||
<p class="copyright" id="copyright-text">© 2026 nudoragon. All rights reserved.</p>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script src="config.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Set Favicon
|
||||
document.getElementById('favicon').href = CONFIG.profile.favicon;
|
||||
|
||||
// Render Profile
|
||||
const header = document.getElementById('profile-header');
|
||||
header.innerHTML = `
|
||||
<div class="avatar-container">
|
||||
<img src="${CONFIG.profile.avatar}" alt="${CONFIG.profile.name} Avatar" class="avatar">
|
||||
<div class="avatar-ring"></div>
|
||||
</div>
|
||||
<h1 class="name">${CONFIG.profile.name}</h1>
|
||||
<p class="bio">${CONFIG.profile.bio}</p>
|
||||
`;
|
||||
|
||||
// Render Links - 支援可見性和排序
|
||||
const linksContainer = document.getElementById('links-container');
|
||||
const visibleLinks = CONFIG.links
|
||||
.filter(link => link.visible !== false)
|
||||
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
|
||||
linksContainer.innerHTML = visibleLinks.map(link => `
|
||||
<a href="${link.url}" class="link-card animate-slide-up" style="animation-delay: ${link.delay};" target="_blank" rel="noopener noreferrer">
|
||||
<div class="link-icon"><i class="${link.icon}"></i></div>
|
||||
<div class="link-content">
|
||||
<span class="link-title">${link.title}</span>
|
||||
<span class="link-desc">${link.desc}</span>
|
||||
</div>
|
||||
<div class="link-arrow"><i class="fas fa-chevron-right"></i></div>
|
||||
</a>
|
||||
`).join('');
|
||||
|
||||
// Render Socials - 支援可見性和排序
|
||||
const socialIcons = document.getElementById('social-icons');
|
||||
const visibleSocials = CONFIG.socials
|
||||
.filter(social => social.visible !== false)
|
||||
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
|
||||
socialIcons.innerHTML = visibleSocials.map(social => `
|
||||
<a href="${social.url}" title="${social.name}${social.note ? ' - ' + social.note : ''}" target="_blank" rel="noopener noreferrer"><i class="${social.icon}"></i></a>
|
||||
`).join('');
|
||||
|
||||
// Render Copyright
|
||||
document.getElementById('copyright-text').innerHTML = `© ${CONFIG.profile.copyrightYear} ${CONFIG.profile.name}. All rights reserved.`;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
</body></html>
|
||||
6
websites/linktree-test/project.json
Normal file
6
websites/linktree-test/project.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "linktree-test",
|
||||
"slug": "linktree-test",
|
||||
"description": "",
|
||||
"created_at": "2026-05-18T04:37:34"
|
||||
}
|
||||
297
websites/linktree-test/style.css
Normal file
297
websites/linktree-test/style.css
Normal file
@@ -0,0 +1,297 @@
|
||||
/* CSS Variables & Design Tokens */
|
||||
:root {
|
||||
--primary-color: #6366f1;
|
||||
--accent-color: #a855f7;
|
||||
--bg-dark: #0f172a;
|
||||
--card-bg: rgba(30, 41, 59, 0.7);
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Base Reset */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
overflow-x: hidden;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
/* Background Blobs */
|
||||
.background-blobs {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.4;
|
||||
animation: blob-float 20s infinite alternate;
|
||||
}
|
||||
|
||||
.blob-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: var(--primary-color);
|
||||
top: -100px;
|
||||
left: -100px;
|
||||
}
|
||||
|
||||
.blob-2 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: var(--accent-color);
|
||||
bottom: -50px;
|
||||
right: -50px;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
.blob-3 {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
background: #ec4899;
|
||||
top: 40%;
|
||||
left: 60%;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
|
||||
@keyframes blob-float {
|
||||
0% { transform: translate(0, 0) scale(1); }
|
||||
100% { transform: translate(50px, 50px) scale(1.1); }
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 580px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Profile Section */
|
||||
.profile {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 4px solid var(--glass-border);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.avatar-ring {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
right: -8px;
|
||||
bottom: -8px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(45deg, var(--primary-color), var(--accent-color));
|
||||
opacity: 0.5;
|
||||
z-index: 1;
|
||||
animation: ring-pulse 3s infinite;
|
||||
}
|
||||
|
||||
@keyframes ring-pulse {
|
||||
0% { transform: scale(1); opacity: 0.5; }
|
||||
50% { transform: scale(1.05); opacity: 0.2; }
|
||||
100% { transform: scale(1); opacity: 0.5; }
|
||||
}
|
||||
|
||||
.name {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 2.2rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.bio {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
max-width: 80%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Links Section */
|
||||
.links-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.link-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 1.2rem;
|
||||
padding: 1.2rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: var(--transition);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.link-card:hover {
|
||||
transform: translateY(-5px) scale(1.02);
|
||||
background: rgba(45, 55, 72, 0.8);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.8rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 1.5rem;
|
||||
margin-right: 1.2rem;
|
||||
color: var(--primary-color);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.link-card:hover .link-icon {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.link-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.link-title {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.link-desc {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.link-arrow {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.5;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.link-card:hover .link-arrow {
|
||||
transform: translateX(5px);
|
||||
opacity: 1;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Footer Section */
|
||||
.footer {
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.social-icons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.social-icons a {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.8rem;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.social-icons a:hover {
|
||||
color: var(--primary-color);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.copyright {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.animate-fade-in {
|
||||
opacity: 0;
|
||||
animation: fade-in 1s forwards;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
animation: slide-up 0.8s forwards;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 480px) {
|
||||
body {
|
||||
padding: 1.5rem 0.8rem;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.bio {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
<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>
|
||||
<title>About us</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">
|
||||
@@ -23,11 +23,10 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-lg-12 text-center">
|
||||
<h1 class="mt-5">Bootstrap 5 start page</h1>
|
||||
<h1 class="mt-5">About Us page</h1>
|
||||
<p class="lead">Start by dragging components to page or double click to edit text</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</body></html>
|
||||
@@ -34,7 +34,7 @@
|
||||
<a class="nav-link active" aria-current="page" href="#">Home</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#">Link</a>
|
||||
<a class="nav-link" href="#">Link</a><a class="nav-link" href="about-us.html">About</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link disabled">Disabled</a>
|
||||
|
||||
Reference in New Issue
Block a user