@geekity/cms 0.34.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 (255) hide show
  1. package/admin/components/job-status.njk +15 -10
  2. package/admin/components/upload.njk +26 -0
  3. package/admin/pages/documents/chooser.njk +30 -0
  4. package/admin/pages/documents/editor.njk +410 -165
  5. package/admin/pages/federation/followers.njk +2 -1
  6. package/admin/pages/media/file.njk +143 -0
  7. package/admin/pages/media/library.njk +107 -198
  8. package/admin/pages/settings/general.njk +52 -2
  9. package/admin/pages/settings/privacy.njk +1 -1
  10. package/admin/pages/tools/export.njk +54 -51
  11. package/admin/pages/tools/maintenance.njk +49 -0
  12. package/admin/pages/users/edit.njk +3 -0
  13. package/admin/src/admin.css +2 -0
  14. package/admin/static/admin.css +843 -106
  15. package/admin/static/copy.js +30 -23
  16. package/admin/static/item-lookup.js +179 -0
  17. package/admin/static/job-status.js +158 -0
  18. package/admin/static/media.js +82 -0
  19. package/admin/static/upload-drop.js +225 -0
  20. package/dist/admin/documents.d.ts.map +1 -1
  21. package/dist/admin/documents.js +141 -24
  22. package/dist/admin/documents.js.map +1 -1
  23. package/dist/admin/editor-actions.d.ts.map +1 -1
  24. package/dist/admin/editor-actions.js +2 -3
  25. package/dist/admin/editor-actions.js.map +1 -1
  26. package/dist/admin/editor-fields.d.ts +20 -8
  27. package/dist/admin/editor-fields.d.ts.map +1 -1
  28. package/dist/admin/editor-fields.js +35 -12
  29. package/dist/admin/editor-fields.js.map +1 -1
  30. package/dist/admin/editor-kinds.d.ts +30 -0
  31. package/dist/admin/editor-kinds.d.ts.map +1 -0
  32. package/dist/admin/editor-kinds.js +136 -0
  33. package/dist/admin/editor-kinds.js.map +1 -0
  34. package/dist/admin/editor-layout.d.ts +10 -1
  35. package/dist/admin/editor-layout.d.ts.map +1 -1
  36. package/dist/admin/editor-layout.js +38 -31
  37. package/dist/admin/editor-layout.js.map +1 -1
  38. package/dist/admin/federation.d.ts +1 -1
  39. package/dist/admin/federation.d.ts.map +1 -1
  40. package/dist/admin/federation.js +2 -1
  41. package/dist/admin/federation.js.map +1 -1
  42. package/dist/admin/formatting.d.ts +2 -0
  43. package/dist/admin/formatting.d.ts.map +1 -1
  44. package/dist/admin/formatting.js +8 -0
  45. package/dist/admin/formatting.js.map +1 -1
  46. package/dist/admin/index.d.ts +1 -1
  47. package/dist/admin/index.d.ts.map +1 -1
  48. package/dist/admin/index.js +1 -1
  49. package/dist/admin/index.js.map +1 -1
  50. package/dist/admin/item-lookup.d.ts +16 -0
  51. package/dist/admin/item-lookup.d.ts.map +1 -0
  52. package/dist/admin/item-lookup.js +192 -0
  53. package/dist/admin/item-lookup.js.map +1 -0
  54. package/dist/admin/job-status.d.ts +1 -0
  55. package/dist/admin/job-status.d.ts.map +1 -1
  56. package/dist/admin/job-status.js +1 -0
  57. package/dist/admin/job-status.js.map +1 -1
  58. package/dist/admin/maintenance.d.ts +2 -0
  59. package/dist/admin/maintenance.d.ts.map +1 -1
  60. package/dist/admin/maintenance.js +14 -0
  61. package/dist/admin/maintenance.js.map +1 -1
  62. package/dist/admin/media.d.ts +47 -36
  63. package/dist/admin/media.d.ts.map +1 -1
  64. package/dist/admin/media.js +293 -68
  65. package/dist/admin/media.js.map +1 -1
  66. package/dist/admin/preview.d.ts.map +1 -1
  67. package/dist/admin/preview.js +9 -12
  68. package/dist/admin/preview.js.map +1 -1
  69. package/dist/admin/routes.d.ts.map +1 -1
  70. package/dist/admin/routes.js +4 -0
  71. package/dist/admin/routes.js.map +1 -1
  72. package/dist/admin/settings-general.d.ts +6 -0
  73. package/dist/admin/settings-general.d.ts.map +1 -1
  74. package/dist/admin/settings-general.js +46 -1
  75. package/dist/admin/settings-general.js.map +1 -1
  76. package/dist/admin/templates.d.ts +2 -0
  77. package/dist/admin/templates.d.ts.map +1 -1
  78. package/dist/admin/templates.js +4 -0
  79. package/dist/admin/templates.js.map +1 -1
  80. package/dist/admin/uploads.d.ts +2 -1
  81. package/dist/admin/uploads.d.ts.map +1 -1
  82. package/dist/admin/uploads.js +5 -2
  83. package/dist/admin/uploads.js.map +1 -1
  84. package/dist/cli.d.ts.map +1 -1
  85. package/dist/cli.js +2 -1
  86. package/dist/cli.js.map +1 -1
  87. package/dist/cms/app.d.ts.map +1 -1
  88. package/dist/cms/app.js +1 -0
  89. package/dist/cms/app.js.map +1 -1
  90. package/dist/cms/services.d.ts +2 -0
  91. package/dist/cms/services.d.ts.map +1 -1
  92. package/dist/cms/services.js +13 -0
  93. package/dist/cms/services.js.map +1 -1
  94. package/dist/content/document-form.d.ts +5 -13
  95. package/dist/content/document-form.d.ts.map +1 -1
  96. package/dist/content/document-form.js +2 -3
  97. package/dist/content/document-form.js.map +1 -1
  98. package/dist/content/excerpt.d.ts +1 -3
  99. package/dist/content/excerpt.d.ts.map +1 -1
  100. package/dist/content/excerpt.js +2 -7
  101. package/dist/content/excerpt.js.map +1 -1
  102. package/dist/content/post-fields.d.ts +1 -1
  103. package/dist/content/post-fields.d.ts.map +1 -1
  104. package/dist/content/post-fields.js +30 -35
  105. package/dist/content/post-fields.js.map +1 -1
  106. package/dist/content/post-type.d.ts +2 -3
  107. package/dist/content/post-type.d.ts.map +1 -1
  108. package/dist/content/post-type.js +8 -9
  109. package/dist/content/post-type.js.map +1 -1
  110. package/dist/content/review-form.d.ts +49 -0
  111. package/dist/content/review-form.d.ts.map +1 -0
  112. package/dist/content/review-form.js +282 -0
  113. package/dist/content/review-form.js.map +1 -0
  114. package/dist/content/review.d.ts +57 -0
  115. package/dist/content/review.d.ts.map +1 -0
  116. package/dist/content/review.js +264 -0
  117. package/dist/content/review.js.map +1 -0
  118. package/dist/content/write.d.ts.map +1 -1
  119. package/dist/content/write.js +6 -9
  120. package/dist/content/write.js.map +1 -1
  121. package/dist/exports.d.ts +1 -1
  122. package/dist/exports.d.ts.map +1 -1
  123. package/dist/federation/article.d.ts.map +1 -1
  124. package/dist/federation/article.js +27 -29
  125. package/dist/federation/article.js.map +1 -1
  126. package/dist/federation/federation.d.ts +3 -0
  127. package/dist/federation/federation.d.ts.map +1 -1
  128. package/dist/federation/federation.js +5 -1
  129. package/dist/federation/federation.js.map +1 -1
  130. package/dist/federation/refusals.d.ts +18 -0
  131. package/dist/federation/refusals.d.ts.map +1 -0
  132. package/dist/federation/refusals.js +84 -0
  133. package/dist/federation/refusals.js.map +1 -0
  134. package/dist/images/variants.d.ts +1 -0
  135. package/dist/images/variants.d.ts.map +1 -1
  136. package/dist/images/variants.js +10 -0
  137. package/dist/images/variants.js.map +1 -1
  138. package/dist/item-lookup/item-id.d.ts +33 -0
  139. package/dist/item-lookup/item-id.d.ts.map +1 -0
  140. package/dist/item-lookup/item-id.js +71 -0
  141. package/dist/item-lookup/item-id.js.map +1 -0
  142. package/dist/item-lookup/lookup.d.ts +58 -0
  143. package/dist/item-lookup/lookup.d.ts.map +1 -0
  144. package/dist/item-lookup/lookup.js +532 -0
  145. package/dist/item-lookup/lookup.js.map +1 -0
  146. package/dist/media/library.d.ts +9 -0
  147. package/dist/media/library.d.ts.map +1 -1
  148. package/dist/media/library.js +25 -1
  149. package/dist/media/library.js.map +1 -1
  150. package/dist/micropub/create.d.ts +1 -1
  151. package/dist/micropub/create.d.ts.map +1 -1
  152. package/dist/micropub/create.js +4 -2
  153. package/dist/micropub/create.js.map +1 -1
  154. package/dist/micropub/endpoint.d.ts.map +1 -1
  155. package/dist/micropub/endpoint.js +3 -10
  156. package/dist/micropub/endpoint.js.map +1 -1
  157. package/dist/micropub/update.d.ts.map +1 -1
  158. package/dist/micropub/update.js +12 -6
  159. package/dist/micropub/update.js.map +1 -1
  160. package/dist/plugin.d.ts +26 -2
  161. package/dist/plugin.d.ts.map +1 -1
  162. package/dist/plugin.js.map +1 -1
  163. package/dist/settings/names.d.ts +1 -1
  164. package/dist/settings/names.d.ts.map +1 -1
  165. package/dist/settings/names.js +8 -1
  166. package/dist/settings/names.js.map +1 -1
  167. package/dist/settings/registry.d.ts.map +1 -1
  168. package/dist/settings/registry.js +2 -1
  169. package/dist/settings/registry.js.map +1 -1
  170. package/dist/settings/tmdb.d.ts +5 -0
  171. package/dist/settings/tmdb.d.ts.map +1 -0
  172. package/dist/settings/tmdb.js +22 -0
  173. package/dist/settings/tmdb.js.map +1 -0
  174. package/dist/store/profiles.d.ts +3 -2
  175. package/dist/store/profiles.d.ts.map +1 -1
  176. package/dist/store/profiles.js +2 -4
  177. package/dist/store/profiles.js.map +1 -1
  178. package/dist/web/context.d.ts +9 -21
  179. package/dist/web/context.d.ts.map +1 -1
  180. package/dist/web/context.js +20 -20
  181. package/dist/web/context.js.map +1 -1
  182. package/dist/web/feed-item.d.ts +6 -6
  183. package/dist/web/feed-item.d.ts.map +1 -1
  184. package/dist/web/feed-item.js +7 -3
  185. package/dist/web/feed-item.js.map +1 -1
  186. package/dist/web/plugin-federation.d.ts +2 -0
  187. package/dist/web/plugin-federation.d.ts.map +1 -1
  188. package/dist/web/plugin-federation.js +3 -0
  189. package/dist/web/plugin-federation.js.map +1 -1
  190. package/dist/webmention/cited-picture.d.ts +1 -0
  191. package/dist/webmention/cited-picture.d.ts.map +1 -1
  192. package/dist/webmention/cited-picture.js +7 -4
  193. package/dist/webmention/cited-picture.js.map +1 -1
  194. package/dist/webmention/fetch-public.d.ts +3 -0
  195. package/dist/webmention/fetch-public.d.ts.map +1 -1
  196. package/dist/webmention/fetch-public.js +1 -1
  197. package/dist/webmention/fetch-public.js.map +1 -1
  198. package/dist/webmention/reply-context/json-ld.d.ts +8 -0
  199. package/dist/webmention/reply-context/json-ld.d.ts.map +1 -1
  200. package/dist/webmention/reply-context/json-ld.js +21 -0
  201. package/dist/webmention/reply-context/json-ld.js.map +1 -1
  202. package/dist/webmention/service.d.ts.map +1 -1
  203. package/dist/webmention/service.js +3 -5
  204. package/dist/webmention/service.js.map +1 -1
  205. package/package.json +6 -1
  206. package/themes/default/layouts/post.njk +6 -5
  207. package/themes/default/partials/post-list.njk +15 -20
  208. package/themes/default/partials/review.njk +1 -0
  209. package/themes/default/src/style.css +24 -4
  210. package/themes/default/static/style.css +33 -5
  211. package/dist/comments/routes.d.ts +0 -65
  212. package/dist/comments/routes.d.ts.map +0 -1
  213. package/dist/comments/routes.js +0 -235
  214. package/dist/comments/routes.js.map +0 -1
  215. package/dist/content/media.d.ts +0 -85
  216. package/dist/content/media.d.ts.map +0 -1
  217. package/dist/content/media.js +0 -237
  218. package/dist/content/media.js.map +0 -1
  219. package/dist/content/read-form.d.ts +0 -31
  220. package/dist/content/read-form.d.ts.map +0 -1
  221. package/dist/content/read-form.js +0 -157
  222. package/dist/content/read-form.js.map +0 -1
  223. package/dist/content/read.d.ts +0 -29
  224. package/dist/content/read.d.ts.map +0 -1
  225. package/dist/content/read.js +0 -82
  226. package/dist/content/read.js.map +0 -1
  227. package/dist/export/export-archives.d.ts +0 -29
  228. package/dist/export/export-archives.d.ts.map +0 -1
  229. package/dist/export/export-archives.js +0 -88
  230. package/dist/export/export-archives.js.map +0 -1
  231. package/dist/federation/mount.d.ts +0 -53
  232. package/dist/federation/mount.d.ts.map +0 -1
  233. package/dist/federation/mount.js +0 -344
  234. package/dist/federation/mount.js.map +0 -1
  235. package/dist/mail/credentials.d.ts +0 -62
  236. package/dist/mail/credentials.d.ts.map +0 -1
  237. package/dist/mail/credentials.js +0 -86
  238. package/dist/mail/credentials.js.map +0 -1
  239. package/dist/site/setting-rows.d.ts +0 -45
  240. package/dist/site/setting-rows.d.ts.map +0 -1
  241. package/dist/site/setting-rows.js +0 -361
  242. package/dist/site/setting-rows.js.map +0 -1
  243. package/dist/site/settings-form.d.ts +0 -30
  244. package/dist/site/settings-form.d.ts.map +0 -1
  245. package/dist/site/settings-form.js +0 -54
  246. package/dist/site/settings-form.js.map +0 -1
  247. package/dist/site/site-json.d.ts +0 -38
  248. package/dist/site/site-json.d.ts.map +0 -1
  249. package/dist/site/site-json.js +0 -69
  250. package/dist/site/site-json.js.map +0 -1
  251. package/dist/webmention/routes.d.ts +0 -37
  252. package/dist/webmention/routes.d.ts.map +0 -1
  253. package/dist/webmention/routes.js +0 -75
  254. package/dist/webmention/routes.js.map +0 -1
  255. package/themes/default/partials/read.njk +0 -5
@@ -1,32 +1,32 @@
1
1
  /**
2
- * The media screen: what is under `content/uploads/`, what links to it, and
3
- * the things that can be done to it — add one, describe one, take one away,
4
- * and queue the sweep that strips metadata from the ones uploaded before
5
- * uploads were stripped on arrival.
2
+ * The media screen: what is under `content/uploads/` as a grid of tiles, a
3
+ * details screen per file with what links to it, and the things that can be
4
+ * done to one — add one, describe one, take one away.
6
5
  */
7
6
  import type { Hono } from 'hono';
8
- import type { CatalogReader } from '../content/store.ts';
9
7
  import type { GeekityEnv } from '../web/env.ts';
8
+ import type { UploadKind } from '../files/upload-types.ts';
10
9
  import type { RemoveDerived } from '../media/library.ts';
11
10
  import type { AdminRender } from './documents.ts';
12
11
  /** Where the media screen lives. */
13
12
  export declare const MEDIA_PATH = "/admin/media";
14
13
  /** The navigation section the screen marks as current. */
15
14
  export declare const MEDIA_SECTION = "media";
15
+ /** Where one file's details screen lives, with the file as `?path=`. */
16
+ export declare const MEDIA_FILE_PATH = "/admin/media/file";
16
17
  /** Where the screen's upload form posts. */
17
18
  export declare const MEDIA_UPLOAD_PATH = "/admin/media/upload";
18
- /** Where a row's Delete button posts. */
19
+ /** Where a file's Delete button posts. */
19
20
  export declare const MEDIA_DELETE_PATH = "/admin/media/delete";
20
- /** Where a row's alt-text form posts. */
21
+ /** Where a file's alt-text form posts. */
21
22
  export declare const MEDIA_ALT_PATH = "/admin/media/alt";
22
- export declare const MEDIA_STRIP_METADATA_PATH = "/admin/media/strip-metadata";
23
23
  /** How many files one page of the library shows. */
24
24
  export declare const MEDIA_PER_PAGE = 24;
25
- /** The fields the forms on the media screen submit. */
25
+ /** The fields the forms on the media screens submit. */
26
26
  export declare const MEDIA_FIELDS: {
27
27
  /** The file being added, on the upload form. */
28
28
  readonly file: 'file';
29
- /** The upload being deleted, as a path under `content/uploads/`. */
29
+ /** The upload being deleted or described, as a path under `content/uploads/`. */
30
30
  readonly path: 'path';
31
31
  /** Set once the admin has been shown what still references the file. */
32
32
  readonly confirm: 'confirm';
@@ -34,35 +34,46 @@ export declare const MEDIA_FIELDS: {
34
34
  readonly alt: 'alt';
35
35
  /** Present when an image is decorative, which wins over any alt text. */
36
36
  readonly decorative: 'decorative';
37
- /** The library page an alt-text form was on, so saving it goes back there. */
38
- readonly page: 'page';
37
+ /** The library view a form was posted from, as a query string, so the answer goes back to it. */
38
+ readonly from: 'from';
39
39
  };
40
- /** One document that mentions an upload, as the confirmation names it. */
40
+ /** The orders the library can be sorted in. Newest is the default and is never written into a URL. */
41
+ declare const SORTS: {
42
+ readonly newest: 'Newest first';
43
+ readonly oldest: 'Oldest first';
44
+ readonly largest: 'Largest first';
45
+ readonly name: 'Name';
46
+ };
47
+ type MediaSort = keyof typeof SORTS;
48
+ /** One view of the library: what the tabs, the filter form and the page links say. */
49
+ export interface MediaView {
50
+ kind: UploadKind | undefined;
51
+ /** Matched against the path and the alt text, ignoring case. Empty matches everything. */
52
+ search: string;
53
+ needsAlt: boolean;
54
+ unused: boolean;
55
+ sort: MediaSort;
56
+ page: number;
57
+ }
58
+ /** A view from a query string. Anything it does not recognise is left at its default. */
59
+ export declare function parseMediaView(query: URLSearchParams): MediaView;
60
+ /** A view as a query string, without the `?`, leaving out everything at its default. */
61
+ export declare function mediaViewQuery(view: MediaView): string;
62
+ /** The library showing a view. */
63
+ export declare function mediaLibraryUrl(view: MediaView): string;
64
+ /** One file's details screen, reached from a view of the library. */
65
+ export declare function mediaFileUrl(path: string, view: MediaView): string;
66
+ /** One thing that uses an upload — a document, a setting, a profile — as the screens name it. */
41
67
  export interface MediaReference {
42
- /** Path relative to the content directory: what identifies the document. */
43
- path: string;
44
- /** Its title, or its path when it has none worth showing. */
68
+ /** A document's title, or what the site uses the file as: "The site icon". */
45
69
  title: string;
46
- /** Where to edit it. */
70
+ /** Where to change it: the document's editor, the settings page, the user's screen. */
47
71
  editUrl: string;
48
- /** Whether it is in the trash rather than the live tree. */
72
+ /** Where it is: a document's path relative to the content directory, or the screen that sets it. */
73
+ where: string;
74
+ /** Whether it is a document in the trash rather than the live tree. */
49
75
  trashed: boolean;
50
76
  }
51
- /**
52
- * Every document whose body mentions an upload's URL, trash included.
53
- *
54
- * The trash counts. A trashed post can be restored tomorrow, and restoring one
55
- * whose picture was deleted in the meantime is a broken post nobody was warned
56
- * about — so a file the trash still points at asks for confirmation exactly as
57
- * a published one does.
58
- *
59
- * This is the one thing on the screen that comes out of the index rather than
60
- * off the disk. The index is a cache of the files (decision-1), so it may be a
61
- * moment behind a hand edit; that is acceptable here because the answer is a
62
- * warning rather than a decision, and the confirmation step is what makes an
63
- * out-of-date answer harmless in both directions.
64
- */
65
- export declare function referencesTo(store: CatalogReader, url: string): MediaReference[];
66
77
  /** What {@link mountMediaScreen} needs from the admin around it. */
67
78
  export interface MountMediaScreenOptions {
68
79
  /** The admin's renderer, which injects the chrome, the CSRF token and the flash. */
@@ -71,13 +82,13 @@ export interface MountMediaScreenOptions {
71
82
  removeDerived?: RemoveDerived | undefined;
72
83
  }
73
84
  /**
74
- * Register the media screen: the library, the upload form and the delete.
85
+ * Register the media screens: the library, one file's details, the upload,
86
+ * the alt text and the delete.
75
87
  *
76
88
  * Uploading here is {@link storeUpload}, exactly as the editor's control and
77
89
  * the avatar are, so what a site accepts is one answer given in one place
78
90
  * rather than three screens that might disagree.
79
91
  */
80
92
  export declare function mountMediaScreen(app: Hono<GeekityEnv>, options: MountMediaScreenOptions): void;
81
- /** The URL of one page of the library. Page one is the screen's own URL. */
82
- export declare function mediaPageUrl(page: number): string;
93
+ export {};
83
94
  //# sourceMappingURL=media.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"media.d.ts","sourceRoot":"","sources":["../../src/admin/media.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAW,IAAI,EAAE,MAAM,MAAM,CAAC;AAI1C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAOhD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAMzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAMlD,oCAAoC;AACpC,eAAO,MAAM,UAAU,iBAA0B,CAAC;AAElD,0DAA0D;AAC1D,eAAO,MAAM,aAAa,UAAU,CAAC;AAErC,4CAA4C;AAC5C,eAAO,MAAM,iBAAiB,wBAAyB,CAAC;AAExD,yCAAyC;AACzC,eAAO,MAAM,iBAAiB,wBAAyB,CAAC;AAExD,yCAAyC;AACzC,eAAO,MAAM,cAAc,qBAAsB,CAAC;AAElD,eAAO,MAAM,yBAAyB,gCAAiC,CAAC;AAExE,oDAAoD;AACpD,eAAO,MAAM,cAAc,KAAK,CAAC;AAEjC,uDAAuD;AACvD,eAAO,MAAM,YAAY;IACvB,gDAAgD;aAChD,IAAI,EAAE,MAAM;IACZ,oEAAoE;aACpE,IAAI,EAAE,MAAM;IACZ,wEAAwE;aACxE,OAAO,EAAE,SAAS;IAClB,2BAA2B;aAC3B,GAAG,EAAE,KAAK;IACV,yEAAyE;aACzE,UAAU,EAAE,YAAY;IACxB,8EAA8E;aAC9E,IAAI,EAAE,MAAM;CACJ,CAAC;AAEX,0EAA0E;AAC1E,MAAM,WAAW,cAAc;IAC7B,4EAA4E;IAC5E,IAAI,EAAE,MAAM,CAAC;IACb,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAC;IACd,wBAAwB;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,aAAa,EAAE,GAAG,EAAE,MAAM,GAAG,cAAc,EAAE,CAYhF;AAED,oEAAoE;AACpE,MAAM,WAAW,uBAAuB;IACtC,oFAAoF;IACpF,MAAM,EAAE,WAAW,CAAC;IACpB,2FAA2F;IAC3F,aAAa,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;CAC3C;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,IAAI,CAAC,UAAU,CAAC,EAAE,OAAO,EAAE,uBAAuB,GAAG,IAAI,CAkI9F;AASD,4EAA4E;AAC5E,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEjD"}
1
+ {"version":3,"file":"media.d.ts","sourceRoot":"","sources":["../../src/admin/media.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAW,IAAI,EAAE,MAAM,MAAM,CAAC;AAM1C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAIhD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAU3D,OAAO,KAAK,EAAa,aAAa,EAAE,MAAM,qBAAqB,CAAC;AASpE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAWlD,oCAAoC;AACpC,eAAO,MAAM,UAAU,iBAA0B,CAAC;AAElD,0DAA0D;AAC1D,eAAO,MAAM,aAAa,UAAU,CAAC;AAErC,wEAAwE;AACxE,eAAO,MAAM,eAAe,sBAAuB,CAAC;AAEpD,4CAA4C;AAC5C,eAAO,MAAM,iBAAiB,wBAAyB,CAAC;AAExD,0CAA0C;AAC1C,eAAO,MAAM,iBAAiB,wBAAyB,CAAC;AAExD,0CAA0C;AAC1C,eAAO,MAAM,cAAc,qBAAsB,CAAC;AAElD,oDAAoD;AACpD,eAAO,MAAM,cAAc,KAAK,CAAC;AAEjC,wDAAwD;AACxD,eAAO,MAAM,YAAY;IACvB,gDAAgD;aAChD,IAAI,EAAE,MAAM;IACZ,iFAAiF;aACjF,IAAI,EAAE,MAAM;IACZ,wEAAwE;aACxE,OAAO,EAAE,SAAS;IAClB,2BAA2B;aAC3B,GAAG,EAAE,KAAK;IACV,yEAAyE;aACzE,UAAU,EAAE,YAAY;IACxB,iGAAiG;aACjG,IAAI,EAAE,MAAM;CACJ,CAAC;AAWX,sGAAsG;AACtG,QAAA,MAAM,KAAK;aACT,MAAM,EAAE,cAAc;aACtB,MAAM,EAAE,cAAc;aACtB,OAAO,EAAE,eAAe;aACxB,IAAI,EAAE,MAAM;CACJ,CAAC;AAEX,KAAK,SAAS,GAAG,MAAM,OAAO,KAAK,CAAC;AAEpC,sFAAsF;AACtF,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,UAAU,GAAG,SAAS,CAAC;IAC7B,0FAA0F;IAC1F,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,SAAS,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,yFAAyF;AACzF,wBAAgB,cAAc,CAAC,KAAK,EAAE,eAAe,GAAG,SAAS,CAWhE;AAED,wFAAwF;AACxF,wBAAgB,cAAc,CAAC,IAAI,EAAE,SAAS,GAAG,MAAM,CAStD;AAED,kCAAkC;AAClC,wBAAgB,eAAe,CAAC,IAAI,EAAE,SAAS,GAAG,MAAM,CAGvD;AAED,qEAAqE;AACrE,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,MAAM,CAGlE;AAED,iGAAiG;AACjG,MAAM,WAAW,cAAc;IAC7B,8EAA8E;IAC9E,KAAK,EAAE,MAAM,CAAC;IACd,uFAAuF;IACvF,OAAO,EAAE,MAAM,CAAC;IAChB,oGAAoG;IACpG,KAAK,EAAE,MAAM,CAAC;IACd,uEAAuE;IACvE,OAAO,EAAE,OAAO,CAAC;CAClB;AAsJD,oEAAoE;AACpE,MAAM,WAAW,uBAAuB;IACtC,oFAAoF;IACpF,MAAM,EAAE,WAAW,CAAC;IACpB,2FAA2F;IAC3F,aAAa,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;CAC3C;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,IAAI,CAAC,UAAU,CAAC,EAAE,OAAO,EAAE,uBAAuB,GAAG,IAAI,CAkN9F"}
@@ -1,44 +1,49 @@
1
1
  /**
2
- * The media screen: what is under `content/uploads/`, what links to it, and
3
- * the things that can be done to it — add one, describe one, take one away,
4
- * and queue the sweep that strips metadata from the ones uploaded before
5
- * uploads were stripped on arrival.
2
+ * The media screen: what is under `content/uploads/` as a grid of tiles, a
3
+ * details screen per file with what links to it, and the things that can be
4
+ * done to one — add one, describe one, take one away.
6
5
  */
7
6
  import { postLabel } from '../content/post-type.js';
8
7
  import { isTrashedPath } from '../content/store.js';
9
8
  import { UPLOAD_ASSET_PREFIX } from '../content/uploads.js';
10
9
  import { writeAltText } from '../content/alt-text.js';
11
10
  import { STRIP_METADATA_JOB } from '../content/metadata/strip-job.js';
12
- import { finishedResult } from '../jobs/types.js';
13
- import { removeImageVariants } from '../images/variants.js';
14
- import { deleteUpload, listUploads, mentionsUpload, resolveUpload } from '../media/library.js';
11
+ import { describeImage, removeImageVariants, thumbnailUrl } from '../images/variants.js';
12
+ import { deleteUpload, listUploads, resolveUpload, usageText, usesUpload, } from '../media/library.js';
15
13
  import { refusedUpload, storeUpload } from '../media/uploads.js';
14
+ import { SITE } from '../site/settings.js';
15
+ import { listUsers } from '../site/users.js';
16
16
  import { isFile } from '../util/files.js';
17
17
  import { stringOrEmpty } from '../util/forms.js';
18
18
  import { pageCount } from '../web/pagination.js';
19
19
  import { documentEditorPath, PAGE_SCREENS, POST_SCREENS } from './documents.js';
20
20
  import { flash } from './flash.js';
21
- import { jobStatusView, queueJobFromForm } from './job-status.js';
22
- import { ADMIN_PREFIX } from '../site/admin-paths.js';
21
+ import { jobStatusView } from './job-status.js';
22
+ import { STRIP_METADATA_PANEL, STRIP_METADATA_PATH } from './maintenance.js';
23
+ import { ADMIN_PREFIX, editUserPath } from '../site/admin-paths.js';
24
+ import { SETTINGS_PATH } from './settings.js';
25
+ import { GENERAL_SETTINGS } from './settings-general.js';
26
+ import { ALL_SETTINGS_PAGES } from './settings-pages.js';
23
27
  import { ADMIN_TEMPLATES } from './templates.js';
24
28
  /** Where the media screen lives. */
25
29
  export const MEDIA_PATH = `${ADMIN_PREFIX}/media`;
26
30
  /** The navigation section the screen marks as current. */
27
31
  export const MEDIA_SECTION = 'media';
32
+ /** Where one file's details screen lives, with the file as `?path=`. */
33
+ export const MEDIA_FILE_PATH = `${MEDIA_PATH}/file`;
28
34
  /** Where the screen's upload form posts. */
29
35
  export const MEDIA_UPLOAD_PATH = `${MEDIA_PATH}/upload`;
30
- /** Where a row's Delete button posts. */
36
+ /** Where a file's Delete button posts. */
31
37
  export const MEDIA_DELETE_PATH = `${MEDIA_PATH}/delete`;
32
- /** Where a row's alt-text form posts. */
38
+ /** Where a file's alt-text form posts. */
33
39
  export const MEDIA_ALT_PATH = `${MEDIA_PATH}/alt`;
34
- export const MEDIA_STRIP_METADATA_PATH = `${MEDIA_PATH}/strip-metadata`;
35
40
  /** How many files one page of the library shows. */
36
41
  export const MEDIA_PER_PAGE = 24;
37
- /** The fields the forms on the media screen submit. */
42
+ /** The fields the forms on the media screens submit. */
38
43
  export const MEDIA_FIELDS = {
39
44
  /** The file being added, on the upload form. */
40
45
  file: 'file',
41
- /** The upload being deleted, as a path under `content/uploads/`. */
46
+ /** The upload being deleted or described, as a path under `content/uploads/`. */
42
47
  path: 'path',
43
48
  /** Set once the admin has been shown what still references the file. */
44
49
  confirm: 'confirm',
@@ -46,38 +51,192 @@ export const MEDIA_FIELDS = {
46
51
  alt: 'alt',
47
52
  /** Present when an image is decorative, which wins over any alt text. */
48
53
  decorative: 'decorative',
49
- /** The library page an alt-text form was on, so saving it goes back there. */
50
- page: 'page',
54
+ /** The library view a form was posted from, as a query string, so the answer goes back to it. */
55
+ from: 'from',
51
56
  };
57
+ /** The kinds the library's tabs pick between. */
58
+ const KIND_TABS = [
59
+ { kind: undefined, label: 'All' },
60
+ { kind: 'image', label: 'Images' },
61
+ { kind: 'audio', label: 'Audio' },
62
+ { kind: 'video', label: 'Video' },
63
+ { kind: 'document', label: 'Documents' },
64
+ ];
65
+ /** The orders the library can be sorted in. Newest is the default and is never written into a URL. */
66
+ const SORTS = {
67
+ newest: 'Newest first',
68
+ oldest: 'Oldest first',
69
+ largest: 'Largest first',
70
+ name: 'Name',
71
+ };
72
+ /** A view from a query string. Anything it does not recognise is left at its default. */
73
+ export function parseMediaView(query) {
74
+ const kind = query.get('kind');
75
+ const sort = query.get('sort');
76
+ return {
77
+ kind: KIND_TABS.find((tab) => tab.kind !== undefined && tab.kind === kind)?.kind,
78
+ search: (query.get('q') ?? '').trim(),
79
+ needsAlt: query.get('alt') === 'missing',
80
+ unused: query.get('unused') === '1',
81
+ sort: sort !== null && Object.hasOwn(SORTS, sort) ? sort : 'newest',
82
+ page: pageNumber(query.get('page') ?? undefined),
83
+ };
84
+ }
85
+ /** A view as a query string, without the `?`, leaving out everything at its default. */
86
+ export function mediaViewQuery(view) {
87
+ const query = new URLSearchParams();
88
+ if (view.kind !== undefined)
89
+ query.set('kind', view.kind);
90
+ if (view.search !== '')
91
+ query.set('q', view.search);
92
+ if (view.needsAlt)
93
+ query.set('alt', 'missing');
94
+ if (view.unused)
95
+ query.set('unused', '1');
96
+ if (view.sort !== 'newest')
97
+ query.set('sort', view.sort);
98
+ if (view.page > 1)
99
+ query.set('page', String(view.page));
100
+ return query.toString();
101
+ }
102
+ /** The library showing a view. */
103
+ export function mediaLibraryUrl(view) {
104
+ const query = mediaViewQuery(view);
105
+ return query === '' ? MEDIA_PATH : `${MEDIA_PATH}?${query}`;
106
+ }
107
+ /** One file's details screen, reached from a view of the library. */
108
+ export function mediaFileUrl(path, view) {
109
+ const query = mediaViewQuery(view);
110
+ return `${MEDIA_FILE_PATH}?path=${encodeURIComponent(path)}${query === '' ? '' : `&${query}`}`;
111
+ }
52
112
  /**
53
- * Every document whose body mentions an upload's URL, trash included.
113
+ * Everything that could use an upload: every document, live and trashed, and
114
+ * the site itself — its settings and its users' profiles.
54
115
  *
55
116
  * The trash counts. A trashed post can be restored tomorrow, and restoring one
56
117
  * whose picture was deleted in the meantime is a broken post nobody was warned
57
118
  * about — so a file the trash still points at asks for confirmation exactly as
58
119
  * a published one does.
59
120
  *
60
- * This is the one thing on the screen that comes out of the index rather than
61
- * off the disk. The index is a cache of the files (decision-1), so it may be a
62
- * moment behind a hand edit; that is acceptable here because the answer is a
63
- * warning rather than a decision, and the confirmation step is what makes an
64
- * out-of-date answer harmless in both directions.
121
+ * The documents come out of the index rather than off the disk. The index is
122
+ * a cache of the files (decision-1), so it may be a moment behind a hand edit;
123
+ * that is acceptable here because the answer is a warning rather than a
124
+ * decision, and the confirmation step is what makes an out-of-date answer
125
+ * harmless in both directions.
65
126
  */
66
- export function referencesTo(store, url) {
127
+ async function referenceSources(c) {
67
128
  // Two queries rather than one because `listAll` shows the live tree or the
68
129
  // trash and never both, which is the same reason the taxonomy screens ask
69
130
  // twice.
70
- return [...store.listAll({ trashed: false }), ...store.listAll({ trashed: true })]
71
- .filter((document) => mentionsUpload(document.body, url))
72
- .map((document) => ({
73
- path: document.path,
74
- title: postLabel(document),
75
- editUrl: documentEditorPath(document.type === 'page' ? PAGE_SCREENS : POST_SCREENS, document),
76
- trashed: isTrashedPath(document.path),
77
- }));
131
+ const documents = [
132
+ ...c.var.store.listAll({ trashed: false }),
133
+ ...c.var.store.listAll({ trashed: true }),
134
+ ];
135
+ return [
136
+ ...documents.map(documentSource),
137
+ ...(await settingSources(c.var.settings)),
138
+ ...userSources(c.var.config.dataDir),
139
+ ];
140
+ }
141
+ function documentSource(document) {
142
+ const { html: _html, hash: _hash, path: where, ...written } = document;
143
+ return {
144
+ reference: {
145
+ title: postLabel(document),
146
+ editUrl: documentEditorPath(document.type === 'page' ? PAGE_SCREENS : POST_SCREENS, document),
147
+ where,
148
+ trashed: isTrashedPath(document.path),
149
+ },
150
+ text: usageText(written),
151
+ };
152
+ }
153
+ const SITE_SETTING_TITLES = {
154
+ icon: 'The site icon',
155
+ avatar: 'The site’s old avatar, which the site icon falls back to',
156
+ };
157
+ async function settingSources(settings) {
158
+ const sources = [];
159
+ for (const ref of settings.list()) {
160
+ if (ref.declaration.secret === true)
161
+ continue;
162
+ const text = usageText(await settings.get(ref));
163
+ if (!text.includes(UPLOAD_ASSET_PREFIX))
164
+ continue;
165
+ const page = ref.namespace === SITE.declaration.namespace ? settingsPageOf(ref.key) : undefined;
166
+ sources.push({
167
+ reference: {
168
+ title: SITE_SETTING_TITLES[ref.key] ?? `The ${ref.namespace} setting ${ref.key}`,
169
+ editUrl: page?.path ?? SETTINGS_PATH,
170
+ where: page === undefined ? 'Settings' : (page.heading ?? `Settings › ${page.label}`),
171
+ trashed: false,
172
+ },
173
+ text,
174
+ });
175
+ }
176
+ return sources;
177
+ }
178
+ function settingsPageOf(key) {
179
+ if (key === 'avatar')
180
+ return GENERAL_SETTINGS;
181
+ return ALL_SETTINGS_PAGES.find((page) => page.fields.includes(key));
182
+ }
183
+ function userSources(dataDir) {
184
+ return listUsers(dataDir).flatMap((user) => {
185
+ const { avatar, ...rest } = user.profile ?? {};
186
+ const name = user.profile?.displayName ?? user.username;
187
+ const reference = { editUrl: editUserPath(user.id), where: 'Users', trashed: false };
188
+ return [
189
+ { reference: { ...reference, title: `${name}’s avatar` }, text: avatar ?? '' },
190
+ { reference: { ...reference, title: `${name}’s profile` }, text: usageText(rest) },
191
+ ];
192
+ });
193
+ }
194
+ function referencesAmong(sources, relative) {
195
+ return sources
196
+ .filter((source) => usesUpload(source.text, relative))
197
+ .map((source) => source.reference);
198
+ }
199
+ function libraryFile(config, sources, file) {
200
+ const image = file.kind === 'image' ? describeImage(config, file.path) : undefined;
201
+ return {
202
+ ...file,
203
+ references: referencesAmong(sources, file.path),
204
+ thumbnailUrl: file.kind === 'image' ? thumbnailUrl(config, file.path, file.url) : file.url,
205
+ width: image?.width,
206
+ height: image?.height,
207
+ };
208
+ }
209
+ function needsAlt(file) {
210
+ return file.kind === 'image' && file.alt === undefined;
211
+ }
212
+ /** Whether a file is in a view, leaving the kind tabs and the page aside. */
213
+ function matches(file, view) {
214
+ if (view.needsAlt && !needsAlt(file))
215
+ return false;
216
+ if (view.unused && file.references.length > 0)
217
+ return false;
218
+ if (view.search === '')
219
+ return true;
220
+ const search = view.search.toLowerCase();
221
+ const alt = file.alt?.kind === 'described' ? file.alt.text : '';
222
+ return file.path.toLowerCase().includes(search) || alt.toLowerCase().includes(search);
223
+ }
224
+ /** `listUploads` is newest first already; the other orders are stable sorts of it. */
225
+ function sorted(files, sort) {
226
+ switch (sort) {
227
+ case 'newest':
228
+ return files;
229
+ case 'oldest':
230
+ return files.toReversed();
231
+ case 'largest':
232
+ return files.toSorted((left, right) => right.bytes - left.bytes);
233
+ case 'name':
234
+ return files.toSorted((left, right) => left.name.localeCompare(right.name));
235
+ }
78
236
  }
79
237
  /**
80
- * Register the media screen: the library, the upload form and the delete.
238
+ * Register the media screens: the library, one file's details, the upload,
239
+ * the alt text and the delete.
81
240
  *
82
241
  * Uploading here is {@link storeUpload}, exactly as the editor's control and
83
242
  * the avatar are, so what a site accepts is one answer given in one place
@@ -85,7 +244,16 @@ export function referencesTo(store, url) {
85
244
  */
86
245
  export function mountMediaScreen(app, options) {
87
246
  const { render, removeDerived } = options;
88
- app.get(MEDIA_PATH, async (c) => render(c, ADMIN_TEMPLATES.media, await screen(c)));
247
+ app.get(MEDIA_PATH, async (c) => render(c, ADMIN_TEMPLATES.media, await library(c, viewOf(c.req.url))));
248
+ app.get(MEDIA_FILE_PATH, async (c) => {
249
+ const view = viewOf(c.req.url);
250
+ const details = await fileScreen(c, c.req.query('path') ?? '', view);
251
+ if (details === undefined) {
252
+ flash(c, 'error', 'That is not a file in this site’s uploads.');
253
+ return c.redirect(mediaLibraryUrl(view), 303);
254
+ }
255
+ return render(c, ADMIN_TEMPLATES.mediaFile, details);
256
+ });
89
257
  app.post(MEDIA_UPLOAD_PATH, async (c) => {
90
258
  const body = await c.req.parseBody();
91
259
  const outcome = await storeUpload(body[MEDIA_FIELDS.file], c.var.config);
@@ -98,11 +266,12 @@ export function mountMediaScreen(app, options) {
98
266
  });
99
267
  app.post(MEDIA_ALT_PATH, async (c) => {
100
268
  const body = await c.req.parseBody();
269
+ const view = postedView(body);
101
270
  const relative = stringOrEmpty(body[MEDIA_FIELDS.path]).trim();
102
271
  const file = relative === '' ? undefined : resolveUpload(c.var.config.contentDir, relative);
103
272
  if (file === undefined || !(await isFile(file))) {
104
273
  flash(c, 'error', 'That is not a file in this site’s uploads.');
105
- return c.redirect(MEDIA_PATH, 303);
274
+ return c.redirect(mediaLibraryUrl(view), 303);
106
275
  }
107
276
  const text = stringOrEmpty(body[MEDIA_FIELDS.alt]).trim().replace(/\s+/g, ' ');
108
277
  const alt = body[MEDIA_FIELDS.decorative] !== undefined
@@ -112,28 +281,29 @@ export function mountMediaScreen(app, options) {
112
281
  : { kind: 'described', text };
113
282
  await writeAltText(c.var.config.contentDir, relative, alt);
114
283
  flash(c, 'notice', altSavedMessage(relative, alt));
115
- return c.redirect(mediaPageUrl(pageNumber(stringOrEmpty(body[MEDIA_FIELDS.page]).trim())), 303);
284
+ return c.redirect(mediaFileUrl(relative, view), 303);
116
285
  });
117
- app.post(MEDIA_STRIP_METADATA_PATH, (c) => queueJobFromForm(c, STRIP_METADATA_JOB, null, {
118
- panel: `${MEDIA_PATH}#strip-metadata`,
119
- queued: 'Queued the sweep. It runs in the background; its progress is below.',
120
- busy: 'A sweep is already queued or running. Nothing new was queued.',
121
- }));
122
286
  app.post(MEDIA_DELETE_PATH, async (c) => {
123
287
  const body = await c.req.parseBody();
288
+ const view = postedView(body);
124
289
  const relative = stringOrEmpty(body[MEDIA_FIELDS.path]).trim();
125
290
  const file = relative === '' ? undefined : resolveUpload(c.var.config.contentDir, relative);
126
291
  if (file === undefined) {
127
292
  flash(c, 'error', 'That is not a file in this site’s uploads.');
128
- return c.redirect(MEDIA_PATH, 303);
293
+ return c.redirect(mediaLibraryUrl(view), 303);
129
294
  }
130
295
  const url = `${UPLOAD_ASSET_PREFIX}${relative}`;
131
- const references = referencesTo(c.var.store, url);
296
+ const references = referencesAmong(await referenceSources(c), relative);
132
297
  // A file something still points at is offered rather than deleted: the
133
298
  // bytes do not come back, and the documents naming them are the whole
134
299
  // reason to think twice.
135
300
  if (references.length > 0 && stringOrEmpty(body[MEDIA_FIELDS.confirm]).trim() === '') {
136
- return render(c, ADMIN_TEMPLATES.media, await screen(c, { confirm: { path: relative, url, references } }));
301
+ const details = await fileScreen(c, relative, view);
302
+ if (details === undefined) {
303
+ flash(c, 'error', 'That is not a file in this site’s uploads.');
304
+ return c.redirect(mediaLibraryUrl(view), 303);
305
+ }
306
+ return render(c, ADMIN_TEMPLATES.mediaFile, { ...details, confirm: { url, references } });
137
307
  }
138
308
  const deleted = await deleteUpload({
139
309
  contentDir: c.var.config.contentDir,
@@ -144,43 +314,102 @@ export function mountMediaScreen(app, options) {
144
314
  removeDerived: removeDerived ?? ((uploadPath) => removeImageVariants(c.var.config, uploadPath)),
145
315
  });
146
316
  flash(c, deleted ? 'notice' : 'error', deleted ? `Deleted ${url}.` : `There is no longer a file at ${url}.`);
147
- return c.redirect(MEDIA_PATH, 303);
317
+ return c.redirect(mediaLibraryUrl(view), 303);
148
318
  });
149
- /** Everything the media template renders. */
150
- async function screen(c, extra = {}) {
151
- const files = await listUploads(c.var.config.contentDir);
152
- const latestSweep = await c.var.jobs.latest(STRIP_METADATA_JOB.name);
153
- const page = pageNumber(c.req.query('page'));
154
- const start = (page - 1) * MEDIA_PER_PAGE;
319
+ async function libraryFiles(c) {
320
+ const sources = await referenceSources(c);
321
+ return (await listUploads(c.var.config.contentDir)).map((file) => libraryFile(c.var.config, sources, file));
322
+ }
323
+ /** Everything the library template renders. */
324
+ async function library(c, view) {
325
+ const all = await libraryFiles(c);
326
+ const matching = all.filter((file) => matches(file, view));
327
+ const files = sorted(matching.filter((file) => view.kind === undefined || file.kind === view.kind), view.sort);
328
+ const pages = pageCount(files.length, MEDIA_PER_PAGE);
329
+ const start = (view.page - 1) * MEDIA_PER_PAGE;
330
+ const latestSweep = jobStatusView(await c.var.jobs.latest(STRIP_METADATA_JOB.name));
155
331
  return {
156
332
  section: MEDIA_SECTION,
157
333
  child: 'library',
158
334
  heading: 'Media',
159
335
  listUrl: MEDIA_PATH,
160
336
  uploadUrl: MEDIA_UPLOAD_PATH,
161
- deleteUrl: MEDIA_DELETE_PATH,
162
- altUrl: MEDIA_ALT_PATH,
163
- stripMetadataUrl: MEDIA_STRIP_METADATA_PATH,
164
- sweep: jobStatusView(latestSweep),
165
- sweepReport: finishedResult(STRIP_METADATA_JOB, latestSweep),
166
337
  fields: MEDIA_FIELDS,
167
338
  accepts: c.var.config.uploadTypes.join(','),
168
339
  maxBytes: c.var.config.uploadMaxBytes,
169
340
  mediaMaxBytes: c.var.config.uploadMediaMaxBytes,
170
- total: files.length,
171
- page,
172
- pages: pageCount(files.length, MEDIA_PER_PAGE),
173
- previousUrl: page > 1 ? mediaPageUrl(page - 1) : undefined,
174
- nextUrl: start + MEDIA_PER_PAGE < files.length ? mediaPageUrl(page + 1) : undefined,
175
- // Reference counts are one index scan per file on the page rather than
176
- // per file in the library, which is what paging is for.
341
+ // Until a sweep has finished, the older uploads may still carry where
342
+ // they were taken; after one has, everything since was cleaned on arrival.
343
+ sweepNotice: latestSweep?.state === 'finished'
344
+ ? undefined
345
+ : { sweep: latestSweep, url: STRIP_METADATA_PATH, panelUrl: STRIP_METADATA_PANEL },
346
+ view,
347
+ sorts: SORTS,
348
+ kindTabs: KIND_TABS.map((tab) => ({
349
+ label: tab.label,
350
+ url: mediaLibraryUrl({ ...view, kind: tab.kind, page: 1 }),
351
+ current: tab.kind === view.kind,
352
+ count: matching.filter((file) => tab.kind === undefined || file.kind === tab.kind).length,
353
+ })),
354
+ filtered: view.search !== '' || view.needsAlt || view.unused,
355
+ clearUrl: mediaLibraryUrl({ ...parseMediaView(new URLSearchParams()), kind: view.kind }),
356
+ total: all.length,
357
+ shown: files.length,
358
+ page: view.page,
359
+ pages,
360
+ previousUrl: view.page > 1 ? mediaLibraryUrl({ ...view, page: view.page - 1 }) : undefined,
361
+ nextUrl: start + MEDIA_PER_PAGE < files.length
362
+ ? mediaLibraryUrl({ ...view, page: view.page + 1 })
363
+ : undefined,
177
364
  files: files.slice(start, start + MEDIA_PER_PAGE).map((file) => ({
178
365
  ...file,
179
- references: referencesTo(c.var.store, file.url),
366
+ needsAlt: needsAlt(file),
367
+ detailsUrl: mediaFileUrl(file.path, view),
180
368
  })),
181
- ...extra,
182
369
  };
183
370
  }
371
+ /**
372
+ * Everything the details template renders, or `undefined` when the path is
373
+ * not a file in the library.
374
+ *
375
+ * Previous and next step through the view the file was opened from. The
376
+ * file itself always counts as part of that view, so describing the last
377
+ * image a "needs alt text" view showed does not strand it.
378
+ */
379
+ async function fileScreen(c, relative, view) {
380
+ const all = await libraryFiles(c);
381
+ const file = all.find((candidate) => candidate.path === relative);
382
+ if (file === undefined)
383
+ return undefined;
384
+ const around = sorted(all.filter((candidate) => candidate === file ||
385
+ (matches(candidate, view) && (view.kind === undefined || candidate.kind === view.kind))), view.sort);
386
+ const at = around.indexOf(file);
387
+ const previous = around[at - 1];
388
+ const next = around[at + 1];
389
+ const viewAroundFile = { ...view, page: Math.floor(Math.max(at, 0) / MEDIA_PER_PAGE) + 1 };
390
+ return {
391
+ section: MEDIA_SECTION,
392
+ child: 'library',
393
+ heading: file.name,
394
+ file: { ...file, needsAlt: needsAlt(file) },
395
+ fields: MEDIA_FIELDS,
396
+ from: mediaViewQuery(view),
397
+ altUrl: MEDIA_ALT_PATH,
398
+ deleteUrl: MEDIA_DELETE_PATH,
399
+ libraryUrl: mediaLibraryUrl(viewAroundFile),
400
+ selfUrl: mediaFileUrl(file.path, view),
401
+ previousUrl: previous === undefined ? undefined : mediaFileUrl(previous.path, view),
402
+ nextUrl: next === undefined ? undefined : mediaFileUrl(next.path, view),
403
+ position: at + 1,
404
+ count: around.length,
405
+ };
406
+ }
407
+ }
408
+ function viewOf(url) {
409
+ return parseMediaView(new URL(url).searchParams);
410
+ }
411
+ function postedView(body) {
412
+ return parseMediaView(new URLSearchParams(stringOrEmpty(body[MEDIA_FIELDS.from])));
184
413
  }
185
414
  /** What the flash says once an image's alt text has been saved. */
186
415
  function altSavedMessage(relative, alt) {
@@ -190,10 +419,6 @@ function altSavedMessage(relative, alt) {
190
419
  return `${relative} is marked decorative.`;
191
420
  return `Saved the alt text for ${relative}.`;
192
421
  }
193
- /** The URL of one page of the library. Page one is the screen's own URL. */
194
- export function mediaPageUrl(page) {
195
- return page > 1 ? `${MEDIA_PATH}?page=${String(page)}` : MEDIA_PATH;
196
- }
197
422
  /** A `page` query as a page number. Anything that is not one is page one. */
198
423
  function pageNumber(value) {
199
424
  const parsed = Number(value ?? '1');