diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc
index 44e028f..bf25bc9 100644
Binary files a/__pycache__/main.cpython-313.pyc and b/__pycache__/main.cpython-313.pyc differ
diff --git a/main.py b/main.py
index 450c178..1207a23 100644
--- a/main.py
+++ b/main.py
@@ -137,9 +137,74 @@ def _copy_blank_template(dest: Path) -> None:
)
+def _flatten_tree_items(items: list[dict[str, Any]], file_names: set[str]) -> None:
+ for item in items:
+ if item.get("type") == "file" and isinstance(item.get("name"), str):
+ file_names.add(item["name"])
+ if isinstance(item.get("children"), list):
+ _flatten_tree_items(item["children"], file_names)
+
+
+def _normalize_page_tree(slug: str, tree: dict[str, Any]) -> dict[str, Any]:
+ """將已儲存的 page_tree 與實際檔案同步並補齊新頁面。"""
+ pages = set(_list_pages(slug))
+ existing_files: set[str] = set()
+ root_items = tree.get("root") if isinstance(tree.get("root"), list) else []
+ _flatten_tree_items(root_items, existing_files)
+
+ def filter_valid_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ result: list[dict[str, Any]] = []
+ for item in items:
+ item_type = item.get("type")
+ if item_type == "file":
+ name = item.get("name")
+ if isinstance(name, str) and name in pages:
+ existing_files.add(name)
+ result.append({
+ "type": "file",
+ "name": name,
+ "title": item.get("title", Path(name).stem.replace("-", " ").title()),
+ "show_in_nav": bool(item.get("show_in_nav", True)),
+ "is_homepage": bool(item.get("is_homepage", False)),
+ "requires_password": bool(item.get("requires_password", False)),
+ })
+ elif item_type == "folder":
+ name = item.get("name")
+ if isinstance(name, str):
+ children = filter_valid_items(item.get("children", []) if isinstance(item.get("children"), list) else [])
+ result.append({
+ "type": "folder",
+ "name": name,
+ "title": item.get("title", name.replace("-", " ").title()),
+ "show_in_nav": bool(item.get("show_in_nav", True)),
+ "children": children,
+ })
+ else:
+ continue
+ return result
+
+ normalized = {"root": filter_valid_items(root_items)}
+ missing_pages = sorted(pages - existing_files)
+ if missing_pages:
+ for rel in missing_pages:
+ normalized["root"].append({
+ "type": "file",
+ "name": rel,
+ "title": Path(rel).stem.replace("-", " ").title(),
+ "show_in_nav": True,
+ "is_homepage": rel == "index.html",
+ "requires_password": False,
+ })
+ return normalized
+
+
def build_page_tree(slug: str, max_depth: int = 3) -> dict[str, Any]:
- """遞歸建構專案的頁面樹狀結構(根據資料夾結構)。"""
+ """遞歸建構專案的頁面樹狀結構。"""
proj_dir = _project_dir(slug)
+ data = _load_project(slug)
+ page_tree = data.get("page_tree")
+ if isinstance(page_tree, dict) and isinstance(page_tree.get("root"), list):
+ return _normalize_page_tree(slug, page_tree)
def scan_folder(folder: Path, current_depth: int = 0):
items: list[dict[str, Any]] = []
@@ -168,6 +233,9 @@ def build_page_tree(slug: str, max_depth: int = 3) -> dict[str, Any]:
"type": "file",
"name": rel,
"title": Path(name).stem.replace("-", " ").title(),
+ "show_in_nav": True,
+ "is_homepage": rel == "index.html",
+ "requires_password": False,
})
return items
diff --git a/static/css/my-style.css b/static/css/my-style.css
index 25b7f55..91212dd 100644
--- a/static/css/my-style.css
+++ b/static/css/my-style.css
@@ -428,6 +428,22 @@ button { cursor: pointer; font-family: inherit; }
background: rgba(99,102,241,0.1);
}
+.tree-item-meta {
+ display: flex;
+ align-items: center;
+ gap: 0.3rem;
+ margin-left: 1rem;
+}
+
+.tree-item-meta .btn {
+ min-width: 2.2rem;
+}
+
+.tree-item-meta .btn.active {
+ border-color: rgba(59,130,246,0.6);
+ background: rgba(59,130,246,0.12);
+}
+
.tree-item-actions .btn {
padding: 0.35rem 0.7rem;
}
diff --git a/templates/project_dashboard.html b/templates/project_dashboard.html
index 20ff238..dcd45e0 100644
--- a/templates/project_dashboard.html
+++ b/templates/project_dashboard.html
@@ -340,6 +340,24 @@
const tree = await res.json();
const container = document.getElementById('pagesTree');
container.innerHTML = '';
+ container.addEventListener('dragover', e => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ });
+ container.addEventListener('drop', e => {
+ e.preventDefault();
+ const nameEnc = e.dataTransfer.getData('text/name');
+ if (!nameEnc) return;
+ const name = decodeURIComponent(nameEnc);
+ const src = container.querySelector(`[data-name='${CSS.escape(name)}']`);
+ if (!src) return;
+ let rootUl = container.querySelector(':scope > ul');
+ if (!rootUl) {
+ rootUl = document.createElement('ul');
+ container.appendChild(rootUl);
+ }
+ rootUl.appendChild(src);
+ });
function createLi(item) {
const li = document.createElement('li');
@@ -347,6 +365,9 @@
li.draggable = true;
li.dataset.type = item.type || 'file';
li.dataset.name = item.name || item.title || '';
+ li.dataset.showInNav = item.show_in_nav !== false ? 'true' : 'false';
+ li.dataset.isHomepage = item.is_homepage ? 'true' : 'false';
+ li.dataset.requiresPassword = item.requires_password ? 'true' : 'false';
const titleWrap = document.createElement('span');
titleWrap.className = 'tree-title';
@@ -356,7 +377,56 @@
btns.className = 'tree-item-actions';
btns.innerHTML = "";
+ const meta = document.createElement('span');
+ meta.className = 'tree-item-meta';
+ meta.innerHTML = `
+
+
+
+ `;
+
+ const btnNav = meta.querySelector('.btn-nav');
+ const btnHome = meta.querySelector('.btn-homepage');
+ const btnPassword = meta.querySelector('.btn-password');
+
+ const updateMetaState = () => {
+ const showNav = li.dataset.showInNav === 'true';
+ const isHomepage = li.dataset.isHomepage === 'true';
+ const requiresPassword = li.dataset.requiresPassword === 'true';
+ btnNav.classList.toggle('active', showNav);
+ btnNav.title = showNav ? '顯示於導覽列' : '從導覽列隱藏';
+ btnHome.classList.toggle('active', isHomepage);
+ btnHome.title = isHomepage ? '首頁' : '設為首頁';
+ btnPassword.classList.toggle('active', requiresPassword);
+ btnPassword.title = requiresPassword ? '需要密碼' : '不需要密碼';
+ if (li.dataset.type !== 'file') {
+ btnHome.disabled = true;
+ btnHome.title = '僅限單一頁面';
+ }
+ };
+
+ updateMetaState();
+
+ btnNav.addEventListener('click', () => {
+ li.dataset.showInNav = li.dataset.showInNav === 'true' ? 'false' : 'true';
+ updateMetaState();
+ });
+ btnPassword.addEventListener('click', () => {
+ li.dataset.requiresPassword = li.dataset.requiresPassword === 'true' ? 'false' : 'true';
+ updateMetaState();
+ });
+ btnHome.addEventListener('click', () => {
+ if (li.dataset.type !== 'file') return;
+ container.querySelectorAll('.tree-item').forEach(other => {
+ other.dataset.isHomepage = 'false';
+ });
+ container.querySelectorAll('.btn-homepage').forEach(button => button.classList.remove('active'));
+ li.dataset.isHomepage = 'true';
+ updateMetaState();
+ });
+
li.appendChild(titleWrap);
+ li.appendChild(meta);
li.appendChild(btns);
if (item.children && item.children.length) {
@@ -374,6 +444,7 @@
li.addEventListener('dragleave', () => { li.classList.remove('tree-drop-hover'); });
li.addEventListener('drop', e => {
e.preventDefault();
+ e.stopPropagation();
li.classList.remove('tree-drop-hover');
const nameEnc = e.dataTransfer.getData('text/name');
if (!nameEnc) return;
@@ -422,7 +493,14 @@
const title = li.querySelector('.tree-title')?.textContent || '';
const name = li.dataset.name || '';
const type = li.dataset.type || 'file';
- const obj = { name, title, type };
+ const obj = {
+ name,
+ title,
+ type,
+ show_in_nav: li.dataset.showInNav === 'true',
+ is_homepage: li.dataset.isHomepage === 'true',
+ requires_password: li.dataset.requiresPassword === 'true',
+ };
const childUl = li.querySelector(':scope > ul');
if (childUl) obj.children = walk(childUl);
arr.push(obj);
diff --git a/websites/my-website/project.json b/websites/my-website/project.json
index 534e4cb..cc5a0ac 100644
--- a/websites/my-website/project.json
+++ b/websites/my-website/project.json
@@ -2,5 +2,86 @@
"name": "My Website",
"slug": "my-website",
"description": "",
- "created_at": "2026-05-17T13:53:12"
+ "created_at": "2026-05-17T13:53:12",
+ "page_tree": {
+ "root": [
+ {
+ "name": "fold.html",
+ "title": "Fold",
+ "type": "file"
+ },
+ {
+ "name": "about-final.html",
+ "title": "About Final",
+ "type": "file"
+ },
+ {
+ "name": "index.html",
+ "title": "Index",
+ "type": "file"
+ },
+ {
+ "name": "my-page.html",
+ "title": "My Page",
+ "type": "file"
+ },
+ {
+ "name": "my-page3.html",
+ "title": "My Page3",
+ "type": "file"
+ },
+ {
+ "name": "my-page4.html",
+ "title": "My Page4",
+ "type": "file"
+ },
+ {
+ "name": "subfolder",
+ "title": "Subfolder",
+ "type": "folder",
+ "children": [
+ {
+ "name": "subfolder/subpage-title.html",
+ "title": "Subpage Title",
+ "type": "file"
+ }
+ ]
+ },
+ {
+ "name": "temp",
+ "title": "Temp",
+ "type": "folder",
+ "children": [
+ {
+ "name": "temp/my-page5.html",
+ "title": "My Page5",
+ "type": "file"
+ },
+ {
+ "name": "temp/new.html",
+ "title": "New",
+ "type": "file"
+ },
+ {
+ "name": "subsubfolder",
+ "title": "Subsubfolder",
+ "type": "folder",
+ "children": [
+ {
+ "name": "temp/subsubfolder/myname.html",
+ "title": "Myname",
+ "type": "file"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "test.html",
+ "title": "Test",
+ "type": "file"
+ }
+ ]
+ },
+ "updated_at": "2026-05-26T04:12:23"
}
\ No newline at end of file