@geekity/cms 0.35.0 → 0.36.1

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.
Files changed (85) hide show
  1. package/admin/components/job-status.njk +15 -10
  2. package/admin/components/upload.njk +26 -0
  3. package/admin/pages/documents/editor.njk +34 -19
  4. package/admin/pages/federation/followers.njk +2 -1
  5. package/admin/pages/media/file.njk +5 -4
  6. package/admin/pages/media/library.njk +7 -3
  7. package/admin/pages/settings/general.njk +4 -1
  8. package/admin/pages/tools/export.njk +54 -51
  9. package/admin/pages/tools/maintenance.njk +2 -1
  10. package/admin/pages/users/edit.njk +3 -0
  11. package/admin/src/admin.css +1 -0
  12. package/admin/static/admin.css +74 -0
  13. package/admin/static/job-status.js +158 -0
  14. package/admin/static/upload-drop.js +225 -0
  15. package/dist/admin/documents.d.ts.map +1 -1
  16. package/dist/admin/documents.js +0 -2
  17. package/dist/admin/documents.js.map +1 -1
  18. package/dist/admin/index.d.ts +1 -1
  19. package/dist/admin/index.d.ts.map +1 -1
  20. package/dist/admin/index.js +1 -1
  21. package/dist/admin/index.js.map +1 -1
  22. package/dist/admin/job-status.d.ts +1 -0
  23. package/dist/admin/job-status.d.ts.map +1 -1
  24. package/dist/admin/job-status.js +1 -0
  25. package/dist/admin/job-status.js.map +1 -1
  26. package/dist/admin/media.d.ts +6 -9
  27. package/dist/admin/media.d.ts.map +1 -1
  28. package/dist/admin/media.js +85 -27
  29. package/dist/admin/media.js.map +1 -1
  30. package/dist/admin/routes.d.ts.map +1 -1
  31. package/dist/admin/routes.js +1 -0
  32. package/dist/admin/routes.js.map +1 -1
  33. package/dist/admin/uploads.d.ts +2 -1
  34. package/dist/admin/uploads.d.ts.map +1 -1
  35. package/dist/admin/uploads.js +5 -2
  36. package/dist/admin/uploads.js.map +1 -1
  37. package/dist/media/library.d.ts +9 -0
  38. package/dist/media/library.d.ts.map +1 -1
  39. package/dist/media/library.js +25 -1
  40. package/dist/media/library.js.map +1 -1
  41. package/package.json +6 -1
  42. package/dist/comments/routes.d.ts +0 -65
  43. package/dist/comments/routes.d.ts.map +0 -1
  44. package/dist/comments/routes.js +0 -235
  45. package/dist/comments/routes.js.map +0 -1
  46. package/dist/content/media.d.ts +0 -85
  47. package/dist/content/media.d.ts.map +0 -1
  48. package/dist/content/media.js +0 -237
  49. package/dist/content/media.js.map +0 -1
  50. package/dist/content/read-form.d.ts +0 -31
  51. package/dist/content/read-form.d.ts.map +0 -1
  52. package/dist/content/read-form.js +0 -157
  53. package/dist/content/read-form.js.map +0 -1
  54. package/dist/content/read.d.ts +0 -29
  55. package/dist/content/read.d.ts.map +0 -1
  56. package/dist/content/read.js +0 -82
  57. package/dist/content/read.js.map +0 -1
  58. package/dist/export/export-archives.d.ts +0 -29
  59. package/dist/export/export-archives.d.ts.map +0 -1
  60. package/dist/export/export-archives.js +0 -88
  61. package/dist/export/export-archives.js.map +0 -1
  62. package/dist/federation/mount.d.ts +0 -53
  63. package/dist/federation/mount.d.ts.map +0 -1
  64. package/dist/federation/mount.js +0 -344
  65. package/dist/federation/mount.js.map +0 -1
  66. package/dist/mail/credentials.d.ts +0 -62
  67. package/dist/mail/credentials.d.ts.map +0 -1
  68. package/dist/mail/credentials.js +0 -86
  69. package/dist/mail/credentials.js.map +0 -1
  70. package/dist/site/setting-rows.d.ts +0 -45
  71. package/dist/site/setting-rows.d.ts.map +0 -1
  72. package/dist/site/setting-rows.js +0 -361
  73. package/dist/site/setting-rows.js.map +0 -1
  74. package/dist/site/settings-form.d.ts +0 -30
  75. package/dist/site/settings-form.d.ts.map +0 -1
  76. package/dist/site/settings-form.js +0 -54
  77. package/dist/site/settings-form.js.map +0 -1
  78. package/dist/site/site-json.d.ts +0 -38
  79. package/dist/site/site-json.d.ts.map +0 -1
  80. package/dist/site/site-json.js +0 -69
  81. package/dist/site/site-json.js.map +0 -1
  82. package/dist/webmention/routes.d.ts +0 -37
  83. package/dist/webmention/routes.d.ts.map +0 -1
  84. package/dist/webmention/routes.js +0 -75
  85. package/dist/webmention/routes.js.map +0 -1
@@ -8,38 +8,43 @@
8
8
  failed the error, in an alert
9
9
  interrupted that restarts or crashes cut it off and it will not run again
10
10
 
11
- While it is queued or running, `refreshUrl` is offered as a link to look
12
- again: the page does not reload itself. `verb` and `unit` word the
13
- progress. The error and every word passed in are escaped. #}
11
+ While it is queued or running, the box names `refreshUrl` in
12
+ `data-job-refresh`. A page that loads static/job-status.js has it fetch
13
+ that page every few seconds and swap in its box with the same
14
+ `data-job-status` until the job ends. It swaps the closest
15
+ `data-job-region` around the box instead when there is one, so a page can
16
+ redraw the controls it hides while the job runs. Without JavaScript,
17
+ `refreshUrl` is a Check again link. `verb` and `unit` word the progress.
18
+ The error and every word passed in are escaped. #}
14
19
  {% from "components/alert.njk" import alert %}
15
20
  {% from "components/badge.njk" import status %}
16
21
  {% from "components/button.njk" import buttonLink %}
17
22
 
18
23
  {% macro jobStatus(job, refreshUrl, verb='Done', unit='items') -%}
19
- <div class="flex flex-col gap-3">
24
+ <div class="flex flex-col gap-3" data-job-status="{{ job.name }}" data-job-state="{{ job.state }}" data-job-label="{{ job.label }}"{% if job.active %} data-job-refresh="{{ refreshUrl }}"{% endif %}>
20
25
  <p class="flex flex-wrap items-center gap-2">
21
26
  {{ status(job.state, job.label) }}
22
27
  <span class="text-sm"><time datetime="{{ job.at }}">{{ job.at | date('readable') }}</time></span>
23
28
  </p>
24
29
  {% if job.state == 'queued' %}
25
- <p>Waiting for the worker to start it.{% if job.requeued %} A restart cut off the last try, so it is queued again as try {{ job.attempt }}.{% endif %}</p>
30
+ <p data-job-message>Waiting for the worker to start it.{% if job.requeued %} A restart cut off the last try, so it is queued again as try {{ job.attempt }}.{% endif %}</p>
26
31
  {% elif job.state == 'running' %}
27
32
  {% if job.progress and job.progress.total %}
28
33
  <progress class="progress progress-primary w-full max-w-md" value="{{ job.progress.done }}" max="{{ job.progress.total }}" aria-label="{{ verb }} {{ job.progress.done }} of {{ job.progress.total }} {{ unit }}"></progress>
29
- <p>{{ verb }} {{ job.progress.done }} of {{ job.progress.total }} {{ unit }}.{% if job.progress.note %} {{ job.progress.note }}{% endif %}</p>
34
+ <p data-job-message>{{ verb }} {{ job.progress.done }} of {{ job.progress.total }} {{ unit }}.{% if job.progress.note %} {{ job.progress.note }}{% endif %}</p>
30
35
  {% else %}
31
36
  <progress class="progress progress-primary w-full max-w-md" aria-label="Starting"></progress>
32
- <p>Starting.</p>
37
+ <p data-job-message>Starting.</p>
33
38
  {% endif %}
34
39
  {% elif job.state == 'finished' %}
35
40
  {% if caller %}{{ caller() }}{% endif %}
36
41
  {% elif job.state == 'failed' %}
37
- {% call alert(color='error', style='soft') %}<span>{{ job.error }}</span>{% endcall %}
42
+ {% call alert(color='error', style='soft') %}<span data-job-message>{{ job.error }}</span>{% endcall %}
38
43
  {% elif job.state == 'interrupted' %}
39
- <p>Restarts or crashes cut it off {{ job.attempt }} times, so it is not run again on its own.</p>
44
+ <p data-job-message>Restarts or crashes cut it off {{ job.attempt }} times, so it is not run again on its own.</p>
40
45
  {% endif %}
41
46
  {% if job.active %}
42
- <div>{{ buttonLink('Check again', refreshUrl, style='outline') }}</div>
47
+ <div data-job-check-again>{{ buttonLink('Check again', refreshUrl, style='outline') }}</div>
43
48
  {% endif %}
44
49
  </div>
45
50
  {%- endmacro %}
@@ -0,0 +1,26 @@
1
+ {# An upload in place: a file picker beside a box, grid or menu that wants a
2
+ file, so nobody has to leave a post for the media library and come back
3
+ with an address (TASK-439).
4
+
5
+ `uploadControl(kind, target, url)` emits a `hidden` block that
6
+ `static/upload-drop.js` reveals, so a page with no script shows nothing that
7
+ does nothing. The script posts each file to `url` and hands the answer to
8
+ the `kind`'s handler, which fills the element whose id is `target`:
9
+
10
+ photos adds a ticked tile to the photo grid; `name` is the prefix of
11
+ the grid's checkbox names
12
+ field sets the text box's value; `preview` names an <img> to show it
13
+ select adds the file to the menu and chooses it
14
+ library reloads the page once the files have landed
15
+
16
+ Dropping files on the target, or on the field around it, does the same.
17
+ `images` asks the server to refuse anything that is not an image. #}
18
+
19
+ {% macro uploadControl(kind, target, url, images=false, accept='', name='', preview='') -%}
20
+ <div class="mt-1 flex flex-wrap items-center gap-2" data-upload-drop="{{ kind }}" data-upload-url="{{ url }}" data-upload-target="{{ target }}"{% if images %} data-upload-images{% endif %}{% if name %} data-upload-name="{{ name }}"{% endif %}{% if preview %} data-upload-preview="{{ preview }}"{% endif %} hidden>
21
+ <label class="text-xs" for="{{ target }}-upload">Upload</label>
22
+ <input id="{{ target }}-upload" type="file" class="file-input file-input-xs w-auto max-w-full"{% if images %} accept="image/*"{% elif accept %} accept="{{ accept }}"{% endif %}{% if kind == 'photos' or kind == 'library' %} multiple{% endif %} data-upload-input />
23
+ <span class="text-xs text-base-content/70">or drop {% if kind == 'photos' or kind == 'library' %}files{% else %}a file{% endif %} here</span>
24
+ <p class="w-full text-xs empty:hidden" role="status" aria-live="polite" data-upload-status></p>
25
+ </div>
26
+ {%- endmacro %}
@@ -13,6 +13,7 @@
13
13
  {% from "components/card.njk" import card, cardActions %}
14
14
  {% from "components/collapse.njk" import collapse %}
15
15
  {% from "components/dropdown.njk" import dropdown %}
16
+ {% from "components/upload.njk" import uploadControl %}
16
17
 
17
18
  {% set ACTION_COLORS = { 'publish': 'primary', 'update': 'primary', 'trash': 'error' } %}
18
19
  {% set ACTION_STYLES = { 'trash': 'ghost' } %}
@@ -20,6 +21,14 @@
20
21
 
21
22
  {% set ICON_SIZES = { md: 'size-6', sm: 'size-4' } %}
22
23
 
24
+ {% macro photoTile(name, url, thumbnail, alt) -%}
25
+ <label class="relative cursor-pointer">
26
+ <input class="peer sr-only" type="checkbox" name="{{ name }}" value="{{ url }}" />
27
+ <img class="aspect-square w-full rounded-box object-cover ring-primary ring-offset-2 ring-offset-base-100 peer-checked:ring-2 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-base-content" src="{{ thumbnail }}" alt="{{ alt }}" loading="lazy" />
28
+ <span class="badge badge-primary badge-xs absolute end-1 top-1 hidden peer-checked:inline-flex" aria-hidden="true">✓</span>
29
+ </label>
30
+ {%- endmacro %}
31
+
23
32
  {% macro icon(path, size='md') -%}
24
33
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.6" stroke="currentColor" class="shrink-0{{ ICON_SIZES | modifier(size) }}" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="{{ path }}"/></svg>
25
34
  {%- endmacro %}
@@ -152,22 +161,21 @@
152
161
  </div>
153
162
  </div>
154
163
  {% endfor %}
155
- {% if photos.picks | length %}
156
- <fieldset class="fieldset">
157
- <legend class="fieldset-legend">Add from the media library</legend>
158
- <div class="grid grid-cols-3 gap-2 sm:grid-cols-6">
159
- {% for pick in photos.picks %}
160
- <label class="relative cursor-pointer">
161
- <input class="peer sr-only" type="checkbox" name="{{ photoFields.url }}{{ pick.index }}" value="{{ pick.url }}" />
162
- <img class="aspect-square w-full rounded-box object-cover ring-primary ring-offset-2 ring-offset-base-100 peer-checked:ring-2 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-base-content" src="{{ pick.thumbnail }}" alt="{{ pick.alt or pick.url }}" loading="lazy" />
163
- <span class="badge badge-primary badge-xs absolute end-1 top-1 hidden peer-checked:inline-flex" aria-hidden="true">✓</span>
164
- </label>
165
- {% endfor %}
166
- </div>
167
- <p class="label block whitespace-normal">A ticked photo is added when you save, with the media library’s alt text. Add them now to write alt text of your own first.</p>
168
- <div>{{ button('Add the ticked photos', type='submit', name='switch-kind', value=postKind, style='soft', formnovalidate=true) }}</div>
169
- </fieldset>
170
- {% endif %}
164
+ <fieldset class="fieldset rounded-box">
165
+ <legend class="fieldset-legend">Add from the media library</legend>
166
+ <div class="grid grid-cols-3 gap-2 sm:grid-cols-6" id="editor-photo-picks">
167
+ {% for pick in photos.picks %}
168
+ {{ photoTile(photoFields.url + pick.index, pick.url, pick.thumbnail, pick.alt or pick.url) }}
169
+ {% endfor %}
170
+ </div>
171
+ <template data-upload-tile="editor-photo-picks">{{ photoTile('', '', '', '') }}</template>
172
+ {% if not photos.picks | length %}
173
+ <p class="text-sm" data-upload-empty>The media library has no images yet.</p>
174
+ {% endif %}
175
+ {{ uploadControl('photos', 'editor-photo-picks', uploadsUrl, images=true, name=photoFields.url) }}
176
+ <p class="label block whitespace-normal">A ticked photo is added when you save, with the media library’s alt text. Add them now to write alt text of your own first.</p>
177
+ <div{% if not photos.picks | length %} hidden data-upload-reveal{% endif %}>{{ button('Add the ticked photos', type='submit', name='switch-kind', value=postKind, style='soft', formnovalidate=true) }}</div>
178
+ </fieldset>
171
179
  <div class="grid gap-x-4 sm:grid-cols-2">
172
180
  {{ field.text('editor-photo-url-' + photos.blank, photoFields.url + photos.blank, 'Or a web address', value='', list='editor-photo-uploads', autocomplete='off', error=(error if errorField == ('editor-photo-url-' + photos.blank) else '')) }}
173
181
  {{ field.text('editor-photo-alt-' + photos.blank, photoFields.alt + photos.blank, 'Its alt text', value='') }}
@@ -224,15 +232,19 @@
224
232
  {{ placeFields(true) }}
225
233
 
226
234
  {% elif name == 'episode' %}
227
- {% call field.select('editor-enclosure-url', enclosureFields.url, 'Audio or video file', error=(error if errorField == 'editor-enclosure-url' else ''), hint='Upload the file to the media library first. Feeds carry it as the post’s enclosure and the post page plays it. None removes the recording, its transcript and its other versions.', hintId='editor-enclosure-url-hint') %}
235
+ {% call field.select('editor-enclosure-url', enclosureFields.url, 'Audio or video file', error=(error if errorField == 'editor-enclosure-url' else ''), hint='Feeds carry it as the post’s enclosure and the post page plays it. None removes the recording, its transcript and its other versions.', hintId='editor-enclosure-url-hint') %}
228
236
  <option value=""{% if form.enclosure.url == "" %} selected{% endif %}>None</option>
229
237
  {% for choice in enclosureChoices.media %}
230
238
  <option value="{{ choice.url }}"{% if choice.url == form.enclosure.url %} selected{% endif %}>{{ choice.label }}</option>
231
239
  {% endfor %}
232
240
  {% endcall %}
241
+ {{ uploadControl('select', 'editor-enclosure-url', uploadsUrl, accept='audio/*,video/*') }}
233
242
  <div class="grid gap-x-4 sm:grid-cols-2">
234
243
  {{ field.text('editor-enclosure-duration', enclosureFields.duration, 'Duration', value=form.enclosure.duration, inputmode='numeric', autocomplete='off', error=(error if errorField == 'editor-enclosure-duration' else ''), hint='Seconds, or 30:34, or 1:02:03. Leave it empty if you do not know.', hintId='editor-enclosure-duration-hint') }}
235
- {{ field.text('editor-enclosure-transcript', enclosureFields.transcriptUrl, 'Transcript or captions', value=form.enclosure.transcriptUrl, list='editor-transcript-uploads', autocomplete='off', error=(error if errorField == 'editor-enclosure-transcript' else ''), hintId='editor-enclosure-transcript-hint') }}
244
+ <div>
245
+ {{ field.text('editor-enclosure-transcript', enclosureFields.transcriptUrl, 'Transcript or captions', value=form.enclosure.transcriptUrl, list='editor-transcript-uploads', autocomplete='off', error=(error if errorField == 'editor-enclosure-transcript' else ''), hintId='editor-enclosure-transcript-hint') }}
246
+ {{ uploadControl('field', 'editor-enclosure-transcript', uploadsUrl, accept='.vtt,.srt,.txt') }}
247
+ </div>
236
248
  </div>
237
249
  <datalist id="editor-transcript-uploads">
238
250
  {% for url in enclosureChoices.transcripts %}
@@ -256,6 +268,7 @@
256
268
  {% set n = loop.index0 %}
257
269
  {% call collapse(('Version ' + loop.index) if row.url else 'Add a version', row.url, fields=true) %}
258
270
  {{ field.text('editor-alternate-url-' + n, enclosureFields.alternate.url + n, 'Address', value=row.url, list='editor-media-uploads', autocomplete='off', error=(error if errorField == ('editor-alternate-url-' + n) else '')) }}
271
+ {{ uploadControl('field', 'editor-alternate-url-' + n, uploadsUrl, accept='audio/*,video/*') }}
259
272
  {{ field.text('editor-alternate-type-' + n, enclosureFields.alternate.type + n, 'Type', value=row.type, placeholder='video/mp4', autocomplete='off', spellcheck='false', error=(error if errorField == ('editor-alternate-type-' + n) else '')) }}
260
273
  {{ field.text('editor-alternate-title-' + n, enclosureFields.alternate.title + n, 'Title', value=row.title, maxlength='32', error=(error if errorField == ('editor-alternate-title-' + n) else '')) }}
261
274
  {{ field.text('editor-alternate-height-' + n, enclosureFields.alternate.height + n, 'Height', value=row.height, inputmode='numeric', autocomplete='off', error=(error if errorField == ('editor-alternate-height-' + n) else '')) }}
@@ -364,6 +377,7 @@
364
377
  <img class="h-24 w-16 shrink-0 rounded-box object-cover" id="editor-item-photo-preview"{% if reviewPhotoThumbnail %} src="{{ reviewPhotoThumbnail }}"{% else %} hidden{% endif %} alt="" loading="lazy" />
365
378
  <div class="flex min-w-0 flex-1 flex-col">
366
379
  {{ field.text('editor-item-photo', reviewFields.photo, '', labelHtml=itemWord('picture'), value=form.review.photo, list='editor-photo-uploads', autocomplete='off', error=(error if errorField == 'editor-item-photo' else ''), hint='An image in the media library or a web address. It is the item’s picture, not a photo for the post: it shows beside the item’s details.', hintId='editor-item-photo-hint') }}
380
+ {{ uploadControl('field', 'editor-item-photo', uploadsUrl, images=true, preview='editor-item-photo-preview') }}
367
381
  {{ field.text('editor-item-photo-alt', reviewFields.photoAlt, '', labelHtml=itemWord('picture', ' alt text'), value=form.review.photoAlt) }}
368
382
  </div>
369
383
  </div>
@@ -478,7 +492,7 @@
478
492
  class="flex flex-wrap items-center gap-2"
479
493
  id="editor-enhance"
480
494
  data-preview-url="{{ previewUrl }}"
481
- data-upload-url="{{ uploadUrl }}"
495
+ data-upload-url="{{ uploadsUrl }}"
482
496
  data-kind="{{ kind.type }}"
483
497
  >
484
498
  {{ button('Preview', type='submit', id='editor-preview-fallback', style='soft', formaction=previewUrl, formtarget='_blank', formnovalidate=true) }}
@@ -667,6 +681,7 @@
667
681
  {# The slug and permalink auto-fill, in a file of its own so the policy never
668
682
  has to allow an inline script. #}
669
683
  <script defer src="{{ assetPrefix }}slug.js"></script>
684
+ <script defer src="{{ assetPrefix }}upload-drop.js"></script>
670
685
  {% if kind.type == 'post' %}
671
686
  <script defer src="{{ assetPrefix }}location.js"></script>
672
687
  {% endif %}
@@ -175,7 +175,7 @@
175
175
  {% endif %}
176
176
  </section>
177
177
 
178
- <section id="resend" class="mb-10 max-w-2xl" aria-labelledby="resend-heading">
178
+ <section id="resend" class="mb-10 max-w-2xl" aria-labelledby="resend-heading" data-job-region>
179
179
  {% call card() %}
180
180
  <h2 id="resend-heading" class="card-title">Resend</h2>
181
181
  <p class="max-w-prose">
@@ -295,4 +295,5 @@
295
295
  <p>No post has been announced to the fediverse yet.</p>
296
296
  {% endif %}
297
297
  </section>
298
+ <script defer src="{{ assetPrefix }}job-status.js"></script>
298
299
  {% endblock %}
@@ -44,15 +44,15 @@
44
44
  {% call card(title='Delete ' + confirm.url + '?', style='border') %}
45
45
  <p>
46
46
  {{ confirm.references.length }}
47
- {% if confirm.references.length == 1 %}document still points{% else %}documents still point{% endif %}
48
- at it. Deleting the file does not change them, so they will point at nothing.
47
+ {% if confirm.references.length == 1 %}thing still uses{% else %}things still use{% endif %}
48
+ it. Deleting the file does not change them, so they will point at nothing.
49
49
  </p>
50
50
  <ul class="list-disc pl-6">
51
51
  {% for reference in confirm.references %}
52
52
  <li>
53
53
  <a class="link" href="{{ reference.editUrl }}">{{ reference.title }}</a>
54
54
  {% if reference.trashed %}{{ status('trashed', 'Trash') }}{% endif %}
55
- <code>{{ reference.path }}</code>
55
+ <code>{{ reference.where }}</code>
56
56
  </li>
57
57
  {% endfor %}
58
58
  </ul>
@@ -120,11 +120,12 @@
120
120
  <li>
121
121
  <a class="link link-hover" href="{{ reference.editUrl }}">{{ reference.title }}</a>
122
122
  {% if reference.trashed %}{{ status('trashed', 'Trash') }}{% endif %}
123
+ <span class="text-base-content/70">{{ reference.where }}</span>
123
124
  </li>
124
125
  {% endfor %}
125
126
  </ul>
126
127
  {% else %}
127
- <p class="text-sm">Nothing links to it.</p>
128
+ <p class="text-sm">Nothing uses it.</p>
128
129
  {% endif %}
129
130
  </div>
130
131
 
@@ -5,13 +5,14 @@
5
5
  Everything works with JavaScript switched off: the upload form opens from a
6
6
  <details>, the filters are a GET form, and a tile is a link. media.js opens
7
7
  a tile's details in a dialog instead and steps through them with the arrow
8
- keys. #}
8
+ keys, and upload-drop.js takes files dropped anywhere on the page. #}
9
9
  {% extends "layouts/shell.njk" %}
10
10
  {% from "components/alert.njk" import alert %}
11
11
  {% from "components/badge.njk" import badge %}
12
12
  {% from "components/button.njk" import button, buttonLink %}
13
13
  {% from "components/pagination.njk" import pagination %}
14
14
  {% from "components/tabs.njk" import tabs %}
15
+ {% from "components/upload.njk" import uploadControl %}
15
16
 
16
17
  {% block title %}{{ heading }}{% endblock %}
17
18
 
@@ -34,12 +35,13 @@
34
35
  <div class="mt-2">{{ button('Upload', type='submit', color='primary') }}</div>
35
36
  </form>
36
37
  </details>
38
+ {{ uploadControl('library', 'main', uploadsUrl) }}
37
39
  </div>
38
40
 
39
41
  {% if sweepNotice %}
40
- <section id="strip-metadata-notice" class="mb-6 max-w-4xl" aria-label="Location and camera metadata">
42
+ <section id="strip-metadata-notice" class="mb-6 max-w-4xl" aria-label="Location and camera metadata"{% if sweepNotice.sweep %} data-job-status="{{ sweepNotice.sweep.name }}" data-job-state="{{ sweepNotice.sweep.state }}" data-job-label="{{ sweepNotice.sweep.label }}"{% if sweepNotice.sweep.active %} data-job-refresh="{{ sweepNotice.panelUrl }}" data-job-redraw="{{ listUrl }}"{% endif %}{% endif %}>
41
43
  {% call alert(color='info', style='soft', direction='responsive', polite=true) %}
42
- <span>
44
+ <span data-job-message>
43
45
  {% if sweepNotice.sweep and sweepNotice.sweep.active %}
44
46
  The sweep that strips location and camera metadata from older uploads is {{ sweepNotice.sweep.label | lower }}.
45
47
  {% else %}
@@ -129,5 +131,7 @@
129
131
  {% endif %}
130
132
 
131
133
  <script defer src="{{ assetPrefix }}copy.js"></script>
134
+ <script defer src="{{ assetPrefix }}upload-drop.js"></script>
132
135
  <script defer src="{{ assetPrefix }}media.js"></script>
136
+ <script defer src="{{ assetPrefix }}job-status.js"></script>
133
137
  {% endblock %}
@@ -4,6 +4,7 @@
4
4
  {% from "components/badge.njk" import status %}
5
5
  {% from "components/button.njk" import button %}
6
6
  {% from "components/card.njk" import card, cardActions %}
7
+ {% from "components/upload.njk" import uploadControl %}
7
8
 
8
9
  {% block settingsProblems %}
9
10
  {{ field.problem('settings-title', problems.title) }}
@@ -49,9 +50,11 @@
49
50
  {{ field.text('settings-locale', fields.locale, 'Locale', form.locale,
50
51
  error=problems.locale, hintHtml=localeHint) }}
51
52
 
52
- {% set iconHint %}A square image at least 512 pixels wide, uploaded on <a href="{{ adminUrl }}/media">Media</a>: a path such as <code>/uploads/2026/10/icon.png</code>. It is the favicon, the home screen icon, <code>/favicon.ico</code> and the web app manifest's icon. Anything not square is cropped to its middle.{% endset %}
53
+ {% set iconHint %}A square image at least 512 pixels wide in the media library: a path such as <code>/uploads/2026/10/icon.png</code>. It is the favicon, the home screen icon, <code>/favicon.ico</code> and the web app manifest's icon. Anything not square is cropped to its middle.{% endset %}
53
54
  {{ field.text('settings-icon', fields.icon, 'Site icon', form.icon,
54
55
  error=problems.icon, hintHtml=iconHint) }}
56
+ {{ uploadControl('field', 'settings-icon', uploadsUrl, images=true) }}
57
+ <script defer src="{{ assetPrefix }}upload-drop.js"></script>
55
58
 
56
59
  <div class="flex items-center gap-4">
57
60
  {% if iconPreview %}
@@ -29,61 +29,64 @@
29
29
  site, wait until none are, and look again here.
30
30
  </p>
31
31
 
32
- {% if job %}
33
- <section id="export-status" aria-labelledby="export-status-heading">
34
- {% call card() %}
35
- <h2 id="export-status-heading" class="card-title">Latest export</h2>
36
- {% call jobStatus(job, exportUrl, verb='Exported', unit='files') %}
37
- {% if result %}
38
- <p>
39
- {{ result.files }} {% if result.files == 1 %}file{% else %}files{% endif %},
40
- {{ result.size }} before compression, {% if result.credentials %}with{% else %}without{% endif %} credentials.
41
- </p>
42
- {% if result.credentialsLeftOut.length %}
43
- <p>Credentials left out: {{ result.credentialsLeftOut | join(', ') }}.</p>
44
- {% endif %}
45
- {% if result.undeclared.length %}
46
- <p>Left out because no declaration says what they are:</p>
47
- <ul class="list-disc pl-6">
48
- {% for file in result.undeclared %}<li><code class="break-all">{{ file }}</code></li>{% endfor %}
49
- </ul>
50
- {% endif %}
51
- {% if result.notCarried.length %}
52
- <p>Left out because an export holds only regular files:</p>
53
- <ul class="list-disc pl-6">
54
- {% for entry in result.notCarried %}<li><code class="break-all">{{ entry.path }}</code>, {{ entry.why }}</li>{% endfor %}
55
- </ul>
56
- {% endif %}
57
- {% if archive %}
32
+ <div class="flex flex-col gap-6" data-job-region>
33
+ {% if job %}
34
+ <section id="export-status" aria-labelledby="export-status-heading">
35
+ {% call card() %}
36
+ <h2 id="export-status-heading" class="card-title">Latest export</h2>
37
+ {% call jobStatus(job, exportUrl, verb='Exported', unit='files') %}
38
+ {% if result %}
58
39
  <p>
59
- <code class="break-all">{{ archive.name }}</code> is deleted at
60
- <time datetime="{{ archive.expiresAt }}">{{ archive.expiresAt | date('datetime') }}</time>,
61
- or when the next export finishes.
40
+ {{ result.files }} {% if result.files == 1 %}file{% else %}files{% endif %},
41
+ {{ result.size }} before compression, {% if result.credentials %}with{% else %}without{% endif %} credentials.
62
42
  </p>
63
- <div>{{ buttonLink('Download ' + archive.size, downloadUrl, color='primary') }}</div>
64
- {% else %}
65
- <p>The archive was deleted 24 hours after it was written, or a later export replaced it. Export again to download one.</p>
43
+ {% if result.credentialsLeftOut.length %}
44
+ <p>Credentials left out: {{ result.credentialsLeftOut | join(', ') }}.</p>
45
+ {% endif %}
46
+ {% if result.undeclared.length %}
47
+ <p>Left out because no declaration says what they are:</p>
48
+ <ul class="list-disc pl-6">
49
+ {% for file in result.undeclared %}<li><code class="break-all">{{ file }}</code></li>{% endfor %}
50
+ </ul>
51
+ {% endif %}
52
+ {% if result.notCarried.length %}
53
+ <p>Left out because an export holds only regular files:</p>
54
+ <ul class="list-disc pl-6">
55
+ {% for entry in result.notCarried %}<li><code class="break-all">{{ entry.path }}</code>, {{ entry.why }}</li>{% endfor %}
56
+ </ul>
57
+ {% endif %}
58
+ {% if archive %}
59
+ <p>
60
+ <code class="break-all">{{ archive.name }}</code> is deleted at
61
+ <time datetime="{{ archive.expiresAt }}">{{ archive.expiresAt | date('datetime') }}</time>,
62
+ or when the next export finishes.
63
+ </p>
64
+ <div>{{ buttonLink('Download ' + archive.size, downloadUrl, color='primary') }}</div>
65
+ {% else %}
66
+ <p>The archive was deleted 24 hours after it was written, or a later export replaced it. Export again to download one.</p>
67
+ {% endif %}
66
68
  {% endif %}
67
- {% endif %}
69
+ {% endcall %}
68
70
  {% endcall %}
69
- {% endcall %}
70
- </section>
71
- {% endif %}
71
+ </section>
72
+ {% endif %}
72
73
 
73
- {% if not job or not job.active %}
74
- {% call card(title='Export the site') %}
75
- <form method="post" action="{{ exportUrl }}">
76
- <input type="hidden" name="csrf_token" value="{{ csrfToken }}" />
77
- {{ field.checkbox('export-with-credentials', fields.withCredentials,
78
- 'Include credentials', checked=withCredentials,
79
- hint='API keys and logins for other services, such as an email provider. Left out, they are typed in again after an import.') }}
80
- {{ field.password('export-password', fields.password, 'Your password',
81
- error=problem, autocomplete='current-password', required=true) }}
82
- {% call cardActions() %}
83
- {{ button('Export', type='submit', color='primary') }}
84
- {% endcall %}
85
- </form>
86
- {% endcall %}
87
- {% endif %}
74
+ {% if not job or not job.active %}
75
+ {% call card(title='Export the site') %}
76
+ <form method="post" action="{{ exportUrl }}">
77
+ <input type="hidden" name="csrf_token" value="{{ csrfToken }}" />
78
+ {{ field.checkbox('export-with-credentials', fields.withCredentials,
79
+ 'Include credentials', checked=withCredentials,
80
+ hint='API keys and logins for other services, such as an email provider. Left out, they are typed in again after an import.') }}
81
+ {{ field.password('export-password', fields.password, 'Your password',
82
+ error=problem, autocomplete='current-password', required=true) }}
83
+ {% call cardActions() %}
84
+ {{ button('Export', type='submit', color='primary') }}
85
+ {% endcall %}
86
+ </form>
87
+ {% endcall %}
88
+ {% endif %}
89
+ </div>
88
90
  </div>
91
+ <script defer src="{{ assetPrefix }}job-status.js"></script>
89
92
  {% endblock %}
@@ -49,7 +49,7 @@
49
49
  same switch, and a running site notices either within a second.
50
50
  </p>
51
51
 
52
- <section id="strip-metadata" aria-labelledby="strip-metadata-heading">
52
+ <section id="strip-metadata" aria-labelledby="strip-metadata-heading" data-job-region>
53
53
  {% call card() %}
54
54
  <h2 id="strip-metadata-heading" class="card-title">Location and camera metadata</h2>
55
55
  <p class="max-w-prose">
@@ -96,4 +96,5 @@
96
96
  {% endcall %}
97
97
  </section>
98
98
  </div>
99
+ <script defer src="{{ assetPrefix }}job-status.js"></script>
99
100
  {% endblock %}
@@ -15,6 +15,7 @@
15
15
  {% from "components/badge.njk" import badge, status %}
16
16
  {% from "components/button.njk" import button %}
17
17
  {% from "components/card.njk" import card, cardActions %}
18
+ {% from "components/upload.njk" import uploadControl %}
18
19
 
19
20
  {% block title %}Edit {{ account.username }}{% endblock %}
20
21
 
@@ -97,6 +98,7 @@
97
98
  {% set avatarHint %}A path such as <code>/uploads/2026/09/me.jpg</code>, or a full URL.{% endset %}
98
99
  {{ field.text('profile-avatar', fields.avatar, 'Avatar', account.profile.avatar,
99
100
  hintHtml=avatarHint) }}
101
+ {{ uploadControl('field', 'profile-avatar', uploadsUrl, images=true) }}
100
102
 
101
103
  {{ field.text('profile-job-title', fields.jobTitle, 'Job title', account.profile.jobTitle,
102
104
  hint='What they do, printed beside the name on the archive.') }}
@@ -280,4 +282,5 @@
280
282
  {% endif %}
281
283
  {% endcall %}
282
284
  </div>
285
+ <script defer src="{{ assetPrefix }}upload-drop.js"></script>
283
286
  {% endblock %}
@@ -4,6 +4,7 @@
4
4
  @source '../pages';
5
5
  @source '../../editor/look.ts';
6
6
  @source '../static/media.js';
7
+ @source '../static/upload-drop.js';
7
8
 
8
9
  @plugin 'daisyui' {
9
10
  themes:
@@ -5277,6 +5277,44 @@
5277
5277
  border-top: 3px solid;
5278
5278
  }
5279
5279
 
5280
+ .swap {
5281
+ cursor: pointer;
5282
+ vertical-align: middle;
5283
+ webkit-user-select: none;
5284
+ -webkit-user-select: none;
5285
+ user-select: none;
5286
+ place-content: center;
5287
+ display: inline-grid;
5288
+ position: relative;
5289
+ }
5290
+
5291
+ .swap input {
5292
+ appearance: none;
5293
+ border: none;
5294
+ }
5295
+
5296
+ .swap > * {
5297
+ grid-row-start: 1;
5298
+ grid-column-start: 1;
5299
+ }
5300
+
5301
+ @media (prefers-reduced-motion: no-preference) {
5302
+ .swap > * {
5303
+ transition-property: transform, rotate, opacity;
5304
+ transition-duration: .2s;
5305
+ transition-timing-function: cubic-bezier(0, 0, .2, 1);
5306
+ }
5307
+ }
5308
+
5309
+ .swap .swap-on, .swap .swap-indeterminate, .swap input:indeterminate ~ .swap-on, .swap input:is(:checked, :indeterminate) ~ .swap-off {
5310
+ opacity: 0;
5311
+ }
5312
+
5313
+ .swap input:checked ~ .swap-on, .swap input:indeterminate ~ .swap-indeterminate {
5314
+ opacity: 1;
5315
+ backface-visibility: visible;
5316
+ }
5317
+
5280
5318
  .select:has( > select[disabled]) {
5281
5319
  cursor: not-allowed;
5282
5320
  border-color: var(--color-base-200);
@@ -6295,6 +6333,16 @@
6295
6333
  font-size: .75rem;
6296
6334
  }
6297
6335
 
6336
+ .file-input-xs {
6337
+ --size: calc(var(--size-field, .25rem) * 6);
6338
+ font-size: .6875rem;
6339
+ line-height: 1rem;
6340
+ }
6341
+
6342
+ .file-input-xs::file-selector-button {
6343
+ font-size: .6875rem;
6344
+ }
6345
+
6298
6346
  .tabs-lg {
6299
6347
  --tab-height: calc(var(--size-field, .25rem) * 12);
6300
6348
  }
@@ -7281,6 +7329,10 @@
7281
7329
  width: calc(var(--spacing) * 80);
7282
7330
  }
7283
7331
 
7332
+ .w-auto {
7333
+ width: auto;
7334
+ }
7335
+
7284
7336
  .w-full {
7285
7337
  width: 100%;
7286
7338
  }
@@ -7739,6 +7791,19 @@
7739
7791
  outline-width: 1px;
7740
7792
  }
7741
7793
 
7794
+ .outline-2 {
7795
+ outline-style: var(--tw-outline-style);
7796
+ outline-width: 2px;
7797
+ }
7798
+
7799
+ .outline-offset-4 {
7800
+ outline-offset: 4px;
7801
+ }
7802
+
7803
+ .outline-primary {
7804
+ outline-color: var(--color-primary);
7805
+ }
7806
+
7742
7807
  .transition {
7743
7808
  transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
7744
7809
  transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
@@ -7762,6 +7827,11 @@
7762
7827
  contain: var(--tw-contain-size, ) var(--tw-contain-layout, ) var(--tw-contain-paint, ) var(--tw-contain-style, );
7763
7828
  }
7764
7829
 
7830
+ .outline-dashed {
7831
+ --tw-outline-style: dashed;
7832
+ outline-style: dashed;
7833
+ }
7834
+
7765
7835
  :is(.\*\:align-top > *) {
7766
7836
  vertical-align: top;
7767
7837
  }
@@ -7827,6 +7897,10 @@
7827
7897
  background-color: var(--color-base-200);
7828
7898
  }
7829
7899
 
7900
+ .empty\:hidden:empty {
7901
+ display: none;
7902
+ }
7903
+
7830
7904
  @media (hover: hover) {
7831
7905
  .hover\:bg-base-200:hover {
7832
7906
  background-color: var(--color-base-200);