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