Compare commits
6 Commits
9be9d5d924
...
6cb46fe772
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cb46fe772 | |||
| ee5a54ea2c | |||
| 56f9a703b8 | |||
| f1bc0a30e1 | |||
| 03d7eca139 | |||
| e9ac2ae1a8 |
296
README.md
296
README.md
@@ -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) — 簡單但搜尋效能差
|
||||||
|
- 方案 B:SQLite 資料庫 — 更好的搜尋和篩選
|
||||||
|
- 方案 C:混合 — 快取 + 搜尋索引
|
||||||
|
|
||||||
|
2. **評論審核**
|
||||||
|
V- 開放所有評論?
|
||||||
|
- 需要管理員審核?
|
||||||
|
- 支援評論回覆線程?
|
||||||
|
|
||||||
|
3. **圖片儲存**
|
||||||
|
V- 本地檔案系統?
|
||||||
|
- 外部 CDN(如 Cloudinary)?
|
||||||
|
- S3 相容服務?
|
||||||
|
|
||||||
|
4. **版本控制深度**
|
||||||
|
- 簡單版本記錄(時間、使用者)?
|
||||||
|
V- 完整 diff(顯示改變內容)?
|
||||||
|
- Git 整合?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**你對這個計畫有什麼想法?** 需要調整優先級或合併某些階段嗎?
|
||||||
BIN
__pycache__/main.cpython-313.pyc
Normal file
BIN
__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
369
docs/phase1.md
Normal file
369
docs/phase1.md
Normal 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
226
main.py
@@ -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():
|
||||||
|
|||||||
@@ -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
63
scripts/test_phase1.py
Normal 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')
|
||||||
@@ -20,14 +20,31 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Page Content -->
|
<header class="page-header bg-light py-3">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<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>
|
<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>
|
<p class="lead">Start by dragging components to page or double click to edit text</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="page-footer bg-light py-3">
|
||||||
|
<div class="container text-center">
|
||||||
|
<small>Editable footer content</small>
|
||||||
</div>
|
</div>
|
||||||
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
571
templates/project_dashboard.html
Normal file
571
templates/project_dashboard.html
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
BIN
utils/__pycache__/media_manager.cpython-313.pyc
Normal file
BIN
utils/__pycache__/media_manager.cpython-313.pyc
Normal file
Binary file not shown.
105
utils/media_manager.py
Normal file
105
utils/media_manager.py
Normal 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
|
||||||
50
websites/my-website/headerandfooter.html
Normal file
50
websites/my-website/headerandfooter.html
Normal 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>
|
||||||
50
websites/my-website/headerandfooter2.html
Normal file
50
websites/my-website/headerandfooter2.html
Normal 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>
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
<!-- Page Content -->
|
<header class="page-header bg-light py-3">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<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>
|
<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>
|
<p class="lead">Start by dragging components to page or double click to edit text</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="page-footer bg-light py-3">
|
||||||
|
<div class="container text-center" aria-readonly="false"><p><small>Editable footer content</small></p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</footer>
|
||||||
</html>
|
|
||||||
|
|
||||||
|
</body></html>
|
||||||
33
websites/phase1-e2e/index.html
Normal file
33
websites/phase1-e2e/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>
|
||||||
|
</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 |
10
websites/phase1-e2e/media/images/uploads.json
Normal file
10
websites/phase1-e2e/media/images/uploads.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"2026-05": [
|
||||||
|
{
|
||||||
|
"filename": "5717be82fe7246a6b7b437182cc5748f.png",
|
||||||
|
"original_name": "test.png",
|
||||||
|
"size": 289,
|
||||||
|
"uploaded_at": "2026-05-26T03:57:55"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
11
websites/phase1-e2e/project.json
Normal file
11
websites/phase1-e2e/project.json
Normal 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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user