Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Error Page Studio | 404 页面设计工坊</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> | |
| <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> | |
| <style> | |
| [v-cloak] { display: none; } | |
| .color-input-wrapper { | |
| position: relative; | |
| overflow: hidden; | |
| width: 30px; | |
| height: 30px; | |
| border-radius: 50%; | |
| border: 2px solid #e5e7eb; | |
| } | |
| .color-input { | |
| position: absolute; | |
| top: -50%; | |
| left: -50%; | |
| width: 200%; | |
| height: 200%; | |
| cursor: pointer; | |
| } | |
| /* Custom scrollbar for sidebar */ | |
| ::-webkit-scrollbar { | |
| width: 6px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: #f1f1f1; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: #d1d5db; | |
| border-radius: 3px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: #9ca3af; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 text-gray-800 h-screen overflow-hidden"> | |
| {% raw %} | |
| <div id="app" v-cloak class="flex h-full"> | |
| <!-- Sidebar / Config --> | |
| <div class="w-1/3 min-w-[320px] max-w-[450px] bg-white border-r border-gray-200 flex flex-col h-full z-10 shadow-lg transition-all duration-300"> | |
| <div class="p-5 border-b border-gray-100 flex items-center justify-between"> | |
| <h1 class="text-xl font-bold text-gray-900 flex items-center"> | |
| <i class="fas fa-exclamation-triangle text-red-500 mr-2"></i> | |
| <span>Error Page Studio</span> | |
| </h1> | |
| <span class="text-xs bg-red-100 text-red-600 px-2 py-1 rounded-full">Beta</span> | |
| </div> | |
| <div class="flex-1 overflow-y-auto p-5 space-y-6"> | |
| <!-- Error Message Display --> | |
| <div v-if="errorMessage" class="bg-red-50 text-red-700 p-3 rounded-md text-sm mb-4 border border-red-200 flex items-start"> | |
| <i class="fas fa-times-circle mt-0.5 mr-2"></i> | |
| <div class="flex-1">{{ errorMessage }}</div> | |
| <button @click="errorMessage = ''" class="text-red-500 hover:text-red-700"><i class="fas fa-times"></i></button> | |
| </div> | |
| <!-- Theme Selection --> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-3">Theme Style</label> | |
| <div class="grid grid-cols-3 gap-2"> | |
| <button | |
| v-for="t in ['modern', 'retro', 'minimal']" | |
| :key="t" | |
| @click="config.theme = t" | |
| :class="{'ring-2 ring-red-500 bg-red-50': config.theme === t, 'bg-gray-50 hover:bg-gray-100': config.theme !== t}" | |
| class="p-2 rounded-lg border border-gray-200 text-sm capitalize transition-all" | |
| > | |
| {{ t }} | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Colors --> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-3">Colors</label> | |
| <div class="flex space-x-6"> | |
| <div class="flex flex-col items-center"> | |
| <div class="color-input-wrapper" :style="{ backgroundColor: config.bgColor }"> | |
| <input type="color" v-model="config.bgColor" class="color-input"> | |
| </div> | |
| <span class="text-xs mt-1 text-gray-500">Bg</span> | |
| </div> | |
| <div class="flex flex-col items-center"> | |
| <div class="color-input-wrapper" :style="{ backgroundColor: config.textColor }"> | |
| <input type="color" v-model="config.textColor" class="color-input"> | |
| </div> | |
| <span class="text-xs mt-1 text-gray-500">Text</span> | |
| </div> | |
| <div class="flex flex-col items-center"> | |
| <div class="color-input-wrapper" :style="{ backgroundColor: config.accentColor }"> | |
| <input type="color" v-model="config.accentColor" class="color-input"> | |
| </div> | |
| <span class="text-xs mt-1 text-gray-500">Accent</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Content --> | |
| <div class="space-y-4"> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Heading</label> | |
| <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"> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Message</label> | |
| <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> | |
| </div> | |
| </div> | |
| <!-- Button --> | |
| <div class="grid grid-cols-2 gap-3"> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Button Text</label> | |
| <input type="text" v-model="config.buttonText" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Button Link</label> | |
| <input type="text" v-model="config.buttonLink" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"> | |
| </div> | |
| </div> | |
| <!-- Illustration --> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-3">Illustration</label> | |
| <div class="grid grid-cols-4 gap-2"> | |
| <button | |
| v-for="ill in illustrations" | |
| :key="ill.id" | |
| @click="config.illustration = ill.id" | |
| :class="{'ring-2 ring-red-500 bg-red-50': config.illustration === ill.id}" | |
| class="p-2 rounded-lg border border-gray-200 text-2xl flex items-center justify-center hover:bg-gray-50 transition-colors" | |
| > | |
| {{ ill.icon }} | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Game --> | |
| <div class="flex items-center justify-between bg-gray-50 p-3 rounded-lg border border-gray-200"> | |
| <div> | |
| <span class="block text-sm font-medium text-gray-900">Embedded Game</span> | |
| <span class="block text-xs text-gray-500">Add Snake game for retention</span> | |
| </div> | |
| <label class="relative inline-flex items-center cursor-pointer"> | |
| <input type="checkbox" v-model="config.showGame" class="sr-only peer"> | |
| <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> | |
| </label> | |
| </div> | |
| </div> | |
| <!-- Footer Actions --> | |
| <div class="p-5 border-t border-gray-200 bg-gray-50"> | |
| <button | |
| @click="download" | |
| 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" | |
| :disabled="loading" | |
| :class="{'opacity-75 cursor-not-allowed': loading}" | |
| > | |
| <i class="fas fa-download mr-2"></i> | |
| <span v-if="loading">Processing...</span> | |
| <span v-else>Download HTML</span> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Preview Area --> | |
| <div class="flex-1 bg-gray-100 flex flex-col relative overflow-hidden"> | |
| <div class="absolute top-4 right-4 z-10 flex space-x-2 bg-white rounded-lg shadow p-1"> | |
| <button @click="viewMode = 'desktop'" :class="{'text-red-500 bg-red-50': viewMode === 'desktop'}" class="p-2 rounded hover:bg-gray-50 transition-colors" title="Desktop View"><i class="fas fa-desktop"></i></button> | |
| <button @click="viewMode = 'mobile'" :class="{'text-red-500 bg-red-50': viewMode === 'mobile'}" class="p-2 rounded hover:bg-gray-50 transition-colors" title="Mobile View"><i class="fas fa-mobile-alt"></i></button> | |
| </div> | |
| <div class="flex-1 flex items-center justify-center p-8 overflow-hidden"> | |
| <div | |
| class="bg-white shadow-2xl transition-all duration-300 overflow-hidden relative" | |
| :style="{ | |
| width: viewMode === 'desktop' ? '100%' : '375px', | |
| height: viewMode === 'desktop' ? '100%' : '667px', | |
| maxHeight: '100%', | |
| borderRadius: viewMode === 'desktop' ? '8px' : '20px', | |
| border: viewMode === 'mobile' ? '8px solid #333' : 'none' | |
| }" | |
| > | |
| <iframe id="preview-frame" class="w-full h-full border-none"></iframe> | |
| <!-- Loading Overlay --> | |
| <div v-if="loading" class="absolute inset-0 bg-white/50 flex items-center justify-center backdrop-blur-sm z-20"> | |
| <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-red-500"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {% endraw %} | |
| <script> | |
| const { createApp, ref, watch, onMounted } = Vue; | |
| createApp({ | |
| setup() { | |
| const viewMode = ref('desktop'); | |
| const loading = ref(false); | |
| const errorMessage = ref(''); | |
| // Initialize with safe defaults | |
| const config = ref({ | |
| theme: 'modern', | |
| title: '404', | |
| message: 'Oops! The page you are looking for has vanished into the void.', | |
| buttonText: 'Back to Safety', | |
| buttonLink: '/', | |
| bgColor: '#ffffff', | |
| textColor: '#1f2937', | |
| accentColor: '#ef4444', | |
| showGame: false, | |
| illustration: 'ghost' | |
| }); | |
| const illustrations = [ | |
| { id: 'ghost', icon: '👻' }, | |
| { id: 'robot', icon: '🤖' }, | |
| { id: 'broken', icon: '💔' }, | |
| { id: 'planet', icon: '🪐' } | |
| ]; | |
| let debounceTimer; | |
| const updatePreview = async () => { | |
| loading.value = true; | |
| errorMessage.value = ''; | |
| try { | |
| const response = await fetch('/preview', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(config.value) | |
| }); | |
| const html = await response.text(); | |
| if (!response.ok) { | |
| throw new Error(html || 'Server returned an error'); | |
| } | |
| const frame = document.getElementById('preview-frame'); | |
| if (frame) { | |
| frame.srcdoc = html; | |
| } | |
| } catch (error) { | |
| console.error('Preview error:', error); | |
| errorMessage.value = 'Preview failed: ' + error.message; | |
| } finally { | |
| loading.value = false; | |
| } | |
| }; | |
| const debouncedUpdate = () => { | |
| clearTimeout(debounceTimer); | |
| debounceTimer = setTimeout(updatePreview, 500); | |
| }; | |
| const download = async () => { | |
| loading.value = true; | |
| errorMessage.value = ''; | |
| try { | |
| const response = await fetch('/download', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(config.value) | |
| }); | |
| if (!response.ok) { | |
| const errText = await response.text(); | |
| throw new Error(errText || 'Download failed'); | |
| } | |
| const blob = await response.blob(); | |
| const url = window.URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = '404.html'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| } catch (error) { | |
| console.error('Download error:', error); | |
| errorMessage.value = 'Download failed: ' + error.message; | |
| alert('Download failed. Please check the console or try again.'); | |
| } finally { | |
| loading.value = false; | |
| } | |
| }; | |
| watch(config, debouncedUpdate, { deep: true }); | |
| onMounted(() => { | |
| updatePreview(); | |
| }); | |
| return { | |
| config, | |
| viewMode, | |
| illustrations, | |
| loading, | |
| download, | |
| errorMessage | |
| }; | |
| } | |
| }).mount('#app'); | |
| </script> | |
| </body> | |
| </html> | |