duqing2026 commited on
Commit
ace086b
·
0 Parent(s):

Initial commit: Launch Kit Gen MVP

Browse files
Files changed (5) hide show
  1. Dockerfile +10 -0
  2. README.md +51 -0
  3. app.py +15 -0
  4. requirements.txt +2 -0
  5. templates/index.html +560 -0
Dockerfile ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt 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,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Launch Kit Gen
3
+ emoji: 🚀
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ short_description: 发布物料生成器
9
+ app_port: 7860
10
+ ---
11
+
12
+ # 独立开发发布物料生成器 (Launch Kit Gen)
13
+
14
+ 专为独立开发者、创客和产品经理设计的一站式发布物料生成工具。
15
+
16
+ ## 核心功能
17
+
18
+ * **一键生成多平台物料**:只需输入产品信息和上传 Logo,即可自动生成 Product Hunt、Twitter/X、Instagram Story 等平台的标准尺寸宣传图。
19
+ * **实时预览**:所见即所得,实时查看不同尺寸下的显示效果。
20
+ * **多风格主题**:
21
+ * **极简 (Minimal)**:干净利落,适合工具类产品。
22
+ * **创投 (Startup)**:经典的渐变风格,适合 SaaS 产品。
23
+ * **醒目 (Bold)**:高对比度,适合潮流或 C 端应用。
24
+ * **本地隐私优先**:所有图片生成均在浏览器端完成,无需上传服务器,保护你的设计素材安全。
25
+ * **高清导出**:基于 Canvas 技术,支持高清 PNG 导出。
26
+
27
+ ## 使用场景
28
+
29
+ * **Product Hunt Launch**:快速准备 Gallery Image (1270x760) 和 Thumbnail (240x240)。
30
+ * **Social Media Announcement**:生成 Twitter Card (1200x675) 和 Instagram Story (1080x1920)。
31
+ * **Landing Page Assets**:生成统一风格的 Hero 图片。
32
+
33
+ ## 技术栈
34
+
35
+ * **Frontend**: Vue 3, Tailwind CSS, html2canvas
36
+ * **Backend**: Flask (Python) - 用于静态托管
37
+ * **Deployment**: Docker
38
+
39
+ ## 运行方式
40
+
41
+ ```bash
42
+ # 构建 Docker 镜像
43
+ docker build -t launch-kit-gen .
44
+
45
+ # 运行容器
46
+ docker run -p 7860:7860 launch-kit-gen
47
+ ```
48
+
49
+ ## 贡献
50
+
51
+ 欢迎提交 Issue 和 PR!让我们一起帮助独立开发者更高效地发布产品。
app.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from flask import Flask, render_template, send_from_directory
3
+
4
+ app = Flask(__name__)
5
+
6
+ @app.route('/')
7
+ def index():
8
+ return render_template('index.html')
9
+
10
+ @app.route('/static/<path:path>')
11
+ def send_static(path):
12
+ return send_from_directory('static', path)
13
+
14
+ if __name__ == '__main__':
15
+ app.run(host='0.0.0.0', port=7860)
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ flask
2
+ gunicorn
templates/index.html ADDED
@@ -0,0 +1,560 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>独立开发发布物料生成器 | Launch Kit Gen</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
9
+ <script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>
10
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
11
+ <style>
12
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&family=Noto+Sans+SC:wght@400;700;900&display=swap');
13
+
14
+ body {
15
+ font-family: 'Inter', 'Noto Sans SC', sans-serif;
16
+ background-color: #f3f4f6;
17
+ }
18
+
19
+ .preview-container {
20
+ transform-origin: top left;
21
+ overflow: hidden;
22
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
23
+ }
24
+
25
+ /* 隐藏滚动条但允许滚动 */
26
+ .no-scrollbar::-webkit-scrollbar {
27
+ display: none;
28
+ }
29
+ .no-scrollbar {
30
+ -ms-overflow-style: none;
31
+ scrollbar-width: none;
32
+ }
33
+
34
+ .glass {
35
+ background: rgba(255, 255, 255, 0.2);
36
+ backdrop-filter: blur(10px);
37
+ border: 1px solid rgba(255, 255, 255, 0.3);
38
+ }
39
+ </style>
40
+ </head>
41
+ <body>
42
+ <div id="app" class="flex h-screen overflow-hidden">
43
+ <!-- Sidebar Controls -->
44
+ <div class="w-96 bg-white border-r border-gray-200 flex flex-col h-full z-10 shadow-lg">
45
+ <div class="p-6 border-b border-gray-100">
46
+ <h1 class="text-2xl font-black text-gray-900 flex items-center gap-2">
47
+ <i class="fa-solid fa-rocket text-indigo-600"></i> Launch Kit
48
+ </h1>
49
+ <p class="text-sm text-gray-500 mt-1">独立开发发布物料一键生成</p>
50
+ </div>
51
+
52
+ <div class="flex-1 overflow-y-auto p-6 space-y-6 no-scrollbar">
53
+ <!-- Branding Section -->
54
+ <div class="space-y-4">
55
+ <h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider">品牌信息</h3>
56
+
57
+ <div>
58
+ <label class="block text-sm font-medium text-gray-700 mb-1">产品名称</label>
59
+ <input type="text" v-model="config.productName" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" placeholder="例如: SuperTool">
60
+ </div>
61
+
62
+ <div>
63
+ <label class="block text-sm font-medium text-gray-700 mb-1">主要标语 (Slogan)</label>
64
+ <textarea v-model="config.tagline" rows="2" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" placeholder="一句话介绍产品的核心价值"></textarea>
65
+ </div>
66
+
67
+ <div>
68
+ <label class="block text-sm font-medium text-gray-700 mb-1">次要描述 (Sub-text)</label>
69
+ <textarea v-model="config.subtext" rows="2" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" placeholder="更多细节描述..."></textarea>
70
+ </div>
71
+
72
+ <div>
73
+ <label class="block text-sm font-medium text-gray-700 mb-1">上传 Logo</label>
74
+ <div class="flex items-center gap-3">
75
+ <div v-if="config.logo" class="w-10 h-10 rounded-lg border border-gray-200 p-1 bg-white flex items-center justify-center overflow-hidden relative group cursor-pointer" @click="triggerFileInput">
76
+ <img :src="config.logo" class="max-w-full max-h-full object-contain">
77
+ <div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
78
+ <i class="fa-solid fa-pen text-white text-xs"></i>
79
+ </div>
80
+ </div>
81
+ <label class="flex-1 cursor-pointer bg-gray-50 border border-dashed border-gray-300 rounded-lg px-4 py-2 text-center hover:bg-gray-100 transition-colors">
82
+ <span class="text-sm text-gray-600"><i class="fa-solid fa-cloud-arrow-up mr-1"></i> 选择图片</span>
83
+ <input type="file" @change="handleLogoUpload" accept="image/*" class="hidden" ref="fileInput">
84
+ </label>
85
+ </div>
86
+ </div>
87
+ </div>
88
+
89
+ <hr class="border-gray-100">
90
+
91
+ <!-- Style Section -->
92
+ <div class="space-y-4">
93
+ <h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider">设计风格</h3>
94
+
95
+ <div>
96
+ <label class="block text-sm font-medium text-gray-700 mb-1">主题模板</label>
97
+ <div class="grid grid-cols-3 gap-2">
98
+ <button
99
+ v-for="theme in themes"
100
+ :key="theme.id"
101
+ @click="config.theme = theme.id"
102
+ :class="['px-3 py-2 rounded-lg text-xs font-medium border transition-all', config.theme === theme.id ? 'border-indigo-600 bg-indigo-50 text-indigo-700 ring-1 ring-indigo-600' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-300']"
103
+ >
104
+ {{ theme.name }}
105
+ </button>
106
+ </div>
107
+ </div>
108
+
109
+ <div>
110
+ <label class="block text-sm font-medium text-gray-700 mb-1">品牌主色</label>
111
+ <div class="flex gap-2 flex-wrap">
112
+ <button
113
+ v-for="color in presetColors"
114
+ :key="color"
115
+ @click="config.color = color"
116
+ class="w-8 h-8 rounded-full border border-gray-200 transition-transform hover:scale-110 focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500"
117
+ :style="{ backgroundColor: color }"
118
+ :class="{'ring-2 ring-offset-1 ring-gray-400': config.color === color}"
119
+ ></button>
120
+ <input type="color" v-model="config.color" class="w-8 h-8 p-0 border-0 rounded-full overflow-hidden cursor-pointer">
121
+ </div>
122
+ </div>
123
+
124
+ <div>
125
+ <label class="block text-sm font-medium text-gray-700 mb-1">背景模式</label>
126
+ <select v-model="config.bgMode" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
127
+ <option value="solid">纯色 (Solid)</option>
128
+ <option value="gradient">渐变 (Gradient)</option>
129
+ <option value="mesh">网格 (Mesh)</option>
130
+ </select>
131
+ </div>
132
+ </div>
133
+ </div>
134
+
135
+ <div class="p-6 border-t border-gray-200 bg-gray-50">
136
+ <button
137
+ @click="downloadAll"
138
+ :disabled="isGenerating"
139
+ class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 px-4 rounded-xl shadow-lg shadow-indigo-200 transition-all flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed"
140
+ >
141
+ <i v-if="isGenerating" class="fa-solid fa-circle-notch fa-spin"></i>
142
+ <i v-else class="fa-solid fa-download"></i>
143
+ {{ isGenerating ? '生成中...' : '下载所有物料' }}
144
+ </button>
145
+ </div>
146
+ </div>
147
+
148
+ <!-- Main Preview Area -->
149
+ <div class="flex-1 bg-gray-100 overflow-y-auto p-8 relative">
150
+ <div class="max-w-6xl mx-auto space-y-12 pb-20">
151
+
152
+ <!-- 1. Product Hunt Gallery (1270x760) -->
153
+ <div class="space-y-2">
154
+ <div class="flex justify-between items-end">
155
+ <h2 class="text-lg font-bold text-gray-700">Product Hunt Gallery / Website Hero</h2>
156
+ <span class="text-xs font-mono text-gray-400">1270 x 760</span>
157
+ </div>
158
+ <div class="w-full bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden relative group">
159
+ <!-- Wrapper to maintain aspect ratio and scale content -->
160
+ <div class="relative w-full" style="padding-bottom: 59.84%;"> <!-- 760/1270 * 100 -->
161
+ <div class="absolute inset-0 flex items-center justify-center bg-gray-50">
162
+ <!-- The actual scalable content -->
163
+ <div :id="'preview-ph-gallery'" class="preview-target relative overflow-hidden flex flex-col items-center justify-center text-center p-16"
164
+ :style="containerStyle(1270, 760)">
165
+
166
+ <!-- Background Elements -->
167
+ <div class="absolute inset-0 z-0" :style="backgroundStyle"></div>
168
+ <div v-if="config.bgMode === 'mesh'" class="absolute inset-0 z-0 opacity-30" style="background-image: radial-gradient(#000 1px, transparent 1px); background-size: 40px 40px;"></div>
169
+
170
+ <!-- Content -->
171
+ <div class="relative z-10 max-w-4xl flex flex-col items-center">
172
+ <div v-if="config.logo" class="mb-8 p-4 bg-white rounded-2xl shadow-xl">
173
+ <img :src="config.logo" class="h-32 w-32 object-contain">
174
+ </div>
175
+ <div v-else class="mb-8 h-32 w-32 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm border border-white/30">
176
+ <i class="fa-solid fa-image text-4xl text-white/50"></i>
177
+ </div>
178
+
179
+ <h1 class="text-7xl font-black mb-6 leading-tight" :style="{ color: textColor.title }">
180
+ {{ config.productName || 'Product Name' }}
181
+ </h1>
182
+ <p class="text-4xl font-bold mb-8 opacity-90 max-w-3xl" :style="{ color: textColor.tagline }">
183
+ {{ config.tagline || 'Your amazing product tagline goes here.' }}
184
+ </p>
185
+
186
+ <!-- Mockup UI Element for visual interest -->
187
+ <div class="mt-8 w-full max-w-2xl h-64 bg-white/90 rounded-t-xl shadow-2xl border border-white/50 backdrop-blur-md p-4 flex flex-col gap-4 opacity-90 transform translate-y-4">
188
+ <div class="flex gap-2">
189
+ <div class="w-3 h-3 rounded-full bg-red-400"></div>
190
+ <div class="w-3 h-3 rounded-full bg-yellow-400"></div>
191
+ <div class="w-3 h-3 rounded-full bg-green-400"></div>
192
+ </div>
193
+ <div class="flex-1 bg-gray-50 rounded border border-dashed border-gray-200 flex items-center justify-center">
194
+ <span class="text-gray-400 font-mono text-xl">App Preview UI</span>
195
+ </div>
196
+ </div>
197
+ </div>
198
+ </div>
199
+ </div>
200
+ </div>
201
+ <!-- Scaling script for this specific preview -->
202
+ <div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
203
+ <button @click="downloadSingle('preview-ph-gallery', 'ph-gallery')" class="bg-white/90 hover:bg-white text-gray-700 p-2 rounded shadow-sm text-sm font-medium border border-gray-200">
204
+ <i class="fa-solid fa-download"></i>
205
+ </button>
206
+ </div>
207
+ </div>
208
+ </div>
209
+
210
+ <!-- 2. Twitter / OG Card (1200x675) -->
211
+ <div class="space-y-2">
212
+ <div class="flex justify-between items-end">
213
+ <h2 class="text-lg font-bold text-gray-700">Twitter / X Card</h2>
214
+ <span class="text-xs font-mono text-gray-400">1200 x 675</span>
215
+ </div>
216
+ <div class="w-full bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden relative group">
217
+ <div class="relative w-full" style="padding-bottom: 56.25%;">
218
+ <div class="absolute inset-0 flex items-center justify-center bg-gray-50">
219
+ <div :id="'preview-twitter'" class="preview-target relative overflow-hidden flex flex-row items-center p-16"
220
+ :style="containerStyle(1200, 675)">
221
+
222
+ <div class="absolute inset-0 z-0" :style="backgroundStyle"></div>
223
+
224
+ <!-- Layout Variation: Left Align -->
225
+ <div class="relative z-10 flex-1 pr-12">
226
+ <div class="flex items-center gap-4 mb-6">
227
+ <div v-if="config.logo" class="h-16 w-16 bg-white rounded-xl shadow-lg p-2 flex items-center justify-center">
228
+ <img :src="config.logo" class="max-h-full max-w-full">
229
+ </div>
230
+ <h2 class="text-3xl font-bold opacity-80" :style="{ color: textColor.tagline }">{{ config.productName || 'Product' }}</h2>
231
+ </div>
232
+ <h1 class="text-6xl font-black mb-6 leading-tight" :style="{ color: textColor.title }">
233
+ {{ config.tagline || 'Catchy Headline For Social Media.' }}
234
+ </h1>
235
+ <p class="text-2xl font-medium opacity-70" :style="{ color: textColor.tagline }">
236
+ {{ config.subtext || 'Short description that makes people want to click immediately.' }}
237
+ </p>
238
+ </div>
239
+
240
+ <!-- Right Graphic -->
241
+ <div class="relative z-10 w-1/3 h-full flex items-center justify-center">
242
+ <div class="w-64 h-64 bg-white/20 backdrop-blur-md rounded-full border border-white/30 shadow-2xl flex items-center justify-center relative">
243
+ <div class="absolute inset-0 rounded-full border-4 border-dashed border-white/20 animate-spin-slow" style="animation-duration: 20s"></div>
244
+ <i class="fa-solid fa-bolt text-8xl text-white/80"></i>
245
+ </div>
246
+ </div>
247
+ </div>
248
+ </div>
249
+ </div>
250
+ <div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
251
+ <button @click="downloadSingle('preview-twitter', 'twitter-card')" class="bg-white/90 hover:bg-white text-gray-700 p-2 rounded shadow-sm text-sm font-medium border border-gray-200">
252
+ <i class="fa-solid fa-download"></i>
253
+ </button>
254
+ </div>
255
+ </div>
256
+ </div>
257
+
258
+ <!-- Grid for smaller items -->
259
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-8">
260
+
261
+ <!-- 3. Product Hunt Thumbnail (240x240) -->
262
+ <div class="space-y-2">
263
+ <div class="flex justify-between items-end">
264
+ <h2 class="text-lg font-bold text-gray-700">PH Thumbnail</h2>
265
+ <span class="text-xs font-mono text-gray-400">240 x 240</span>
266
+ </div>
267
+ <div class="w-full bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden relative group p-8 flex justify-center">
268
+ <!-- Fixed display size for this one since it's small -->
269
+ <div :id="'preview-ph-thumb'" class="preview-target relative overflow-hidden flex items-center justify-center"
270
+ :style="[containerStyle(240, 240, 1), { width: '240px', height: '240px' }]"> <!-- Force 1:1 scale for display -->
271
+
272
+ <div class="absolute inset-0 z-0" :style="backgroundStyle"></div>
273
+
274
+ <div class="relative z-10 flex flex-col items-center justify-center p-4">
275
+ <div v-if="config.logo" class="h-32 w-32 bg-white rounded-2xl shadow-lg p-4 flex items-center justify-center mb-2">
276
+ <img :src="config.logo" class="max-h-full max-w-full object-contain">
277
+ </div>
278
+ <div v-else class="h-32 w-32 bg-white/20 rounded-2xl border-2 border-white/50 flex items-center justify-center mb-2">
279
+ <span class="text-4xl font-black text-white">P</span>
280
+ </div>
281
+ </div>
282
+ </div>
283
+ <div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
284
+ <button @click="downloadSingle('preview-ph-thumb', 'ph-thumbnail')" class="bg-white/90 hover:bg-white text-gray-700 p-2 rounded shadow-sm text-sm font-medium border border-gray-200">
285
+ <i class="fa-solid fa-download"></i>
286
+ </button>
287
+ </div>
288
+ </div>
289
+ </div>
290
+
291
+ <!-- 4. Instagram Story (1080x1920) -->
292
+ <div class="space-y-2">
293
+ <div class="flex justify-between items-end">
294
+ <h2 class="text-lg font-bold text-gray-700">Mobile Story</h2>
295
+ <span class="text-xs font-mono text-gray-400">1080 x 1920</span>
296
+ </div>
297
+ <div class="w-full bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden relative group flex justify-center bg-gray-50 py-4">
298
+ <!-- Scaled down view -->
299
+ <div class="relative shadow-xl rounded-2xl overflow-hidden" style="width: 270px; height: 480px;"> <!-- 1:4 scale of 1080x1920 -->
300
+ <div class="absolute inset-0 bg-white">
301
+ <div :id="'preview-story'" class="preview-target relative overflow-hidden flex flex-col p-12"
302
+ :style="containerStyle(1080, 1920)">
303
+
304
+ <div class="absolute inset-0 z-0" :style="backgroundStyle"></div>
305
+
306
+ <!-- Top -->
307
+ <div class="relative z-10 pt-20 flex flex-col items-center text-center">
308
+ <div class="inline-block px-4 py-2 bg-white/20 backdrop-blur rounded-full text-white font-bold text-xl mb-8 border border-white/20">
309
+ NEW LAUNCH
310
+ </div>
311
+ <h1 class="text-8xl font-black mb-6 leading-tight" :style="{ color: textColor.title }">
312
+ {{ config.productName || 'Product' }}
313
+ </h1>
314
+ </div>
315
+
316
+ <!-- Middle Visual -->
317
+ <div class="relative z-10 flex-1 flex items-center justify-center my-12">
318
+ <div class="w-full aspect-square bg-white/10 rounded-3xl border border-white/20 shadow-2xl flex items-center justify-center p-8 backdrop-blur-md relative overflow-hidden">
319
+ <!-- Abstract Shapes -->
320
+ <div class="absolute -top-20 -right-20 w-60 h-60 bg-blue-500 rounded-full blur-3xl opacity-30 mix-blend-overlay"></div>
321
+ <div class="absolute -bottom-20 -left-20 w-60 h-60 bg-purple-500 rounded-full blur-3xl opacity-30 mix-blend-overlay"></div>
322
+
323
+ <img v-if="config.logo" :src="config.logo" class="relative z-10 max-w-full max-h-full drop-shadow-2xl">
324
+ <i v-else class="fa-solid fa-cube text-9xl text-white/80"></i>
325
+ </div>
326
+ </div>
327
+
328
+ <!-- Bottom -->
329
+ <div class="relative z-10 pb-20 text-center">
330
+ <h2 class="text-5xl font-bold mb-6 leading-snug" :style="{ color: textColor.tagline }">
331
+ {{ config.tagline || 'We are live now!' }}
332
+ </h2>
333
+ <div class="bg-white text-black font-bold text-3xl py-6 rounded-xl shadow-lg mt-8 flex items-center justify-center gap-4">
334
+ Link in Bio <i class="fa-solid fa-arrow-up rotate-45"></i>
335
+ </div>
336
+ </div>
337
+ </div>
338
+ </div>
339
+ </div>
340
+ <div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
341
+ <button @click="downloadSingle('preview-story', 'mobile-story')" class="bg-white/90 hover:bg-white text-gray-700 p-2 rounded shadow-sm text-sm font-medium border border-gray-200">
342
+ <i class="fa-solid fa-download"></i>
343
+ </button>
344
+ </div>
345
+ </div>
346
+ </div>
347
+
348
+ </div>
349
+
350
+ </div>
351
+ </div>
352
+ </div>
353
+
354
+ <script>
355
+ const { createApp, ref, computed, onMounted } = Vue;
356
+
357
+ createApp({
358
+ setup() {
359
+ const isGenerating = ref(false);
360
+ const fileInput = ref(null);
361
+
362
+ const config = ref({
363
+ productName: '',
364
+ tagline: '',
365
+ subtext: '',
366
+ logo: null,
367
+ theme: 'startup',
368
+ color: '#6366f1',
369
+ bgMode: 'gradient'
370
+ });
371
+
372
+ const themes = [
373
+ { id: 'minimal', name: '极简 (Minimal)' },
374
+ { id: 'startup', name: '创投 (Startup)' },
375
+ { id: 'bold', name: '醒目 (Bold)' }
376
+ ];
377
+
378
+ const presetColors = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#111827'];
379
+
380
+ // Handle Logo Upload
381
+ const triggerFileInput = () => fileInput.value.click();
382
+ const handleLogoUpload = (e) => {
383
+ const file = e.target.files[0];
384
+ if (file) {
385
+ const reader = new FileReader();
386
+ reader.onload = (e) => config.value.logo = e.target.result;
387
+ reader.readAsDataURL(file);
388
+ }
389
+ };
390
+
391
+ // Dynamic Styles
392
+ const backgroundStyle = computed(() => {
393
+ const c = config.value.color;
394
+ const mode = config.value.bgMode;
395
+ const theme = config.value.theme;
396
+
397
+ if (theme === 'minimal') {
398
+ return { backgroundColor: '#ffffff' };
399
+ }
400
+
401
+ if (theme === 'bold') {
402
+ return { backgroundColor: c };
403
+ }
404
+
405
+ // Startup Theme Logic
406
+ if (mode === 'solid') return { backgroundColor: c };
407
+ if (mode === 'gradient') {
408
+ return {
409
+ background: `linear-gradient(135deg, ${c} 0%, #ffffff 100%)`
410
+ };
411
+ }
412
+ if (mode === 'mesh') {
413
+ return {
414
+ backgroundColor: '#ffffff',
415
+ backgroundImage: `radial-gradient(at 0% 0%, ${c}22 0px, transparent 50%), radial-gradient(at 100% 100%, ${c}44 0px, transparent 50%)`
416
+ };
417
+ }
418
+ return { backgroundColor: c };
419
+ });
420
+
421
+ const textColor = computed(() => {
422
+ const theme = config.value.theme;
423
+ if (theme === 'minimal') {
424
+ return { title: '#111827', tagline: '#4b5563' };
425
+ }
426
+ if (theme === 'bold') {
427
+ return { title: '#ffffff', tagline: 'rgba(255,255,255,0.9)' };
428
+ }
429
+ // Startup
430
+ return { title: '#1f2937', tagline: '#4b5563' };
431
+ });
432
+
433
+ // Helper to scale preview containers to fit real pixel dimensions into display areas
434
+ // We use transform: scale() to fit the massive 1270px etc into the UI,
435
+ // but when downloading we capture the full resolution.
436
+ const containerStyle = (w, h, displayScale = null) => {
437
+ // Logic: The container in DOM is displayed smaller, but we define it as WxH pixels
438
+ // Then we scale it down with CSS transform to fit the parent.
439
+ // Actually, a simpler way for html2canvas is:
440
+ // Render it at full size, but use CSS transform: scale() on the container to make it look small in UI.
441
+
442
+ // However, calculating exact scale for responsive UI is tricky.
443
+ // Strategy: The parent has a fixed aspect ratio or width.
444
+ // We set the child to absolute width/height and scale it.
445
+
446
+ // For this MVP, to keep it simple and robust:
447
+ // We will rely on the "transform: scale" approach relative to parent width.
448
+ // But here we return the raw dimensions.
449
+ // The scaling is handled by a resize observer or hardcoded for specific previews.
450
+
451
+ // Simplified: We just set width/height. The parent container handles the view.
452
+ // To make it fit in the UI, we add a transform.
453
+
454
+ let scale = 1;
455
+ if (!displayScale) {
456
+ // Estimate scale based on width.
457
+ // Gallery: 1270px. UI width approx 800px?
458
+ // Let's make it responsive via a tiny inline script or simple calc
459
+ // Ideally we use a wrapper with container queries, but here:
460
+ if (w > 1000) scale = 0.6;
461
+ if (w < 500) scale = 1;
462
+ } else {
463
+ scale = displayScale;
464
+ }
465
+
466
+ return {
467
+ width: `${w}px`,
468
+ height: `${h}px`,
469
+ // Transform origin top left to fit in the wrapper
470
+ transform: `scale(${1})`, // We will actually control scale via parent or just let it overflow hidden?
471
+ // Better approach: Use a "zoom" CSS property or transform on a wrapper.
472
+ // For this demo, I will use a specific technique:
473
+ // The parent `relative` div has padding-bottom for aspect ratio.
474
+ // This child is absolute inset 0.
475
+ // BUT, we want high res export.
476
+ // So we actually render the High Res div, and scale it down to fit the parent.
477
+
478
+ position: 'absolute',
479
+ top: '50%',
480
+ left: '50%',
481
+ transform: `translate(-50%, -50%) scale(${ getScale(w) })`,
482
+ };
483
+ };
484
+
485
+ const getScale = (targetWidth) => {
486
+ // Quick hack for responsive scaling based on typical screen size
487
+ // Real implementation would use ResizeObserver.
488
+ if (targetWidth === 1270) return 0.5; // fits in ~635px
489
+ if (targetWidth === 1200) return 0.5;
490
+ if (targetWidth === 1080) return 0.25; // fits in 270px width
491
+ return 1;
492
+ };
493
+
494
+ // Action: Download Single
495
+ const downloadSingle = async (elementId, filename) => {
496
+ const el = document.getElementById(elementId);
497
+ if (!el) return;
498
+
499
+ // Temporarily remove scale transform for full res capture?
500
+ // html2canvas captures what is rendered. If it's scaled down, it might be blurry or small.
501
+ // TRICK: We clone the node, append to body (hidden), scale up, capture, remove.
502
+
503
+ isGenerating.value = true;
504
+ try {
505
+ const canvas = await html2canvas(el, {
506
+ scale: 1, // Capture at 1:1 of the defined width/height (which are high res)
507
+ useCORS: true,
508
+ backgroundColor: null,
509
+ // We need to ensure the transform scale doesn't affect the output resolution
510
+ // If the element has transform: scale(0.5), html2canvas might respect that.
511
+ // We use onclone to reset transform.
512
+ onclone: (clonedDoc) => {
513
+ const clonedEl = clonedDoc.getElementById(elementId);
514
+ clonedEl.style.transform = 'translate(0,0) scale(1)';
515
+ clonedEl.style.top = '0';
516
+ clonedEl.style.left = '0';
517
+ clonedEl.style.position = 'static';
518
+ }
519
+ });
520
+
521
+ const link = document.createElement('a');
522
+ link.download = `${filename}-${Date.now()}.png`;
523
+ link.href = canvas.toDataURL();
524
+ link.click();
525
+ } catch (e) {
526
+ console.error(e);
527
+ alert('生成失败,请重试');
528
+ } finally {
529
+ isGenerating.value = false;
530
+ }
531
+ };
532
+
533
+ const downloadAll = async () => {
534
+ if (confirm('即将依次下载4张图片,浏览器可能会拦截弹窗,请允许。')) {
535
+ await downloadSingle('preview-ph-gallery', 'product-hunt-gallery');
536
+ await downloadSingle('preview-twitter', 'twitter-card');
537
+ await downloadSingle('preview-ph-thumb', 'ph-thumbnail');
538
+ await downloadSingle('preview-story', 'instagram-story');
539
+ }
540
+ };
541
+
542
+ return {
543
+ config,
544
+ themes,
545
+ presetColors,
546
+ fileInput,
547
+ triggerFileInput,
548
+ handleLogoUpload,
549
+ backgroundStyle,
550
+ textColor,
551
+ containerStyle,
552
+ downloadSingle,
553
+ downloadAll,
554
+ isGenerating
555
+ };
556
+ }
557
+ }).mount('#app');
558
+ </script>
559
+ </body>
560
+ </html>