duqing2026 commited on
Commit
2e4ea36
·
0 Parent(s):

Initial commit: Error Page Studio MVP

Browse files
Files changed (6) hide show
  1. Dockerfile +10 -0
  2. README.md +91 -0
  3. app.py +39 -0
  4. requirements.txt +2 -0
  5. templates/export_theme.html +272 -0
  6. templates/index.html +266 -0
Dockerfile ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: 404 页面设计工坊
3
+ emoji: 🚧
4
+ colorFrom: red
5
+ colorTo: gray
6
+ sdk: docker
7
+ pinned: false
8
+ short_description: 可视化设计并导出带有小游戏的创意 404 页面
9
+ ---
10
+
11
+ # 404 页面设计工坊 (Error Page Studio)
12
+
13
+ [English](./README_EN.md) | 中文
14
+
15
+ **404 页面设计工坊** 是一款专为独立开发者、创作者和初创公司设计的可视化工具,帮助你快速生成美观、有趣且能留住用户的 404 错误页面。
16
+
17
+ 不再让你的用户面对枯燥的 "Not Found" —— 用创意和游戏挽回流失的流量!
18
+
19
+ ## ✨ 核心功能
20
+
21
+ - **🎨 多种主题风格**:
22
+ - **Modern (现代)**:简洁大气,适配大多数 SaaS 产品。
23
+ - **Retro (复古)**:极客风格,终端字体,致敬经典。
24
+ - **Minimal (极简)**:极致留白,专注于核心信息。
25
+
26
+ - **👻 创意插画**:
27
+ - 内置幽灵 (Ghost)、机器人 (Robot)、心碎 (Broken Heart) 等纯 CSS/SVG 插画,无需外部资源。
28
+
29
+ - **🎮 嵌入式小游戏**:
30
+ - **贪吃蛇 (Snake)**:一键开启。当用户迷路时,让他们玩把游戏冷静一下(有效降低跳出率!)。
31
+ - 游戏代码完全内嵌,无需额外依赖。
32
+
33
+ - **⚡️ 实时预览**:
34
+ - 支持桌面端 (Desktop) 和移动端 (Mobile) 尺寸切换预览。
35
+ - 所见即所得。
36
+
37
+ - **📦 一键导出**:
38
+ - 生成**单文件 HTML**。
39
+ - 所有 CSS、JS、SVG 全部内联 (Inlined)。
40
+ - 0 依赖,直接丢到你的 Nginx/Apache/Vercel 的 `public` 目录即可使用。
41
+
42
+ ## 🚀 快速开始
43
+
44
+ ### 在 Hugging Face Spaces 运行
45
+
46
+ 本项目已适配 Hugging Face Spaces (Docker SDK)。
47
+
48
+ ### 本地运行
49
+
50
+ 1. 克隆项目:
51
+ ```bash
52
+ git clone https://github.com/your-username/error-page-studio.git
53
+ cd error-page-studio
54
+ ```
55
+
56
+ 2. 安装依赖:
57
+ ```bash
58
+ pip install -r requirements.txt
59
+ ```
60
+
61
+ 3. 启动服务:
62
+ ```bash
63
+ python app.py
64
+ ```
65
+
66
+ 4. 访问:http://localhost:7860
67
+
68
+ ### Docker 运行
69
+
70
+ ```bash
71
+ docker build -t error-page-studio .
72
+ docker run -p 7860:7860 error-page-studio
73
+ ```
74
+
75
+ ## 🛠 技术栈
76
+
77
+ - **Frontend**: Vue 3 (CDN), Tailwind CSS (CDN)
78
+ - **Backend**: Flask (Python 3.9)
79
+ - **Export Engine**: Jinja2 Template Rendering
80
+ - **Deployment**: Docker
81
+
82
+ ## 📝 为什么要关注 404 页面?
83
+
84
+ 404 页面是用户体验中常被忽视的一环。一个好的 404 页面应该:
85
+ 1. **安抚用户情绪**(幽默、美观)。
86
+ 2. **提供解决方案**(返回首页按钮)。
87
+ 3. **留住用户**(小游戏、彩蛋)。
88
+
89
+ ---
90
+
91
+ Made with ❤️ by [Du Qing]
app.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template, request, send_file, make_response
2
+ import io
3
+
4
+ app = Flask(__name__)
5
+
6
+ @app.route('/')
7
+ def index():
8
+ return render_template('index.html')
9
+
10
+ @app.route('/preview', methods=['POST'])
11
+ def preview():
12
+ """
13
+ Returns the HTML for the preview iframe.
14
+ """
15
+ data = request.json
16
+ return render_template('export_theme.html', **data)
17
+
18
+ @app.route('/download', methods=['POST'])
19
+ def download():
20
+ """
21
+ Returns the HTML as a downloadable file.
22
+ """
23
+ data = request.json
24
+ html_content = render_template('export_theme.html', **data)
25
+
26
+ # Create a file-like object
27
+ mem = io.BytesIO()
28
+ mem.write(html_content.encode('utf-8'))
29
+ mem.seek(0)
30
+
31
+ return send_file(
32
+ mem,
33
+ as_attachment=True,
34
+ download_name='404.html',
35
+ mimetype='text/html'
36
+ )
37
+
38
+ if __name__ == '__main__':
39
+ app.run(debug=True, port=7860, host='0.0.0.0')
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ flask
2
+ gunicorn
templates/export_theme.html ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{ title }}</title>
7
+ <style>
8
+ :root {
9
+ --bg-color: {{ bgColor }};
10
+ --text-color: {{ textColor }};
11
+ --accent-color: {{ accentColor }};
12
+ }
13
+
14
+ body {
15
+ margin: 0;
16
+ padding: 0;
17
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
18
+ background-color: var(--bg-color);
19
+ color: var(--text-color);
20
+ display: flex;
21
+ flex-direction: column;
22
+ align-items: center;
23
+ justify-content: center;
24
+ min-height: 100vh;
25
+ text-align: center;
26
+ overflow-x: hidden;
27
+ }
28
+
29
+ .container {
30
+ max-width: 600px;
31
+ padding: 20px;
32
+ }
33
+
34
+ h1 {
35
+ font-size: 5rem;
36
+ margin: 0;
37
+ line-height: 1;
38
+ font-weight: 800;
39
+ color: var(--accent-color);
40
+ }
41
+
42
+ p {
43
+ font-size: 1.5rem;
44
+ margin: 20px 0;
45
+ opacity: 0.8;
46
+ }
47
+
48
+ .btn {
49
+ display: inline-block;
50
+ padding: 12px 24px;
51
+ background-color: var(--accent-color);
52
+ color: var(--bg-color);
53
+ text-decoration: none;
54
+ border-radius: 6px;
55
+ font-weight: 600;
56
+ transition: opacity 0.2s;
57
+ margin-top: 20px;
58
+ }
59
+
60
+ .btn:hover {
61
+ opacity: 0.9;
62
+ }
63
+
64
+ /* Retro Theme Overrides */
65
+ {% if theme == 'retro' %}
66
+ body {
67
+ font-family: 'Courier New', Courier, monospace;
68
+ }
69
+ h1 {
70
+ text-shadow: 2px 2px 0px var(--text-color);
71
+ }
72
+ .btn {
73
+ border-radius: 0;
74
+ border: 2px solid var(--text-color);
75
+ background: transparent;
76
+ color: var(--text-color);
77
+ }
78
+ .btn:hover {
79
+ background: var(--text-color);
80
+ color: var(--bg-color);
81
+ }
82
+ {% endif %}
83
+
84
+ /* Minimal Theme Overrides */
85
+ {% if theme == 'minimal' %}
86
+ h1 {
87
+ font-size: 3rem;
88
+ font-weight: 300;
89
+ }
90
+ p {
91
+ font-size: 1rem;
92
+ }
93
+ .btn {
94
+ border-radius: 50px;
95
+ }
96
+ {% endif %}
97
+
98
+ /* Illustrations */
99
+ .illustration {
100
+ margin-bottom: 30px;
101
+ height: 150px;
102
+ display: flex;
103
+ align-items: center;
104
+ justify-content: center;
105
+ }
106
+
107
+ .ghost {
108
+ font-size: 100px;
109
+ animation: float 3s ease-in-out infinite;
110
+ }
111
+
112
+ @keyframes float {
113
+ 0% { transform: translateY(0px); }
114
+ 50% { transform: translateY(-10px); }
115
+ 100% { transform: translateY(0px); }
116
+ }
117
+
118
+ /* Game Container */
119
+ #game-container {
120
+ margin-top: 40px;
121
+ display: none;
122
+ border: 2px solid var(--text-color);
123
+ padding: 10px;
124
+ background: rgba(0,0,0,0.05);
125
+ }
126
+
127
+ canvas {
128
+ background: var(--bg-color);
129
+ display: block;
130
+ }
131
+
132
+ .game-toggle {
133
+ margin-top: 20px;
134
+ font-size: 0.9rem;
135
+ cursor: pointer;
136
+ text-decoration: underline;
137
+ color: var(--accent-color);
138
+ }
139
+
140
+ </style>
141
+ </head>
142
+ <body>
143
+ <div class="container">
144
+ <div class="illustration">
145
+ {% if illustration == 'ghost' %}
146
+ <div class="ghost">👻</div>
147
+ {% elif illustration == 'robot' %}
148
+ <div class="ghost">🤖</div>
149
+ {% elif illustration == 'broken' %}
150
+ <div class="ghost">💔</div>
151
+ {% elif illustration == 'planet' %}
152
+ <div class="ghost">🪐</div>
153
+ {% endif %}
154
+ </div>
155
+
156
+ <h1>{{ title }}</h1>
157
+ <p>{{ message }}</p>
158
+
159
+ <a href="{{ buttonLink }}" class="btn">{{ buttonText }}</a>
160
+
161
+ {% if showGame %}
162
+ <div class="game-toggle" onclick="toggleGame()">Play Snake while you wait?</div>
163
+ <div id="game-container">
164
+ <canvas id="gameCanvas" width="300" height="300"></canvas>
165
+ <div style="font-size: 0.8rem; margin-top: 5px;">Use Arrow Keys to Move</div>
166
+ </div>
167
+
168
+ <script>
169
+ let gameRunning = false;
170
+ let canvas, ctx;
171
+ let snake = [{x: 10, y: 10}];
172
+ let food = {x: 15, y: 15};
173
+ let dx = 0;
174
+ let dy = 0;
175
+ let score = 0;
176
+ let gridSize = 15;
177
+ let tileCount = 20;
178
+ let gameInterval;
179
+
180
+ function toggleGame() {
181
+ const container = document.getElementById('game-container');
182
+ if (container.style.display === 'block') {
183
+ container.style.display = 'none';
184
+ gameRunning = false;
185
+ clearInterval(gameInterval);
186
+ } else {
187
+ container.style.display = 'block';
188
+ if (!canvas) initGame();
189
+ resetGame();
190
+ }
191
+ }
192
+
193
+ function initGame() {
194
+ canvas = document.getElementById('gameCanvas');
195
+ ctx = canvas.getContext('2d');
196
+ document.addEventListener('keydown', keyDownEvent);
197
+ }
198
+
199
+ function resetGame() {
200
+ snake = [{x: 10, y: 10}];
201
+ food = {x: 15, y: 15};
202
+ dx = 0;
203
+ dy = 0;
204
+ score = 0;
205
+ gameRunning = true;
206
+ if (gameInterval) clearInterval(gameInterval);
207
+ gameInterval = setInterval(drawGame, 100);
208
+ }
209
+
210
+ function drawGame() {
211
+ if (!gameRunning) return;
212
+
213
+ // Move snake
214
+ const head = {x: snake[0].x + dx, y: snake[0].y + dy};
215
+
216
+ // Wrap around
217
+ if (head.x < 0) head.x = tileCount - 1;
218
+ if (head.x >= tileCount) head.x = 0;
219
+ if (head.y < 0) head.y = tileCount - 1;
220
+ if (head.y >= tileCount) head.y = 0;
221
+
222
+ // Check collision with self
223
+ for (let i = 0; i < snake.length; i++) {
224
+ if (head.x === snake[i].x && head.y === snake[i].y) {
225
+ // Game Over logic (soft reset)
226
+ snake = [{x: 10, y: 10}];
227
+ dx = 0;
228
+ dy = 0;
229
+ }
230
+ }
231
+
232
+ snake.unshift(head);
233
+
234
+ // Check food
235
+ if (head.x === food.x && head.y === food.y) {
236
+ score++;
237
+ food = {
238
+ x: Math.floor(Math.random() * tileCount),
239
+ y: Math.floor(Math.random() * tileCount)
240
+ };
241
+ } else {
242
+ snake.pop();
243
+ }
244
+
245
+ // Draw
246
+ ctx.fillStyle = '{{ bgColor }}';
247
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
248
+
249
+ // Snake
250
+ ctx.fillStyle = '{{ accentColor }}';
251
+ for (let i = 0; i < snake.length; i++) {
252
+ ctx.fillRect(snake[i].x * gridSize, snake[i].y * gridSize, gridSize - 2, gridSize - 2);
253
+ }
254
+
255
+ // Food
256
+ ctx.fillStyle = '{{ textColor }}';
257
+ ctx.fillRect(food.x * gridSize, food.y * gridSize, gridSize - 2, gridSize - 2);
258
+ }
259
+
260
+ function keyDownEvent(e) {
261
+ switch(e.keyCode) {
262
+ case 37: if(dx !== 1) { dx = -1; dy = 0; } break;
263
+ case 38: if(dy !== 1) { dx = 0; dy = -1; } break;
264
+ case 39: if(dx !== -1) { dx = 1; dy = 0; } break;
265
+ case 40: if(dy !== -1) { dx = 0; dy = 1; } break;
266
+ }
267
+ }
268
+ </script>
269
+ {% endif %}
270
+ </div>
271
+ </body>
272
+ </html>
templates/index.html ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Error Page Studio | 404 页面设计工坊</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
9
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
10
+ <style>
11
+ [v-cloak] { display: none; }
12
+ .color-input-wrapper {
13
+ position: relative;
14
+ overflow: hidden;
15
+ width: 30px;
16
+ height: 30px;
17
+ border-radius: 50%;
18
+ border: 2px solid #e5e7eb;
19
+ }
20
+ .color-input {
21
+ position: absolute;
22
+ top: -50%;
23
+ left: -50%;
24
+ width: 200%;
25
+ height: 200%;
26
+ cursor: pointer;
27
+ }
28
+ </style>
29
+ </head>
30
+ <body class="bg-gray-50 text-gray-800 h-screen overflow-hidden">
31
+ <div id="app" v-cloak class="flex h-full">
32
+ <!-- Sidebar / Config -->
33
+ <div class="w-1/3 min-w-[350px] bg-white border-r border-gray-200 flex flex-col h-full z-10 shadow-lg">
34
+ <div class="p-5 border-b border-gray-100 flex items-center justify-between">
35
+ <h1 class="text-xl font-bold text-gray-900"><i class="fas fa-exclamation-triangle text-red-500 mr-2"></i>Error Page Studio</h1>
36
+ <span class="text-xs bg-red-100 text-red-600 px-2 py-1 rounded-full">Beta</span>
37
+ </div>
38
+
39
+ <div class="flex-1 overflow-y-auto p-5 space-y-6">
40
+ <!-- Theme Selection -->
41
+ <div>
42
+ <label class="block text-sm font-medium text-gray-700 mb-3">Theme Style</label>
43
+ <div class="grid grid-cols-3 gap-2">
44
+ <button
45
+ v-for="t in ['modern', 'retro', 'minimal']"
46
+ :key="t"
47
+ @click="config.theme = t"
48
+ :class="{'ring-2 ring-red-500 bg-red-50': config.theme === t, 'bg-gray-50 hover:bg-gray-100': config.theme !== t}"
49
+ class="p-2 rounded-lg border border-gray-200 text-sm capitalize transition-all"
50
+ >
51
+ {{ t }}
52
+ </button>
53
+ </div>
54
+ </div>
55
+
56
+ <!-- Colors -->
57
+ <div>
58
+ <label class="block text-sm font-medium text-gray-700 mb-3">Colors</label>
59
+ <div class="flex space-x-6">
60
+ <div class="flex flex-col items-center">
61
+ <div class="color-input-wrapper" :style="{ backgroundColor: config.bgColor }">
62
+ <input type="color" v-model="config.bgColor" class="color-input">
63
+ </div>
64
+ <span class="text-xs mt-1 text-gray-500">Bg</span>
65
+ </div>
66
+ <div class="flex flex-col items-center">
67
+ <div class="color-input-wrapper" :style="{ backgroundColor: config.textColor }">
68
+ <input type="color" v-model="config.textColor" class="color-input">
69
+ </div>
70
+ <span class="text-xs mt-1 text-gray-500">Text</span>
71
+ </div>
72
+ <div class="flex flex-col items-center">
73
+ <div class="color-input-wrapper" :style="{ backgroundColor: config.accentColor }">
74
+ <input type="color" v-model="config.accentColor" class="color-input">
75
+ </div>
76
+ <span class="text-xs mt-1 text-gray-500">Accent</span>
77
+ </div>
78
+ </div>
79
+ </div>
80
+
81
+ <!-- Content -->
82
+ <div class="space-y-4">
83
+ <div>
84
+ <label class="block text-sm font-medium text-gray-700 mb-1">Heading</label>
85
+ <input type="text" v-model="config.title" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-red-500 focus:border-red-500 text-sm">
86
+ </div>
87
+ <div>
88
+ <label class="block text-sm font-medium text-gray-700 mb-1">Message</label>
89
+ <textarea v-model="config.message" rows="2" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-red-500 focus:border-red-500 text-sm"></textarea>
90
+ </div>
91
+ </div>
92
+
93
+ <!-- Button -->
94
+ <div class="grid grid-cols-2 gap-3">
95
+ <div>
96
+ <label class="block text-sm font-medium text-gray-700 mb-1">Button Text</label>
97
+ <input type="text" v-model="config.buttonText" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
98
+ </div>
99
+ <div>
100
+ <label class="block text-sm font-medium text-gray-700 mb-1">Button Link</label>
101
+ <input type="text" v-model="config.buttonLink" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
102
+ </div>
103
+ </div>
104
+
105
+ <!-- Illustration -->
106
+ <div>
107
+ <label class="block text-sm font-medium text-gray-700 mb-3">Illustration</label>
108
+ <div class="grid grid-cols-4 gap-2">
109
+ <button
110
+ v-for="ill in illustrations"
111
+ :key="ill.id"
112
+ @click="config.illustration = ill.id"
113
+ :class="{'ring-2 ring-red-500 bg-red-50': config.illustration === ill.id}"
114
+ class="p-2 rounded-lg border border-gray-200 text-2xl flex items-center justify-center hover:bg-gray-50"
115
+ >
116
+ {{ ill.icon }}
117
+ </button>
118
+ </div>
119
+ </div>
120
+
121
+ <!-- Game -->
122
+ <div class="flex items-center justify-between bg-gray-50 p-3 rounded-lg border border-gray-200">
123
+ <div>
124
+ <span class="block text-sm font-medium text-gray-900">Embedded Game</span>
125
+ <span class="block text-xs text-gray-500">Add Snake game for retention</span>
126
+ </div>
127
+ <label class="relative inline-flex items-center cursor-pointer">
128
+ <input type="checkbox" v-model="config.showGame" class="sr-only peer">
129
+ <div class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-red-500"></div>
130
+ </label>
131
+ </div>
132
+ </div>
133
+
134
+ <!-- Footer Actions -->
135
+ <div class="p-5 border-t border-gray-200 bg-gray-50">
136
+ <button
137
+ @click="download"
138
+ class="w-full flex items-center justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-all"
139
+ >
140
+ <i class="fas fa-download mr-2"></i> Download HTML
141
+ </button>
142
+ </div>
143
+ </div>
144
+
145
+ <!-- Preview Area -->
146
+ <div class="flex-1 bg-gray-100 flex flex-col relative overflow-hidden">
147
+ <div class="absolute top-4 right-4 z-10 flex space-x-2 bg-white rounded-lg shadow p-1">
148
+ <button @click="viewMode = 'desktop'" :class="{'text-red-500 bg-red-50': viewMode === 'desktop'}" class="p-2 rounded hover:bg-gray-50 transition-colors"><i class="fas fa-desktop"></i></button>
149
+ <button @click="viewMode = 'mobile'" :class="{'text-red-500 bg-red-50': viewMode === 'mobile'}" class="p-2 rounded hover:bg-gray-50 transition-colors"><i class="fas fa-mobile-alt"></i></button>
150
+ </div>
151
+
152
+ <div class="flex-1 flex items-center justify-center p-8 overflow-hidden">
153
+ <div
154
+ class="bg-white shadow-2xl transition-all duration-300 overflow-hidden relative"
155
+ :style="{
156
+ width: viewMode === 'desktop' ? '100%' : '375px',
157
+ height: viewMode === 'desktop' ? '100%' : '667px',
158
+ maxHeight: '100%',
159
+ borderRadius: viewMode === 'desktop' ? '8px' : '20px',
160
+ border: viewMode === 'mobile' ? '8px solid #333' : 'none'
161
+ }"
162
+ >
163
+ <iframe id="preview-frame" class="w-full h-full border-none"></iframe>
164
+
165
+ <!-- Loading Overlay -->
166
+ <div v-if="loading" class="absolute inset-0 bg-white/50 flex items-center justify-center backdrop-blur-sm">
167
+ <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-red-500"></div>
168
+ </div>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ </div>
173
+
174
+ <script>
175
+ const { createApp, ref, watch, onMounted } = Vue;
176
+
177
+ createApp({
178
+ setup() {
179
+ const viewMode = ref('desktop');
180
+ const loading = ref(false);
181
+ const config = ref({
182
+ theme: 'modern',
183
+ title: '404',
184
+ message: 'Oops! The page you are looking for has vanished into the void.',
185
+ buttonText: 'Back to Safety',
186
+ buttonLink: '/',
187
+ bgColor: '#ffffff',
188
+ textColor: '#1f2937',
189
+ accentColor: '#ef4444',
190
+ showGame: false,
191
+ illustration: 'ghost'
192
+ });
193
+
194
+ const illustrations = [
195
+ { id: 'ghost', icon: '👻' },
196
+ { id: 'robot', icon: '🤖' },
197
+ { id: 'broken', icon: '💔' },
198
+ { id: 'planet', icon: '🪐' }
199
+ ];
200
+
201
+ let debounceTimer;
202
+
203
+ const updatePreview = async () => {
204
+ loading.value = true;
205
+ try {
206
+ const response = await fetch('/preview', {
207
+ method: 'POST',
208
+ headers: { 'Content-Type': 'application/json' },
209
+ body: JSON.stringify(config.value)
210
+ });
211
+ const html = await response.text();
212
+ const frame = document.getElementById('preview-frame');
213
+ if (frame) {
214
+ frame.srcdoc = html;
215
+ }
216
+ } catch (error) {
217
+ console.error('Preview error:', error);
218
+ } finally {
219
+ loading.value = false;
220
+ }
221
+ };
222
+
223
+ const debouncedUpdate = () => {
224
+ clearTimeout(debounceTimer);
225
+ debounceTimer = setTimeout(updatePreview, 500);
226
+ };
227
+
228
+ const download = async () => {
229
+ try {
230
+ const response = await fetch('/download', {
231
+ method: 'POST',
232
+ headers: { 'Content-Type': 'application/json' },
233
+ body: JSON.stringify(config.value)
234
+ });
235
+ const blob = await response.blob();
236
+ const url = window.URL.createObjectURL(blob);
237
+ const a = document.createElement('a');
238
+ a.href = url;
239
+ a.download = '404.html';
240
+ document.body.appendChild(a);
241
+ a.click();
242
+ document.body.removeChild(a);
243
+ } catch (error) {
244
+ console.error('Download error:', error);
245
+ alert('Download failed. Please try again.');
246
+ }
247
+ };
248
+
249
+ watch(config, debouncedUpdate, { deep: true });
250
+
251
+ onMounted(() => {
252
+ updatePreview();
253
+ });
254
+
255
+ return {
256
+ config,
257
+ viewMode,
258
+ illustrations,
259
+ loading,
260
+ download
261
+ };
262
+ }
263
+ }).mount('#app');
264
+ </script>
265
+ </body>
266
+ </html>