opentwig 1.0.7 → 1.1.0
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/AGENTS.md +323 -0
- package/API.md +582 -0
- package/CODE_OF_CONDUCT.md +91 -0
- package/CONTRIBUTING.md +312 -0
- package/README.md +164 -7
- package/SECURITY.md +56 -0
- package/THEME_DEVELOPMENT.md +388 -0
- package/package.json +19 -3
- package/src/constants.js +14 -2
- package/src/index.js +14 -2
- package/src/live-ui/editor.js +173 -0
- package/src/live-ui/preview.js +77 -0
- package/src/live-ui/sidebar.js +523 -0
- package/src/utils/escapeHTML.js +10 -0
- package/src/utils/generateOGImage.js +51 -10
- package/src/utils/parseArgs.js +33 -2
- package/src/utils/readImageAsBase64.js +16 -4
- package/src/utils/setupWatcher.js +69 -0
- package/src/utils/showHelp.js +15 -2
- package/src/utils/startLiveServer.js +218 -0
- package/src/utils/websocketServer.js +53 -0
- package/test-og.js +40 -0
- package/theme/dark/style.css +1 -0
- package/theme/default/index.js +10 -8
- package/validateConfig.js +59 -0
- package/vitest.config.js +11 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
# Theme Development Guide
|
|
2
|
+
|
|
3
|
+
This guide will help you create custom themes for OpenTwig. Themes control the visual appearance of generated "link in bio" pages.
|
|
4
|
+
|
|
5
|
+
## Theme Structure
|
|
6
|
+
|
|
7
|
+
A theme consists of two main files in a directory under `theme/`:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
theme/my-theme/
|
|
11
|
+
├── index.js # Theme template (HTML generation)
|
|
12
|
+
├── style.css # Theme styles (CSS)
|
|
13
|
+
└── components/ # Optional: Custom components
|
|
14
|
+
├── avatar.js
|
|
15
|
+
├── link.js
|
|
16
|
+
├── footer-link.js
|
|
17
|
+
├── share-button.js
|
|
18
|
+
└── qr.js
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Creating a Simple Theme
|
|
22
|
+
|
|
23
|
+
### Step 1: Create Theme Directory
|
|
24
|
+
|
|
25
|
+
Create a new directory for your theme:
|
|
26
|
+
```bash
|
|
27
|
+
mkdir theme/my-theme
|
|
28
|
+
cd theme/my-theme
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Step 2: Create index.js (Template)
|
|
32
|
+
|
|
33
|
+
The `index.js` file exports a function that generates the HTML structure. You can either:
|
|
34
|
+
|
|
35
|
+
**Option A: Create a custom template**
|
|
36
|
+
```javascript
|
|
37
|
+
const escapeHTML = require('../../src/utils/escapeHTML');
|
|
38
|
+
const { avatar } = require('./components/avatar');
|
|
39
|
+
const { link } = require('./components/link');
|
|
40
|
+
|
|
41
|
+
module.exports = function(config) {
|
|
42
|
+
return `<!DOCTYPE html>
|
|
43
|
+
<html lang="en">
|
|
44
|
+
<head>
|
|
45
|
+
<meta charset="UTF-8">
|
|
46
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
47
|
+
<title>${escapeHTML(config.title)}</title>
|
|
48
|
+
<link rel="stylesheet" href="style.css">
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<div class="container">
|
|
52
|
+
${avatar(config)}
|
|
53
|
+
<h1>${escapeHTML(config.name)}</h1>
|
|
54
|
+
${config.content ? `<p class="bio">${escapeHTML(config.content)}</p>` : ''}
|
|
55
|
+
<div class="links">
|
|
56
|
+
${config.links.map(link).join('')}
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</body>
|
|
60
|
+
</html>`;
|
|
61
|
+
};
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Option B: Reuse the default template** (recommended for CSS-only themes)
|
|
65
|
+
```javascript
|
|
66
|
+
module.exports = require('../default/index.js');
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Step 3: Create style.css
|
|
70
|
+
|
|
71
|
+
Define your theme's visual styling:
|
|
72
|
+
```css
|
|
73
|
+
/* Basic CSS Reset */
|
|
74
|
+
* {
|
|
75
|
+
margin: 0;
|
|
76
|
+
padding: 0;
|
|
77
|
+
box-sizing: border-box;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* Container */
|
|
81
|
+
.container {
|
|
82
|
+
max-width: 600px;
|
|
83
|
+
margin: 0 auto;
|
|
84
|
+
padding: 40px 20px;
|
|
85
|
+
text-align: center;
|
|
86
|
+
font-family: Arial, sans-serif;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* Avatar */
|
|
90
|
+
.avatar {
|
|
91
|
+
width: 120px;
|
|
92
|
+
height: 120px;
|
|
93
|
+
border-radius: 50%;
|
|
94
|
+
object-fit: cover;
|
|
95
|
+
margin-bottom: 20px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* Links */
|
|
99
|
+
.link {
|
|
100
|
+
display: block;
|
|
101
|
+
background: #fff;
|
|
102
|
+
color: #000;
|
|
103
|
+
padding: 15px 20px;
|
|
104
|
+
margin: 10px 0;
|
|
105
|
+
text-decoration: none;
|
|
106
|
+
border-radius: 8px;
|
|
107
|
+
border: 2px solid #000;
|
|
108
|
+
transition: all 0.3s ease;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.link:hover {
|
|
112
|
+
background: #000;
|
|
113
|
+
color: #fff;
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Step 4: Register Your Theme
|
|
118
|
+
|
|
119
|
+
Add your theme name to the `SUPPORTED_THEMES` array in `src/constants.js`:
|
|
120
|
+
|
|
121
|
+
```javascript
|
|
122
|
+
const SUPPORTED_THEMES = ['default', 'dark', 'minimal', 'colorful', 'my-theme'];
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Step 5: Test Your Theme
|
|
126
|
+
|
|
127
|
+
1. Create or edit a `config.json` file:
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"theme": "my-theme",
|
|
131
|
+
"url": "https://example.com",
|
|
132
|
+
"name": "Your Name",
|
|
133
|
+
"content": "Your bio",
|
|
134
|
+
"links": [
|
|
135
|
+
{"url": "https://twitter.com", "title": "Twitter"},
|
|
136
|
+
{"url": "https://github.com", "title": "GitHub"}
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
2. Generate your page:
|
|
142
|
+
```bash
|
|
143
|
+
npm start
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
3. View the result in `dist/index.html`
|
|
147
|
+
|
|
148
|
+
## Theme Components
|
|
149
|
+
|
|
150
|
+
Themes can include custom components in the `components/` directory. Components are small, reusable functions that generate HTML fragments.
|
|
151
|
+
|
|
152
|
+
### Available Components (from default theme)
|
|
153
|
+
|
|
154
|
+
#### avatar.js
|
|
155
|
+
```javascript
|
|
156
|
+
module.exports = function(config) {
|
|
157
|
+
if (!config.avatar) return '';
|
|
158
|
+
return `<img src="avatar.png" alt="${escapeHTML(config.name)}" class="avatar">`;
|
|
159
|
+
};
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
#### link.js
|
|
163
|
+
```javascript
|
|
164
|
+
module.exports = function(linkConfig) {
|
|
165
|
+
return `<a href="${linkConfig.url}" target="_blank" rel="noopener" class="link">
|
|
166
|
+
<span>${escapeHTML(linkConfig.title)}</span>
|
|
167
|
+
</a>`;
|
|
168
|
+
};
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
#### footer-link.js
|
|
172
|
+
```javascript
|
|
173
|
+
module.exports = function(link) {
|
|
174
|
+
if (link.content) {
|
|
175
|
+
return `<button class="footer-link" data-title="${escapeHTML(link.title)}">
|
|
176
|
+
${escapeHTML(link.title)}
|
|
177
|
+
</button>`;
|
|
178
|
+
}
|
|
179
|
+
return `<a href="${link.url}" class="footer-link" target="_blank" rel="noopener">
|
|
180
|
+
${escapeHTML(link.title)}
|
|
181
|
+
</a>`;
|
|
182
|
+
};
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
#### share-button.js
|
|
186
|
+
```javascript
|
|
187
|
+
module.exports = function(config) {
|
|
188
|
+
if (!config.share) return '';
|
|
189
|
+
return `<button class="share-button" onclick="sharePage()">
|
|
190
|
+
${escapeHTML(config.share.text || 'Share')}
|
|
191
|
+
</button>
|
|
192
|
+
<script>
|
|
193
|
+
function sharePage() {
|
|
194
|
+
if (navigator.share) {
|
|
195
|
+
navigator.share({
|
|
196
|
+
title: '${escapeHTML(config.share.title)}',
|
|
197
|
+
url: '${escapeHTML(config.share.url)}'
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
</script>`;
|
|
202
|
+
};
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
#### qr.js
|
|
206
|
+
```javascript
|
|
207
|
+
module.exports = function(config) {
|
|
208
|
+
return `<img src="qr.svg" alt="QR Code" class="qr-code">`;
|
|
209
|
+
};
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
#### dialog.js
|
|
213
|
+
```javascript
|
|
214
|
+
module.exports = function(link) {
|
|
215
|
+
return `<dialog id="dialog-${escapeHTML(link.title)}">
|
|
216
|
+
<div class="dialog-content">
|
|
217
|
+
<h2>${escapeHTML(link.title)}</h2>
|
|
218
|
+
<p>${escapeHTML(link.content)}</p>
|
|
219
|
+
<button onclick="this.closest('dialog').close()">Close</button>
|
|
220
|
+
</div>
|
|
221
|
+
</dialog>`;
|
|
222
|
+
};
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Theme Best Practices
|
|
226
|
+
|
|
227
|
+
### 1. Use escapeHTML()
|
|
228
|
+
Always escape user-generated content to prevent XSS attacks:
|
|
229
|
+
```javascript
|
|
230
|
+
const escapeHTML = require('../../src/utils/escapeHTML');
|
|
231
|
+
|
|
232
|
+
${escapeHTML(config.name)}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### 2. Responsive Design
|
|
236
|
+
Make your theme work well on mobile devices:
|
|
237
|
+
```css
|
|
238
|
+
@media (max-width: 600px) {
|
|
239
|
+
.container {
|
|
240
|
+
padding: 20px 15px;
|
|
241
|
+
}
|
|
242
|
+
.link {
|
|
243
|
+
padding: 12px 15px;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### 3. Accessibility
|
|
249
|
+
Include proper HTML attributes:
|
|
250
|
+
```html
|
|
251
|
+
<a href="..." target="_blank" rel="noopener" aria-label="${escapeHTML(link.title)}">
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### 4. CSS Optimization
|
|
255
|
+
PostCSS will automatically:
|
|
256
|
+
- Add vendor prefixes
|
|
257
|
+
- Minify CSS (if `config.minify` is true)
|
|
258
|
+
|
|
259
|
+
### 5. Optional Features
|
|
260
|
+
Always check if optional features are configured before including them:
|
|
261
|
+
```javascript
|
|
262
|
+
${config.avatar ? avatar(config) : ''}
|
|
263
|
+
${config.content ? `<p>${escapeHTML(config.content)}</p>` : ''}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## Theme CSS Guidelines
|
|
267
|
+
|
|
268
|
+
### Required CSS Classes
|
|
269
|
+
|
|
270
|
+
While themes can vary in design, they should support these core classes for consistency:
|
|
271
|
+
|
|
272
|
+
```css
|
|
273
|
+
/* Main container */
|
|
274
|
+
.container { }
|
|
275
|
+
|
|
276
|
+
/* Avatar */
|
|
277
|
+
.avatar { }
|
|
278
|
+
|
|
279
|
+
/* Profile name */
|
|
280
|
+
.name { }
|
|
281
|
+
|
|
282
|
+
/* Bio/description */
|
|
283
|
+
.bio { }
|
|
284
|
+
|
|
285
|
+
/* Link buttons */
|
|
286
|
+
.link { }
|
|
287
|
+
.link:hover { }
|
|
288
|
+
|
|
289
|
+
/* Footer links */
|
|
290
|
+
.footer-link { }
|
|
291
|
+
|
|
292
|
+
/* Share button */
|
|
293
|
+
.share-button { }
|
|
294
|
+
|
|
295
|
+
/* QR code (desktop only) */
|
|
296
|
+
.qr-code { }
|
|
297
|
+
|
|
298
|
+
/* Modal dialog */
|
|
299
|
+
dialog { }
|
|
300
|
+
.dialog-content { }
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### CSS Variables (Optional)
|
|
304
|
+
|
|
305
|
+
You can use CSS variables for easier theming:
|
|
306
|
+
```css
|
|
307
|
+
:root {
|
|
308
|
+
--primary-color: #000;
|
|
309
|
+
--secondary-color: #fff;
|
|
310
|
+
--background-color: #f5f5f5;
|
|
311
|
+
--text-color: #333;
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## Examples
|
|
316
|
+
|
|
317
|
+
### Minimal Theme
|
|
318
|
+
Reuses default template with flat, simple CSS:
|
|
319
|
+
```javascript
|
|
320
|
+
module.exports = require('../default/index.js');
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
```css
|
|
324
|
+
.link {
|
|
325
|
+
background: transparent;
|
|
326
|
+
border: 1px solid #ccc;
|
|
327
|
+
color: #333;
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Dark Theme
|
|
332
|
+
Reuses default template with dark colors:
|
|
333
|
+
```javascript
|
|
334
|
+
module.exports = require('../default/index.js');
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
```css
|
|
338
|
+
:root {
|
|
339
|
+
--background-color: #1a1a1a;
|
|
340
|
+
--text-color: #fff;
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### Custom Layout Theme
|
|
345
|
+
Create a completely different layout in index.js:
|
|
346
|
+
```javascript
|
|
347
|
+
module.exports = function(config) {
|
|
348
|
+
return `<!DOCTYPE html>
|
|
349
|
+
<html>
|
|
350
|
+
<head>
|
|
351
|
+
<!-- custom layout -->
|
|
352
|
+
</head>
|
|
353
|
+
<body class="sidebar-layout">
|
|
354
|
+
<!-- sidebar navigation -->
|
|
355
|
+
<!-- main content area -->
|
|
356
|
+
</body>
|
|
357
|
+
</html>`;
|
|
358
|
+
};
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
## Testing Your Theme
|
|
362
|
+
|
|
363
|
+
1. Test with all configuration options (avatar, links, footer links, etc.)
|
|
364
|
+
2. Test on different screen sizes (mobile, tablet, desktop)
|
|
365
|
+
3. Test with long names/titles
|
|
366
|
+
4. Test with many links
|
|
367
|
+
5. Verify HTML validity (use https://validator.w3.org/)
|
|
368
|
+
6. Check accessibility (use https://wave.webaim.org/)
|
|
369
|
+
|
|
370
|
+
## Submitting Your Theme
|
|
371
|
+
|
|
372
|
+
If you'd like to contribute your theme to OpenTwig:
|
|
373
|
+
|
|
374
|
+
1. Follow the contributing guide in `CONTRIBUTING.md`
|
|
375
|
+
2. Ensure your theme includes:
|
|
376
|
+
- Complete CSS styling
|
|
377
|
+
- Responsive design
|
|
378
|
+
- Accessibility considerations
|
|
379
|
+
- JSDoc comments in index.js
|
|
380
|
+
3. Submit a pull request with your theme
|
|
381
|
+
|
|
382
|
+
## Resources
|
|
383
|
+
|
|
384
|
+
- **Default Theme**: `theme/default/` - Reference implementation
|
|
385
|
+
- **Dark Theme**: `theme/dark/` - Example of CSS-only customization
|
|
386
|
+
- **Minimal Theme**: `theme/minimal/` - Example of simplified styling
|
|
387
|
+
- **Colorful Theme**: `theme/colorful/` - Example of advanced CSS effects
|
|
388
|
+
- **Utility Functions**: `src/utils/` - Helper functions available for themes
|
package/package.json
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentwig",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "opentwig 🌿 is an open source link in bio page generator.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"opentwig": "./src/index.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"start": "node src/index.js"
|
|
10
|
+
"start": "node src/index.js",
|
|
11
|
+
"test": "vitest",
|
|
12
|
+
"test:run": "vitest run",
|
|
13
|
+
"test:coverage": "vitest run --coverage",
|
|
14
|
+
"test-og": "node test-og.js",
|
|
15
|
+
"live": "node src/index.js --live"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@vitest/coverage-v8": "^2.0.0",
|
|
19
|
+
"baseline-browser-mapping": "^2.9.19",
|
|
20
|
+
"vitest": "^2.0.0"
|
|
11
21
|
},
|
|
12
22
|
"repository": {
|
|
13
23
|
"type": "git",
|
|
@@ -37,11 +47,17 @@
|
|
|
37
47
|
"homepage": "https://github.com/tufantunc/opentwig#readme",
|
|
38
48
|
"dependencies": {
|
|
39
49
|
"autoprefixer": "^10.4.21",
|
|
50
|
+
"chokidar": "^5.0.0",
|
|
51
|
+
"cors": "^2.8.6",
|
|
52
|
+
"express": "^5.2.1",
|
|
40
53
|
"html-minifier-terser": "^7.2.0",
|
|
54
|
+
"multer": "^2.0.2",
|
|
55
|
+
"open": "^11.0.0",
|
|
41
56
|
"postcss": "^8.5.6",
|
|
42
57
|
"postcss-minify": "^1.2.0",
|
|
43
58
|
"qrcode": "^1.5.4",
|
|
44
|
-
"sharp": "^0.34.4"
|
|
59
|
+
"sharp": "^0.34.4",
|
|
60
|
+
"ws": "^8.19.0"
|
|
45
61
|
},
|
|
46
62
|
"browserslist": [
|
|
47
63
|
"ie 9"
|
package/src/constants.js
CHANGED
|
@@ -24,7 +24,10 @@ const CONSTANTS = {
|
|
|
24
24
|
// CLI options
|
|
25
25
|
CLI_OPTIONS: {
|
|
26
26
|
HELP: ['--help', '-h'],
|
|
27
|
-
INIT: ['--init', '-i']
|
|
27
|
+
INIT: ['--init', '-i'],
|
|
28
|
+
VALIDATE: ['--validate-config'],
|
|
29
|
+
LIVE: ['--live', '-l'],
|
|
30
|
+
PORT: ['--port', '-p']
|
|
28
31
|
},
|
|
29
32
|
|
|
30
33
|
// Messages
|
|
@@ -34,6 +37,7 @@ const CONSTANTS = {
|
|
|
34
37
|
CONFIG_CREATED: 'Sample config.json created successfully!',
|
|
35
38
|
CONFIG_EDIT_INSTRUCTIONS: 'Edit config.json with your information and run "npx opentwig" to generate your page.',
|
|
36
39
|
PAGE_GENERATED: '🎉 Page generated successfully!',
|
|
40
|
+
SHOWCASE_REMINDER: '🌟 Don\'t forget to add your site in showcase: https://github.com/tufantunc/opentwig',
|
|
37
41
|
UNKNOWN_OPTION: 'Unknown option:',
|
|
38
42
|
USE_HELP: 'Use --help for usage information.',
|
|
39
43
|
ERROR_PREFIX: '❌ Error:',
|
|
@@ -51,8 +55,16 @@ const CONSTANTS = {
|
|
|
51
55
|
DEFAULT_CONTENT: 'Hello World! Here is my bio.',
|
|
52
56
|
DEFAULT_URL: 'https://links.yourwebsite.com',
|
|
53
57
|
|
|
58
|
+
// Live Mode settings
|
|
59
|
+
LIVE_MODE: {
|
|
60
|
+
PORT: 3000,
|
|
61
|
+
DEFAULT_HOST: 'localhost',
|
|
62
|
+
AUTO_SAVE_DELAY: 500,
|
|
63
|
+
WS_PATH: '/ws'
|
|
64
|
+
},
|
|
65
|
+
|
|
54
66
|
// Required fields for validation
|
|
55
67
|
REQUIRED_FIELDS: ['url', 'name']
|
|
56
68
|
};
|
|
57
69
|
|
|
58
|
-
module.exports = CONSTANTS;
|
|
70
|
+
module.exports = CONSTANTS;
|
package/src/index.js
CHANGED
|
@@ -6,6 +6,7 @@ const saveFiles = require('./utils/saveFiles');
|
|
|
6
6
|
const parseArgs = require('./utils/parseArgs');
|
|
7
7
|
const buildPage = require('./utils/buildPage');
|
|
8
8
|
const CONSTANTS = require('./constants');
|
|
9
|
+
const startLiveServer = require('./utils/startLiveServer');
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Main application function with proper error handling
|
|
@@ -13,7 +14,13 @@ const CONSTANTS = require('./constants');
|
|
|
13
14
|
const main = async () => {
|
|
14
15
|
try {
|
|
15
16
|
// Parse CLI arguments first
|
|
16
|
-
parseArgs();
|
|
17
|
+
const args = parseArgs();
|
|
18
|
+
|
|
19
|
+
// Check if live mode is requested
|
|
20
|
+
if (args.mode === 'live') {
|
|
21
|
+
await startLiveServer(args.port);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
17
24
|
|
|
18
25
|
// Load and validate configuration
|
|
19
26
|
const config = loadConfig();
|
|
@@ -26,6 +33,7 @@ const main = async () => {
|
|
|
26
33
|
|
|
27
34
|
// Success message
|
|
28
35
|
console.log(CONSTANTS.MESSAGES.PAGE_GENERATED);
|
|
36
|
+
console.log(CONSTANTS.MESSAGES.SHOWCASE_REMINDER);
|
|
29
37
|
|
|
30
38
|
} catch (error) {
|
|
31
39
|
console.error(`${CONSTANTS.MESSAGES.ERROR_PREFIX} ${error.message}`);
|
|
@@ -33,4 +41,8 @@ const main = async () => {
|
|
|
33
41
|
}
|
|
34
42
|
};
|
|
35
43
|
|
|
36
|
-
main
|
|
44
|
+
if (require.main === module) {
|
|
45
|
+
main();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { main };
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
const API_BASE = '/api';
|
|
2
|
+
|
|
3
|
+
let currentConfig = null;
|
|
4
|
+
let autoSaveTimeout = null;
|
|
5
|
+
let isAutoSaveEnabled = true;
|
|
6
|
+
|
|
7
|
+
const loadConfig = async () => {
|
|
8
|
+
try {
|
|
9
|
+
const response = await fetch(`${API_BASE}/config`);
|
|
10
|
+
const config = await response.json();
|
|
11
|
+
currentConfig = config;
|
|
12
|
+
return config;
|
|
13
|
+
} catch (error) {
|
|
14
|
+
console.error('Error loading config:', error);
|
|
15
|
+
showNotification('Failed to load configuration', 'error');
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const saveConfig = async (config) => {
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetch(`${API_BASE}/config`, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: {
|
|
25
|
+
'Content-Type': 'application/json'
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify(config)
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const result = await response.json();
|
|
31
|
+
|
|
32
|
+
if (result.success) {
|
|
33
|
+
currentConfig = result.config;
|
|
34
|
+
updateLastSavedTime();
|
|
35
|
+
showNotification('Configuration saved', 'success');
|
|
36
|
+
return true;
|
|
37
|
+
} else {
|
|
38
|
+
throw new Error(result.error || 'Save failed');
|
|
39
|
+
}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error('Error saving config:', error);
|
|
42
|
+
showNotification(`Failed to save: ${error.message}`, 'error');
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const autoSave = (config) => {
|
|
48
|
+
if (!isAutoSaveEnabled) return;
|
|
49
|
+
|
|
50
|
+
clearTimeout(autoSaveTimeout);
|
|
51
|
+
autoSaveTimeout = setTimeout(() => {
|
|
52
|
+
saveConfig(config);
|
|
53
|
+
}, 500);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const validateField = (field, value) => {
|
|
57
|
+
const errors = [];
|
|
58
|
+
|
|
59
|
+
switch (field) {
|
|
60
|
+
case 'url':
|
|
61
|
+
if (!value || value.trim() === '') {
|
|
62
|
+
errors.push('URL is required');
|
|
63
|
+
} else if (!value.startsWith('http://') && !value.startsWith('https://')) {
|
|
64
|
+
errors.push('URL must start with http:// or https://');
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
case 'name':
|
|
68
|
+
if (!value || value.trim() === '') {
|
|
69
|
+
errors.push('Name is required');
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
case 'links':
|
|
73
|
+
if (value && value.length > 0) {
|
|
74
|
+
value.forEach((link, index) => {
|
|
75
|
+
if (!link.url || link.url.trim() === '') {
|
|
76
|
+
errors.push(`Link ${index + 1}: URL is required`);
|
|
77
|
+
}
|
|
78
|
+
if (!link.title || link.title.trim() === '') {
|
|
79
|
+
errors.push(`Link ${index + 1}: Title is required`);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return errors;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const updateLastSavedTime = () => {
|
|
90
|
+
const lastSavedEl = document.getElementById('lastSaved');
|
|
91
|
+
const now = new Date();
|
|
92
|
+
lastSavedEl.textContent = now.toLocaleTimeString();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const showNotification = (message, type = 'info') => {
|
|
96
|
+
const container = document.getElementById('notificationContainer');
|
|
97
|
+
const notification = document.createElement('div');
|
|
98
|
+
notification.className = `notification ${type}`;
|
|
99
|
+
notification.textContent = message;
|
|
100
|
+
|
|
101
|
+
container.appendChild(notification);
|
|
102
|
+
|
|
103
|
+
setTimeout(() => {
|
|
104
|
+
notification.style.opacity = '0';
|
|
105
|
+
setTimeout(() => {
|
|
106
|
+
container.removeChild(notification);
|
|
107
|
+
}, 300);
|
|
108
|
+
}, 3000);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const exportConfig = () => {
|
|
112
|
+
if (!currentConfig) {
|
|
113
|
+
showNotification('No configuration to export', 'error');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const dataStr = JSON.stringify(currentConfig, null, 4);
|
|
118
|
+
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
|
119
|
+
const url = URL.createObjectURL(dataBlob);
|
|
120
|
+
|
|
121
|
+
const link = document.createElement('a');
|
|
122
|
+
link.href = url;
|
|
123
|
+
link.download = 'config.json';
|
|
124
|
+
document.body.appendChild(link);
|
|
125
|
+
link.click();
|
|
126
|
+
document.body.removeChild(link);
|
|
127
|
+
|
|
128
|
+
URL.revokeObjectURL(url);
|
|
129
|
+
showNotification('Configuration exported', 'success');
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const handleSave = () => {
|
|
133
|
+
if (currentConfig) {
|
|
134
|
+
saveConfig(currentConfig);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const toggleAutoSave = () => {
|
|
139
|
+
const checkbox = document.getElementById('autoSave');
|
|
140
|
+
isAutoSaveEnabled = checkbox.checked;
|
|
141
|
+
|
|
142
|
+
if (isAutoSaveEnabled) {
|
|
143
|
+
showNotification('Auto-save enabled', 'info');
|
|
144
|
+
} else {
|
|
145
|
+
showNotification('Auto-save disabled', 'info');
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
150
|
+
const saveBtn = document.getElementById('saveBtn');
|
|
151
|
+
const exportBtn = document.getElementById('exportBtn');
|
|
152
|
+
const autoSaveCheckbox = document.getElementById('autoSave');
|
|
153
|
+
|
|
154
|
+
saveBtn.addEventListener('click', handleSave);
|
|
155
|
+
exportBtn.addEventListener('click', exportConfig);
|
|
156
|
+
autoSaveCheckbox.addEventListener('change', toggleAutoSave);
|
|
157
|
+
|
|
158
|
+
loadConfig().then(config => {
|
|
159
|
+
if (config) {
|
|
160
|
+
renderConfigForm(config);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
window.configEditor = {
|
|
166
|
+
loadConfig,
|
|
167
|
+
saveConfig,
|
|
168
|
+
autoSave,
|
|
169
|
+
currentConfig: () => currentConfig,
|
|
170
|
+
updateConfig: (newConfig) => {
|
|
171
|
+
currentConfig = newConfig;
|
|
172
|
+
}
|
|
173
|
+
};
|