基本架構
This commit is contained in:
102
build_editor.py
Normal file
102
build_editor.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Build editor.html template from vvvebjs source with Jinja2 escaping."""
|
||||
from pathlib import Path
|
||||
|
||||
BASE = Path(__file__).parent
|
||||
SRC = BASE / "static" / "Vvvebjs" / "editor.html"
|
||||
OUT = BASE / "templates" / "editor.html"
|
||||
|
||||
content = SRC.read_text(encoding="utf-8")
|
||||
|
||||
# ── 1. Fix static asset paths ────────────────────────────────────
|
||||
VBASE = "/static/Vvvebjs/"
|
||||
replacements = [
|
||||
('href="css/', f'href="{VBASE}css/'),
|
||||
('href="libs/', f'href="{VBASE}libs/'),
|
||||
('href="img/', f'href="{VBASE}img/'),
|
||||
('href="favicon.ico"', f'href="{VBASE}favicon.ico"'),
|
||||
('src="libs/', f'src="{VBASE}libs/'),
|
||||
('src="js/', f'src="{VBASE}js/'),
|
||||
('src="img/', f'src="{VBASE}img/'),
|
||||
('src="media/', f'src="{VBASE}media/'),
|
||||
('src="fonts/', f'src="{VBASE}fonts/'),
|
||||
('src="demo/', f'src="{VBASE}demo/'),
|
||||
]
|
||||
for old, new in replacements:
|
||||
content = content.replace(old, new)
|
||||
|
||||
# ── 2. Fix PHP save URL → Flask API ─────────────────────────────
|
||||
content = content.replace('data-vvveb-url="save.php"', 'data-vvveb-url="/api/save"')
|
||||
|
||||
# ── 3. Fix JS url variables ──────────────────────────────────────
|
||||
JS_VARS = [
|
||||
("let renameUrl = 'save.php?action=rename';",
|
||||
"let renameUrl = '/api/save?action=rename';"),
|
||||
("let deleteUrl = 'save.php?action=delete';",
|
||||
"let deleteUrl = '/api/save?action=delete';"),
|
||||
("let saveReusableUrl = 'save.php?action=saveReusable';",
|
||||
"let saveReusableUrl = '/api/save?action=saveReusable';"),
|
||||
("let oEmbedProxyUrl = 'save.php?action=oembedProxy';",
|
||||
"let oEmbedProxyUrl = '/api/save?action=oembedProxy';"),
|
||||
]
|
||||
for old, new in JS_VARS:
|
||||
content = content.replace(old, new)
|
||||
|
||||
# ── 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.
|
||||
content = "{% raw %}" + content + "{% endraw %}"
|
||||
|
||||
# ── 5. Replace defaultPages block with Escaped Jinja2 Injection ──
|
||||
# Since we are inside a {% raw %} block, we use {% endraw %}{{ pages_json | safe }}{% raw %}
|
||||
start = content.find("let defaultPages = {")
|
||||
end = content.find("};\n\n\nlet pages = defaultPages;")
|
||||
if start != -1 and end != -1:
|
||||
end += len("};\n\n\nlet pages = defaultPages;")
|
||||
content = (
|
||||
content[:start]
|
||||
+ "let defaultPages = {% endraw %}{{ pages_json | safe }}{% raw %};\nlet pages = defaultPages;"
|
||||
+ content[end:]
|
||||
)
|
||||
|
||||
# ── 6. Add back button + slug variable before </body> ───────────
|
||||
# We break out of {% raw %} using {% endraw %} to interpolate {{ slug | safe }}
|
||||
BACK_BTN = """
|
||||
<style>
|
||||
#back-to-dashboard {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
left: 1rem;
|
||||
z-index: 9999;
|
||||
background: rgba(30,32,48,0.92);
|
||||
color: #a5b4fc;
|
||||
border: 1px solid rgba(99,102,241,0.4);
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.9rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
#back-to-dashboard:hover {
|
||||
background: rgba(99,102,241,0.25);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
<a id="back-to-dashboard" href="/dashboard">
|
||||
← 返回管理器
|
||||
</a>
|
||||
<script>
|
||||
// Inject project slug for save bridge
|
||||
window.VVVEB_PROJECT_SLUG = "{% endraw %}{{ slug | safe }}{% raw %}";
|
||||
</script>
|
||||
<script src="/static/js/my-editor.js"></script>
|
||||
"""
|
||||
content = content.replace("</body>", BACK_BTN + "\n</body>")
|
||||
|
||||
OUT.write_text(content, encoding="utf-8")
|
||||
print(f"Successfully generated escaped Jinja2 template! Size: {len(content)} bytes.")
|
||||
295
main.py
295
main.py
@@ -1,6 +1,295 @@
|
||||
def main():
|
||||
print("Hello from vvvebjs!")
|
||||
"""VvvebJS 網頁管理器 — Flask 主程式."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, Response, abort, jsonify, render_template, request, send_from_directory
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# ── 路徑常數 ────────────────────────────────────────────────────────────────
|
||||
BASE_DIR = Path(__file__).parent
|
||||
WEBSITES_DIR = BASE_DIR / "websites"
|
||||
BLANK_TEMPLATE = BASE_DIR / "static" / "Vvvebjs" / "new-page-blank-template.html"
|
||||
|
||||
|
||||
# ── 輔助函式 ────────────────────────────────────────────────────────────────
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
"""將文字轉成 URL-safe slug."""
|
||||
text = text.lower().strip()
|
||||
text = re.sub(r"[^\w\s-]", "", text, flags=re.UNICODE)
|
||||
text = re.sub(r"[\s_-]+", "-", text)
|
||||
text = re.sub(r"^-+|-+$", "", text)
|
||||
return text or "untitled"
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
|
||||
def _project_dir(slug: str) -> Path:
|
||||
return WEBSITES_DIR / slug
|
||||
|
||||
|
||||
def _project_json_path(slug: str) -> Path:
|
||||
return _project_dir(slug) / "project.json"
|
||||
|
||||
|
||||
def _load_project(slug: str) -> dict[str, Any]:
|
||||
"""讀取專案設定,若不存在則回傳預設值."""
|
||||
path = _project_json_path(slug)
|
||||
if path.exists():
|
||||
data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
||||
return data
|
||||
return {
|
||||
"name": slug,
|
||||
"slug": slug,
|
||||
"description": "",
|
||||
"created_at": _now_iso(),
|
||||
}
|
||||
|
||||
|
||||
def _save_project(slug: str, data: dict[str, Any]) -> None:
|
||||
path = _project_json_path(slug)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _list_pages(slug: str) -> list[str]:
|
||||
"""列出專案目錄下所有 .html 頁面."""
|
||||
proj_dir = _project_dir(slug)
|
||||
if not proj_dir.exists():
|
||||
return []
|
||||
return sorted(p.name for p in proj_dir.glob("*.html"))
|
||||
|
||||
|
||||
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"))
|
||||
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")
|
||||
else:
|
||||
last_mod_str = str(data.get("created_at", _now_iso()))
|
||||
return {
|
||||
**data,
|
||||
"slug": slug,
|
||||
"page_count": len(pages),
|
||||
"pages": pages,
|
||||
"last_modified": last_mod_str,
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_file_path(slug: str, filename: str) -> Path | None:
|
||||
"""驗證並回傳安全的檔案路徑(防止路徑遍歷)."""
|
||||
proj_dir = _project_dir(slug).resolve()
|
||||
target = (proj_dir / Path(filename).name).resolve()
|
||||
if not str(target).startswith(str(proj_dir)):
|
||||
return None
|
||||
if target.suffix.lower() != ".html":
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
def _copy_blank_template(dest: Path) -> None:
|
||||
"""複製空白範本或建立最小 HTML."""
|
||||
if BLANK_TEMPLATE.exists():
|
||||
shutil.copy(BLANK_TEMPLATE, dest)
|
||||
else:
|
||||
dest.write_text(
|
||||
"<!DOCTYPE html><html><head><title>My Page</title></head><body></body></html>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
# ── 路由:一般頁面 ──────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/") # type: ignore[untyped-decorator]
|
||||
def index() -> Response:
|
||||
from flask import redirect, url_for
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
|
||||
@app.route("/dashboard") # type: ignore[untyped-decorator]
|
||||
def dashboard() -> str:
|
||||
return str(render_template("dashboard.html"))
|
||||
|
||||
|
||||
@app.route("/editor/<slug>") # type: ignore[untyped-decorator]
|
||||
def editor(slug: str) -> str:
|
||||
proj_dir = _project_dir(slug)
|
||||
if not proj_dir.exists():
|
||||
abort(404)
|
||||
pages = _list_pages(slug)
|
||||
project = _load_project(slug)
|
||||
|
||||
pages_obj: dict[str, Any] = {}
|
||||
for page in pages:
|
||||
name = page.replace(".html", "")
|
||||
pages_obj[name] = {
|
||||
"name": name,
|
||||
"title": name.replace("-", " ").title(),
|
||||
"filename": page,
|
||||
"file": page,
|
||||
"url": f"/sites/{slug}/{page}",
|
||||
}
|
||||
|
||||
if not pages_obj:
|
||||
idx_path = proj_dir / "index.html"
|
||||
_copy_blank_template(idx_path)
|
||||
pages_obj["index"] = {
|
||||
"name": "index",
|
||||
"title": "Index",
|
||||
"filename": "index.html",
|
||||
"file": "index.html",
|
||||
"url": f"/sites/{slug}/index.html",
|
||||
}
|
||||
|
||||
return str(render_template(
|
||||
"editor.html",
|
||||
slug=slug,
|
||||
project=project,
|
||||
pages_json=json.dumps(pages_obj, ensure_ascii=False),
|
||||
))
|
||||
|
||||
|
||||
# ── 路由:API ───────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/api/projects", methods=["GET"]) # type: ignore[untyped-decorator]
|
||||
def api_list_projects() -> Response:
|
||||
WEBSITES_DIR.mkdir(exist_ok=True)
|
||||
projects = [_project_summary(d.name) for d in sorted(WEBSITES_DIR.iterdir()) if d.is_dir()]
|
||||
return jsonify(projects)
|
||||
|
||||
|
||||
@app.route("/api/projects", methods=["POST"]) # type: ignore[untyped-decorator]
|
||||
def api_create_project() -> tuple[Response, int]:
|
||||
body: dict[str, Any] = request.get_json(force=True) or {}
|
||||
name: str = str(body.get("name", "")).strip()
|
||||
if not name:
|
||||
return jsonify({"error": "名稱不能為空"}), 400
|
||||
|
||||
slug = slugify(name)
|
||||
proj_dir = _project_dir(slug)
|
||||
base_slug = slug
|
||||
counter = 1
|
||||
while proj_dir.exists():
|
||||
slug = f"{base_slug}-{counter}"
|
||||
proj_dir = _project_dir(slug)
|
||||
counter += 1
|
||||
|
||||
proj_dir.mkdir(parents=True, exist_ok=True)
|
||||
data: dict[str, Any] = {
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"description": str(body.get("description", "")),
|
||||
"created_at": _now_iso(),
|
||||
}
|
||||
_save_project(slug, data)
|
||||
_copy_blank_template(proj_dir / "index.html")
|
||||
return jsonify(_project_summary(slug)), 201
|
||||
|
||||
|
||||
@app.route("/api/projects/<slug>", methods=["GET"]) # type: ignore[untyped-decorator]
|
||||
def api_get_project(slug: str) -> tuple[Response, int] | Response:
|
||||
if not _project_dir(slug).exists():
|
||||
return jsonify({"error": "專案不存在"}), 404
|
||||
return jsonify(_project_summary(slug))
|
||||
|
||||
|
||||
@app.route("/api/projects/<slug>", methods=["PUT"]) # type: ignore[untyped-decorator]
|
||||
def api_update_project(slug: str) -> tuple[Response, int] | Response:
|
||||
if not _project_dir(slug).exists():
|
||||
return jsonify({"error": "專案不存在"}), 404
|
||||
body: dict[str, Any] = request.get_json(force=True) or {}
|
||||
data = _load_project(slug)
|
||||
if "name" in body and str(body["name"]).strip():
|
||||
data["name"] = str(body["name"]).strip()
|
||||
if "description" in body:
|
||||
data["description"] = str(body["description"])
|
||||
data["updated_at"] = _now_iso()
|
||||
_save_project(slug, data)
|
||||
return jsonify(_project_summary(slug))
|
||||
|
||||
|
||||
@app.route("/api/projects/<slug>", methods=["DELETE"]) # type: ignore[untyped-decorator]
|
||||
def api_delete_project(slug: str) -> tuple[Response, int] | Response:
|
||||
proj_dir = _project_dir(slug)
|
||||
if not proj_dir.exists():
|
||||
return jsonify({"error": "專案不存在"}), 404
|
||||
shutil.rmtree(proj_dir)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/api/projects/<slug>/pages", methods=["GET"]) # type: ignore[untyped-decorator]
|
||||
def api_list_pages(slug: str) -> tuple[Response, int] | Response:
|
||||
if not _project_dir(slug).exists():
|
||||
return jsonify({"error": "專案不存在"}), 404
|
||||
return jsonify(_list_pages(slug))
|
||||
|
||||
|
||||
@app.route("/api/projects/<slug>/pages", methods=["POST"]) # type: ignore[untyped-decorator]
|
||||
def api_create_page(slug: str) -> tuple[Response, int]:
|
||||
if not _project_dir(slug).exists():
|
||||
return jsonify({"error": "專案不存在"}), 404
|
||||
body: dict[str, Any] = request.get_json(force=True) or {}
|
||||
page_name: str = str(body.get("name", "")).strip()
|
||||
if not page_name:
|
||||
return jsonify({"error": "頁面名稱不能為空"}), 400
|
||||
page_slug = slugify(page_name)
|
||||
page_path = _project_dir(slug) / f"{page_slug}.html"
|
||||
if page_path.exists():
|
||||
return jsonify({"error": "頁面已存在"}), 409
|
||||
_copy_blank_template(page_path)
|
||||
return jsonify({"page": f"{page_slug}.html"}), 201
|
||||
|
||||
|
||||
@app.route("/api/save", methods=["POST"]) # type: ignore[untyped-decorator]
|
||||
def api_save() -> tuple[Response, int] | Response:
|
||||
"""接受 vvvebjs 的儲存請求,寫入對應專案目錄."""
|
||||
if request.is_json:
|
||||
raw: dict[str, Any] = request.get_json(force=True) or {}
|
||||
body: dict[str, Any] = raw
|
||||
else:
|
||||
form = dict(request.form)
|
||||
body = {k: (v[0] if isinstance(v, list) else v) for k, v in form.items()}
|
||||
|
||||
slug: str = str(body.get("slug", "")).strip()
|
||||
filename: str = str(body.get("file", "")).strip()
|
||||
html: str = str(body.get("html", "")).strip()
|
||||
|
||||
if not slug or not filename or not html:
|
||||
return jsonify({"error": "缺少必要參數 slug / file / html"}), 400
|
||||
|
||||
if not _project_dir(slug).exists():
|
||||
return jsonify({"error": "專案不存在"}), 404
|
||||
|
||||
safe_path = _sanitize_file_path(slug, filename)
|
||||
if safe_path is None:
|
||||
return jsonify({"error": "不合法的檔案路徑"}), 400
|
||||
|
||||
safe_path.write_text(html, encoding="utf-8")
|
||||
return jsonify({"ok": True, "saved": safe_path.name})
|
||||
|
||||
|
||||
@app.route("/sites/<slug>/<path:filename>") # type: ignore[untyped-decorator]
|
||||
def serve_site_file(slug: str, filename: str) -> Response:
|
||||
proj_dir = _project_dir(slug)
|
||||
if not proj_dir.exists():
|
||||
abort(404)
|
||||
return send_from_directory(proj_dir, filename)
|
||||
|
||||
|
||||
# ── 啟動 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
WEBSITES_DIR.mkdir(exist_ok=True)
|
||||
app.run(debug=True, port=5000)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
[project]
|
||||
name = "vvvebjs"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
description = "VvvebJS 網頁管理器"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
"flask>=3.1",
|
||||
]
|
||||
@@ -0,0 +1,624 @@
|
||||
/* ================================================================
|
||||
VvvebJS 網頁管理器 — 儀表板樣式
|
||||
深色主題 + 玻璃態 (Glassmorphism)
|
||||
================================================================ */
|
||||
|
||||
/* ── CSS 變數 ──────────────────────────────────────────────────── */
|
||||
:root {
|
||||
--bg-base: #0d0f14;
|
||||
--bg-surface: #13161e;
|
||||
--bg-card: rgba(255, 255, 255, 0.04);
|
||||
--bg-card-hover: rgba(255, 255, 255, 0.07);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--border-focus: rgba(99, 102, 241, 0.6);
|
||||
|
||||
--text-primary: #f0f2f8;
|
||||
--text-secondary: #8b90a8;
|
||||
--text-muted: #555a72;
|
||||
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #4f52d8;
|
||||
--accent-glow: rgba(99, 102, 241, 0.25);
|
||||
--purple: #8b5cf6;
|
||||
--danger: #ef4444;
|
||||
--danger-hover: #dc2626;
|
||||
--success: #22c55e;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 18px;
|
||||
|
||||
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
|
||||
--transition: 0.2s ease;
|
||||
--font: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* ── Reset ─────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { font-size: 16px; scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
ul { list-style: none; }
|
||||
input, textarea, select { font-family: inherit; }
|
||||
button { cursor: pointer; font-family: inherit; }
|
||||
|
||||
/* ── 頂部導覽列 ─────────────────────────────────────────────────── */
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 2rem;
|
||||
height: 62px;
|
||||
background: rgba(13, 15, 20, 0.85);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.topbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
background: linear-gradient(135deg, #e0e2ff 0%, #a5b4fc 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.brand-name em {
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.site-count {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── 按鈕 ─────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.5rem 1.1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
transition: background var(--transition), transform var(--transition), box-shadow var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:active { transform: scale(0.97); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 0 var(--accent-glow);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
box-shadow: 0 0 20px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.btn-danger:hover { background: var(--danger-hover); }
|
||||
|
||||
.btn-sm { padding: 0.35rem 0.8rem; font-size: 0.8rem; }
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── 主內容 ───────────────────────────────────────────────────── */
|
||||
.main-content {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 2rem 4rem;
|
||||
}
|
||||
|
||||
/* ── 搜尋列 ─────────────────────────────────────────────────────── */
|
||||
.search-bar-wrap {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
position: relative;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 0.9rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.9rem 0.6rem 2.6rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
transition: border-color var(--transition), background var(--transition);
|
||||
outline: none;
|
||||
}
|
||||
.search-bar input::placeholder { color: var(--text-muted); }
|
||||
.search-bar input:focus {
|
||||
border-color: var(--border-focus);
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
}
|
||||
|
||||
/* ── 專案卡片網格 ─────────────────────────────────────────────── */
|
||||
.projects-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
/* ── 專案卡片 ────────────────────────────────────────────────── */
|
||||
.project-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.4rem;
|
||||
transition: background var(--transition), border-color var(--transition), transform var(--transition), box-shadow var(--transition);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-card), 0 0 40px rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
|
||||
.card-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at 20% 10%, rgba(99,102,241,0.08) 0%, transparent 60%);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
.project-card:hover .card-glow { opacity: 1; }
|
||||
|
||||
.card-header-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.15));
|
||||
border: 1px solid rgba(99,102,241,0.25);
|
||||
border-radius: var(--radius-md);
|
||||
color: #a5b4fc;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
flex: 1;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.35rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-actions { margin-top: auto; }
|
||||
.card-actions .btn { width: 100%; justify-content: center; }
|
||||
|
||||
/* ── 空狀態 ─────────────────────────────────────────────────── */
|
||||
.empty-state {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 5rem 2rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.empty-state svg { opacity: 0.3; }
|
||||
.empty-state h2 { font-size: 1.3rem; color: var(--text-secondary); }
|
||||
.empty-state p { font-size: 0.9rem; max-width: 320px; }
|
||||
.empty-state .btn { margin-top: 0.5rem; }
|
||||
|
||||
/* ── 輸入框 ─────────────────────────────────────────────────── */
|
||||
input[type=text],
|
||||
input[type=email],
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
transition: border-color var(--transition);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input[type=text]:focus,
|
||||
input[type=email]:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--border-focus);
|
||||
background: rgba(99,102,241,0.06);
|
||||
}
|
||||
|
||||
input[type=text]::placeholder,
|
||||
textarea::placeholder { color: var(--text-muted); }
|
||||
|
||||
.input-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Form group ─────────────────────────────────────────────── */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.required { color: var(--danger); }
|
||||
|
||||
.slug-preview {
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent);
|
||||
font-family: monospace;
|
||||
min-height: 1.1em;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.hint-text.danger { color: rgba(239,68,68,0.7); }
|
||||
|
||||
/* ── Modal ──────────────────────────────────────────────────── */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.65);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.modal-backdrop.active {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
background: #1a1d27;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-modal);
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
transform: translateY(20px) scale(0.97);
|
||||
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.modal-backdrop.active .modal-box {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.modal-sm { max-width: 380px; }
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
.modal-close:hover { background: rgba(255,255,255,0.08); color: var(--text-primary); }
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-body p { font-size: 0.9rem; color: var(--text-secondary); line-height: 1.6; }
|
||||
.modal-body strong { color: var(--text-primary); }
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Offcanvas ──────────────────────────────────────────────── */
|
||||
.offcanvas-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 200;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
.offcanvas-backdrop.active {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.offcanvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 380px;
|
||||
max-width: 100%;
|
||||
background: #1a1d27;
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: -8px 0 40px rgba(0,0,0,0.4);
|
||||
}
|
||||
.offcanvas-backdrop.active .offcanvas {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.offcanvas-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.offcanvas-header h2 { font-size: 1rem; font-weight: 600; }
|
||||
|
||||
.offcanvas-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
.offcanvas-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── 分隔線 / 區段標籤 ──────────────────────────────────────── */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.section-label.danger { color: rgba(239,68,68,0.6); }
|
||||
|
||||
/* ── 頁面清單 ─────────────────────────────────────────────── */
|
||||
.pages-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.page-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pages-loading, .no-pages {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* ── 危險區域 ────────────────────────────────────────────── */
|
||||
.danger-zone {
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(239,68,68,0.2);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(239,68,68,0.04);
|
||||
}
|
||||
.danger-zone .btn-danger { width: 100%; justify-content: center; }
|
||||
|
||||
/* ── Toast ──────────────────────────────────────────────── */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||
transform: translateX(120%);
|
||||
opacity: 0;
|
||||
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.35s ease;
|
||||
pointer-events: all;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.toast.show { transform: translateX(0); opacity: 1; }
|
||||
.toast-success { background: #166534; border: 1px solid #22c55e40; }
|
||||
.toast-error { background: #7f1d1d; border: 1px solid #ef444440; }
|
||||
|
||||
/* ── 捲軸美化 ─────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.15); }
|
||||
|
||||
/* ── 響應式 ─────────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.topbar { padding: 0 1rem; }
|
||||
.main-content { padding: 1.25rem 1rem 3rem; }
|
||||
.site-count { display: none; }
|
||||
.offcanvas { width: 100%; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* my-editor.js — VvvebJS 儲存橋接腳本
|
||||
* 覆蓋 vvvebjs 的 saveAjax 行為,附加 project slug 後送至 Flask /api/save
|
||||
*/
|
||||
(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);
|
||||
}
|
||||
|
||||
// ── 覆蓋 saveAjax ───────────────────────────────────────────────
|
||||
function patchSaveAjax() {
|
||||
if (typeof Vvveb === "undefined" || !Vvveb.Builder) {
|
||||
// 等待 Vvveb 載入
|
||||
setTimeout(patchSaveAjax, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
const originalSaveAjax = Vvveb.Builder.saveAjax
|
||||
? Vvveb.Builder.saveAjax.bind(Vvveb.Builder)
|
||||
: null;
|
||||
|
||||
Vvveb.Builder.saveAjax = function (saveUrl) {
|
||||
if (!SLUG) {
|
||||
showToast("錯誤:找不到專案識別碼 (slug)", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 取得目前編輯的頁面檔名
|
||||
const currentPage = Vvveb.FileManager
|
||||
? Vvveb.FileManager.getCurrentPage
|
||||
? Vvveb.FileManager.getCurrentPage()
|
||||
: null
|
||||
: null;
|
||||
|
||||
let filename = "index.html";
|
||||
if (currentPage && currentPage.filename) {
|
||||
filename = currentPage.filename;
|
||||
} else if (currentPage && currentPage.file) {
|
||||
// 只取檔名部分
|
||||
filename = currentPage.file.split("/").pop();
|
||||
}
|
||||
|
||||
// 取得 HTML 內容
|
||||
let html = "";
|
||||
try {
|
||||
html = Vvveb.Builder.getHtml ? Vvveb.Builder.getHtml() : "";
|
||||
} catch (e) {
|
||||
console.error("[my-editor] getHtml failed:", e);
|
||||
}
|
||||
|
||||
if (!html) {
|
||||
showToast("無法取得頁面內容", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 顯示儲存中狀態
|
||||
const saveBtn = document.querySelector(".save-btn");
|
||||
if (saveBtn) {
|
||||
saveBtn.querySelector(".loading")?.classList.remove("d-none");
|
||||
saveBtn.querySelector(".button-text")?.classList.add("d-none");
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
slug: SLUG,
|
||||
file: filename,
|
||||
html: html,
|
||||
});
|
||||
|
||||
fetch("/api/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.ok) {
|
||||
showToast("✓ 已儲存:" + (data.saved || filename));
|
||||
} else {
|
||||
showToast("儲存失敗:" + (data.error || "未知錯誤"), "error");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast("網路錯誤:" + err.message, "error");
|
||||
})
|
||||
.finally(() => {
|
||||
if (saveBtn) {
|
||||
saveBtn.querySelector(".loading")?.classList.add("d-none");
|
||||
saveBtn.querySelector(".button-text")?.classList.remove("d-none");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
console.log("[my-editor] saveAjax patched for slug:", SLUG);
|
||||
}
|
||||
|
||||
// ── 啟用 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) {
|
||||
Vvveb.Builder.saveAjax("/api/save");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── 初始化 ────────────────────────────────────────────────────────
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
patchSaveAjax();
|
||||
enableSaveBtn();
|
||||
});
|
||||
} else {
|
||||
patchSaveAjax();
|
||||
enableSaveBtn();
|
||||
}
|
||||
})();
|
||||
|
||||
502
templates/dashboard.html
Normal file
502
templates/dashboard.html
Normal file
@@ -0,0 +1,502 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VvvebJS 網頁管理器</title>
|
||||
<meta name="description" content="使用 VvvebJS 管理您的靜態網站專案">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/my-style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── 頂部導覽 ────────────────────────────────────────────────── -->
|
||||
<header class="topbar">
|
||||
<div class="topbar-brand">
|
||||
<svg class="brand-icon" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="url(#grad)"/>
|
||||
<path d="M8 10h16M8 16h10M8 22h13" stroke="#fff" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6366f1"/>
|
||||
<stop offset="1" stop-color="#8b5cf6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<span class="brand-name">VvvebJS <em>Manager</em></span>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<span class="site-count" id="site-count">— 個專案</span>
|
||||
<button class="btn btn-primary" id="new-project-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
新增專案
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── 主內容 ─────────────────────────────────────────────────── -->
|
||||
<main class="main-content">
|
||||
|
||||
<!-- 搜尋列 -->
|
||||
<div class="search-bar-wrap">
|
||||
<div class="search-bar">
|
||||
<svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input type="text" id="search-input" placeholder="搜尋專案名稱…" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片網格 -->
|
||||
<div class="projects-grid" id="projects-grid">
|
||||
<!-- 由 JS 動態填入 -->
|
||||
</div>
|
||||
|
||||
<!-- 空狀態 -->
|
||||
<div class="empty-state" id="empty-state" style="display:none">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
<h2>尚無專案</h2>
|
||||
<p>點擊右上角「新增專案」開始建立您的第一個網站</p>
|
||||
<button class="btn btn-primary" id="empty-new-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
建立第一個專案
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- ── 新增專案 Modal ──────────────────────────────────────────── -->
|
||||
<div class="modal-backdrop" id="create-modal-backdrop">
|
||||
<div class="modal-box" id="create-modal" role="dialog" aria-labelledby="create-modal-title" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2 id="create-modal-title">新增專案</h2>
|
||||
<button class="modal-close" id="create-modal-close" aria-label="關閉">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="new-project-name">專案名稱 <span class="required">*</span></label>
|
||||
<input type="text" id="new-project-name" placeholder="例如:我的公司網站" autocomplete="off">
|
||||
<span class="slug-preview" id="slug-preview"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-project-desc">描述(選填)</label>
|
||||
<textarea id="new-project-desc" rows="3" placeholder="簡短說明這個網站的用途…"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" id="create-cancel-btn">取消</button>
|
||||
<button class="btn btn-primary" id="create-confirm-btn">建立專案</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 設定 Offcanvas ─────────────────────────────────────────── -->
|
||||
<div class="offcanvas-backdrop" id="settings-backdrop">
|
||||
<div class="offcanvas" id="settings-panel" role="dialog" aria-labelledby="settings-title">
|
||||
<div class="offcanvas-header">
|
||||
<h2 id="settings-title">專案設定</h2>
|
||||
<button class="modal-close" id="settings-close" aria-label="關閉">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<input type="hidden" id="settings-slug">
|
||||
<div class="form-group">
|
||||
<label for="settings-name">專案名稱</label>
|
||||
<input type="text" id="settings-name" placeholder="專案名稱">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="settings-desc">描述</label>
|
||||
<textarea id="settings-desc" rows="4"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Slug(資料夾名稱)</label>
|
||||
<input type="text" id="settings-slug-display" disabled class="input-disabled">
|
||||
<p class="hint-text">Slug 為唯一識別碼,建立後無法更改</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<h3 class="section-label">頁面清單</h3>
|
||||
<ul class="pages-list" id="settings-pages-list">
|
||||
<li class="pages-loading">載入中…</li>
|
||||
</ul>
|
||||
<button class="btn btn-ghost btn-sm" id="add-page-btn" style="margin-top:0.75rem; width:100%">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
新增頁面
|
||||
</button>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="danger-zone">
|
||||
<h3 class="section-label danger">危險操作</h3>
|
||||
<button class="btn btn-danger" id="delete-project-btn">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/>
|
||||
<path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/>
|
||||
</svg>
|
||||
刪除此專案
|
||||
</button>
|
||||
<p class="hint-text danger">此操作不可復原,將刪除所有頁面檔案</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="offcanvas-footer">
|
||||
<button class="btn btn-ghost" id="settings-cancel-btn">取消</button>
|
||||
<button class="btn btn-primary" id="settings-save-btn">儲存設定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 刪除確認 Modal ──────────────────────────────────────────── -->
|
||||
<div class="modal-backdrop" id="delete-modal-backdrop">
|
||||
<div class="modal-box modal-sm" role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2>確認刪除</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>確定要刪除專案 <strong id="delete-project-name"></strong> 嗎?<br>
|
||||
此操作不可復原,所有頁面檔案都將被刪除。</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" id="delete-cancel-btn">取消</button>
|
||||
<button class="btn btn-danger" id="delete-confirm-btn">確認刪除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Toast 通知 ─────────────────────────────────────────────── -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<script>
|
||||
// ── 狀態 ─────────────────────────────────────────────────────────
|
||||
let allProjects = [];
|
||||
let settingsCurrentSlug = '';
|
||||
let deleteTargetSlug = '';
|
||||
|
||||
// ── API 輔助 ─────────────────────────────────────────────────────
|
||||
async function apiFetch(url, opts = {}) {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...opts,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────────
|
||||
function showToast(msg, type = 'success') {
|
||||
const tc = document.getElementById('toast-container');
|
||||
const t = document.createElement('div');
|
||||
t.className = `toast toast-${type}`;
|
||||
t.textContent = msg;
|
||||
tc.appendChild(t);
|
||||
requestAnimationFrame(() => t.classList.add('show'));
|
||||
setTimeout(() => {
|
||||
t.classList.remove('show');
|
||||
setTimeout(() => t.remove(), 400);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// ── Slug 預覽 ─────────────────────────────────────────────────────
|
||||
function toSlug(s) {
|
||||
return s.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'untitled';
|
||||
}
|
||||
|
||||
// ── 渲染卡片 ─────────────────────────────────────────────────────
|
||||
function renderProjects(projects) {
|
||||
const grid = document.getElementById('projects-grid');
|
||||
const empty = document.getElementById('empty-state');
|
||||
const count = document.getElementById('site-count');
|
||||
count.textContent = `${projects.length} 個專案`;
|
||||
|
||||
if (projects.length === 0) {
|
||||
grid.innerHTML = '';
|
||||
empty.style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
|
||||
grid.innerHTML = projects.map(p => {
|
||||
const mod = p.last_modified ? p.last_modified.slice(0, 10) : '—';
|
||||
const pages = p.page_count ?? 0;
|
||||
return `
|
||||
<div class="project-card" data-slug="${p.slug}">
|
||||
<div class="card-glow"></div>
|
||||
<div class="card-header-row">
|
||||
<div class="card-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-menu">
|
||||
<button class="icon-btn settings-btn" data-slug="${p.slug}" title="設定">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">${escHtml(p.name)}</h3>
|
||||
<p class="card-desc">${escHtml(p.description || '—')}</p>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span class="meta-item">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<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>
|
||||
${pages} 頁
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
${mod}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<a href="/editor/${p.slug}" class="btn btn-primary btn-sm" id="edit-btn-${p.slug}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 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>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// 綁定設定按鈕
|
||||
grid.querySelectorAll('.settings-btn').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
openSettings(btn.dataset.slug);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── 載入專案 ─────────────────────────────────────────────────────
|
||||
async function loadProjects() {
|
||||
try {
|
||||
allProjects = await apiFetch('/api/projects');
|
||||
renderProjects(allProjects);
|
||||
} catch (e) {
|
||||
showToast('載入專案失敗:' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 搜尋 ─────────────────────────────────────────────────────────
|
||||
document.getElementById('search-input').addEventListener('input', function() {
|
||||
const q = this.value.toLowerCase();
|
||||
const filtered = allProjects.filter(p =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
(p.description || '').toLowerCase().includes(q)
|
||||
);
|
||||
renderProjects(filtered);
|
||||
});
|
||||
|
||||
// ── 新增專案 Modal ────────────────────────────────────────────────
|
||||
function openCreateModal() {
|
||||
document.getElementById('new-project-name').value = '';
|
||||
document.getElementById('new-project-desc').value = '';
|
||||
document.getElementById('slug-preview').textContent = '';
|
||||
document.getElementById('create-modal-backdrop').classList.add('active');
|
||||
setTimeout(() => document.getElementById('new-project-name').focus(), 50);
|
||||
}
|
||||
function closeCreateModal() {
|
||||
document.getElementById('create-modal-backdrop').classList.remove('active');
|
||||
}
|
||||
|
||||
document.getElementById('new-project-btn').addEventListener('click', openCreateModal);
|
||||
document.getElementById('empty-new-btn').addEventListener('click', openCreateModal);
|
||||
document.getElementById('create-modal-close').addEventListener('click', closeCreateModal);
|
||||
document.getElementById('create-cancel-btn').addEventListener('click', closeCreateModal);
|
||||
document.getElementById('create-modal-backdrop').addEventListener('click', e => {
|
||||
if (e.target.id === 'create-modal-backdrop') closeCreateModal();
|
||||
});
|
||||
|
||||
document.getElementById('new-project-name').addEventListener('input', function() {
|
||||
const slug = toSlug(this.value);
|
||||
const preview = document.getElementById('slug-preview');
|
||||
preview.textContent = this.value ? `資料夾名稱:${slug}` : '';
|
||||
});
|
||||
|
||||
document.getElementById('create-confirm-btn').addEventListener('click', async () => {
|
||||
const name = document.getElementById('new-project-name').value.trim();
|
||||
const desc = document.getElementById('new-project-desc').value.trim();
|
||||
if (!name) {
|
||||
showToast('請輸入專案名稱', 'error');
|
||||
document.getElementById('new-project-name').focus();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const btn = document.getElementById('create-confirm-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '建立中…';
|
||||
await apiFetch('/api/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description: desc }),
|
||||
});
|
||||
closeCreateModal();
|
||||
showToast(`專案「${name}」建立成功!`);
|
||||
await loadProjects();
|
||||
} catch(e) {
|
||||
showToast('建立失敗:' + e.message, 'error');
|
||||
} finally {
|
||||
const btn = document.getElementById('create-confirm-btn');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '建立專案';
|
||||
}
|
||||
});
|
||||
|
||||
// ── 設定 Offcanvas ────────────────────────────────────────────────
|
||||
async function openSettings(slug) {
|
||||
settingsCurrentSlug = slug;
|
||||
document.getElementById('settings-backdrop').classList.add('active');
|
||||
document.getElementById('settings-slug').value = slug;
|
||||
document.getElementById('settings-slug-display').value = slug;
|
||||
document.getElementById('settings-pages-list').innerHTML = '<li class="pages-loading">載入中…</li>';
|
||||
|
||||
try {
|
||||
const proj = await apiFetch(`/api/projects/${slug}`);
|
||||
document.getElementById('settings-name').value = proj.name;
|
||||
document.getElementById('settings-desc').value = proj.description || '';
|
||||
|
||||
const pages = proj.pages || [];
|
||||
const listEl = document.getElementById('settings-pages-list');
|
||||
if (pages.length === 0) {
|
||||
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">
|
||||
<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)}
|
||||
</li>`).join('');
|
||||
}
|
||||
} catch(e) {
|
||||
showToast('載入設定失敗:' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
document.getElementById('settings-backdrop').classList.remove('active');
|
||||
}
|
||||
|
||||
document.getElementById('settings-close').addEventListener('click', closeSettings);
|
||||
document.getElementById('settings-cancel-btn').addEventListener('click', closeSettings);
|
||||
document.getElementById('settings-backdrop').addEventListener('click', e => {
|
||||
if (e.target.id === 'settings-backdrop') closeSettings();
|
||||
});
|
||||
|
||||
document.getElementById('settings-save-btn').addEventListener('click', async () => {
|
||||
const name = document.getElementById('settings-name').value.trim();
|
||||
const desc = document.getElementById('settings-desc').value.trim();
|
||||
if (!name) {
|
||||
showToast('名稱不能為空', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiFetch(`/api/projects/${settingsCurrentSlug}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, description: desc }),
|
||||
});
|
||||
closeSettings();
|
||||
showToast('設定已儲存');
|
||||
await loadProjects();
|
||||
} catch(e) {
|
||||
showToast('儲存失敗:' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// 新增頁面
|
||||
document.getElementById('add-page-btn').addEventListener('click', async () => {
|
||||
const name = prompt('請輸入新頁面名稱(例如:about):');
|
||||
if (!name) return;
|
||||
try {
|
||||
await apiFetch(`/api/projects/${settingsCurrentSlug}/pages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
showToast(`頁面「${name}」建立成功`);
|
||||
openSettings(settingsCurrentSlug); // 重新整理面板
|
||||
} catch(e) {
|
||||
showToast('建立頁面失敗:' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ── 刪除 ─────────────────────────────────────────────────────────
|
||||
document.getElementById('delete-project-btn').addEventListener('click', () => {
|
||||
deleteTargetSlug = settingsCurrentSlug;
|
||||
const proj = allProjects.find(p => p.slug === settingsCurrentSlug);
|
||||
document.getElementById('delete-project-name').textContent = proj ? proj.name : deleteTargetSlug;
|
||||
document.getElementById('delete-modal-backdrop').classList.add('active');
|
||||
});
|
||||
|
||||
document.getElementById('delete-cancel-btn').addEventListener('click', () => {
|
||||
document.getElementById('delete-modal-backdrop').classList.remove('active');
|
||||
});
|
||||
|
||||
document.getElementById('delete-confirm-btn').addEventListener('click', async () => {
|
||||
try {
|
||||
await apiFetch(`/api/projects/${deleteTargetSlug}`, { method: 'DELETE' });
|
||||
document.getElementById('delete-modal-backdrop').classList.remove('active');
|
||||
closeSettings();
|
||||
showToast('專案已刪除');
|
||||
await loadProjects();
|
||||
} catch(e) {
|
||||
showToast('刪除失敗:' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('delete-modal-backdrop').addEventListener('click', e => {
|
||||
if (e.target.id === 'delete-modal-backdrop')
|
||||
document.getElementById('delete-modal-backdrop').classList.remove('active');
|
||||
});
|
||||
|
||||
// ── Enter 鍵快速提交 ─────────────────────────────────────────────
|
||||
document.getElementById('new-project-name').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') document.getElementById('create-confirm-btn').click();
|
||||
});
|
||||
|
||||
// ── 初始化 ────────────────────────────────────────────────────────
|
||||
loadProjects();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
146
uv.lock
generated
Normal file
146
uv.lock
generated
Normal file
@@ -0,0 +1,146 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
name = "blinker"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "3.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "blinker" },
|
||||
{ name = "click" },
|
||||
{ name = "itsdangerous" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "markupsafe" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vvvebjs"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "flask" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "flask", specifier = ">=3.1" }]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
|
||||
]
|
||||
33
websites/my-website/index.html
Normal file
33
websites/my-website/index.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>
|
||||
6
websites/my-website/project.json
Normal file
6
websites/my-website/project.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "My Website",
|
||||
"slug": "my-website",
|
||||
"description": "",
|
||||
"created_at": "2026-05-17T13:53:12"
|
||||
}
|
||||
6
websites/second-web/project.json
Normal file
6
websites/second-web/project.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Second Web",
|
||||
"slug": "second-web",
|
||||
"description": "",
|
||||
"created_at": "2026-05-17T13:53:12"
|
||||
}
|
||||
Reference in New Issue
Block a user