Compare commits

..

6 Commits

21 changed files with 2349 additions and 13 deletions

296
README.md
View File

@@ -23,5 +23,301 @@ uv(python 3.13)
- [ ] 網站設定如網站名稱、網站描述、網站logo等 - [ ] 網站設定如網站名稱、網站描述、網站logo等
- [ ] 自動導覽列模組(根據頁面名稱自動生成導覽列) - [ ] 自動導覽列模組(根據頁面名稱自動生成導覽列)
- [ ] 電子報設計功能
# 招募貢獻 # 招募貢獻
目前還在開發中,歡迎有興趣的開發者一起參與開發,可以[來信](mailto:hi@l.nudoragon.com)跟我說! 目前還在開發中,歡迎有興趣的開發者一起參與開發,可以[來信](mailto:hi@l.nudoragon.com)跟我說!
## 📋 **分步計畫架構**
根據你的需求,我建議將開發分為 **5 個優先級階段**,共 1-2 個月:
### **Phase 1網站核心功能第 1-2 週)**
✨ 對標 Google Sites 的基礎管理功能
**步驟:**
1. **網站設定模組** — 獨立儀表板頁面支援編輯「網站名稱、描述、Logo、主色、favicon」
- 儲存到 `project.json` 擴展結構
- 前端:新增 `/dashboard/project-settings` 路由
2. **檔案管理系統** — 統一的媒體資源管理
- 改進現有 `upload.php` → 遷移到 Flask
- 支援資料夾分類上傳
- 圖片預覽 + 刪除功能
3. **頁面組織改進** — 支援頁面群組和排序
-`project.json` 新增「頁面樹狀結構」和「頁面順序」
- 編輯器面板顯示頁面層級關係
**相關文件**
- main.py — 擴展 `/api/projects/<slug>/settings` 端點
- dashboard.html — 新增設定頁面
- my-editor.js — 更新檔案管理器
**依賴**: 無 | **驗證**: ✅ 能建立和編輯網站基本資訊
Phase 1檔案管理優化
API 端點POST /api/projects/<slug>/media/upload
圖片預覽和刪除
自動生成縮圖
本地圖片上傳結構:
websites/
└─ my-website/
└─ media/
├─ 2026-05/
│ ├─ banner.jpg
│ └─ thumbnail.png
└─ index.json (圖片元數據上傳時間、大小、alt文本)
---
### **Phase 2自動導覽列模組第 2-3 週)**
🧭 根據頁面結構自動生成
**步驟:**
1. **導覽列配置系統** — 支援多種模式
- 模式 A自動頁面層級自動生成
- 模式 B手動拖拽排序選擇
- 模式 C混合自動+手動覆蓋)
2. **導覽列組件化** — 建立可拖拽的導覽列編輯UI
-`project.json` 儲存導覽結構
- 前端:建立可視化編輯器(拖拽排序、新增/刪除項目)
3. **導覽列注入** — 自動將導覽列代碼注入所有頁面
- 建立 HTML 範本 (`_navbar.html`)
- 編輯器保存時自動合併(防止重複注入)
**相關文件**
- main.py — 新增 `/api/projects/<slug>/navbar` 端點
- templates — 新增導覽列管理頁面
- Vvvebjs — 建立導覽列組件庫
**依賴**: 依賴 Phase 1 | **驗證**: ✅ 導覽列在所有頁面正確顯示和更新
---
### **Phase 3部落格模組基礎第 3-4 週)**
📰 文章列表、分類、存檔
**步驟:**
1. **部落格資料模型** — 擴展頁面結構
- 新增文章元數據:`title`, `date`, `category`, `tags`, `excerpt`, `author`
- 儲存位置:`websites/<slug>/blog/posts/` 下的 `post.json` + `post.html`
2. **文章管理 API** — 專用的部落格 CRUD 端點
- `POST /api/projects/<slug>/blog/posts` — 建立文章
- `GET /api/projects/<slug>/blog/posts?category=xxx` — 篩選
- 支援日期排序、分類篩選
3. **部落格頁面範本** — 自動生成部落格首頁
- 文章列表(含摘要)
- 分類側邊欄
- 存檔面板(按月份)
4. **前端整合** — 編輯器新增「部落格」編輯模式
- 部落格文章編輯表單(填充元數據)
- 預覽實時更新
**相關文件**
- main.py — 新增 `/api/projects/<slug>/blog/*` 端點
- `templates/blog-editor.html` — 新增部落格編輯頁面
- blog — 參考範本
**依賴**: 依賴 Phase 1 | **驗證**: ✅ 能建立/編輯/分類文章,自動生成列表
Phase 3部落格文件組織
搜尋策略:
啟動時掃描所有 post.json建立內存索引
簡單全文搜尋title + excerpt 關鍵詞匹配)
快取文章索引5 分鐘更新一次)
websites/my-website/
└─ blog/
├─ posts/
│ ├─ 2026-05-20-first-post/
│ │ ├─ post.json {title, date, category, tags, author, excerpt}
│ │ └─ index.html {完整文章內容}
│ └─ 2026-05-19-second-post/
├─ categories.json {category_name: count}
└─ index.json {最後更新時間、文章總數}
---
### **Phase 4進階部落格功能第 4-5 週)**
💬 評論、標籤、RSS
**步驟:**
1. **評論系統** — 支援簡單評論(可選認證)
- 後端:`comments/` 資料夾儲存評論 JSON
- 前端:評論表單 + 列表顯示
- 可選垃圾過濾(簡單關鍵詞檢查)
2. **標籤系統** — 完整的標籤索引
- `tags/` 頁面自動生成(顯示所有標籤及文章計數)
- 支援標籤雲視覺化
3. **RSS 源** — 自動生成 RSS feed
- `GET /sites/<slug>/blog/feed.xml` — 最新 N 篇文章
- 支援類別篩選 RSS
4. **搜尋功能** — 部落格內搜尋
- 簡單全文搜尋Python 端搜尋文章標題/內容)
- 前端搜尋表單
**相關文件**
- main.py — 新增 RSS 端點、評論端點、搜尋端點
- templates — 評論表單、搜尋結果頁面
- 新增 `utils/rss_generator.py` — RSS 生成工具
**依賴**: 依賴 Phase 3 | **驗證**: ✅ RSS 訂閱有效,評論顯示正確,搜尋功能工作
Phase 4評論和垃圾過濾
垃圾過濾(基礎):
檢查過多連結(>3 個 URL = 高風險)
大寫字母比例(>70% = 可疑)
黑名單關鍵詞
評論長度限制(最多 5000 字)
websites/my-website/blog/posts/2026-05-20-first-post/
└─ comments.json
[
{
"id": "uuid",
"author": "name",
"email": "test@example.com",
"content": "Great article!",
"timestamp": "2026-05-20T10:30:00",
"spam_score": 0.1 // 簡單評分
}
]
---
### **Phase 5簡易認證 + 共享機制(第 5-6 週)**
🔐 社團成員協作與角色管理
**步驟:**
1. **簡易認證系統** — 輕量級密碼保護
- 後端:管理員密碼 + session token
- 可選CSV 匯入成員清單(每人隨機密碼)
2. **角色與權限** — 基於角色的訪問控制
- 角色:管理員(完全控制)、編輯者(編輯頁面)、查看者(只讀)
- 儲存在 `project.json``members` 陣列
3. **共享邀請** — 簡單的邀請機制
- 產生邀請連結(有效期 7 天)
- 訪客可透過邀請連結加入
4. **審計日誌** — 追蹤編輯歷史
- 簡單版本控制:記錄每次保存的「時間、使用者、變更內容」
- 前端:「版本歷史」側邊欄,支援回溯預覽
**相關文件**
- main.py — 認證中介軟體、權限檢查
- main.py — 新增 `/api/auth/*` 端點(登入、邀請、成員管理)
- `templates/login.html`, `members.html` — 新增認證頁面
- `utils/audit.py` — 審計日誌工具
**依賴**: 依賴 Phase 1 | **驗證**: ✅ 能登入、邀請成員、檢視版本歷史
Phase 5審計日誌與版本 Diff
Diff 工具:
使用 Python difflib 對 HTML 進行行級比較
前端顯示「並排對比」或「高亮改變」
支援「回復到此版本」功能
websites/my-website/
└─ .audits/
├─ 2026-05-20-10-30-00-user1-edit.json
│ {
│ "timestamp": "...",
│ "user": "user1",
│ "action": "edit",
│ "page": "index.html",
│ "diff_type": "html_diff",
│ "diff": [
│ {"type": "unchanged", "content": "<body>..."},
│ {"type": "added", "content": "<h1>New Title</h1>"},
│ {"type": "removed", "content": "<h1>Old Title</h1>"}
│ ],
│ "full_hash": "sha256:abc123..."
│ }
└─ index.json {版本清單}
---
### **Phase 6部署與進階功能第 6-8 週)**
🚀 自託管選項、進階範本、國際化
**步驟:**
1. **部署支援包** — Docker 容器化 + 部署指南
- 編寫 `Dockerfile` 最佳實踐
- 提供 `docker-compose.yml` 配置
- 支援環境變數配置資料庫、CDN URL
2. **進階範本庫** — 20+ 預設網站模板
- 行業模板(餐廳、工作室、社群、新聞)
- 一鍵部署模板到新專案
3. **SEO 最佳化** — 後設資料管理
- 每頁支援 title、description、og tags
- 自動 sitemap 生成
- 結構化資料JSON-LD
4. **完整國際化** — i18n 支援
- 多語言界面至少繁體中文、English
- 前端翻譯系統
**相關文件**
- `Dockerfile`, `docker-compose.yml` — 容器化
- 新增 `templates/templates/` — 範本庫管理
- 新增 `utils/seo.py`, `utils/i18n.py` — SEO 和翻譯工具
**依賴**: 依賴 Phase 1 | **驗證**: ✅ 可用 Docker 快速啟動,能選擇範本,多語言切換正常
---
## 🎯 **實施優先級與並行工作**
| 週次 | 主要任務 | 平行工作 | 完成指標 |
|-----|--------|--------|--------|
| W1-2 | Phase 1網站設定、檔案管理 | — | 可編輯網站基本資訊 |
| W2-3 | Phase 2自動導覽列 | 回顧 Phase 1 | 所有頁面有正確導覽列 |
| W3-4 | Phase 3部落格基礎 | 準備 Phase 4 元素 | 能發佈文章、自動生成列表 |
| W4-5 | Phase 4評論、RSS、搜尋 | — | RSS 訂閱、評論、搜尋可用 |
| W5-6 | Phase 5簡易認證、邀請 | 進行 Phase 6 規劃 | 能登入、邀請成員、檢視版本 |
| W6-8 | Phase 6部署、範本、SEO | 用戶測試 | Docker 快速部署、支援多語言 |
---
## 🤔 **需要你決定的事項**
1. **部落格存儲方案**
V- 方案 A純文件系統 (JSON + HTML) — 簡單但搜尋效能差
- 方案 BSQLite 資料庫 — 更好的搜尋和篩選
- 方案 C混合 — 快取 + 搜尋索引
2. **評論審核**
V- 開放所有評論?
- 需要管理員審核?
- 支援評論回覆線程?
3. **圖片儲存**
V- 本地檔案系統?
- 外部 CDN如 Cloudinary
- S3 相容服務?
4. **版本控制深度**
- 簡單版本記錄(時間、使用者)?
V- 完整 diff顯示改變內容
- Git 整合?
---
**你對這個計畫有什麼想法?** 需要調整優先級或合併某些階段嗎?

Binary file not shown.

369
docs/phase1.md Normal file
View File

@@ -0,0 +1,369 @@
## 📋 **Plan: Phase 1 詳細實施方案**
根據所有決定,以下是完整的 Phase 1 規劃:
---
## **🎯 Phase 1 三大核心模組**
### **1⃣ 網站設定模組Project Settings**
**數據結構** — 擴展 `project.json`
```json
{
"name": "My Website",
"slug": "my-website",
"description": "社團網站",
"created_at": "2026-05-20T10:00:00",
"updated_at": "2026-05-26T15:30:00",
"settings": {
"title": "社團官網",
"subtitle": "歡迎來到我們的社團",
"logo_url": "/sites/my-website/media/logo.png",
"favicon_url": "/sites/my-website/media/favicon.ico",
"primary_color": "#007bff",
"secondary_color": "#6c757d",
"description": "關於我們的社團",
"keywords": "社團,網站",
"author": "社團名稱"
},
"pages_order": ["index.html", "about.html", "contact.html"],
"navbar_enabled": false,
"blog_enabled": false
}
```
**後端 API 端點**
- `GET /api/projects/<slug>/settings` — 獲取網站設定
- `PUT /api/projects/<slug>/settings` — 更新網站設定
- `POST /api/projects/<slug>/settings/logo` — 上傳 Logo轉向媒體管理
- `POST /api/projects/<slug>/settings/favicon` — 上傳 Favicon
**前端介面(兩個位置)**
**位置 A儀表板** — dashboard.html 新增設定標籤
```html
<div class="nav-tabs">
<button class="tab-btn active">概覽</button>
<button class="tab-btn">🔧 網站設定</button>
<button class="tab-btn">📄 頁面管理</button>
<button class="tab-btn">🖼️ 媒體庫</button>
</div>
<div class="settings-panel">
<form id="settingsForm">
<input type="text" id="title" placeholder="網站標題" />
<textarea id="description" placeholder="網站描述"></textarea>
<input type="color" id="primaryColor" />
<button type="submit">💾 儲存設定</button>
</form>
</div>
```
**位置 B編輯器快速存取** — editor.html 右上方快速菜單
```html
<button class="quick-settings-btn" title="快速設定">⚙️</button>
<!-- 點擊時展開小面板(不離開編輯器) -->
```
**依賴**: 無 | **相關改動**:
- main.py — 新增 `/api/projects/<slug>/settings` 兩個端點
- dashboard.html — 新增「網站設定」標籤頁
- my-editor.js — 快速設定面板
---
### **2⃣ 檔案管理系統Media Manager**
**本地文件結構** — 圖片存儲在專案下:
```
websites/my-website/
├─ project.json
├─ index.html
├─ about.html
├─ media/
│ ├─ images/
│ │ ├─ 2026-05/
│ │ │ ├─ banner.jpg (上傳時間組織)
│ │ │ ├─ banner-thumb.jpg (自動生成縮圖)
│ │ │ └─ thumbnail.png
│ │ └─ 2026-06/
│ ├─ uploads.json (元數據索引)
│ └─ .gitkeep
```
**後端 API 端點**
- `POST /api/projects/<slug>/media/upload` — 上傳圖片
- 接受jpg, png, gif, webp, svg
- 限制:單檔 2MB自動生成縮圖
- 返回:`{filename, url, thumb_url, size, upload_time}`
- `GET /api/projects/<slug>/media/list` — 列出所有媒體
- 返回:`[{filename, url, thumb_url, size, upload_time}, ...]`
- `DELETE /api/projects/<slug>/media/<filename>` — 刪除媒體
**前端介面** — 儀表板媒體庫頁籤:
```html
<div class="media-manager">
<!-- 拖拽上傳區 -->
<div class="upload-dropzone" id="dropZone">
拖放圖片到此或 <button>點擊上傳</button>
</div>
<!-- 媒體網格 -->
<div class="media-grid" id="mediaGrid">
<!-- 動態生成媒體卡片 -->
<div class="media-card">
<img src="banner-thumb.jpg" />
<div class="card-info">
<p class="filename">banner.jpg</p>
<p class="size">152 KB • 2026-05-20</p>
<div class="card-actions">
<button class="copy-url" title="複製連結">📋</button>
<button class="delete-btn" title="刪除">🗑️</button>
</div>
</div>
</div>
</div>
</div>
```
**依賴**: 依賴 1網站設定先完成 | **相關改動**:
- main.py — 新增 `/api/projects/<slug>/media/*` 三個端點
- 新增 utils/media_manager.py — 媒體上傳、縮圖生成、驗證
- dashboard.html — 新增媒體庫頁籤
- my-editor.js — 拖拽上傳邏輯、網格展示
**技術細節**
```python
# 縮圖生成邏輯Pillow
from PIL import Image
original = Image.open(file)
original.thumbnail((300, 300))
original.save(thumb_path)
# 檔名安全化
import uuid
safe_filename = f"{uuid.uuid4()}_{secure_filename(original_name)}"
```
---
### **3⃣ 頁面組織改進Page Organization**
**數據結構** — 在 `project.json` 中新增頁面樹狀結構和排序:
```json
{
"page_tree": {
"root": [
{"name": "index.html", "title": "首頁", "order": 0},
{
"name": "about",
"title": "關於我們",
"order": 1,
"children": [
{"name": "about.html", "title": "簡介", "order": 0},
{"name": "team.html", "title": "成員", "order": 1}
]
},
{"name": "contact.html", "title": "聯絡", "order": 2}
]
}
}
```
**頁面層級規則** — 按資料夾深度自動決定(如決定 5
```
index.html → Level 0
about.html → Level 0
services/ → Level 1資料夾
consulting.html → Level 2子頁面
team/operations/ → Level 2嵌套資料夾
manager.html → Level 3子子頁面
```
**後端 API 端點**
- `GET /api/projects/<slug>/pages-tree` — 獲取頁面樹狀結構
- `PUT /api/projects/<slug>/pages-tree` — 更新頁面順序和層級
- `POST /api/projects/<slug>/pages/<page_path>/reorder` — 移動頁面
**前端介面** — 編輯器左側面板改進:
```html
<div class="pages-panel">
<div class="pages-tree" id="pagesTree">
<!-- 遞歸樹狀展示 -->
<div class="tree-item root-folder">
<span class="folder-icon">📁</span>
<span class="folder-name">根目錄</span>
<ul class="tree-children">
<li class="tree-item page">
<span class="page-icon">📄</span>
<span class="page-name">首頁 (index.html)</span>
<div class="page-actions">
<button class="edit-page">✏️</button>
<button class="duplicate-page">📋</button>
<button class="delete-page">🗑️</button>
</div>
</li>
<li class="tree-item folder">
<span class="folder-toggle"></span>
<span class="folder-icon">📁</span>
<span class="folder-name">services/</span>
<ul class="tree-children" style="display:none">
<li class="tree-item page">
<span class="page-icon">📄</span>
<span class="page-name">consulting.html</span>
</li>
</ul>
</li>
</ul>
</div>
</div>
<!-- 拖拽排序控制 -->
<div class="sort-controls">
<button id="sortAlpha">按字母排序 A-Z</button>
<button id="sortRecent">按修改時間</button>
<button id="sortCustom">自訂順序</button>
</div>
</div>
```
**排序邏輯**
- **字母排序**: 遞歸排序每層頁面
- **修改時間**: 按 `updated_at` 時間戳排序
- **自訂順序**: 允許拖拽重新排序(存到 `page_tree`
**依賴**: 依賴 1、2 | **相關改動**:
- main.py — 新增 `/api/projects/<slug>/pages-tree` 端點
- 修改現有 main.py 的頁面掃描邏輯(支援層級)
- editor.html — 新增樹狀面板
- my-editor.js — 樹狀展示、拖拽邏輯、排序功能
**技術細節** — 遞歸掃描資料夾構建層級:
```python
def build_page_tree(project_path, max_depth=3):
"""遞歸構建頁面樹狀結構"""
def scan_folder(folder_path, current_depth=0):
if current_depth > max_depth:
return []
items = []
for item in sorted(os.listdir(folder_path)):
if item.startswith('.'):
continue
full_path = os.path.join(folder_path, item)
if item.endswith('.html'):
# HTML 文件
title = item.replace('.html', '').replace('-', ' ').title()
items.append({
"type": "file",
"name": item,
"title": title,
"order": len(items)
})
elif os.path.isdir(full_path):
# 資料夾
items.append({
"type": "folder",
"name": item,
"title": item.replace('-', ' ').title(),
"children": scan_folder(full_path, current_depth + 1),
"order": len(items)
})
return items
return {"root": scan_folder(project_path)}
```
---
## 📐 **實施步驟(優先級順序)**
| 步驟 | 任務 | 預計時間 | 依賴 |
|-----|------|--------|------|
| **1.1** | 設計 `project.json` 擴展結構 | 1 小時 | — |
| **1.2** | 後端:網站設定 API (GET/PUT) | 2 小時 | 1.1 |
| **1.3** | 前端:儀表板設定頁籤 | 3 小時 | 1.2 |
| **1.4** | 前端:編輯器快速設定面板 | 2 小時 | 1.2 |
| **2.1** | 建立媒體上傳 API | 3 小時 | — |
| **2.2** | 實現縮圖生成 + 安全驗證 | 2 小時 | 2.1 |
| **2.3** | 前端:媒體庫 UI網格 + 上傳) | 4 小時 | 2.1 |
| **2.4** | 前端:編輯器集成媒體插入 | 2 小時 | 2.3 |
| **3.1** | 設計頁面樹狀結構數據 | 1 小時 | — |
| **3.2** | 後端:頁面樹狀掃描 + API | 3 小時 | 3.1 |
| **3.3** | 前端:樹狀面板 UI | 4 小時 | 3.2 |
| **3.4** | 前端:拖拽排序邏輯 | 3 小時 | 3.3 |
| **3.5** | 前端:排序按鈕(字母/時間/自訂) | 2 小時 | 3.4 |
**總計**: 約 33 小時 ≈ **5 工作天**
---
## 🗂️ **檔案修改清單**
| 檔案 | 修改類型 | 主要改動 |
|-----|--------|--------|
| main.py | 修改 | 新增 `settings` 相關端點、媒體 API、頁面樹狀 API |
| dashboard.html | 新建/擴展 | 新增 3 個主要標籤頁(設定、頁面、媒體) |
| editor.html | 修改 | 新增快速設定按鈕、樹狀頁面面板 |
| my-editor.js | 擴展 | 加入媒體管理、樹狀邏輯、拖拽排序 |
| utils/media_manager.py | 新建 | 媒體上傳、驗證、縮圖生成 |
| 無需修改 | — | build_editor.py暫不涉及 |
---
## ✅ **驗收標準**
### **网站設定模組**
1. ✅ 可在儀表板編輯並儲存網站基本資訊(標題、描述、顏色)
2. ✅ 編輯器快速面板可快速存取設定
3. ✅ 刷新頁面後設定值保留
### **檔案管理系統**
1. ✅ 可拖拽上傳圖片到媒體庫
2. ✅ 上傳後自動生成縮圖和複製 URL 按鈕
3. ✅ 可刪除媒體,檔案系統同時刪除
4. ✅ 在編輯器內可插入媒體庫圖片
### **頁面組織改進**
1. ✅ 左側面板以樹狀顯示所有頁面及資料夾結構
2. ✅ 可按字母/時間/手動排序頁面
3. ✅ 拖拽頁面重新排序後自動儲存
4. ✅ 深度超過 3 層的頁面不顯示(避免過度複雜)
---
## 🚀 **建議開發流程**
**第一天**
- 開會確認所有決定 ✅(已完成)
- 設計數據結構,建立 `project.json` 擴展
**第二天**
- 實現後端 API設定、媒體、頁面樹狀
- 單元測試確保 API 正常
**第三天**
- 儀表板 UI設定、媒體庫
- 前端 API 集成
**第四天**
- 編輯器快速面板 + 樹狀頁面面板
- 拖拽排序邏輯
**第五天**
- 集成測試、bug 修復
- 本地運行測試
---
**你對 Phase 1 計畫有任何問題或想要調整嗎?** 是否確認可以開始實施?

226
main.py
View File

@@ -8,8 +8,13 @@ import shutil
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import os
import uuid
from flask import Flask, Response, abort, jsonify, render_template, request, send_from_directory from flask import Flask, Response, abort, jsonify, render_template, request, send_from_directory
from werkzeug.utils import secure_filename
from utils.media_manager import save_upload, list_media, delete_media
app = Flask(__name__) app = Flask(__name__)
@@ -132,6 +137,115 @@ 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)),
"has_header": item.get("has_header") is not False,
"has_footer": bool(item.get("has_footer", 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,
"has_header": True,
"has_footer": 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]] = []
if current_depth > max_depth:
return items
try:
names = sorted([p.name for p in folder.iterdir()])
except Exception:
return items
for name in names:
if name.startswith("."):
continue
full = folder / name
if full.is_dir():
children = scan_folder(full, current_depth + 1)
items.append({
"type": "folder",
"name": name,
"title": name.replace("-", " ").title(),
"children": children,
})
elif full.is_file() and full.suffix.lower() == ".html":
rel = str(full.relative_to(proj_dir)).replace("\\", "/")
items.append({
"type": "file",
"name": rel,
"title": Path(name).stem.replace("-", " ").title(),
"show_in_nav": True,
"is_homepage": rel == "index.html",
"requires_password": False,
})
return items
return {"root": scan_folder(proj_dir, 0)}
# ── 路由:一般頁面 ────────────────────────────────────────────────────────── # ── 路由:一般頁面 ──────────────────────────────────────────────────────────
@app.route("/") # type: ignore[untyped-decorator] @app.route("/") # type: ignore[untyped-decorator]
@@ -142,9 +256,19 @@ def index() -> Response:
@app.route("/dashboard") # type: ignore[untyped-decorator] @app.route("/dashboard") # type: ignore[untyped-decorator]
def dashboard() -> str: def dashboard() -> str:
# render projects overview
return str(render_template("dashboard.html")) return str(render_template("dashboard.html"))
@app.route("/dashboard/project/<slug>") # type: ignore[untyped-decorator]
def project_dashboard(slug: str) -> str:
# render single project management dashboard
proj_dir = _project_dir(slug)
if not proj_dir.exists():
abort(404)
return str(render_template("project_dashboard.html", slug=slug))
@app.route("/editor/<slug>") # type: ignore[untyped-decorator] @app.route("/editor/<slug>") # type: ignore[untyped-decorator]
def editor(slug: str) -> str: def editor(slug: str) -> str:
proj_dir = _project_dir(slug) proj_dir = _project_dir(slug)
@@ -195,6 +319,40 @@ def api_list_projects() -> Response:
return jsonify(projects) return jsonify(projects)
@app.route("/api/projects/<slug>/settings", methods=["GET"]) # type: ignore[untyped-decorator]
def api_get_settings(slug: str) -> tuple[Response, int] | Response:
if not _project_dir(slug).exists():
return jsonify({"error": "專案不存在"}), 404
data = _load_project(slug)
settings = data.get("settings", {})
# provide sensible defaults
defaults = {
"title": data.get("name", slug),
"description": data.get("description", ""),
"logo_url": None,
"favicon_url": None,
"primary_color": "#007bff",
"secondary_color": "#6c757d",
}
merged = {**defaults, **settings}
return jsonify(merged)
@app.route("/api/projects/<slug>/settings", methods=["PUT"]) # type: ignore[untyped-decorator]
def api_put_settings(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)
settings = data.get("settings", {})
settings.update(body)
data["settings"] = settings
data["updated_at"] = _now_iso()
_save_project(slug, data)
return jsonify({"ok": True, "settings": settings})
@app.route("/api/projects", methods=["POST"]) # type: ignore[untyped-decorator] @app.route("/api/projects", methods=["POST"]) # type: ignore[untyped-decorator]
def api_create_project() -> tuple[Response, int]: def api_create_project() -> tuple[Response, int]:
body: dict[str, Any] = request.get_json(force=True) or {} body: dict[str, Any] = request.get_json(force=True) or {}
@@ -261,6 +419,74 @@ def api_list_pages(slug: str) -> tuple[Response, int] | Response:
return jsonify(_list_pages(slug)) return jsonify(_list_pages(slug))
@app.route("/api/projects/<slug>/pages-tree", methods=["GET"]) # type: ignore[untyped-decorator]
def api_pages_tree(slug: str) -> tuple[Response, int] | Response:
if not _project_dir(slug).exists():
return jsonify({"error": "專案不存在"}), 404
tree = build_page_tree(slug, max_depth=5)
return jsonify(tree)
@app.route("/api/projects/<slug>/pages-tree", methods=["PUT"]) # type: ignore[untyped-decorator]
def api_put_pages_tree(slug: str) -> tuple[Response, int] | Response:
"""儲存自訂的頁面樹狀結構到 project.json 的 page_tree 欄位。"""
if not _project_dir(slug).exists():
return jsonify({"error": "專案不存在"}), 404
body: dict[str, Any] = request.get_json(force=True) or {}
if not isinstance(body, dict):
return jsonify({"error": "不正確的資料格式"}), 400
data = _load_project(slug)
data["page_tree"] = body
data["updated_at"] = _now_iso()
_save_project(slug, data)
return jsonify({"ok": True, "page_tree": body})
@app.route("/api/projects/<slug>/media/upload", methods=["POST"]) # type: ignore[untyped-decorator]
def api_media_upload(slug: str) -> tuple[Response, int] | Response:
proj_dir = _project_dir(slug)
if not proj_dir.exists():
return jsonify({"error": "專案不存在"}), 404
if "file" not in request.files:
return jsonify({"error": "缺少檔案 (file)"}), 400
f = request.files["file"]
if f.filename == "":
return jsonify({"error": "檔名為空"}), 400
# basic validation
filename = secure_filename(f.filename)
meta = save_upload(proj_dir, f)
# substitute slug in returned urls
if isinstance(meta.get("url"), str):
meta["url"] = meta["url"].replace("{slug}", slug)
if isinstance(meta.get("thumb"), str):
meta["thumb"] = meta["thumb"].replace("{slug}", slug)
return jsonify({"ok": True, "file": meta})
@app.route("/api/projects/<slug>/media/list", methods=["GET"]) # type: ignore[untyped-decorator]
def api_media_list(slug: str) -> tuple[Response, int] | Response:
proj_dir = _project_dir(slug)
if not proj_dir.exists():
return jsonify({"error": "專案不存在"}), 404
items = list_media(proj_dir)
# patch urls
for it in items:
if "filename" in it and "date" in it:
it["url"] = f"/sites/{slug}/media/images/{it['date']}/{it['filename']}"
return jsonify(items)
@app.route("/api/projects/<slug>/media/<path:rel_path>", methods=["DELETE"]) # type: ignore[untyped-decorator]
def api_media_delete(slug: str, rel_path: str) -> tuple[Response, int] | Response:
proj_dir = _project_dir(slug)
if not proj_dir.exists():
return jsonify({"error": "專案不存在"}), 404
ok = delete_media(proj_dir, rel_path)
if not ok:
return jsonify({"error": "刪除失敗或檔案不存在"}), 400
return jsonify({"ok": True})
@app.route("/api/projects/<slug>/pages", methods=["POST"]) # type: ignore[untyped-decorator] @app.route("/api/projects/<slug>/pages", methods=["POST"]) # type: ignore[untyped-decorator]
def api_create_page(slug: str) -> tuple[Response, int]: def api_create_page(slug: str) -> tuple[Response, int]:
if not _project_dir(slug).exists(): if not _project_dir(slug).exists():

View File

@@ -7,3 +7,9 @@ requires-python = ">=3.13"
dependencies = [ dependencies = [
"flask>=3.1", "flask>=3.1",
] ]
[tool.pip]
# Image processing for media manager
dependencies = [
"Pillow>=10.0"
]

63
scripts/test_phase1.py Normal file
View File

@@ -0,0 +1,63 @@
"""Automated basic end-to-end test for Phase 1 features.
Creates a project, updates settings, uploads an image, lists media, deletes image.
"""
import io
import os
import sys
import time
import json
import requests
from PIL import Image
BASE = 'http://127.0.0.1:5000'
def create_project(name='test-project'):
r = requests.post(f'{BASE}/api/projects', json={'name': name, 'description': 'automated test'})
r.raise_for_status()
return r.json()
def update_settings(slug):
r = requests.put(f'{BASE}/api/projects/{slug}/settings', json={'title':'Auto Test','description':'desc'})
r.raise_for_status()
return r.json()
def upload_image(slug):
# generate small PNG
im = Image.new('RGB', (100,100), color=(123,222,100))
buf = io.BytesIO()
im.save(buf, format='PNG')
buf.seek(0)
files = {'file': ('test.png', buf, 'image/png')}
r = requests.post(f'{BASE}/api/projects/{slug}/media/upload', files=files)
r.raise_for_status()
return r.json()
def list_media(slug):
r = requests.get(f'{BASE}/api/projects/{slug}/media/list')
r.raise_for_status()
return r.json()
def delete_media(slug, rel):
r = requests.delete(f'{BASE}/api/projects/{slug}/media/{rel}')
return r
if __name__ == '__main__':
print('Creating project...')
proj = create_project('phase1-e2e')
slug = proj['slug']
print('Project created:', slug)
print('Updating settings...')
print(update_settings(slug))
print('Uploading image...')
up = upload_image(slug)
print('Upload result:', up)
time.sleep(0.5)
items = list_media(slug)
print('Media list count:', len(items))
if items:
it = items[0]
rel = f"media/images/{it['date']}/{it['filename']}"
print('Deleting', rel)
r = delete_media(slug, rel)
print('Delete status', r.status_code)
print('Done')

View File

@@ -20,14 +20,31 @@
</style> </style>
</head> </head>
<body> <body>
<header class="page-header bg-light py-3">
<div class="container">
<div class="row">
<div class="col-lg-12 text-center">
<h1 class="mt-3">Page Header</h1>
<p class="mb-0">This header is editable in the editor.</p>
</div>
</div>
</div>
</header>
<!-- Page Content --> <!-- Page Content -->
<div class="container"> <main class="container py-5">
<div class="row"> <div class="row">
<div class="col-lg-12 text-center"> <div class="col-lg-12 text-center">
<h1 class="mt-5">Bootstrap 5 start page</h1> <h2>Bootstrap 5 start page</h2>
<p class="lead">Start by dragging components to page or double click to edit text</p> <p class="lead">Start by dragging components to page or double click to edit text</p>
</div> </div>
</div> </div>
</div> </main>
<footer class="page-footer bg-light py-3">
<div class="container text-center">
<small>Editable footer content</small>
</div>
</footer>
</body> </body>
</html> </html>

View File

@@ -178,7 +178,323 @@ button { cursor: pointer; font-family: inherit; }
margin: 0 auto; margin: 0 auto;
padding: 2rem 2rem 4rem; padding: 2rem 2rem 4rem;
} }
.project-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
margin-bottom: 1.75rem;
padding: 1.4rem 1.6rem;
border: 1px solid rgba(255,255,255,0.08);
border-radius: var(--radius-lg);
background: rgba(255,255,255,0.04);
backdrop-filter: blur(12px);
}
.project-header-info {
max-width: min(720px, 100%);
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.75rem;
border-radius: 999px;
background: rgba(99,102,241,0.12);
color: #dbeafe;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.04em;
margin-bottom: 0.85rem;
}
.project-header h1 {
font-size: 1.75rem;
line-height: 1.1;
margin-bottom: 0.5rem;
}
.project-subtitle {
color: var(--text-secondary);
max-width: 740px;
}
.project-header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.btn-secondary {
background: transparent;
color: var(--text-primary);
border: 1px solid rgba(255,255,255,0.14);
}
.btn-secondary:hover {
background: rgba(255,255,255,0.08);
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.tab {
padding: 0.85rem 1.15rem;
border-radius: 999px;
background: rgba(255,255,255,0.04);
color: var(--text-secondary);
font-weight: 600;
cursor: pointer;
transition: background var(--transition), color var(--transition), transform var(--transition);
}
.tab:hover {
background: rgba(255,255,255,0.08);
}
.tab.active {
background: var(--accent);
color: #fff;
}
.panel {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: var(--radius-lg);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.panel-header,
.panel-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
}
.panel-header h2,
.panel-card-header h3 {
font-size: 1.1rem;
margin: 0;
}
.panel-note {
color: var(--text-secondary);
font-size: 0.92rem;
margin-top: 0.35rem;
}
.panel-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.panel-card {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: var(--radius-md);
padding: 1.2rem;
}
.panel-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.summary-card {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
min-height: 140px;
}
.summary-card-title {
color: var(--text-secondary);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.summary-card-value {
font-size: 1.45rem;
font-weight: 700;
color: var(--text-primary);
}
.summary-card-note {
color: var(--text-muted);
font-size: 0.88rem;
}
.card-form {
display: grid;
gap: 1rem;
}
.pages-list {
display: grid;
gap: 0.75rem;
}
.page-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem;
border-radius: var(--radius-sm);
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
}
.page-item-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.page-item-info strong {
color: var(--text-primary);
}
.page-file {
color: var(--text-secondary);
font-size: 0.85rem;
}
.page-item-actions {
display: flex;
align-items: center;
gap: 0.65rem;
flex-wrap: wrap;
}
.page-empty {
padding: 1.2rem;
border-radius: var(--radius-sm);
border: 1px dashed rgba(255,255,255,0.16);
color: var(--text-secondary);
background: rgba(255,255,255,0.02);
}
.tree-root {
padding: 0.75rem;
border-radius: var(--radius-sm);
border: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.03);
min-height: 170px;
}
.tree-item {
list-style: none;
padding: 0.65rem 0.8rem;
display: flex;
align-items: center;
justify-content: space-between;
border-radius: var(--radius-sm);
border: 1px solid transparent;
transition: background var(--transition), border-color var(--transition);
}
.tree-item:hover {
background: rgba(255,255,255,0.05);
border-color: rgba(255,255,255,0.1);
}
.tree-title {
color: var(--text-primary);
font-size: 0.95rem;
}
.tree-children {
list-style: none;
padding-left: 1rem;
margin-top: 0.35rem;
}
.tree-drop-hover {
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;
}
.media-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.85rem;
margin-bottom: 1rem;
}
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.media-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border-radius: var(--radius-sm);
border: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.03);
}
.media-card img {
width: 100%;
height: auto;
border-radius: var(--radius-sm);
object-fit: cover;
}
.media-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.media-meta {
color: var(--text-secondary);
font-size: 0.82rem;
}
.media-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
/* ── 搜尋列 ─────────────────────────────────────────────────────── */ /* ── 搜尋列 ─────────────────────────────────────────────────────── */
.search-bar-wrap { .search-bar-wrap {
margin-bottom: 2rem; margin-bottom: 2rem;

View File

@@ -281,6 +281,14 @@ function renderProjects(projects) {
</span> </span>
</div> </div>
<div class="card-actions"> <div class="card-actions">
<a href="/dashboard/project/${encodeURIComponent(p.slug)}" class="btn btn-secondary btn-sm" id="dashboard-btn-${p.slug}" style="margin-right:0.5rem;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M3 13h8V3H3v10z"/>
<path d="M13 21h8V11h-8v10z"/>
<path d="M3 21h8v-6H3v6z"/>
</svg>
專案儀表板
</a>
<a href="/editor/${p.slug}" class="btn btn-primary btn-sm" id="edit-btn-${p.slug}"> <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"> <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="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>

View File

@@ -108,6 +108,9 @@
<i class="la la-external-link-alt fs-6"></i> <i class="la la-external-link-alt fs-6"></i>
</a> </a>
<!-- 快速設定按鈕(點擊開啟儀表板的設定) -->
<button id="quick-settings-btn" class="btn btn-light px-1" title="快速設定">⚙️</button>
<div class="btn-group responsive-btns" role="group"> <div class="btn-group responsive-btns" role="group">
<button type="button" class="btn btn-light btn-sm px-1 me-1" data-bs-toggle="dropdown" <button type="button" class="btn btn-light btn-sm px-1 me-1" data-bs-toggle="dropdown"
@@ -2308,6 +2311,21 @@ window.VVVEB_PROJECT_SLUG = "{% endraw %}{{ slug | safe }}{% raw %}";
</script> </script>
<script src="/static/js/my-editor.js?v={% endraw %}{{ range(1, 100000) | random }}{% raw %}"></script> <script src="/static/js/my-editor.js?v={% endraw %}{{ range(1, 100000) | random }}{% raw %}"></script>
<script>
// Quick settings button opens dashboard for current project
try{
document.addEventListener('DOMContentLoaded', function(){
var btn = document.getElementById('quick-settings-btn');
if(!btn) return;
btn.addEventListener('click', function(e){
var slug = window.VVVEB_PROJECT_SLUG || '';
var url = '/dashboard/project/' + encodeURIComponent(slug || '');
window.open(url, '_blank');
});
});
}catch(e){console.warn(e)}
</script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,571 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>專案管理</title>
<link rel="stylesheet" href="/static/css/my-style.css">
</head>
<body>
<main class="main-content project-dashboard">
<header class="project-header">
<div class="project-header-info">
<span class="eyebrow">專案儀表板</span>
<h1 id="project-title">專案管理</h1>
<p id="project-desc" class="project-subtitle">使用此頁面管理網站設定、頁面與媒體內容。</p>
</div>
<div class="project-header-actions">
<a id="open-dashboard-btn" class="btn btn-secondary" href="/dashboard">回網站管理器</a>
<a id="open-editor-btn" class="btn btn-primary" href="#" target="_blank">開啟編輯器</a>
<a id="open-project-btn" class="btn btn-secondary" href="#" target="_blank">查看網站</a>
</div>
</header>
<div class="tabs">
<div class="tab active" data-tab="overview">概覽</div>
<div class="tab" data-tab="settings">網站設定</div>
<div class="tab" data-tab="pages">頁面管理</div>
<div class="tab" data-tab="media">媒體庫</div>
</div>
<div id="content">
<section id="overview" class="panel">
<div class="panel-grid">
<div class="panel-card summary-card">
<div class="summary-card-title">專案</div>
<div class="summary-card-value" id="projName"></div>
<div class="summary-card-note" id="summary-slug">Slug</div>
</div>
<div class="panel-card summary-card">
<div class="summary-card-title">頁面數量</div>
<div class="summary-card-value" id="summary-pages">0</div>
<div class="summary-card-note">可管理的 HTML 頁面</div>
</div>
<div class="panel-card summary-card">
<div class="summary-card-title">最後更新</div>
<div class="summary-card-value" id="summary-updated"></div>
<div class="summary-card-note">最近變更時間</div>
</div>
</div>
</section>
<section id="settings" class="panel" style="display:none">
<div class="panel-header">
<h2>網站設定</h2>
</div>
<form id="settingsForm" class="card-form">
<div class="form-group">
<label for="siteTitle">網站標題</label>
<input id="siteTitle" type="text">
</div>
<div class="form-group">
<label for="siteDesc">描述</label>
<textarea id="siteDesc"></textarea>
</div>
<button class="btn btn-primary" id="saveSettings">儲存設定</button>
</form>
</section>
<section id="pages" class="panel" style="display:none">
<div class="panel-header">
<div>
<h2>頁面管理</h2>
<p class="panel-note">新增、編輯與刪除專案中的頁面,並調整頁面結構。</p>
</div>
<div class="panel-actions">
<button class="btn btn-secondary btn-sm" id="page-refresh-btn">重新整理</button>
<button class="btn btn-primary btn-sm" id="new-page-btn">新增頁面</button>
</div>
</div>
<div id="page-list" class="pages-list"></div>
<div id="page-empty" class="page-empty" style="display:none">
<p>目前沒有頁面項目。按一下「新增頁面」建立第一個頁面。</p>
</div>
<div class="divider"></div>
<div id="pagesTreeContainer" class="panel-card">
<div class="panel-card-header">
<div>
<h3>頁面樹狀結構</h3>
<p class="panel-note">拖曳排列頁面順序,並使用「儲存頁面結構」保存。</p>
</div>
<button class="btn btn-primary btn-sm" id="saveTreeBtn">儲存頁面結構</button>
</div>
<div id="pagesTree" class="tree-root"></div>
</div>
</section>
<section id="media" class="panel" style="display:none">
<div class="panel-header">
<h2>媒體庫</h2>
</div>
<div class="media-controls">
<input type="file" id="mediaFile">
<button class="btn btn-primary" id="uploadBtn">上傳</button>
</div>
<div id="mediaGrid" class="media-grid"></div>
</section>
</div>
</main>
<script>
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
t.classList.add('active');
document.querySelectorAll('.panel').forEach(p => p.style.display = 'none');
document.getElementById(t.dataset.tab).style.display = 'block';
}));
const parts = location.pathname.split('/');
const slug = parts.length >= 4 ? decodeURIComponent(parts[3]) : null;
if (!slug) {
document.getElementById('project-title').innerText = '專案未找到';
document.getElementById('projName').innerText = '—';
}
function escHtml(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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;
}
async function loadProjectSummary() {
try {
const project = await apiFetch(`/api/projects/${encodeURIComponent(slug)}`);
document.getElementById('project-title').innerText = project.name || slug;
document.getElementById('project-desc').innerText = project.description || '使用此頁面管理網站設定、頁面與媒體內容。';
document.getElementById('projName').innerText = project.slug;
document.getElementById('summary-slug').innerText = project.slug;
document.getElementById('summary-pages').innerText = `${project.page_count ?? 0}`;
document.getElementById('summary-updated').innerText = project.last_modified ? new Date(project.last_modified).toLocaleString() : '—';
document.getElementById('open-editor-btn').href = `/editor/${encodeURIComponent(slug)}`;
document.getElementById('open-project-btn').href = `/sites/${encodeURIComponent(slug)}/index.html`;
} catch (e) {
console.warn('載入專案摘要失敗', e);
}
}
async function loadSettings() {
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/settings`);
if (!res.ok) return;
const data = await res.json();
document.getElementById('siteTitle').value = data.title || '';
document.getElementById('siteDesc').value = data.description || '';
}
async function renderPageList() {
const listEl = document.getElementById('page-list');
const emptyEl = document.getElementById('page-empty');
try {
const pages = await apiFetch(`/api/projects/${encodeURIComponent(slug)}/pages`);
if (!Array.isArray(pages) || pages.length === 0) {
listEl.innerHTML = '';
emptyEl.style.display = 'block';
return;
}
emptyEl.style.display = 'none';
listEl.innerHTML = pages.map(file => {
const title = file.replace(/\.html$/, '').split('/').pop().replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
return `
<div class="page-item">
<div class="page-item-info">
<strong>${escHtml(title)}</strong>
<span class="page-file">${escHtml(file)}</span>
</div>
<div class="page-item-actions">
<a class="btn btn-primary btn-sm" href="/editor/${encodeURIComponent(slug)}?page=${encodeURIComponent(file)}">編輯</a>
<button class="btn btn-ghost btn-sm delete-page-btn" data-file="${escHtml(file)}">刪除</button>
</div>
</div>`;
}).join('');
listEl.querySelectorAll('.delete-page-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const file = btn.dataset.file;
if (!file) return;
if (file.toLowerCase() === 'index.html') {
alert('無法刪除主頁 index.html');
return;
}
if (!confirm(`確定要刪除頁面「${file}」嗎?`)) return;
try {
await apiFetch(`/api/save?slug=${encodeURIComponent(slug)}&action=delete`, {
method: 'POST',
body: JSON.stringify({ file }),
});
await loadProjectSummary();
await renderPageList();
await loadPagesTree();
alert('頁面已刪除');
} catch (e) {
alert('刪除失敗:' + e.message);
}
});
});
} catch (e) {
listEl.innerHTML = '';
emptyEl.style.display = 'block';
console.warn('載入頁面列表失敗', e);
}
}
async function createPage() {
const name = prompt('請輸入新頁面名稱例如about:');
if (!name) return;
try {
await apiFetch(`/api/projects/${encodeURIComponent(slug)}/pages`, {
method: 'POST',
body: JSON.stringify({ name }),
});
await loadProjectSummary();
await renderPageList();
await loadPagesTree();
alert(`頁面「${name}」建立成功`);
} catch (e) {
alert('建立頁面失敗:' + e.message);
}
}
document.getElementById('new-page-btn').addEventListener('click', createPage);
document.getElementById('page-refresh-btn').addEventListener('click', async () => {
await renderPageList();
await loadPagesTree();
});
document.getElementById('saveSettings').addEventListener('click', async function (e) {
e.preventDefault();
const body = {
title: document.getElementById('siteTitle').value,
description: document.getElementById('siteDesc').value,
};
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) {
alert('設定已儲存');
} else {
alert('儲存失敗');
}
});
async function listMedia() {
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/media/list`);
const grid = document.getElementById('mediaGrid');
grid.innerHTML = '';
if (!res.ok) return;
const items = await res.json();
items.reverse();
items.forEach(it => {
const card = document.createElement('div');
card.className = 'media-card';
const img = document.createElement('img');
img.src = it.thumb || it.url;
const info = document.createElement('div');
info.className = 'media-info';
info.innerHTML = `<div>${escHtml(it.original_name || it.filename)}</div><div class="media-meta">${escHtml(it.uploaded_at || '')}</div>`;
const btnCopy = document.createElement('button');
btnCopy.className = 'btn btn-ghost btn-sm';
btnCopy.textContent = '複製連結';
btnCopy.addEventListener('click', () => {
navigator.clipboard.writeText(it.url);
alert('已複製連結');
});
const btnDel = document.createElement('button');
btnDel.className = 'btn btn-danger btn-sm';
btnDel.textContent = '刪除';
btnDel.addEventListener('click', async () => {
if (!confirm('確定刪除此檔案?')) return;
const rel = `media/images/${it.date}/${it.filename}`;
const del = await fetch(`/api/projects/${encodeURIComponent(slug)}/media/${encodeURIComponent(rel)}`, { method: 'DELETE' });
if (del.ok) {
alert('刪除成功');
listMedia();
} else {
alert('刪除失敗');
}
});
const controls = document.createElement('div');
controls.className = 'media-actions';
controls.appendChild(btnCopy);
controls.appendChild(btnDel);
card.appendChild(img);
card.appendChild(info);
card.appendChild(controls);
grid.appendChild(card);
});
}
document.getElementById('uploadBtn').addEventListener('click', async () => {
const fileInput = document.getElementById('mediaFile');
if (!fileInput.files.length) {
alert('請選擇檔案');
return;
}
const fd = new FormData();
fd.append('file', fileInput.files[0]);
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/media/upload`, {
method: 'POST',
body: fd,
});
if (res.ok) {
alert('上傳完成');
fileInput.value = '';
listMedia();
} else {
alert('上傳失敗');
}
});
async function loadPagesTree() {
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/pages-tree`);
if (!res.ok) return;
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');
li.className = 'tree-item';
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';
li.dataset.hasHeader = item.has_header !== false ? 'true' : 'false';
li.dataset.hasFooter = item.has_footer === true ? 'true' : 'false';
const titleWrap = document.createElement('span');
titleWrap.className = 'tree-title';
titleWrap.textContent = item.title || item.name || '';
const btns = document.createElement('span');
btns.className = 'tree-item-actions';
btns.innerHTML = "<button class='btn btn-ghost btn-sm btn-toggle' title='展開/收合'>▼</button>";
const meta = document.createElement('span');
meta.className = 'tree-item-meta';
meta.innerHTML = `
<button class='btn btn-ghost btn-sm btn-nav' title='顯示於導覽列'>☰</button>
<button class='btn btn-ghost btn-sm btn-homepage' title='設為首頁'>⭐</button>
<button class='btn btn-ghost btn-sm btn-header' title='頁首'>🧾</button>
<button class='btn btn-ghost btn-sm btn-footer' title='頁尾'>☷</button>
<button class='btn btn-ghost btn-sm btn-password' title='需要密碼'>🔒</button>
`;
const btnNav = meta.querySelector('.btn-nav');
const btnHome = meta.querySelector('.btn-homepage');
const btnHeader = meta.querySelector('.btn-header');
const btnFooter = meta.querySelector('.btn-footer');
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';
const hasHeader = li.dataset.hasHeader === 'true';
const hasFooter = li.dataset.hasFooter === 'true';
btnNav.classList.toggle('active', showNav);
btnNav.title = showNav ? '顯示於導覽列' : '從導覽列隱藏';
btnHome.classList.toggle('active', isHomepage);
btnHome.title = isHomepage ? '首頁' : '設為首頁';
btnHeader.classList.toggle('active', hasHeader);
btnHeader.title = hasHeader ? '包含頁首' : '不包含頁首';
btnFooter.classList.toggle('active', hasFooter);
btnFooter.title = hasFooter ? '包含頁尾' : '不包含頁尾';
btnPassword.classList.toggle('active', requiresPassword);
btnPassword.title = requiresPassword ? '需要密碼' : '不需要密碼';
if (li.dataset.type !== 'file') {
btnHome.disabled = true;
btnHeader.disabled = true;
btnFooter.disabled = true;
btnHome.title = '僅限單一頁面';
btnHeader.title = '僅限單一頁面';
btnFooter.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();
});
btnHeader.addEventListener('click', () => {
if (li.dataset.type !== 'file') return;
li.dataset.hasHeader = li.dataset.hasHeader === 'true' ? 'false' : 'true';
updateMetaState();
});
btnFooter.addEventListener('click', () => {
if (li.dataset.type !== 'file') return;
li.dataset.hasFooter = li.dataset.hasFooter === '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) {
const childUl = renderList(item.children);
childUl.className = 'tree-children';
li.appendChild(childUl);
}
li.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/name', encodeURIComponent(li.dataset.name));
e.currentTarget.style.opacity = 0.4;
});
li.addEventListener('dragend', e => { e.currentTarget.style.opacity = 1; });
li.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; li.classList.add('tree-drop-hover'); });
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;
const name = decodeURIComponent(nameEnc);
const src = container.querySelector(`[data-name='${CSS.escape(name)}']`);
if (!src || src === li) return;
const onTop = e.clientY < (li.getBoundingClientRect().top + li.getBoundingClientRect().height / 2);
if (li.querySelector('ul')) {
li.querySelector('ul').appendChild(src);
} else if (onTop) {
li.parentNode.insertBefore(src, li);
} else {
if (li.nextSibling) li.parentNode.insertBefore(src, li.nextSibling);
else li.parentNode.appendChild(src);
}
});
const toggleBtn = btns.querySelector('.btn-toggle');
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
const child = li.querySelector('ul');
if (child) {
child.style.display = child.style.display === 'none' ? '' : 'none';
toggleBtn.textContent = child.style.display === 'none' ? '▶' : '▼';
}
});
}
return li;
}
function renderList(items) {
const ul = document.createElement('ul');
items.forEach(it => ul.appendChild(createLi(it)));
return ul;
}
if (tree && tree.root) container.appendChild(renderList(tree.root));
}
function buildPayload(rootEl) {
function walk(ul) {
const arr = [];
const lis = Array.from(ul.children).filter(n => n.tagName === 'LI');
lis.forEach(li => {
const title = li.querySelector('.tree-title')?.textContent || '';
const name = li.dataset.name || '';
const type = li.dataset.type || 'file';
const obj = {
name,
title,
type,
show_in_nav: li.dataset.showInNav === 'true',
is_homepage: li.dataset.isHomepage === 'true',
requires_password: li.dataset.requiresPassword === 'true',
has_header: li.dataset.hasHeader === 'true',
has_footer: li.dataset.hasFooter === 'true',
};
const childUl = li.querySelector(':scope > ul');
if (childUl) obj.children = walk(childUl);
arr.push(obj);
});
return arr;
}
return { root: walk(rootEl.querySelector(':scope > ul') || document.createElement('ul')) };
}
document.getElementById('saveTreeBtn').addEventListener('click', async () => {
const container = document.getElementById('pagesTree');
const payload = buildPayload(container);
const btn = document.getElementById('saveTreeBtn');
btn.disabled = true;
btn.textContent = '儲存中...';
try {
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/pages-tree`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) alert('頁面結構已儲存');
else alert('儲存失敗');
} catch (e) {
alert('儲存失敗');
}
btn.disabled = false;
btn.textContent = '儲存頁面結構';
});
if (slug) {
loadProjectSummary();
loadSettings();
renderPageList();
listMedia();
loadPagesTree();
}
</script>
</body>
</html>

Binary file not shown.

105
utils/media_manager.py Normal file
View File

@@ -0,0 +1,105 @@
from __future__ import annotations
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Any
import uuid
def _now_iso() -> str:
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
def save_upload(project_dir: Path, file_storage) -> dict[str, Any]:
"""Save uploaded file under project media directory and generate thumbnail.
Returns metadata dict.
"""
uploads_dir = project_dir / "media" / "images"
date_folder = datetime.utcnow().strftime("%Y-%m")
target_dir = uploads_dir / date_folder
target_dir.mkdir(parents=True, exist_ok=True)
original_name = file_storage.filename or "upload"
ext = Path(original_name).suffix.lower() or ".bin"
safe_name = f"{uuid.uuid4().hex}{ext}"
target_path = target_dir / safe_name
# save original
file_storage.save(str(target_path))
# try to generate thumbnail for common image types
thumb_name = None
try:
from PIL import Image
if ext in [".jpg", ".jpeg", ".png", ".webp", ".gif"]:
im = Image.open(str(target_path))
im.thumbnail((400, 400))
thumb_name = f"{uuid.uuid4().hex}_thumb{ext}"
thumb_path = target_dir / thumb_name
im.save(str(thumb_path))
except Exception:
thumb_name = None
meta = {
"filename": safe_name,
"original_name": original_name,
"url": f"/sites/{{slug}}/media/images/{date_folder}/{safe_name}",
"thumb": (f"/sites/{{slug}}/media/images/{date_folder}/{thumb_name}" if thumb_name else None),
"size": target_path.stat().st_size,
"uploaded_at": _now_iso(),
}
# update uploads index
uploads_index = uploads_dir / "uploads.json"
data = {}
if uploads_index.exists():
try:
data = json.loads(uploads_index.read_text(encoding="utf-8"))
except Exception:
data = {}
data.setdefault(date_folder, []).append({
"filename": safe_name,
"original_name": original_name,
"size": meta["size"],
"uploaded_at": meta["uploaded_at"],
})
uploads_index.parent.mkdir(parents=True, exist_ok=True)
uploads_index.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
return meta
def list_media(project_dir: Path) -> list[dict[str, Any]]:
uploads_dir = project_dir / "media" / "images"
uploads_index = uploads_dir / "uploads.json"
if not uploads_index.exists():
return []
try:
data = json.loads(uploads_index.read_text(encoding="utf-8"))
except Exception:
return []
items = []
for date_folder, files in data.items():
for f in files:
items.append({
"date": date_folder,
**f,
})
return items
def delete_media(project_dir: Path, rel_path: str) -> bool:
target = (project_dir / rel_path).resolve()
if not str(target).startswith(str(project_dir.resolve())):
return False
if not target.exists():
return False
try:
target.unlink()
except Exception:
return False
return True

View File

@@ -0,0 +1,50 @@
<!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>
</head>
<body>
<header class="page-header bg-light py-3">
<div class="container">
<div class="row">
<div class="col-lg-12 text-center">
<h1 class="mt-3">Page Header</h1>
<p class="mb-0">This header is editable in the editor.</p>
</div>
</div>
</div>
</header>
<!-- Page Content -->
<main class="container py-5">
<div class="row">
<div class="col-lg-12 text-center">
<h2>Bootstrap 5 start page</h2>
<p class="lead">Start by dragging components to page or double click to edit text</p>
</div>
</div>
</main>
<footer class="page-footer bg-light py-3">
<div class="container text-center">
<small>Editable footer content</small>
</div>
</footer>
</body>
</html>

View File

@@ -0,0 +1,50 @@
<!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>
</head>
<body>
<header class="page-header bg-light py-3">
<div class="container">
<div class="row">
<div class="col-lg-12 text-center">
<h1 class="mt-3">Page Header</h1>
<p class="mb-0">This header is editable in the editor.</p>
</div>
</div>
</div>
</header>
<!-- Page Content -->
<main class="container py-5">
<div class="row">
<div class="col-lg-12 text-center">
<h2>Bootstrap 5 start page</h2>
<p class="lead">Start by dragging components to page or double click to edit text</p>
</div>
</div>
</main>
<footer class="page-footer bg-light py-3">
<div class="container text-center">
<small>Editable footer content</small>
</div>
</footer>
</body>
</html>

View File

@@ -2,5 +2,176 @@
"name": "My Website", "name": "My Website",
"slug": "my-website", "slug": "my-website",
"description": "", "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",
"show_in_nav": true,
"is_homepage": false,
"requires_password": false,
"has_header": true,
"has_footer": false
},
{
"name": "about-final.html",
"title": "About Final",
"type": "file",
"show_in_nav": true,
"is_homepage": false,
"requires_password": false,
"has_header": true,
"has_footer": false
},
{
"name": "index.html",
"title": "Index",
"type": "file",
"show_in_nav": true,
"is_homepage": false,
"requires_password": false,
"has_header": true,
"has_footer": false
},
{
"name": "my-page.html",
"title": "My Page",
"type": "file",
"show_in_nav": true,
"is_homepage": false,
"requires_password": false,
"has_header": true,
"has_footer": false
},
{
"name": "my-page3.html",
"title": "My Page3",
"type": "file",
"show_in_nav": true,
"is_homepage": false,
"requires_password": false,
"has_header": true,
"has_footer": false
},
{
"name": "my-page4.html",
"title": "My Page4",
"type": "file",
"show_in_nav": true,
"is_homepage": false,
"requires_password": false,
"has_header": true,
"has_footer": false
},
{
"name": "subfolder",
"title": "Subfolder",
"type": "folder",
"show_in_nav": true,
"is_homepage": false,
"requires_password": false,
"has_header": true,
"has_footer": false,
"children": [
{
"name": "subfolder/subpage-title.html",
"title": "Subpage Title",
"type": "file",
"show_in_nav": true,
"is_homepage": false,
"requires_password": false,
"has_header": true,
"has_footer": false
}
]
},
{
"name": "temp",
"title": "Temp",
"type": "folder",
"show_in_nav": true,
"is_homepage": false,
"requires_password": false,
"has_header": true,
"has_footer": false,
"children": [
{
"name": "temp/my-page5.html",
"title": "My Page5",
"type": "file",
"show_in_nav": true,
"is_homepage": false,
"requires_password": false,
"has_header": true,
"has_footer": false
},
{
"name": "temp/new.html",
"title": "New",
"type": "file",
"show_in_nav": true,
"is_homepage": false,
"requires_password": false,
"has_header": true,
"has_footer": false
},
{
"name": "subsubfolder",
"title": "Subsubfolder",
"type": "folder",
"show_in_nav": true,
"is_homepage": false,
"requires_password": false,
"has_header": true,
"has_footer": false,
"children": [
{
"name": "temp/subsubfolder/myname.html",
"title": "Myname",
"type": "file",
"show_in_nav": true,
"is_homepage": false,
"requires_password": false,
"has_header": true,
"has_footer": false
}
]
}
]
},
{
"name": "test.html",
"title": "Test",
"type": "file",
"show_in_nav": true,
"is_homepage": false,
"requires_password": false,
"has_header": true,
"has_footer": false
},
{
"name": "headerandfooter.html",
"title": "Headerandfooter",
"type": "file",
"show_in_nav": true,
"is_homepage": false,
"requires_password": false,
"has_header": true,
"has_footer": false
},
{
"name": "headerandfooter2.html",
"title": "Headerandfooter2",
"type": "file",
"show_in_nav": true,
"is_homepage": false,
"requires_password": false,
"has_header": true,
"has_footer": false
}
]
},
"updated_at": "2026-05-26T04:30:22"
} }

View File

@@ -1,6 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" =""><head>
<head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content=""> <meta name="description" content="">
@@ -18,16 +17,33 @@
height:100%; height:100%;
} }
</style> </style>
</head> <style id="vvvebjs-styles"></style></head>
<body> <body>
<header class="page-header bg-light py-3">
<div class="container">
<div class="row">
<div class="col-lg-12 text-center">
<h1 class="mt-3">Page Header</h1>
<p class="mb-0">This header is editable in the editor.</p>
</div>
</div>
</div>
</header>
<!-- Page Content --> <!-- Page Content -->
<div class="container"> <main class="container py-5">
<div class="row"> <div class="row">
<div class="col-lg-12 text-center"> <div class="col-lg-12 text-center">
<h1 class="mt-5">Bootstrap 5 start page</h1> <h2>Bootstrap 5 start page</h2>
<p class="lead">Start by dragging components to page or double click to edit text</p> <p class="lead">Start by dragging components to page or double click to edit text</p>
</div> </div>
</div> </div>
</div> </main>
</body>
</html> <footer class="page-footer bg-light py-3">
<div class="container text-center" aria-readonly="false"><p><small>Editable footer content</small></p>
</div>
</footer>
</body></html>

View 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>
</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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 B

View File

@@ -0,0 +1,10 @@
{
"2026-05": [
{
"filename": "5717be82fe7246a6b7b437182cc5748f.png",
"original_name": "test.png",
"size": 289,
"uploaded_at": "2026-05-26T03:57:55"
}
]
}

View File

@@ -0,0 +1,11 @@
{
"name": "phase1-e2e",
"slug": "phase1-e2e",
"description": "automated test",
"created_at": "2026-05-26T03:57:54",
"settings": {
"title": "Auto Test",
"description": "desc"
},
"updated_at": "2026-05-26T03:57:55"
}