|
1
|
// Download utilities for guides and videos |
|
2
|
import type { RepairGuide, TroubleshootingFlow } from "@shared/schema"; |
|
3
|
import { saveDownloadedFile, type DownloadedFile } from "./offline-storage"; |
|
4
|
|
|
5
|
export interface DownloadOptions { |
|
6
|
includeImages?: boolean; |
|
7
|
includeVideos?: boolean; |
|
8
|
format?: 'pdf' | 'html' | 'json'; |
|
9
|
} |
|
10
|
|
|
11
|
// Download a repair guide as a file |
|
12
|
export const downloadGuide = async (guide: RepairGuide, options: DownloadOptions = {}) => { |
|
13
|
const { format = 'html', includeImages = true } = options; |
|
14
|
|
|
15
|
try { |
|
16
|
let content: string; |
|
17
|
let mimeType: string; |
|
18
|
let fileName: string; |
|
19
|
|
|
20
|
switch (format) { |
|
21
|
case 'html': |
|
22
|
content = generateHTMLGuide(guide, includeImages); |
|
23
|
mimeType = 'text/html'; |
|
24
|
fileName = `${sanitizeFileName(guide.title)}.html`; |
|
25
|
break; |
|
26
|
case 'json': |
|
27
|
content = JSON.stringify(guide, null, 2); |
|
28
|
mimeType = 'application/json'; |
|
29
|
fileName = `${sanitizeFileName(guide.title)}.json`; |
|
30
|
break; |
|
31
|
case 'pdf': |
|
32
|
// For PDF, we'll generate HTML and let the browser handle PDF conversion |
|
33
|
content = generatePrintableHTML(guide, includeImages); |
|
34
|
mimeType = 'text/html'; |
|
35
|
fileName = `${sanitizeFileName(guide.title)}_printable.html`; |
|
36
|
break; |
|
37
|
default: |
|
38
|
throw new Error('Unsupported format'); |
|
39
|
} |
|
40
|
|
|
41
|
const blob = new Blob([content], { type: mimeType }); |
|
42
|
const url = URL.createObjectURL(blob); |
|
43
|
|
|
44
|
const link = document.createElement('a'); |
|
45
|
link.href = url; |
|
46
|
link.download = fileName; |
|
47
|
link.style.display = 'none'; |
|
48
|
|
|
49
|
document.body.appendChild(link); |
|
50
|
link.click(); |
|
51
|
document.body.removeChild(link); |
|
52
|
|
|
53
|
URL.revokeObjectURL(url); |
|
54
|
|
|
55
|
// Track the download |
|
56
|
await trackGuideDownload(guide.id); |
|
57
|
const downloadRecord: DownloadedFile = { |
|
58
|
id: `guide_${guide.id}_${Date.now()}`, |
|
59
|
name: guide.title, |
|
60
|
type: 'guide', |
|
61
|
size: blob.size, |
|
62
|
downloadDate: new Date().toISOString(), |
|
63
|
lastAccessed: new Date().toISOString(), |
|
64
|
format: format, |
|
65
|
relatedGuideId: guide.id, |
|
66
|
}; |
|
67
|
saveDownloadedFile(downloadRecord); |
|
68
|
|
|
69
|
return true; |
|
70
|
} catch (error) { |
|
71
|
console.error('Failed to download guide:', error); |
|
72
|
return false; |
|
73
|
} |
|
74
|
}; |
|
75
|
|
|
76
|
// Download video from URL |
|
77
|
export const downloadVideo = async (videoUrl: string, title: string): Promise<boolean> => { |
|
78
|
try { |
|
79
|
const response = await fetch(videoUrl, { |
|
80
|
mode: 'cors', |
|
81
|
}); |
|
82
|
|
|
83
|
if (!response.ok) { |
|
84
|
throw new Error(`HTTP error! status: ${response.status}`); |
|
85
|
} |
|
86
|
|
|
87
|
const blob = await response.blob(); |
|
88
|
const url = URL.createObjectURL(blob); |
|
89
|
|
|
90
|
const link = document.createElement('a'); |
|
91
|
link.href = url; |
|
92
|
link.download = `${sanitizeFileName(title)}.mp4`; |
|
93
|
link.style.display = 'none'; |
|
94
|
|
|
95
|
document.body.appendChild(link); |
|
96
|
link.click(); |
|
97
|
document.body.removeChild(link); |
|
98
|
|
|
99
|
URL.revokeObjectURL(url); |
|
100
|
|
|
101
|
// Track the video download |
|
102
|
const downloadRecord: DownloadedFile = { |
|
103
|
id: `video_${Date.now()}`, |
|
104
|
name: title, |
|
105
|
type: 'video', |
|
106
|
size: blob.size, |
|
107
|
downloadDate: new Date().toISOString(), |
|
108
|
lastAccessed: new Date().toISOString(), |
|
109
|
format: 'mp4', |
|
110
|
}; |
|
111
|
saveDownloadedFile(downloadRecord); |
|
112
|
|
|
113
|
return true; |
|
114
|
} catch (error) { |
|
115
|
console.error('Failed to download video:', error); |
|
116
|
return false; |
|
117
|
} |
|
118
|
}; |
|
119
|
|
|
120
|
// Download multiple guides as a ZIP file |
|
121
|
export const downloadGuidePackage = async (guides: RepairGuide[], packageName: string = 'repair_guides'): Promise<boolean> => { |
|
122
|
try { |
|
123
|
// For now, we'll create individual files. In production, you'd use a ZIP library |
|
124
|
const promises = guides.map(guide => |
|
125
|
downloadGuide(guide, { format: 'html', includeImages: true }) |
|
126
|
); |
|
127
|
|
|
128
|
await Promise.all(promises); |
|
129
|
return true; |
|
130
|
} catch (error) { |
|
131
|
console.error('Failed to download guide package:', error); |
|
132
|
return false; |
|
133
|
} |
|
134
|
}; |
|
135
|
|
|
136
|
// Generate HTML content for a guide |
|
137
|
const generateHTMLGuide = (guide: RepairGuide, includeImages: boolean): string => { |
|
138
|
const steps = guide.steps as Array<{ |
|
139
|
stepNumber: number; |
|
140
|
title: string; |
|
141
|
description: string; |
|
142
|
notes?: string[]; |
|
143
|
warnings?: string[]; |
|
144
|
}> || []; |
|
145
|
|
|
146
|
return ` |
|
147
|
<!DOCTYPE html> |
|
148
|
<html lang="en"> |
|
149
|
<head> |
|
150
|
<meta charset="UTF-8"> |
|
151
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
152
|
<title>${guide.title}</title> |
|
153
|
<style> |
|
154
|
body { |
|
155
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; |
|
156
|
line-height: 1.6; |
|
157
|
max-width: 800px; |
|
158
|
margin: 0 auto; |
|
159
|
padding: 20px; |
|
160
|
color: #333; |
|
161
|
} |
|
162
|
.header { |
|
163
|
border-bottom: 2px solid #e2e8f0; |
|
164
|
padding-bottom: 20px; |
|
165
|
margin-bottom: 30px; |
|
166
|
} |
|
167
|
.title { |
|
168
|
font-size: 2.5rem; |
|
169
|
font-weight: bold; |
|
170
|
margin-bottom: 10px; |
|
171
|
color: #1a202c; |
|
172
|
} |
|
173
|
.description { |
|
174
|
font-size: 1.1rem; |
|
175
|
color: #4a5568; |
|
176
|
margin-bottom: 15px; |
|
177
|
} |
|
178
|
.meta { |
|
179
|
display: flex; |
|
180
|
gap: 15px; |
|
181
|
font-size: 0.9rem; |
|
182
|
color: #718096; |
|
183
|
} |
|
184
|
.difficulty { |
|
185
|
padding: 4px 12px; |
|
186
|
border-radius: 6px; |
|
187
|
font-weight: 500; |
|
188
|
} |
|
189
|
.difficulty.easy { background: #c6f6d5; color: #22543d; } |
|
190
|
.difficulty.medium { background: #fef2c0; color: #744210; } |
|
191
|
.difficulty.hard { background: #fed7d7; color: #742a2a; } |
|
192
|
.section { |
|
193
|
margin: 30px 0; |
|
194
|
} |
|
195
|
.section-title { |
|
196
|
font-size: 1.5rem; |
|
197
|
font-weight: 600; |
|
198
|
margin-bottom: 15px; |
|
199
|
color: #2d3748; |
|
200
|
} |
|
201
|
.tools-list, .safety-list { |
|
202
|
list-style: none; |
|
203
|
padding: 0; |
|
204
|
} |
|
205
|
.tools-list li, .safety-list li { |
|
206
|
padding: 8px 12px; |
|
207
|
margin: 5px 0; |
|
208
|
border-left: 4px solid #4299e1; |
|
209
|
background: #ebf8ff; |
|
210
|
} |
|
211
|
.safety-list li { |
|
212
|
border-left-color: #f56565; |
|
213
|
background: #fed7d7; |
|
214
|
} |
|
215
|
.step { |
|
216
|
margin: 25px 0; |
|
217
|
padding: 20px; |
|
218
|
border: 1px solid #e2e8f0; |
|
219
|
border-radius: 8px; |
|
220
|
background: #f7fafc; |
|
221
|
} |
|
222
|
.step-number { |
|
223
|
font-size: 1.3rem; |
|
224
|
font-weight: bold; |
|
225
|
color: #3182ce; |
|
226
|
margin-bottom: 10px; |
|
227
|
} |
|
228
|
.step-title { |
|
229
|
font-size: 1.2rem; |
|
230
|
font-weight: 600; |
|
231
|
margin-bottom: 10px; |
|
232
|
color: #2d3748; |
|
233
|
} |
|
234
|
.step-description { |
|
235
|
margin-bottom: 15px; |
|
236
|
line-height: 1.8; |
|
237
|
} |
|
238
|
.notes, .warnings { |
|
239
|
margin-top: 10px; |
|
240
|
padding: 10px; |
|
241
|
border-radius: 6px; |
|
242
|
font-size: 0.9rem; |
|
243
|
} |
|
244
|
.notes { |
|
245
|
background: #ebf8ff; |
|
246
|
border-left: 4px solid #4299e1; |
|
247
|
} |
|
248
|
.warnings { |
|
249
|
background: #fed7d7; |
|
250
|
border-left: 4px solid #f56565; |
|
251
|
} |
|
252
|
.guide-image { |
|
253
|
max-width: 100%; |
|
254
|
height: auto; |
|
255
|
border-radius: 8px; |
|
256
|
margin: 20px 0; |
|
257
|
} |
|
258
|
@media print { |
|
259
|
body { font-size: 12pt; } |
|
260
|
.step { break-inside: avoid; } |
|
261
|
} |
|
262
|
</style> |
|
263
|
</head> |
|
264
|
<body> |
|
265
|
<div class="header"> |
|
266
|
<h1 class="title">${guide.title}</h1> |
|
267
|
<p class="description">${guide.description}</p> |
|
268
|
<div class="meta"> |
|
269
|
<span class="difficulty ${guide.difficulty}">${guide.difficulty.toUpperCase()}</span> |
|
270
|
<span>📱 ${guide.deviceType}</span> |
|
271
|
<span>🔧 ${guide.category}</span> |
|
272
|
<span>⏱️ ${guide.estimatedTime}</span> |
|
273
|
</div> |
|
274
|
</div> |
|
275
|
|
|
276
|
${includeImages && guide.imageUrl ? ` |
|
277
|
<div class="section"> |
|
278
|
<img src="${guide.imageUrl}" alt="${guide.title}" class="guide-image" /> |
|
279
|
</div> |
|
280
|
` : ''} |
|
281
|
|
|
282
|
<div class="section"> |
|
283
|
<h2 class="section-title">🛠️ Tools Required</h2> |
|
284
|
<ul class="tools-list"> |
|
285
|
${guide.toolsRequired.map(tool => `<li>${tool}</li>`).join('')} |
|
286
|
</ul> |
|
287
|
</div> |
|
288
|
|
|
289
|
${guide.safetyWarnings && guide.safetyWarnings.length > 0 ? ` |
|
290
|
<div class="section"> |
|
291
|
<h2 class="section-title">⚠️ Safety Warnings</h2> |
|
292
|
<ul class="safety-list"> |
|
293
|
${guide.safetyWarnings.map(warning => `<li>${warning}</li>`).join('')} |
|
294
|
</ul> |
|
295
|
</div> |
|
296
|
` : ''} |
|
297
|
|
|
298
|
<div class="section"> |
|
299
|
<h2 class="section-title">📋 Repair Steps</h2> |
|
300
|
${steps.map(step => ` |
|
301
|
<div class="step"> |
|
302
|
<div class="step-number">Step ${step.stepNumber}</div> |
|
303
|
<div class="step-title">${step.title}</div> |
|
304
|
<div class="step-description">${step.description}</div> |
|
305
|
${step.notes && step.notes.length > 0 ? ` |
|
306
|
<div class="notes"> |
|
307
|
<strong>Notes:</strong> ${step.notes.join(', ')} |
|
308
|
</div> |
|
309
|
` : ''} |
|
310
|
${step.warnings && step.warnings.length > 0 ? ` |
|
311
|
<div class="warnings"> |
|
312
|
<strong>⚠️ Warnings:</strong> ${step.warnings.join(', ')} |
|
313
|
</div> |
|
314
|
` : ''} |
|
315
|
</div> |
|
316
|
`).join('')} |
|
317
|
</div> |
|
318
|
|
|
319
|
${guide.alternativeSolutions ? ` |
|
320
|
<div class="section"> |
|
321
|
<h2 class="section-title">🔄 Alternative Solutions</h2> |
|
322
|
<p>${guide.alternativeSolutions}</p> |
|
323
|
</div> |
|
324
|
` : ''} |
|
325
|
|
|
326
|
<div class="section" style="margin-top: 50px; padding-top: 20px; border-top: 1px solid #e2e8f0; text-align: center; color: #718096; font-size: 0.9rem;"> |
|
327
|
<p>Downloaded from JCRGuru • ${new Date().toLocaleDateString()}</p> |
|
328
|
</div> |
|
329
|
</body> |
|
330
|
</html> |
|
331
|
`.trim(); |
|
332
|
}; |
|
333
|
|
|
334
|
// Generate printable HTML (optimized for PDF) |
|
335
|
const generatePrintableHTML = (guide: RepairGuide, includeImages: boolean): string => { |
|
336
|
const html = generateHTMLGuide(guide, includeImages); |
|
337
|
return html.replace( |
|
338
|
'<style>', |
|
339
|
'<style>@page { margin: 1in; } body { font-size: 11pt; } ' |
|
340
|
); |
|
341
|
}; |
|
342
|
|
|
343
|
// Sanitize filename for downloads |
|
344
|
const sanitizeFileName = (fileName: string): string => { |
|
345
|
return fileName |
|
346
|
.replace(/[^a-z0-9]/gi, '_') |
|
347
|
.replace(/_+/g, '_') |
|
348
|
.replace(/^_|_$/g, '') |
|
349
|
.toLowerCase(); |
|
350
|
}; |
|
351
|
|
|
352
|
// Check if download is supported |
|
353
|
export const isDownloadSupported = (): boolean => { |
|
354
|
return typeof document !== 'undefined' && 'createElement' in document; |
|
355
|
}; |
|
356
|
|
|
357
|
// Get estimated download size |
|
358
|
export const getEstimatedSize = (guide: RepairGuide, options: DownloadOptions = {}): string => { |
|
359
|
const { format = 'html', includeImages = true } = options; |
|
360
|
|
|
361
|
let estimatedBytes = 0; |
|
362
|
|
|
363
|
// Base content size |
|
364
|
estimatedBytes += JSON.stringify(guide).length * 2; // HTML markup overhead |
|
365
|
|
|
366
|
// Add estimated image sizes if included |
|
367
|
if (includeImages && guide.imageUrl) { |
|
368
|
estimatedBytes += 100000; // Estimate 100KB per image |
|
369
|
} |
|
370
|
|
|
371
|
// Format-specific overhead |
|
372
|
switch (format) { |
|
373
|
case 'html': |
|
374
|
estimatedBytes += 5000; // CSS and HTML structure |
|
375
|
break; |
|
376
|
case 'pdf': |
|
377
|
estimatedBytes *= 1.5; // PDF conversion overhead |
|
378
|
break; |
|
379
|
} |
|
380
|
|
|
381
|
return formatFileSize(estimatedBytes); |
|
382
|
}; |
|
383
|
|
|
384
|
// Format file size for display |
|
385
|
const formatFileSize = (bytes: number): string => { |
|
386
|
if (bytes === 0) return '0 B'; |
|
387
|
const k = 1024; |
|
388
|
const sizes = ['B', 'KB', 'MB', 'GB']; |
|
389
|
const i = Math.floor(Math.log(bytes) / Math.log(k)); |
|
390
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; |
|
391
|
}; |
|
392
|
|
|
393
|
// Track guide download |
|
394
|
export const trackGuideDownload = async (guideId: string): Promise<void> => { |
|
395
|
try { |
|
396
|
await fetch(`/api/repair-guides/${guideId}/download`, { |
|
397
|
method: 'POST', |
|
398
|
headers: { |
|
399
|
'Content-Type': 'application/json', |
|
400
|
}, |
|
401
|
}); |
|
402
|
} catch (error) { |
|
403
|
console.error('Failed to track download:', error); |
|
404
|
} |
|
405
|
}; |