wiki-plugin-shoppe 0.0.13 → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
package/server/server.js
CHANGED
|
@@ -9,6 +9,20 @@ const sessionless = require('sessionless-node');
|
|
|
9
9
|
|
|
10
10
|
const SHOPPE_BASE_EMOJI = process.env.SHOPPE_BASE_EMOJI || '🛍️🎨🎁';
|
|
11
11
|
|
|
12
|
+
const TEMPLATES_DIR = path.join(__dirname, 'templates');
|
|
13
|
+
const RECOVER_STRIPE_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'generic-recover-stripe.html'), 'utf8');
|
|
14
|
+
const ADDRESS_STRIPE_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'generic-address-stripe.html'), 'utf8');
|
|
15
|
+
const EBOOK_DOWNLOAD_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'ebook-download.html'), 'utf8');
|
|
16
|
+
|
|
17
|
+
function getAllyabaseOrigin() {
|
|
18
|
+
try { return new URL(getSanoraUrl()).origin; } catch { return getSanoraUrl(); }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function fillTemplate(tmpl, vars) {
|
|
22
|
+
return Object.entries(vars).reduce((html, [k, v]) =>
|
|
23
|
+
html.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), v), tmpl);
|
|
24
|
+
}
|
|
25
|
+
|
|
12
26
|
const DATA_DIR = path.join(process.env.HOME || '/root', '.shoppe');
|
|
13
27
|
const TENANTS_FILE = path.join(DATA_DIR, 'tenants.json');
|
|
14
28
|
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
|
@@ -681,11 +695,13 @@ async function getShoppeGoods(tenant) {
|
|
|
681
695
|
image: product.image ? `${getSanoraUrl()}/images/${product.image}` : null,
|
|
682
696
|
url: isPost
|
|
683
697
|
? `/plugin/shoppe/${tenant.uuid}/post/${encodeURIComponent(title)}`
|
|
684
|
-
:
|
|
685
|
-
?
|
|
686
|
-
: product.category === 'product'
|
|
687
|
-
?
|
|
688
|
-
:
|
|
698
|
+
: product.category === 'book'
|
|
699
|
+
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
|
|
700
|
+
: product.category === 'product' && product.shipping > 0
|
|
701
|
+
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}/address`
|
|
702
|
+
: product.category === 'product'
|
|
703
|
+
? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
|
|
704
|
+
: `${getSanoraUrl()}/products/${tenant.uuid}/${encodeURIComponent(title)}`
|
|
689
705
|
};
|
|
690
706
|
const CATEGORY_BUCKET = { book: 'books', music: 'music', post: 'posts', 'post-series': 'posts', album: 'albums', product: 'products' };
|
|
691
707
|
const bucket = goods[CATEGORY_BUCKET[product.category]];
|
|
@@ -930,6 +946,97 @@ async function startServer(params) {
|
|
|
930
946
|
res.json({ success: true });
|
|
931
947
|
});
|
|
932
948
|
|
|
949
|
+
// Purchase pages — shoppe-hosted versions of the Sanora payment templates
|
|
950
|
+
async function renderPurchasePage(req, res, templateHtml) {
|
|
951
|
+
try {
|
|
952
|
+
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
953
|
+
if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
|
|
954
|
+
|
|
955
|
+
const title = decodeURIComponent(req.params.title);
|
|
956
|
+
const sanoraUrl = getSanoraUrl();
|
|
957
|
+
const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
|
|
958
|
+
const products = await productsResp.json();
|
|
959
|
+
const product = products[title] || Object.values(products).find(p => p.title === title);
|
|
960
|
+
if (!product) return res.status(404).send('<h1>Product not found</h1>');
|
|
961
|
+
|
|
962
|
+
const imageUrl = product.image ? `${sanoraUrl}/images/${product.image}` : '';
|
|
963
|
+
const ebookUrl = `${req.protocol}://${req.get('host')}/plugin/shoppe/${tenant.uuid}/download/${encodeURIComponent(title)}`;
|
|
964
|
+
const shoppeUrl = `${req.protocol}://${req.get('host')}/plugin/shoppe/${tenant.uuid}`;
|
|
965
|
+
|
|
966
|
+
const html = fillTemplate(templateHtml, {
|
|
967
|
+
title: product.title || title,
|
|
968
|
+
description: product.description || '',
|
|
969
|
+
image: `"${imageUrl}"`,
|
|
970
|
+
amount: String(product.price || 0),
|
|
971
|
+
formattedAmount: ((product.price || 0) / 100).toFixed(2),
|
|
972
|
+
productId: product.productId || '',
|
|
973
|
+
pubKey: '',
|
|
974
|
+
signature: '',
|
|
975
|
+
sanoraUrl,
|
|
976
|
+
allyabaseOrigin: getAllyabaseOrigin(),
|
|
977
|
+
ebookUrl,
|
|
978
|
+
shoppeUrl
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
res.set('Content-Type', 'text/html');
|
|
982
|
+
res.send(html);
|
|
983
|
+
} catch (err) {
|
|
984
|
+
console.error('[shoppe] purchase page error:', err);
|
|
985
|
+
res.status(500).send(`<h1>Error</h1><p>${err.message}</p>`);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Books + no-shipping products → recovery key + stripe
|
|
990
|
+
app.get('/plugin/shoppe/:identifier/buy/:title', (req, res) =>
|
|
991
|
+
renderPurchasePage(req, res, RECOVER_STRIPE_TMPL));
|
|
992
|
+
|
|
993
|
+
// Physical products with shipping → address + stripe
|
|
994
|
+
app.get('/plugin/shoppe/:identifier/buy/:title/address', (req, res) =>
|
|
995
|
+
renderPurchasePage(req, res, ADDRESS_STRIPE_TMPL));
|
|
996
|
+
|
|
997
|
+
// Ebook download page (reached after successful payment + hash creation)
|
|
998
|
+
app.get('/plugin/shoppe/:identifier/download/:title', async (req, res) => {
|
|
999
|
+
try {
|
|
1000
|
+
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
1001
|
+
if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
|
|
1002
|
+
|
|
1003
|
+
const title = decodeURIComponent(req.params.title);
|
|
1004
|
+
const sanoraUrl = getSanoraUrl();
|
|
1005
|
+
const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
|
|
1006
|
+
const products = await productsResp.json();
|
|
1007
|
+
const product = products[title] || Object.values(products).find(p => p.title === title);
|
|
1008
|
+
if (!product) return res.status(404).send('<h1>Book not found</h1>');
|
|
1009
|
+
|
|
1010
|
+
const imageUrl = product.image ? `${sanoraUrl}/images/${product.image}` : '';
|
|
1011
|
+
|
|
1012
|
+
// Map artifact UUIDs to download paths by extension
|
|
1013
|
+
let epubPath = '', pdfPath = '', mobiPath = '';
|
|
1014
|
+
(product.artifacts || []).forEach(artifact => {
|
|
1015
|
+
if (artifact.includes('epub')) epubPath = `${sanoraUrl}/artifacts/${artifact}`;
|
|
1016
|
+
if (artifact.includes('pdf')) pdfPath = `${sanoraUrl}/artifacts/${artifact}`;
|
|
1017
|
+
if (artifact.includes('mobi')) mobiPath = `${sanoraUrl}/artifacts/${artifact}`;
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
const html = fillTemplate(EBOOK_DOWNLOAD_TMPL, {
|
|
1021
|
+
title: product.title || title,
|
|
1022
|
+
description: product.description || '',
|
|
1023
|
+
image: imageUrl,
|
|
1024
|
+
productId: product.productId || '',
|
|
1025
|
+
pubKey: '',
|
|
1026
|
+
signature: '',
|
|
1027
|
+
epubPath,
|
|
1028
|
+
pdfPath,
|
|
1029
|
+
mobiPath
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
res.set('Content-Type', 'text/html');
|
|
1033
|
+
res.send(html);
|
|
1034
|
+
} catch (err) {
|
|
1035
|
+
console.error('[shoppe] download page error:', err);
|
|
1036
|
+
res.status(500).send(`<h1>Error</h1><p>${err.message}</p>`);
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
|
|
933
1040
|
// Post reader — fetches markdown from Sanora and renders it as HTML
|
|
934
1041
|
app.get('/plugin/shoppe/:identifier/post/:title', async (req, res) => {
|
|
935
1042
|
try {
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.00, maximum-scale=1.00, minimum-scale=1.00">
|
|
6
|
+
|
|
7
|
+
<!-- Web preview meta tags -->
|
|
8
|
+
<meta name="twitter:title" content="{{title}}">
|
|
9
|
+
<meta name="description" content="{{description}}">
|
|
10
|
+
<meta name="twitter:description" content="{{description}}">
|
|
11
|
+
<meta name="twitter:image" content="{{image}}">
|
|
12
|
+
<meta name="og:title" content="{{title}}">
|
|
13
|
+
<meta name="og:description" content="{{description}}">
|
|
14
|
+
<meta name="og:image" content="{{image}}">
|
|
15
|
+
|
|
16
|
+
<title>{{title}} - Download</title>
|
|
17
|
+
|
|
18
|
+
<style>
|
|
19
|
+
* {
|
|
20
|
+
margin: 0;
|
|
21
|
+
padding: 0;
|
|
22
|
+
box-sizing: border-box;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
body {
|
|
26
|
+
font-family: Arial, sans-serif;
|
|
27
|
+
height: 100vh;
|
|
28
|
+
overflow: hidden;
|
|
29
|
+
background: linear-gradient(135deg, #0f0f12 0%, #1a1a1e 100%);
|
|
30
|
+
color: white;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.main-container {
|
|
34
|
+
width: 100vw;
|
|
35
|
+
height: 100vh;
|
|
36
|
+
display: flex;
|
|
37
|
+
overflow: hidden;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.product-section {
|
|
41
|
+
flex: 1;
|
|
42
|
+
display: flex;
|
|
43
|
+
flex-direction: column;
|
|
44
|
+
justify-content: center;
|
|
45
|
+
align-items: center;
|
|
46
|
+
padding: 20px;
|
|
47
|
+
min-height: 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.download-section {
|
|
51
|
+
flex: 1;
|
|
52
|
+
display: flex;
|
|
53
|
+
flex-direction: column;
|
|
54
|
+
justify-content: center;
|
|
55
|
+
align-items: center;
|
|
56
|
+
padding: 20px;
|
|
57
|
+
min-height: 0;
|
|
58
|
+
background: rgba(42, 42, 46, 0.3);
|
|
59
|
+
border-left: 1px solid #444;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.product-content {
|
|
63
|
+
width: 100%;
|
|
64
|
+
max-width: 500px;
|
|
65
|
+
text-align: center;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.product-image {
|
|
69
|
+
width: 100%;
|
|
70
|
+
height: auto;
|
|
71
|
+
max-height: 40vh;
|
|
72
|
+
object-fit: contain;
|
|
73
|
+
border-radius: 12px;
|
|
74
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
75
|
+
margin-bottom: 20px;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.product-title {
|
|
79
|
+
font-size: clamp(1.2rem, 4vw, 2rem);
|
|
80
|
+
font-weight: bold;
|
|
81
|
+
margin-bottom: 10px;
|
|
82
|
+
background: linear-gradient(90deg, #3eda82, #a855f7);
|
|
83
|
+
-webkit-background-clip: text;
|
|
84
|
+
-webkit-text-fill-color: transparent;
|
|
85
|
+
background-clip: text;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.product-description {
|
|
89
|
+
font-size: clamp(0.9rem, 2.5vw, 1.1rem);
|
|
90
|
+
opacity: 0.8;
|
|
91
|
+
font-weight: normal;
|
|
92
|
+
line-height: 1.4;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.download-container {
|
|
96
|
+
width: 100%;
|
|
97
|
+
max-width: 400px;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.download-header {
|
|
101
|
+
text-align: center;
|
|
102
|
+
margin-bottom: 30px;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.download-title {
|
|
106
|
+
font-size: 1.8rem;
|
|
107
|
+
font-weight: bold;
|
|
108
|
+
margin-bottom: 10px;
|
|
109
|
+
color: #ffffff;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.download-subtitle {
|
|
113
|
+
font-size: 1rem;
|
|
114
|
+
opacity: 0.7;
|
|
115
|
+
color: #bbbbbb;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.format-grid {
|
|
119
|
+
display: grid;
|
|
120
|
+
gap: 15px;
|
|
121
|
+
margin-bottom: 20px;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.format-button {
|
|
125
|
+
background: linear-gradient(135deg, #2a2a2e 0%, #323236 100%);
|
|
126
|
+
border: 2px solid #444;
|
|
127
|
+
border-radius: 12px;
|
|
128
|
+
padding: 20px;
|
|
129
|
+
cursor: pointer;
|
|
130
|
+
transition: all 0.3s ease;
|
|
131
|
+
text-decoration: none;
|
|
132
|
+
color: white;
|
|
133
|
+
display: flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
justify-content: space-between;
|
|
136
|
+
position: relative;
|
|
137
|
+
overflow: hidden;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.format-button:hover {
|
|
141
|
+
border-color: #3eda82;
|
|
142
|
+
transform: translateY(-2px);
|
|
143
|
+
box-shadow: 0 8px 25px rgba(62, 218, 130, 0.2);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.format-button:active {
|
|
147
|
+
transform: translateY(0);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.format-button::before {
|
|
151
|
+
content: '';
|
|
152
|
+
position: absolute;
|
|
153
|
+
top: 0;
|
|
154
|
+
left: -100%;
|
|
155
|
+
width: 100%;
|
|
156
|
+
height: 100%;
|
|
157
|
+
background: linear-gradient(90deg, transparent, rgba(62, 218, 130, 0.1), transparent);
|
|
158
|
+
transition: left 0.5s ease;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.format-button:hover::before {
|
|
162
|
+
left: 100%;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.format-info {
|
|
166
|
+
display: flex;
|
|
167
|
+
flex-direction: column;
|
|
168
|
+
align-items: flex-start;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.format-name {
|
|
172
|
+
font-size: 1.1rem;
|
|
173
|
+
font-weight: bold;
|
|
174
|
+
margin-bottom: 5px;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.format-description {
|
|
178
|
+
font-size: 0.85rem;
|
|
179
|
+
opacity: 0.7;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.download-icon {
|
|
183
|
+
font-size: 1.5rem;
|
|
184
|
+
opacity: 0.8;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.bulk-download {
|
|
188
|
+
margin-top: 20px;
|
|
189
|
+
padding-top: 20px;
|
|
190
|
+
border-top: 1px solid #444;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.bulk-button {
|
|
194
|
+
background: linear-gradient(90deg, #3eda82, #a855f7);
|
|
195
|
+
border: none;
|
|
196
|
+
border-radius: 12px;
|
|
197
|
+
padding: 15px 25px;
|
|
198
|
+
color: white;
|
|
199
|
+
font-size: 1rem;
|
|
200
|
+
font-weight: bold;
|
|
201
|
+
cursor: pointer;
|
|
202
|
+
width: 100%;
|
|
203
|
+
transition: all 0.3s ease;
|
|
204
|
+
text-decoration: none;
|
|
205
|
+
display: inline-block;
|
|
206
|
+
text-align: center;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.bulk-button:hover {
|
|
210
|
+
transform: translateY(-2px);
|
|
211
|
+
box-shadow: 0 8px 25px rgba(62, 218, 130, 0.3);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
@media (max-width: 768px) {
|
|
215
|
+
.main-container {
|
|
216
|
+
flex-direction: column;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.download-section {
|
|
220
|
+
border-left: none;
|
|
221
|
+
border-top: 1px solid #444;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.product-image {
|
|
225
|
+
max-height: 30vh;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
</style>
|
|
229
|
+
</head>
|
|
230
|
+
<body>
|
|
231
|
+
<div class="main-container">
|
|
232
|
+
<!-- Product Section -->
|
|
233
|
+
<div class="product-section">
|
|
234
|
+
<div class="product-content">
|
|
235
|
+
<img id="product-image" class="product-image" src="{{image}}" alt="{{title}}">
|
|
236
|
+
<h1 class="product-title">{{title}}</h1>
|
|
237
|
+
<p class="product-description">{{description}}</p>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<!-- Download Section -->
|
|
242
|
+
<div class="download-section">
|
|
243
|
+
<div class="download-container">
|
|
244
|
+
<div class="download-header">
|
|
245
|
+
<h2 class="download-title">Download Your Book</h2>
|
|
246
|
+
<p class="download-subtitle">Choose your preferred format</p>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<div class="format-grid" id="format-grid">
|
|
250
|
+
<!-- Format buttons will be inserted here -->
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<div class="bulk-download">
|
|
254
|
+
<a href="#" class="bulk-button" id="bulk-download">
|
|
255
|
+
📦 Download All Formats
|
|
256
|
+
</a>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<script>
|
|
263
|
+
// Configuration - replace with your actual file paths
|
|
264
|
+
const downloadConfig = Object.fromEntries(Object.entries({
|
|
265
|
+
epub: { path: "{{epubPath}}", name: "EPUB", description: "Best for e-readers & mobile devices", icon: "📱" },
|
|
266
|
+
pdf: { path: "{{pdfPath}}", name: "PDF", description: "Universal format, preserves layout", icon: "📄" },
|
|
267
|
+
mobi: { path: "{{mobiPath}}", name: "MOBI", description: "Optimized for Kindle devices", icon: "📚" }
|
|
268
|
+
}).filter(([, c]) => c.path));
|
|
269
|
+
|
|
270
|
+
function createFormatButton(format, config) {
|
|
271
|
+
const button = document.createElement('a');
|
|
272
|
+
button.className = 'format-button';
|
|
273
|
+
button.href = config.path;
|
|
274
|
+
button.download = `{{title}}.${format}`;
|
|
275
|
+
|
|
276
|
+
button.innerHTML = `
|
|
277
|
+
<div class="format-info">
|
|
278
|
+
<div class="format-name">${config.icon} ${config.name}</div>
|
|
279
|
+
<div class="format-description">${config.description}</div>
|
|
280
|
+
</div>
|
|
281
|
+
<div class="download-icon">⬇️</div>
|
|
282
|
+
`;
|
|
283
|
+
|
|
284
|
+
// Add download tracking
|
|
285
|
+
button.addEventListener('click', (e) => {
|
|
286
|
+
console.log(`Downloading ${format.toUpperCase()} format`);
|
|
287
|
+
// You can add analytics or tracking here
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
return button;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function setupBulkDownload() {
|
|
294
|
+
const bulkButton = document.getElementById('bulk-download');
|
|
295
|
+
bulkButton.addEventListener('click', (e) => {
|
|
296
|
+
e.preventDefault();
|
|
297
|
+
console.log('Starting bulk download...');
|
|
298
|
+
|
|
299
|
+
// Download each format with a small delay
|
|
300
|
+
Object.entries(downloadConfig).forEach(([format, config], index) => {
|
|
301
|
+
setTimeout(() => {
|
|
302
|
+
const link = document.createElement('a');
|
|
303
|
+
link.href = config.path;
|
|
304
|
+
link.download = `{{title}}.${format}`;
|
|
305
|
+
link.style.display = 'none';
|
|
306
|
+
document.body.appendChild(link);
|
|
307
|
+
link.click();
|
|
308
|
+
document.body.removeChild(link);
|
|
309
|
+
}, index * 500); // 500ms delay between downloads
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function initializeDownloads() {
|
|
315
|
+
const formatGrid = document.getElementById('format-grid');
|
|
316
|
+
|
|
317
|
+
// Create format buttons
|
|
318
|
+
Object.entries(downloadConfig).forEach(([format, config]) => {
|
|
319
|
+
const button = createFormatButton(format, config);
|
|
320
|
+
formatGrid.appendChild(button);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Setup bulk download
|
|
324
|
+
setupBulkDownload();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Initialize when page loads
|
|
328
|
+
document.addEventListener('DOMContentLoaded', initializeDownloads);
|
|
329
|
+
</script>
|
|
330
|
+
</body>
|
|
331
|
+
</html>
|