@rmdes/indiekit-endpoint-homepage 1.0.18 → 1.0.19

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/locales/en.json CHANGED
@@ -91,9 +91,9 @@
91
91
  "email": { "label": "Email" },
92
92
  "keyUrl": { "label": "PGP Key URL", "hint": "URL to your public PGP key" }
93
93
  },
94
- "skills": {
95
- "legend": "Skills & Interests",
96
- "categories": { "label": "Categories", "hint": "Comma-separated skills, interests, or tags" }
94
+ "categories": {
95
+ "legend": "Site Categories",
96
+ "tags": { "label": "Categories", "hint": "Comma-separated tags for your site (rendered as p-category in your h-card)" }
97
97
  },
98
98
  "social": {
99
99
  "legend": "Social Links",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-homepage",
3
- "version": "1.0.18",
3
+ "version": "1.0.19",
4
4
  "description": "Homepage builder endpoint for Indiekit. Configure layout, sections, and sidebar widgets from the admin UI.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -2,7 +2,7 @@
2
2
 
3
3
  {% block content %}
4
4
  <style>
5
- .hp-identity {
5
+ .hp-dashboard {
6
6
  display: flex;
7
7
  flex-direction: column;
8
8
  gap: var(--space-xl, 2rem);
@@ -35,41 +35,125 @@
35
35
  margin-block-end: var(--space-m, 1rem);
36
36
  }
37
37
 
38
+ /* Field grid for two-column layouts */
38
39
  .hp-field-grid {
39
40
  display: grid;
40
41
  grid-template-columns: 1fr 1fr;
41
- gap: var(--space-s, 0.75rem);
42
+ gap: var(--space-m, 1rem) var(--space-s, 0.75rem);
42
43
  }
43
44
 
44
45
  .hp-field-grid .field--full {
45
46
  grid-column: 1 / -1;
46
47
  }
47
48
 
48
- .hp-social-row {
49
- display: grid;
50
- grid-template-columns: 1fr 2fr 0.7fr 0.8fr auto;
51
- gap: var(--space-xs, 0.5rem);
52
- align-items: end;
53
- padding: var(--space-xs, 0.5rem) 0;
54
- border-bottom: 1px solid var(--color-outline-variant, #eee);
49
+ @media (max-width: 640px) {
50
+ .hp-field-grid {
51
+ grid-template-columns: 1fr;
52
+ }
53
+ }
54
+
55
+ /* Field styling consistent with edit panels */
56
+ .hp-dashboard .field {
57
+ display: flex;
58
+ flex-direction: column;
59
+ gap: 0.25rem;
60
+ }
61
+
62
+ .hp-dashboard .field__label {
63
+ display: block;
64
+ font: var(--font-caption, 0.75rem/1.4 sans-serif);
65
+ font-weight: 600;
66
+ color: var(--color-on-surface, inherit);
67
+ }
68
+
69
+ .hp-dashboard .field__input {
70
+ width: 100%;
71
+ padding: var(--space-2xs, 0.375rem) var(--space-xs, 0.5rem);
72
+ border: 1px solid var(--color-outline-variant, #ccc);
73
+ border-radius: var(--border-radius-small, 0.25rem);
74
+ font: var(--font-body, 0.875rem/1.5 sans-serif);
75
+ background: var(--color-background, #fff);
76
+ color: var(--color-on-surface, inherit);
77
+ transition: border-color 0.15s;
78
+ }
79
+
80
+ .hp-dashboard .field__input:focus {
81
+ outline: none;
82
+ border-color: var(--color-primary, #0066cc);
83
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary, #0066cc) 20%, transparent);
55
84
  }
56
85
 
57
- .hp-social-row:last-of-type {
58
- border-bottom: none;
86
+ .hp-dashboard textarea.field__input {
87
+ resize: vertical;
88
+ min-height: 4.5rem;
59
89
  }
60
90
 
91
+ .hp-dashboard .field__hint {
92
+ color: var(--color-on-offset, #888);
93
+ font: var(--font-caption, 0.75rem/1.4 sans-serif);
94
+ margin: 0;
95
+ }
96
+
97
+ /* Social links */
61
98
  .hp-social-header {
62
99
  display: grid;
63
- grid-template-columns: 1fr 2fr 0.7fr 0.8fr auto;
100
+ grid-template-columns: 1.5rem 1fr 2fr 0.7fr 0.8fr auto;
64
101
  gap: var(--space-xs, 0.5rem);
65
- padding: var(--space-xs, 0.5rem) 0;
66
- font-weight: 600;
102
+ padding: var(--space-xs, 0.5rem) var(--space-xs, 0.5rem);
67
103
  font: var(--font-caption, 0.75rem/1.4 sans-serif);
68
104
  font-weight: 600;
69
105
  color: var(--color-on-offset, #666);
70
106
  border-bottom: 2px solid var(--color-outline-variant, #ddd);
71
107
  }
72
108
 
109
+ .hp-social-row {
110
+ display: grid;
111
+ grid-template-columns: 1.5rem 1fr 2fr 0.7fr 0.8fr auto;
112
+ gap: var(--space-xs, 0.5rem);
113
+ align-items: center;
114
+ padding: var(--space-xs, 0.5rem);
115
+ background: var(--color-background, #fff);
116
+ border: 1px solid var(--color-outline-variant, #ddd);
117
+ border-top: none;
118
+ transition: background-color 0.15s;
119
+ }
120
+
121
+ .hp-social-row:first-child {
122
+ border-top: 1px solid var(--color-outline-variant, #ddd);
123
+ border-radius: var(--border-radius-small, 0.25rem) var(--border-radius-small, 0.25rem) 0 0;
124
+ }
125
+
126
+ .hp-social-row:last-child {
127
+ border-radius: 0 0 var(--border-radius-small, 0.25rem) var(--border-radius-small, 0.25rem);
128
+ }
129
+
130
+ .hp-social-row:only-child {
131
+ border-radius: var(--border-radius-small, 0.25rem);
132
+ }
133
+
134
+ .hp-social-row:hover {
135
+ background: color-mix(in srgb, var(--color-offset, #f5f5f5) 50%, transparent);
136
+ }
137
+
138
+ .hp-social-row.sortable-ghost {
139
+ opacity: 0.4;
140
+ background: var(--color-primary-container, #e6f0ff);
141
+ }
142
+
143
+ .hp-social-row.sortable-chosen {
144
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
145
+ }
146
+
147
+ .hp-social-drag {
148
+ cursor: grab;
149
+ color: var(--color-on-offset, #999);
150
+ display: flex;
151
+ align-items: center;
152
+ justify-content: center;
153
+ }
154
+
155
+ .hp-social-drag:active { cursor: grabbing; }
156
+
73
157
  .hp-social-row input,
74
158
  .hp-social-row select {
75
159
  width: 100%;
@@ -79,6 +163,42 @@
79
163
  font: var(--font-body, 0.875rem/1.5 sans-serif);
80
164
  background: var(--color-background, #fff);
81
165
  color: var(--color-on-surface, inherit);
166
+ transition: border-color 0.15s;
167
+ }
168
+
169
+ .hp-social-row input:focus,
170
+ .hp-social-row select:focus {
171
+ outline: none;
172
+ border-color: var(--color-primary, #0066cc);
173
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary, #0066cc) 20%, transparent);
174
+ }
175
+
176
+ .hp-social-empty {
177
+ text-align: center;
178
+ padding: var(--space-m, 1rem);
179
+ color: var(--color-on-offset, #888);
180
+ font: var(--font-caption, 0.875rem/1.4 sans-serif);
181
+ background: var(--color-background, #fff);
182
+ border: 1px dashed var(--color-outline-variant, #ddd);
183
+ border-radius: var(--border-radius-small, 0.25rem);
184
+ }
185
+
186
+ .hp-social-actions {
187
+ display: flex;
188
+ gap: var(--space-xs, 0.5rem);
189
+ align-items: center;
190
+ margin-block-start: var(--space-s, 0.75rem);
191
+ }
192
+
193
+ @media (max-width: 768px) {
194
+ .hp-social-header { display: none; }
195
+ .hp-social-row {
196
+ grid-template-columns: 1.5rem 1fr;
197
+ gap: var(--space-2xs, 0.25rem);
198
+ padding: var(--space-s, 0.75rem);
199
+ }
200
+ .hp-social-row input::placeholder,
201
+ .hp-social-row select { font-size: 0.8125rem; }
82
202
  }
83
203
  </style>
84
204
 
@@ -95,7 +215,7 @@
95
215
  </div>
96
216
  {% endif %}
97
217
 
98
- <form method="post" action="{{ homepageEndpoint }}/save-identity" class="hp-identity" id="identity-form">
218
+ <form method="post" action="{{ homepageEndpoint }}/save-identity" class="hp-dashboard" id="identity-form">
99
219
 
100
220
  {# Profile Section #}
101
221
  <section class="hp-section">
@@ -121,7 +241,7 @@
121
241
  <input class="field__input" type="text" id="identity-pronoun" name="identity-pronoun" value="{{ identity.pronoun or '' }}">
122
242
  <p class="field__hint">{{ __("homepageBuilder.identity.profile.pronoun.hint") }}</p>
123
243
  </div>
124
- <div class="field" style="visibility: hidden;"></div>
244
+ <div class="field"></div>
125
245
  <div class="field field--full">
126
246
  <label class="field__label" for="identity-bio">{{ __("homepageBuilder.identity.profile.bio.label") }}</label>
127
247
  <textarea class="field__input" id="identity-bio" name="identity-bio" rows="3">{{ identity.bio or '' }}</textarea>
@@ -177,13 +297,13 @@
177
297
  </div>
178
298
  </section>
179
299
 
180
- {# Skills Section #}
300
+ {# Categories Section #}
181
301
  <section class="hp-section">
182
- <h2>{{ __("homepageBuilder.identity.skills.legend") }}</h2>
302
+ <h2>{{ __("homepageBuilder.identity.categories.legend") }}</h2>
183
303
  <div class="field">
184
- <label class="field__label" for="identity-categories">{{ __("homepageBuilder.identity.skills.categories.label") }}</label>
304
+ <label class="field__label" for="identity-categories">{{ __("homepageBuilder.identity.categories.tags.label") }}</label>
185
305
  <input class="field__input" type="text" id="identity-categories" name="identity-categories" value="{{ identity.categories | join(', ') if identity.categories else '' }}">
186
- <p class="field__hint">{{ __("homepageBuilder.identity.skills.categories.hint") }}</p>
306
+ <p class="field__hint">{{ __("homepageBuilder.identity.categories.tags.hint") }}</p>
187
307
  </div>
188
308
  </section>
189
309
 
@@ -193,6 +313,7 @@
193
313
  <p class="hp-section__desc">{{ __("homepageBuilder.identity.social.description") }}</p>
194
314
 
195
315
  <div class="hp-social-header">
316
+ <span></span>
196
317
  <span>{{ __("homepageBuilder.identity.social.name.label") }}</span>
197
318
  <span>{{ __("homepageBuilder.identity.social.url.label") }}</span>
198
319
  <span>{{ __("homepageBuilder.identity.social.rel.label") }}</span>
@@ -200,11 +321,9 @@
200
321
  <span></span>
201
322
  </div>
202
323
 
203
- <div id="social-links-container">
204
- {# Populated by JS from identity.social data #}
205
- </div>
324
+ <div id="social-links-container"></div>
206
325
 
207
- <div style="margin-block-start: var(--space-s, 0.75rem);">
326
+ <div class="hp-social-actions">
208
327
  <button type="button" class="button button--small" id="add-social-link">Add Social Link</button>
209
328
  </div>
210
329
  </section>
@@ -219,16 +338,128 @@
219
338
  var socialData = {{ identity.social | dump | default('[]') | safe }};
220
339
  var socialContainer = document.getElementById('social-links-container');
221
340
 
341
+ // Icon options grouped for the select dropdown
342
+ var iconOptions = [
343
+ { value: '', label: 'None', group: '' },
344
+ { value: 'github', label: 'GitHub', group: 'Code' },
345
+ { value: 'gitlab', label: 'GitLab', group: 'Code' },
346
+ { value: 'forgejo', label: 'Forgejo', group: 'Code' },
347
+ { value: 'codeberg', label: 'Codeberg', group: 'Code' },
348
+ { value: 'sourcehut', label: 'SourceHut', group: 'Code' },
349
+ { value: 'linkedin', label: 'LinkedIn', group: 'Social' },
350
+ { value: 'bluesky', label: 'Bluesky', group: 'Social' },
351
+ { value: 'mastodon', label: 'Mastodon', group: 'Social' },
352
+ { value: 'activitypub', label: 'ActivityPub', group: 'Social' },
353
+ { value: 'pixelfed', label: 'PixelFed', group: 'Social' },
354
+ { value: 'twitter', label: 'X / Twitter', group: 'Social' },
355
+ { value: 'facebook', label: 'Facebook', group: 'Social' },
356
+ { value: 'instagram', label: 'Instagram', group: 'Social' },
357
+ { value: 'threads', label: 'Threads', group: 'Social' },
358
+ { value: 'reddit', label: 'Reddit', group: 'Social' },
359
+ { value: 'hackernews', label: 'Hacker News', group: 'Social' },
360
+ { value: 'indieweb', label: 'IndieWeb', group: 'Social' },
361
+ { value: 'youtube', label: 'YouTube', group: 'Content' },
362
+ { value: 'twitch', label: 'Twitch', group: 'Content' },
363
+ { value: 'flickr', label: 'Flickr', group: 'Content' },
364
+ { value: 'spotify', label: 'Spotify', group: 'Content' },
365
+ { value: 'bandcamp', label: 'Bandcamp', group: 'Content' },
366
+ { value: 'soundcloud', label: 'SoundCloud', group: 'Content' },
367
+ { value: 'funkwhale', label: 'Funkwhale', group: 'Content' },
368
+ { value: 'lastfm', label: 'Last.fm', group: 'Content' },
369
+ { value: 'peertube', label: 'PeerTube', group: 'Content' },
370
+ { value: 'bookwyrm', label: 'BookWyrm', group: 'Content' },
371
+ { value: 'matrix', label: 'Matrix', group: 'Messaging' },
372
+ { value: 'discord', label: 'Discord', group: 'Messaging' },
373
+ { value: 'signal', label: 'Signal', group: 'Messaging' },
374
+ { value: 'telegram', label: 'Telegram', group: 'Messaging' },
375
+ { value: 'xmpp', label: 'XMPP', group: 'Messaging' },
376
+ { value: 'rss', label: 'RSS', group: 'Other' },
377
+ { value: 'email', label: 'Email', group: 'Other' },
378
+ { value: 'keybase', label: 'Keybase', group: 'Other' },
379
+ { value: 'orcid', label: 'ORCID', group: 'Other' },
380
+ { value: 'website', label: 'Website', group: 'Other' }
381
+ ];
382
+
383
+ /**
384
+ * Sync DOM input values back into socialData.
385
+ * Must be called before any operation that triggers renderSocialLinks().
386
+ */
387
+ function syncSocialData() {
388
+ var rows = socialContainer.querySelectorAll('.hp-social-row');
389
+ rows.forEach(function(row, index) {
390
+ if (index >= socialData.length) return;
391
+ var inputs = row.querySelectorAll('input');
392
+ var select = row.querySelector('select');
393
+ if (inputs[0]) socialData[index].name = inputs[0].value;
394
+ if (inputs[1]) socialData[index].url = inputs[1].value;
395
+ if (inputs[2]) socialData[index].rel = inputs[2].value;
396
+ if (select) socialData[index].icon = select.value;
397
+ });
398
+ }
399
+
400
+ function createDragHandle() {
401
+ var drag = document.createElement('span');
402
+ drag.className = 'hp-social-drag drag-handle';
403
+ drag.title = 'Drag to reorder';
404
+ var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
405
+ svg.setAttribute('width', '14');
406
+ svg.setAttribute('height', '14');
407
+ svg.setAttribute('viewBox', '0 0 24 24');
408
+ svg.setAttribute('fill', 'currentColor');
409
+ var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
410
+ path.setAttribute('d', 'M8 6a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm8-16a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4z');
411
+ svg.appendChild(path);
412
+ drag.appendChild(svg);
413
+ return drag;
414
+ }
415
+
416
+ function buildIconSelect(selectedValue) {
417
+ var select = document.createElement('select');
418
+ var currentGroup = '';
419
+ var optgroup = null;
420
+
421
+ iconOptions.forEach(function(icon) {
422
+ if (icon.group && icon.group !== currentGroup) {
423
+ currentGroup = icon.group;
424
+ optgroup = document.createElement('optgroup');
425
+ optgroup.label = currentGroup;
426
+ select.appendChild(optgroup);
427
+ }
428
+ var opt = document.createElement('option');
429
+ opt.value = icon.value;
430
+ opt.textContent = icon.label;
431
+ if (icon.value === (selectedValue || '')) opt.selected = true;
432
+ if (optgroup && icon.group) {
433
+ optgroup.appendChild(opt);
434
+ } else {
435
+ select.appendChild(opt);
436
+ }
437
+ });
438
+
439
+ return select;
440
+ }
441
+
222
442
  function renderSocialLinks() {
223
443
  socialContainer.textContent = '';
444
+ if (socialData.length === 0) {
445
+ var empty = document.createElement('div');
446
+ empty.className = 'hp-social-empty';
447
+ empty.textContent = 'No social links configured. Click "Add Social Link" to add one.';
448
+ socialContainer.appendChild(empty);
449
+ return;
450
+ }
224
451
  socialData.forEach(function(link, index) {
225
452
  socialContainer.appendChild(createSocialRow(link, index));
226
453
  });
454
+ initSortable();
227
455
  }
228
456
 
229
457
  function createSocialRow(link, index) {
230
458
  var row = document.createElement('div');
231
459
  row.className = 'hp-social-row';
460
+ row.dataset.index = index;
461
+
462
+ row.appendChild(createDragHandle());
232
463
 
233
464
  var nameInput = document.createElement('input');
234
465
  nameInput.type = 'text';
@@ -251,23 +482,8 @@
251
482
  relInput.placeholder = 'me';
252
483
  row.appendChild(relInput);
253
484
 
254
- var iconSelect = document.createElement('select');
485
+ var iconSelect = buildIconSelect(link.icon);
255
486
  iconSelect.name = 'social[' + index + '][icon]';
256
- var icons = [
257
- { value: '', label: 'None' },
258
- { value: 'github', label: 'GitHub' },
259
- { value: 'linkedin', label: 'LinkedIn' },
260
- { value: 'bluesky', label: 'Bluesky' },
261
- { value: 'mastodon', label: 'Mastodon' },
262
- { value: 'activitypub', label: 'ActivityPub' }
263
- ];
264
- icons.forEach(function(icon) {
265
- var opt = document.createElement('option');
266
- opt.value = icon.value;
267
- opt.textContent = icon.label;
268
- if (icon.value === (link.icon || '')) opt.selected = true;
269
- iconSelect.appendChild(opt);
270
- });
271
487
  row.appendChild(iconSelect);
272
488
 
273
489
  var removeBtn = document.createElement('button');
@@ -275,6 +491,7 @@
275
491
  removeBtn.className = 'button button--small button--secondary';
276
492
  removeBtn.textContent = 'Remove';
277
493
  removeBtn.addEventListener('click', function() {
494
+ syncSocialData();
278
495
  socialData.splice(index, 1);
279
496
  renderSocialLinks();
280
497
  });
@@ -284,10 +501,43 @@
284
501
  }
285
502
 
286
503
  document.getElementById('add-social-link').addEventListener('click', function() {
504
+ syncSocialData();
287
505
  socialData.push({ name: '', url: '', rel: 'me', icon: '' });
288
506
  renderSocialLinks();
289
507
  });
290
508
 
509
+ // --- SortableJS for drag-and-drop reordering ---
510
+ function syncOrderFromDom() {
511
+ syncSocialData();
512
+ var rows = socialContainer.querySelectorAll('.hp-social-row');
513
+ var reordered = [];
514
+ rows.forEach(function(row) {
515
+ var idx = parseInt(row.dataset.index, 10);
516
+ if (socialData[idx]) reordered.push(socialData[idx]);
517
+ });
518
+ socialData = reordered;
519
+ // Re-render to update form field name indices
520
+ renderSocialLinks();
521
+ }
522
+
523
+ function initSortable() {
524
+ if (typeof Sortable === 'undefined') return;
525
+ if (socialContainer._sortable) socialContainer._sortable.destroy();
526
+ socialContainer._sortable = new Sortable(socialContainer, {
527
+ handle: '.drag-handle',
528
+ animation: 150,
529
+ ghostClass: 'sortable-ghost',
530
+ chosenClass: 'sortable-chosen',
531
+ draggable: '.hp-social-row',
532
+ onEnd: syncOrderFromDom
533
+ });
534
+ }
535
+
536
+ var sortableScript = document.createElement('script');
537
+ sortableScript.src = 'https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js';
538
+ sortableScript.onload = function() { initSortable(); };
539
+ document.head.appendChild(sortableScript);
540
+
291
541
  renderSocialLinks();
292
542
  </script>
293
543
  {% endblock %}