@morphemecms/cms 0.1.0 → 0.2.0-next-20261104150211

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 (908) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -21
  3. package/README.md +51 -51
  4. package/astro.mjs +162 -162
  5. package/bin/morpheme.mjs +38 -38
  6. package/client.d.ts +10 -10
  7. package/defaults/blockTexts.ts +33 -33
  8. package/defaults/collections.ts +774 -774
  9. package/defaults/colors.ts +125 -125
  10. package/defaults/company.ts +20 -20
  11. package/defaults/editor.ts +178 -178
  12. package/defaults/internalLinks.ts +30 -30
  13. package/defaults/opens.ts +18 -18
  14. package/defaults/popup.tsx +93 -93
  15. package/defaults/privacy.ts +79 -79
  16. package/defaults/rules.ts +450 -450
  17. package/defaults/schema.ts +16 -16
  18. package/defaults/sitedata.ts +129 -129
  19. package/defaults/standard.ts +106 -106
  20. package/defaults/standardPages.ts +87 -87
  21. package/defaults/texts.ts +481 -481
  22. package/demo/README.md +26 -26
  23. package/demo/src/content/blog/build-a-page-from-blocks.md +19 -19
  24. package/demo/src/content/blog/colors-everyone-can-read.md +19 -19
  25. package/demo/src/content/blog/edit-your-website-from-your-phone.md +21 -21
  26. package/demo/src/content/blog/every-save-is-a-version.md +21 -21
  27. package/demo/src/content/blog/schedule-a-post.md +17 -17
  28. package/demo/src/content/blog/secure-from-day-one.md +17 -17
  29. package/demo/src/content/blog/seo-without-the-guesswork.md +23 -23
  30. package/demo/src/content/blog/switch-themes-without-losing-anything.md +23 -23
  31. package/demo/src/content/blog/the-app-store.md +21 -21
  32. package/demo/src/content/pages/about.md +22 -22
  33. package/demo/src/content/pages/app-store.md +30 -30
  34. package/demo/src/content/pages/blocks.md +98 -98
  35. package/demo/src/content/pages/blog-page.md +11 -11
  36. package/demo/src/content/pages/contact.md +16 -16
  37. package/demo/src/content/pages/get-started.md +20 -20
  38. package/demo/src/content/pages/home.md +26 -26
  39. package/demo/src/content/pages/page-not-found.md +11 -11
  40. package/demo/src/content/pages/privacy-policy.md +24 -24
  41. package/demo/src/data/blocks.json +25 -25
  42. package/demo/src/data/booking.json +6 -6
  43. package/demo/src/data/categories/apps.json +6 -6
  44. package/demo/src/data/categories/design.json +6 -6
  45. package/demo/src/data/categories/editing.json +6 -6
  46. package/demo/src/data/categories/security.json +6 -6
  47. package/demo/src/data/categories/seo.json +6 -6
  48. package/demo/src/data/colors.json +75 -75
  49. package/demo/src/data/contact.json +8 -8
  50. package/demo/src/data/cookies.json +21 -21
  51. package/demo/src/data/footer.json +27 -27
  52. package/demo/src/data/menu.json +35 -35
  53. package/demo/src/data/mobile-menu.json +25 -25
  54. package/demo/src/data/popups/booking-bar.json +13 -13
  55. package/demo/src/data/popups/try-the-editor.json +16 -16
  56. package/demo/src/data/standard-pages.json +8 -8
  57. package/demo/src/data/testimonials/sample-bakery.json +10 -10
  58. package/demo/src/data/testimonials/sample-clinic.json +10 -10
  59. package/demo/src/data/testimonials/sample-studio.json +10 -10
  60. package/demo/src/site/colors.ts +11 -11
  61. package/demo/src/site/config.ts +19 -19
  62. package/icons/lucide/LICENSE +43 -43
  63. package/icons/lucide/address-book.svg +1 -1
  64. package/icons/lucide/app-window.svg +1 -1
  65. package/icons/lucide/arrow-counter-clockwise.svg +1 -1
  66. package/icons/lucide/arrow-down.svg +1 -1
  67. package/icons/lucide/arrow-left.svg +1 -1
  68. package/icons/lucide/arrow-right.svg +1 -1
  69. package/icons/lucide/arrow-square-out.svg +1 -1
  70. package/icons/lucide/arrow-u-up-left.svg +1 -1
  71. package/icons/lucide/arrow-u-up-right.svg +1 -1
  72. package/icons/lucide/arrow-up-right.svg +1 -1
  73. package/icons/lucide/arrow-up.svg +1 -1
  74. package/icons/lucide/arrows-down-up.svg +1 -1
  75. package/icons/lucide/arrows-in-line-horizontal.svg +1 -1
  76. package/icons/lucide/arrows-out-line-horizontal.svg +1 -1
  77. package/icons/lucide/article.svg +1 -1
  78. package/icons/lucide/bank.svg +1 -1
  79. package/icons/lucide/bell.svg +1 -1
  80. package/icons/lucide/bicycle.svg +1 -1
  81. package/icons/lucide/binoculars.svg +1 -1
  82. package/icons/lucide/book-open-text.svg +1 -1
  83. package/icons/lucide/book-open.svg +1 -1
  84. package/icons/lucide/browser.svg +1 -1
  85. package/icons/lucide/buildings.svg +1 -1
  86. package/icons/lucide/bus.svg +1 -1
  87. package/icons/lucide/calendar-blank.svg +1 -1
  88. package/icons/lucide/calendar-check.svg +1 -1
  89. package/icons/lucide/car.svg +1 -1
  90. package/icons/lucide/cards.svg +1 -1
  91. package/icons/lucide/caret-down.svg +1 -1
  92. package/icons/lucide/caret-left.svg +1 -1
  93. package/icons/lucide/caret-right.svg +1 -1
  94. package/icons/lucide/caret-up.svg +1 -1
  95. package/icons/lucide/certificate.svg +1 -1
  96. package/icons/lucide/chart-line-up.svg +1 -1
  97. package/icons/lucide/chats.svg +1 -1
  98. package/icons/lucide/check-circle.svg +1 -1
  99. package/icons/lucide/check.svg +1 -1
  100. package/icons/lucide/circle-half.svg +1 -1
  101. package/icons/lucide/circle.svg +1 -1
  102. package/icons/lucide/clock-counter-clockwise.svg +1 -1
  103. package/icons/lucide/clock.svg +1 -1
  104. package/icons/lucide/code.svg +1 -1
  105. package/icons/lucide/coffee.svg +1 -1
  106. package/icons/lucide/columns.svg +1 -1
  107. package/icons/lucide/cookie.svg +1 -1
  108. package/icons/lucide/copy.svg +1 -1
  109. package/icons/lucide/credit-card.svg +1 -1
  110. package/icons/lucide/currency-circle-dollar.svg +1 -1
  111. package/icons/lucide/cursor-click.svg +1 -1
  112. package/icons/lucide/database.svg +1 -1
  113. package/icons/lucide/desktop.svg +1 -1
  114. package/icons/lucide/device-mobile.svg +1 -1
  115. package/icons/lucide/door-open.svg +1 -1
  116. package/icons/lucide/download-simple.svg +1 -1
  117. package/icons/lucide/envelope-simple.svg +1 -1
  118. package/icons/lucide/eye-slash.svg +1 -1
  119. package/icons/lucide/eye.svg +1 -1
  120. package/icons/lucide/file-pdf.svg +1 -1
  121. package/icons/lucide/file-text.svg +1 -1
  122. package/icons/lucide/file-video.svg +1 -1
  123. package/icons/lucide/file-zip.svg +1 -1
  124. package/icons/lucide/flower-lotus.svg +1 -1
  125. package/icons/lucide/folder-simple.svg +1 -1
  126. package/icons/lucide/funnel.svg +1 -1
  127. package/icons/lucide/gauge.svg +1 -1
  128. package/icons/lucide/gear.svg +1 -1
  129. package/icons/lucide/github-logo.svg +1 -1
  130. package/icons/lucide/globe.svg +1 -1
  131. package/icons/lucide/google-logo.svg +1 -1
  132. package/icons/lucide/graduation-cap.svg +1 -1
  133. package/icons/lucide/hammer.svg +1 -1
  134. package/icons/lucide/hand-heart.svg +1 -1
  135. package/icons/lucide/handshake.svg +1 -1
  136. package/icons/lucide/hard-drives.svg +1 -1
  137. package/icons/lucide/heart.svg +1 -1
  138. package/icons/lucide/house.svg +1 -1
  139. package/icons/lucide/image-square.svg +1 -1
  140. package/icons/lucide/image.svg +1 -1
  141. package/icons/lucide/info.svg +1 -1
  142. package/icons/lucide/keyboard.svg +1 -1
  143. package/icons/lucide/leaf.svg +1 -1
  144. package/icons/lucide/lifebuoy.svg +1 -1
  145. package/icons/lucide/lightbulb.svg +1 -1
  146. package/icons/lucide/link-break.svg +1 -1
  147. package/icons/lucide/link-simple.svg +1 -1
  148. package/icons/lucide/list-bullets.svg +1 -1
  149. package/icons/lucide/list-checks.svg +1 -1
  150. package/icons/lucide/list-numbers.svg +1 -1
  151. package/icons/lucide/list.svg +1 -1
  152. package/icons/lucide/lock-simple-open.svg +1 -1
  153. package/icons/lucide/lock-simple.svg +1 -1
  154. package/icons/lucide/magnifying-glass.svg +1 -1
  155. package/icons/lucide/map-pin.svg +1 -1
  156. package/icons/lucide/map-trifold.svg +1 -1
  157. package/icons/lucide/medal.svg +1 -1
  158. package/icons/lucide/megaphone.svg +1 -1
  159. package/icons/lucide/minus.svg +1 -1
  160. package/icons/lucide/moon.svg +1 -1
  161. package/icons/lucide/newspaper.svg +1 -1
  162. package/icons/lucide/pack.json +23 -23
  163. package/icons/lucide/paint-brush-broad.svg +1 -1
  164. package/icons/lucide/palette.svg +1 -1
  165. package/icons/lucide/paper-plane-tilt.svg +1 -1
  166. package/icons/lucide/pencil-simple.svg +1 -1
  167. package/icons/lucide/phone.svg +1 -1
  168. package/icons/lucide/play-fill.svg +1 -1
  169. package/icons/lucide/plus.svg +1 -1
  170. package/icons/lucide/prohibit.svg +1 -1
  171. package/icons/lucide/puzzle-piece.svg +1 -1
  172. package/icons/lucide/question.svg +1 -1
  173. package/icons/lucide/quotes.svg +1 -1
  174. package/icons/lucide/rows.svg +1 -1
  175. package/icons/lucide/ruler.svg +1 -1
  176. package/icons/lucide/scan.svg +1 -1
  177. package/icons/lucide/shapes.svg +1 -1
  178. package/icons/lucide/share-network.svg +1 -1
  179. package/icons/lucide/shield-check.svg +1 -1
  180. package/icons/lucide/sidebar-simple.svg +1 -1
  181. package/icons/lucide/sign-out.svg +1 -1
  182. package/icons/lucide/signpost.svg +1 -1
  183. package/icons/lucide/sliders-horizontal.svg +1 -1
  184. package/icons/lucide/smiley.svg +1 -1
  185. package/icons/lucide/sparkle.svg +1 -1
  186. package/icons/lucide/square-half-bottom.svg +1 -1
  187. package/icons/lucide/square.svg +1 -1
  188. package/icons/lucide/squares-four.svg +1 -1
  189. package/icons/lucide/sticker.svg +1 -1
  190. package/icons/lucide/storefront.svg +1 -1
  191. package/icons/lucide/sun.svg +1 -1
  192. package/icons/lucide/swatches.svg +1 -1
  193. package/icons/lucide/tag.svg +1 -1
  194. package/icons/lucide/text-aa.svg +1 -1
  195. package/icons/lucide/text-align-left.svg +1 -1
  196. package/icons/lucide/text-b.svg +1 -1
  197. package/icons/lucide/text-h-one.svg +1 -1
  198. package/icons/lucide/text-h-three.svg +1 -1
  199. package/icons/lucide/text-h-two.svg +1 -1
  200. package/icons/lucide/text-italic.svg +1 -1
  201. package/icons/lucide/text-t.svg +1 -1
  202. package/icons/lucide/ticket.svg +1 -1
  203. package/icons/lucide/toggle-right.svg +1 -1
  204. package/icons/lucide/train.svg +1 -1
  205. package/icons/lucide/trash.svg +1 -1
  206. package/icons/lucide/upload-simple.svg +1 -1
  207. package/icons/lucide/user-circle.svg +1 -1
  208. package/icons/lucide/user.svg +1 -1
  209. package/icons/lucide/users-three.svg +1 -1
  210. package/icons/lucide/users.svg +1 -1
  211. package/icons/lucide/video-camera.svg +1 -1
  212. package/icons/lucide/warning-circle.svg +1 -1
  213. package/icons/lucide/warning.svg +1 -1
  214. package/icons/lucide/wave-sine.svg +1 -1
  215. package/icons/lucide/wheelchair.svg +1 -1
  216. package/icons/lucide/wrench.svg +1 -1
  217. package/icons/lucide/x.svg +1 -1
  218. package/icons/material-symbols/LICENSE +201 -201
  219. package/icons/material-symbols/address-book.svg +1 -1
  220. package/icons/material-symbols/app-window.svg +1 -1
  221. package/icons/material-symbols/arrow-counter-clockwise.svg +1 -1
  222. package/icons/material-symbols/arrow-down.svg +1 -1
  223. package/icons/material-symbols/arrow-left.svg +1 -1
  224. package/icons/material-symbols/arrow-right.svg +1 -1
  225. package/icons/material-symbols/arrow-square-out.svg +1 -1
  226. package/icons/material-symbols/arrow-u-up-left.svg +1 -1
  227. package/icons/material-symbols/arrow-u-up-right.svg +1 -1
  228. package/icons/material-symbols/arrow-up-right.svg +1 -1
  229. package/icons/material-symbols/arrow-up.svg +1 -1
  230. package/icons/material-symbols/arrows-down-up.svg +1 -1
  231. package/icons/material-symbols/arrows-in-line-horizontal.svg +1 -1
  232. package/icons/material-symbols/arrows-out-line-horizontal.svg +1 -1
  233. package/icons/material-symbols/article.svg +1 -1
  234. package/icons/material-symbols/bank.svg +1 -1
  235. package/icons/material-symbols/bell.svg +1 -1
  236. package/icons/material-symbols/bicycle.svg +1 -1
  237. package/icons/material-symbols/binoculars.svg +1 -1
  238. package/icons/material-symbols/book-open-text.svg +1 -1
  239. package/icons/material-symbols/book-open.svg +1 -1
  240. package/icons/material-symbols/browser.svg +1 -1
  241. package/icons/material-symbols/buildings.svg +1 -1
  242. package/icons/material-symbols/bus.svg +1 -1
  243. package/icons/material-symbols/calendar-blank.svg +1 -1
  244. package/icons/material-symbols/calendar-check.svg +1 -1
  245. package/icons/material-symbols/car.svg +1 -1
  246. package/icons/material-symbols/cards.svg +1 -1
  247. package/icons/material-symbols/caret-down.svg +1 -1
  248. package/icons/material-symbols/caret-left.svg +1 -1
  249. package/icons/material-symbols/caret-right.svg +1 -1
  250. package/icons/material-symbols/caret-up.svg +1 -1
  251. package/icons/material-symbols/certificate.svg +1 -1
  252. package/icons/material-symbols/chart-line-up.svg +1 -1
  253. package/icons/material-symbols/chats.svg +1 -1
  254. package/icons/material-symbols/check-circle.svg +1 -1
  255. package/icons/material-symbols/check.svg +1 -1
  256. package/icons/material-symbols/circle-half.svg +1 -1
  257. package/icons/material-symbols/circle.svg +1 -1
  258. package/icons/material-symbols/clock-counter-clockwise.svg +1 -1
  259. package/icons/material-symbols/clock.svg +1 -1
  260. package/icons/material-symbols/code.svg +1 -1
  261. package/icons/material-symbols/coffee.svg +1 -1
  262. package/icons/material-symbols/columns.svg +1 -1
  263. package/icons/material-symbols/cookie.svg +1 -1
  264. package/icons/material-symbols/copy.svg +1 -1
  265. package/icons/material-symbols/credit-card.svg +1 -1
  266. package/icons/material-symbols/currency-circle-dollar.svg +1 -1
  267. package/icons/material-symbols/cursor-click.svg +1 -1
  268. package/icons/material-symbols/database.svg +1 -1
  269. package/icons/material-symbols/desktop.svg +1 -1
  270. package/icons/material-symbols/device-mobile.svg +1 -1
  271. package/icons/material-symbols/door-open.svg +1 -1
  272. package/icons/material-symbols/download-simple.svg +1 -1
  273. package/icons/material-symbols/envelope-simple.svg +1 -1
  274. package/icons/material-symbols/eye-slash.svg +1 -1
  275. package/icons/material-symbols/eye.svg +1 -1
  276. package/icons/material-symbols/file-pdf.svg +1 -1
  277. package/icons/material-symbols/file-text.svg +1 -1
  278. package/icons/material-symbols/file-video.svg +1 -1
  279. package/icons/material-symbols/file-zip.svg +1 -1
  280. package/icons/material-symbols/flower-lotus.svg +1 -1
  281. package/icons/material-symbols/folder-simple.svg +1 -1
  282. package/icons/material-symbols/funnel.svg +1 -1
  283. package/icons/material-symbols/gauge.svg +1 -1
  284. package/icons/material-symbols/gear.svg +1 -1
  285. package/icons/material-symbols/github-logo.svg +1 -1
  286. package/icons/material-symbols/globe.svg +1 -1
  287. package/icons/material-symbols/google-logo.svg +1 -1
  288. package/icons/material-symbols/graduation-cap.svg +1 -1
  289. package/icons/material-symbols/hammer.svg +1 -1
  290. package/icons/material-symbols/hand-heart.svg +1 -1
  291. package/icons/material-symbols/handshake.svg +1 -1
  292. package/icons/material-symbols/hard-drives.svg +1 -1
  293. package/icons/material-symbols/heart.svg +1 -1
  294. package/icons/material-symbols/house.svg +1 -1
  295. package/icons/material-symbols/image-square.svg +1 -1
  296. package/icons/material-symbols/image.svg +1 -1
  297. package/icons/material-symbols/info.svg +1 -1
  298. package/icons/material-symbols/keyboard.svg +1 -1
  299. package/icons/material-symbols/leaf.svg +1 -1
  300. package/icons/material-symbols/lifebuoy.svg +1 -1
  301. package/icons/material-symbols/lightbulb.svg +1 -1
  302. package/icons/material-symbols/link-break.svg +1 -1
  303. package/icons/material-symbols/link-simple.svg +1 -1
  304. package/icons/material-symbols/list-bullets.svg +1 -1
  305. package/icons/material-symbols/list-checks.svg +1 -1
  306. package/icons/material-symbols/list-numbers.svg +1 -1
  307. package/icons/material-symbols/list.svg +1 -1
  308. package/icons/material-symbols/lock-simple-open.svg +1 -1
  309. package/icons/material-symbols/lock-simple.svg +1 -1
  310. package/icons/material-symbols/magnifying-glass.svg +1 -1
  311. package/icons/material-symbols/map-pin.svg +1 -1
  312. package/icons/material-symbols/map-trifold.svg +1 -1
  313. package/icons/material-symbols/medal.svg +1 -1
  314. package/icons/material-symbols/megaphone.svg +1 -1
  315. package/icons/material-symbols/minus.svg +1 -1
  316. package/icons/material-symbols/moon.svg +1 -1
  317. package/icons/material-symbols/newspaper.svg +1 -1
  318. package/icons/material-symbols/pack.json +29 -29
  319. package/icons/material-symbols/paint-brush-broad.svg +1 -1
  320. package/icons/material-symbols/palette.svg +1 -1
  321. package/icons/material-symbols/paper-plane-tilt.svg +1 -1
  322. package/icons/material-symbols/pencil-simple.svg +1 -1
  323. package/icons/material-symbols/phone.svg +1 -1
  324. package/icons/material-symbols/play-fill.svg +1 -1
  325. package/icons/material-symbols/plus.svg +1 -1
  326. package/icons/material-symbols/prohibit.svg +1 -1
  327. package/icons/material-symbols/puzzle-piece.svg +1 -1
  328. package/icons/material-symbols/question.svg +1 -1
  329. package/icons/material-symbols/quotes.svg +1 -1
  330. package/icons/material-symbols/rows.svg +1 -1
  331. package/icons/material-symbols/ruler.svg +1 -1
  332. package/icons/material-symbols/scan.svg +1 -1
  333. package/icons/material-symbols/shapes.svg +1 -1
  334. package/icons/material-symbols/share-network.svg +1 -1
  335. package/icons/material-symbols/shield-check.svg +1 -1
  336. package/icons/material-symbols/sidebar-simple.svg +1 -1
  337. package/icons/material-symbols/sign-out.svg +1 -1
  338. package/icons/material-symbols/signpost.svg +1 -1
  339. package/icons/material-symbols/sliders-horizontal.svg +1 -1
  340. package/icons/material-symbols/smiley.svg +1 -1
  341. package/icons/material-symbols/sparkle.svg +1 -1
  342. package/icons/material-symbols/square-half-bottom.svg +1 -1
  343. package/icons/material-symbols/square.svg +1 -1
  344. package/icons/material-symbols/squares-four.svg +1 -1
  345. package/icons/material-symbols/sticker.svg +1 -1
  346. package/icons/material-symbols/storefront.svg +1 -1
  347. package/icons/material-symbols/sun.svg +1 -1
  348. package/icons/material-symbols/swatches.svg +1 -1
  349. package/icons/material-symbols/tag.svg +1 -1
  350. package/icons/material-symbols/text-aa.svg +1 -1
  351. package/icons/material-symbols/text-align-left.svg +1 -1
  352. package/icons/material-symbols/text-b.svg +1 -1
  353. package/icons/material-symbols/text-h-one.svg +1 -1
  354. package/icons/material-symbols/text-h-three.svg +1 -1
  355. package/icons/material-symbols/text-h-two.svg +1 -1
  356. package/icons/material-symbols/text-italic.svg +1 -1
  357. package/icons/material-symbols/text-t.svg +1 -1
  358. package/icons/material-symbols/ticket.svg +1 -1
  359. package/icons/material-symbols/toggle-right.svg +1 -1
  360. package/icons/material-symbols/train.svg +1 -1
  361. package/icons/material-symbols/trash.svg +1 -1
  362. package/icons/material-symbols/upload-simple.svg +1 -1
  363. package/icons/material-symbols/user-circle.svg +1 -1
  364. package/icons/material-symbols/user.svg +1 -1
  365. package/icons/material-symbols/users-three.svg +1 -1
  366. package/icons/material-symbols/users.svg +1 -1
  367. package/icons/material-symbols/video-camera.svg +1 -1
  368. package/icons/material-symbols/warning-circle.svg +1 -1
  369. package/icons/material-symbols/warning.svg +1 -1
  370. package/icons/material-symbols/wave-sine.svg +1 -1
  371. package/icons/material-symbols/wheelchair.svg +1 -1
  372. package/icons/material-symbols/wrench.svg +1 -1
  373. package/icons/material-symbols/x.svg +1 -1
  374. package/icons/phosphor/pack.json +8 -8
  375. package/lib/apps.mjs +142 -139
  376. package/lib/apps.test.d.mts +1 -0
  377. package/lib/apps.test.mjs +50 -0
  378. package/lib/blocks.mjs +90 -90
  379. package/lib/caseCollisions.d.mts +4 -0
  380. package/lib/caseCollisions.mjs +62 -0
  381. package/lib/caseCollisions.test.d.mts +1 -0
  382. package/lib/caseCollisions.test.mjs +74 -0
  383. package/lib/colors.ts +86 -86
  384. package/lib/contactBar.ts +18 -18
  385. package/lib/contracts.d.mts +15 -0
  386. package/lib/contracts.mjs +17 -0
  387. package/lib/cookieCategories.mjs +28 -28
  388. package/lib/crop.mjs +83 -83
  389. package/lib/csp.mjs +130 -130
  390. package/lib/cspSources.mjs +60 -60
  391. package/lib/dynamic-colors.ts +426 -426
  392. package/lib/git.mjs +64 -64
  393. package/lib/hooks.ts +51 -51
  394. package/lib/inline.ts +20 -20
  395. package/lib/license-texts/Apache-2.0.txt +190 -190
  396. package/lib/license-texts/BSD-2-Clause.txt +24 -24
  397. package/lib/license-texts/BSD-3-Clause.txt +28 -28
  398. package/lib/license-texts/ISC.txt +15 -15
  399. package/lib/license-texts/MIT.txt +21 -21
  400. package/lib/licenses.d.mts +1 -1
  401. package/lib/licenses.mjs +683 -683
  402. package/lib/markdown.ts +42 -42
  403. package/lib/motion-schemes.mjs +5 -5
  404. package/lib/motion.ts +131 -131
  405. package/lib/notice.mjs +34 -34
  406. package/lib/scheduled.mjs +37 -37
  407. package/lib/snackbar.ts +149 -149
  408. package/lib/status.mjs +18 -18
  409. package/lib/storeEntries.d.mts +18 -0
  410. package/lib/storeEntries.mjs +64 -0
  411. package/lib/storeEntries.test.d.mts +1 -0
  412. package/lib/storeEntries.test.mjs +71 -0
  413. package/lib/svg.mjs +231 -231
  414. package/lib/text-colors.mjs +43 -43
  415. package/lib/video-id.mjs +72 -72
  416. package/lib/video.mjs +91 -91
  417. package/package.json +136 -131
  418. package/pages/404.astro +10 -10
  419. package/pages/[...slug].astro +51 -51
  420. package/pages/_headers.txt +31 -31
  421. package/pages/blog/index.astro +10 -10
  422. package/pages/blog/kategori/[category].astro +18 -18
  423. package/pages/editor/code-images.json.js +25 -25
  424. package/pages/editor/index.astro +75 -75
  425. package/pages/editor/manifest.webmanifest.js +23 -23
  426. package/pages/editor/sw.js +33 -33
  427. package/pages/editor/sw.js.js +4 -4
  428. package/pages/editor/thumbnails.json.js +21 -21
  429. package/pages/editor/version.json.js +22 -22
  430. package/pages/headers.js +4 -4
  431. package/pages/index.astro +10 -10
  432. package/pages/ny.astro +6 -6
  433. package/pages/robots.txt.js +3 -3
  434. package/pages/rss.xml.js +20 -20
  435. package/scripts/app-screenshots.mjs +164 -164
  436. package/scripts/app-workers.mjs +6 -6
  437. package/scripts/bin.mjs +12 -12
  438. package/scripts/built-site.mjs +134 -99
  439. package/scripts/check-consent.mjs +113 -113
  440. package/scripts/check-content.mjs +151 -149
  441. package/scripts/check-demo.mjs +24 -24
  442. package/scripts/check-store.mjs +160 -0
  443. package/scripts/check-themes.mjs +45 -45
  444. package/scripts/check-worker.mjs +569 -569
  445. package/scripts/compare-html.mjs +81 -81
  446. package/scripts/declarations.mjs +10 -0
  447. package/scripts/declarations.test.mjs +32 -0
  448. package/scripts/demo-site.mjs +42 -42
  449. package/scripts/editor-icons.mjs +56 -56
  450. package/scripts/editor-roundtrip.mjs +134 -134
  451. package/scripts/editor-screens.mjs +101 -101
  452. package/scripts/git-env.mjs +9 -9
  453. package/scripts/icon-packs.mjs +151 -151
  454. package/scripts/link-counts.mjs +22 -22
  455. package/scripts/page-colors.mjs +42 -42
  456. package/scripts/releases.mjs +80 -80
  457. package/scripts/responsive-images.mjs +94 -94
  458. package/scripts/scheduled-list.mjs +15 -15
  459. package/scripts/theme-screenshots.mjs +88 -88
  460. package/scripts/types.mjs +17 -21
  461. package/scripts/video-thumbnails.mjs +89 -89
  462. package/scripts/worker-modules.mjs +34 -34
  463. package/site/appCookies.mjs +74 -74
  464. package/site/apps.ts +21 -21
  465. package/site/autolinks.mjs +200 -200
  466. package/site/blockScripts.ts +31 -31
  467. package/site/categories.ts +16 -16
  468. package/site/consent.ts +26 -26
  469. package/site/content.config.ts +84 -84
  470. package/site/content.ts +104 -104
  471. package/site/cookieList.ts +17 -17
  472. package/site/defineSite.d.mts +2 -14
  473. package/site/defineSite.mjs +131 -136
  474. package/site/directives/interact.mjs +31 -31
  475. package/site/editor-version.mjs +26 -26
  476. package/site/fill.ts +13 -13
  477. package/site/format.ts +11 -11
  478. package/site/home.ts +66 -66
  479. package/site/iconPacks.mjs +43 -43
  480. package/site/icons.mjs +605 -605
  481. package/site/images.mjs +157 -157
  482. package/site/motion.ts +13 -13
  483. package/site/noindex.mjs +21 -21
  484. package/site/pageColors.ts +110 -110
  485. package/site/popups.ts +98 -98
  486. package/site/schema.ts +189 -189
  487. package/site/sections.ts +3 -3
  488. package/site/seo.ts +28 -28
  489. package/site/site.ts +76 -76
  490. package/site/siteConfig.mjs +8 -8
  491. package/site/siteCsp.mjs +75 -75
  492. package/site/siteModules.mjs +27 -27
  493. package/site/standardPages.ts +59 -59
  494. package/site/storeTheme.d.mts +6 -0
  495. package/site/storeTheme.mjs +29 -0
  496. package/site/storeTheme.test.d.mts +1 -0
  497. package/site/storeTheme.test.mjs +53 -0
  498. package/site/testimonials.ts +48 -48
  499. package/site/text.ts +43 -43
  500. package/site/theme.mjs +483 -472
  501. package/site/tracking.client.ts +225 -225
  502. package/site/tracking.ts +9 -9
  503. package/src/ActionParts.tsx +205 -205
  504. package/src/Advanced.tsx +152 -152
  505. package/src/App.tsx +901 -904
  506. package/src/AppList.tsx +1362 -1345
  507. package/src/BlockDialog.tsx +360 -360
  508. package/src/BlockParts.tsx +652 -652
  509. package/src/BlockSettings.tsx +533 -533
  510. package/src/BrandForm.tsx +164 -164
  511. package/src/Colors.tsx +476 -476
  512. package/src/CommunicationParts.tsx +199 -199
  513. package/src/CompanyForm.tsx +209 -209
  514. package/src/Consents.tsx +175 -175
  515. package/src/ContainmentParts.tsx +185 -185
  516. package/src/Crop.tsx +152 -152
  517. package/src/DataForm.tsx +434 -434
  518. package/src/Deleted.tsx +177 -177
  519. package/src/EntryForm.tsx +740 -740
  520. package/src/FileView.tsx +30 -30
  521. package/src/FormFrame.tsx +237 -237
  522. package/src/Keywords.tsx +225 -225
  523. package/src/Library.tsx +1563 -1563
  524. package/src/Licenses.tsx +336 -336
  525. package/src/LinkSheet.tsx +138 -138
  526. package/src/MarkdownEditor.tsx +1075 -1075
  527. package/src/Media.tsx +677 -677
  528. package/src/NavigationParts.tsx +350 -350
  529. package/src/NavigationRestParts.tsx +247 -247
  530. package/src/Notifications.tsx +59 -59
  531. package/src/PagePicker.tsx +136 -136
  532. package/src/Parts.tsx +991 -991
  533. package/src/PartsShown.tsx +168 -168
  534. package/src/PickerParts.tsx +183 -183
  535. package/src/Redirects.tsx +450 -450
  536. package/src/Screen.tsx +184 -184
  537. package/src/SectionsField.tsx +65 -65
  538. package/src/SelectionParts.tsx +134 -134
  539. package/src/SeoScores.tsx +184 -184
  540. package/src/Sheet.tsx +35 -35
  541. package/src/ThemeStore.tsx +376 -376
  542. package/src/Tokens.tsx +346 -346
  543. package/src/Versions.tsx +214 -214
  544. package/src/addresses.ts +75 -75
  545. package/src/allIcons.ts +7 -7
  546. package/src/appFiles.ts +29 -29
  547. package/src/apps.ts +281 -281
  548. package/src/ask.tsx +123 -123
  549. package/src/blocks.ts +251 -251
  550. package/src/brand.ts +153 -153
  551. package/src/cache.ts +42 -42
  552. package/src/check.ts +285 -285
  553. package/src/checkSite.ts +256 -256
  554. package/src/{consents.ts → consentData.ts} +26 -26
  555. package/src/cookieList.ts +70 -70
  556. package/src/countries.ts +59 -59
  557. package/src/deploy.ts +155 -155
  558. package/src/editor.css +602 -602
  559. package/src/examples.ts +85 -85
  560. package/src/fields.tsx +942 -942
  561. package/src/frontmatter.mjs +42 -42
  562. package/src/github.ts +376 -376
  563. package/src/i18n/da-DK/advanced.ts +28 -28
  564. package/src/i18n/da-DK/app.ts +71 -71
  565. package/src/i18n/da-DK/apps.ts +90 -90
  566. package/src/i18n/da-DK/blockSettings.ts +38 -38
  567. package/src/i18n/da-DK/blocks.ts +408 -408
  568. package/src/i18n/da-DK/check.ts +25 -25
  569. package/src/i18n/da-DK/colors.ts +64 -64
  570. package/src/i18n/da-DK/common.ts +118 -118
  571. package/src/i18n/da-DK/company.ts +24 -24
  572. package/src/i18n/da-DK/consents.ts +23 -23
  573. package/src/i18n/da-DK/forms.ts +116 -116
  574. package/src/i18n/da-DK/history.ts +35 -35
  575. package/src/i18n/da-DK/index.ts +49 -49
  576. package/src/i18n/da-DK/keywords.ts +19 -19
  577. package/src/i18n/da-DK/library.ts +135 -135
  578. package/src/i18n/da-DK/licenses.ts +25 -25
  579. package/src/i18n/da-DK/media.ts +79 -79
  580. package/src/i18n/da-DK/parts.ts +696 -696
  581. package/src/i18n/da-DK/preview.ts +13 -13
  582. package/src/i18n/da-DK/redirects.ts +51 -51
  583. package/src/i18n/da-DK/seo.ts +20 -20
  584. package/src/i18n/da-DK/text.ts +35 -35
  585. package/src/i18n/da-DK/theme.ts +41 -41
  586. package/src/i18n/en-US/advanced.ts +32 -32
  587. package/src/i18n/en-US/app.ts +84 -84
  588. package/src/i18n/en-US/apps.ts +134 -134
  589. package/src/i18n/en-US/blockSettings.ts +48 -48
  590. package/src/i18n/en-US/blocks.ts +441 -441
  591. package/src/i18n/en-US/check.ts +31 -31
  592. package/src/i18n/en-US/colors.ts +82 -82
  593. package/src/i18n/en-US/common.ts +144 -144
  594. package/src/i18n/en-US/company.ts +25 -25
  595. package/src/i18n/en-US/consents.ts +25 -25
  596. package/src/i18n/en-US/forms.ts +145 -145
  597. package/src/i18n/en-US/history.ts +43 -43
  598. package/src/i18n/en-US/index.ts +51 -51
  599. package/src/i18n/en-US/keywords.ts +24 -24
  600. package/src/i18n/en-US/library.ts +176 -176
  601. package/src/i18n/en-US/licenses.ts +36 -36
  602. package/src/i18n/en-US/media.ts +80 -80
  603. package/src/i18n/en-US/parts.ts +718 -718
  604. package/src/i18n/en-US/preview.ts +15 -15
  605. package/src/i18n/en-US/redirects.ts +66 -66
  606. package/src/i18n/en-US/seo.ts +26 -26
  607. package/src/i18n/en-US/text.ts +41 -41
  608. package/src/i18n/en-US/theme.ts +41 -41
  609. package/src/i18n/index.ts +124 -124
  610. package/src/icon.tsx +160 -160
  611. package/src/iconPacks.ts +89 -89
  612. package/src/images.ts +163 -163
  613. package/src/{licenses.ts → licenseData.ts} +79 -79
  614. package/src/markdown.mjs +204 -204
  615. package/src/nav.ts +68 -68
  616. package/src/notFound.ts +28 -28
  617. package/src/{notifications.ts → notificationData.ts} +223 -223
  618. package/src/pagePreview.tsx +204 -204
  619. package/src/r2Caching.ts +55 -55
  620. package/src/{redirects.ts → redirectData.ts} +149 -149
  621. package/src/registry.tsx +188 -188
  622. package/src/schema.ts +417 -417
  623. package/src/sections.mjs +303 -303
  624. package/src/seo.ts +333 -333
  625. package/src/shared.ts +44 -44
  626. package/src/sidebar.ts +31 -31
  627. package/src/site.ts +317 -317
  628. package/src/standard/Block.astro +19 -19
  629. package/src/standard/BlockSite.astro +18 -18
  630. package/src/standard/accordion/index.tsx +47 -47
  631. package/src/standard/accordion/load.ts +23 -23
  632. package/src/standard/accordion/shared.ts +15 -15
  633. package/src/standard/accordion/view.ts +32 -32
  634. package/src/standard/buttons/fields.ts +98 -98
  635. package/src/standard/buttons/index.tsx +23 -23
  636. package/src/standard/buttons/load.ts +34 -34
  637. package/src/standard/buttons/shared.ts +12 -12
  638. package/src/standard/buttons/view.ts +30 -30
  639. package/src/standard/cards/index.tsx +71 -71
  640. package/src/standard/cards/load.ts +17 -17
  641. package/src/standard/cards/shared.ts +27 -27
  642. package/src/standard/cards/view.ts +29 -29
  643. package/src/standard/checklist/index.tsx +27 -27
  644. package/src/standard/checklist/load.ts +5 -5
  645. package/src/standard/checklist/view.ts +13 -13
  646. package/src/standard/cookies/index.tsx +17 -17
  647. package/src/standard/cookies/list.ts +56 -56
  648. package/src/standard/cookies/load.ts +11 -11
  649. package/src/standard/cookies/shared.ts +20 -20
  650. package/src/standard/cookies/view.ts +28 -28
  651. package/src/standard/faq/index.tsx +29 -29
  652. package/src/standard/faq/load.ts +14 -14
  653. package/src/standard/faq/view.ts +21 -21
  654. package/src/standard/head.ts +23 -23
  655. package/src/standard/hero/fields.ts +48 -48
  656. package/src/standard/hero/index.tsx +57 -57
  657. package/src/standard/hero/load.ts +23 -23
  658. package/src/standard/hero/view.ts +38 -38
  659. package/src/standard/links/index.tsx +41 -41
  660. package/src/standard/links/load.ts +17 -17
  661. package/src/standard/links/view.ts +25 -25
  662. package/src/standard/list/index.tsx +50 -50
  663. package/src/standard/list/load.ts +5 -5
  664. package/src/standard/list/view.ts +18 -18
  665. package/src/standard/load.ts +7 -7
  666. package/src/standard/notice/index.tsx +30 -30
  667. package/src/standard/notice/load.ts +16 -16
  668. package/src/standard/notice/view.ts +10 -10
  669. package/src/standard/points/index.tsx +42 -42
  670. package/src/standard/points/load.ts +5 -5
  671. package/src/standard/points/view.ts +12 -12
  672. package/src/standard/posts/index.tsx +132 -132
  673. package/src/standard/posts/load.ts +36 -36
  674. package/src/standard/posts/shared.ts +21 -21
  675. package/src/standard/posts/view.ts +63 -63
  676. package/src/standard/pricecards/index.tsx +57 -57
  677. package/src/standard/pricecards/load.ts +11 -11
  678. package/src/standard/pricecards/view.ts +50 -50
  679. package/src/standard/sharedTexts.ts +6 -6
  680. package/src/standard/site.ts +124 -124
  681. package/src/standard/siteLooks.ts +19 -19
  682. package/src/standard/sites.ts +13 -13
  683. package/src/standard/steps/index.tsx +28 -28
  684. package/src/standard/steps/load.ts +5 -5
  685. package/src/standard/steps/view.ts +11 -11
  686. package/src/standard/testimonials/index.tsx +87 -87
  687. package/src/standard/testimonials/list.ts +43 -43
  688. package/src/standard/testimonials/load.ts +58 -58
  689. package/src/standard/testimonials/shared.ts +13 -13
  690. package/src/standard/testimonials/view.ts +76 -76
  691. package/src/standard/themeBlocks.ts +13 -13
  692. package/src/standard/video/index.tsx +23 -23
  693. package/src/standard/video/load.ts +10 -10
  694. package/src/standard/video/view.ts +42 -42
  695. package/src/standard/view.tsx +105 -105
  696. package/src/standard/views.ts +37 -37
  697. package/src/store.ts +479 -499
  698. package/src/theme.ts +90 -90
  699. package/src/themeUi.ts +97 -97
  700. package/src/themes.ts +91 -91
  701. package/src/ui.tsx +3402 -3402
  702. package/src/virtual.d.ts +5 -5
  703. package/src/when.ts +22 -22
  704. package/starter/.githooks/pre-push +4 -4
  705. package/starter/.nvmrc +1 -1
  706. package/starter/.prettierignore +11 -11
  707. package/starter/.prettierrc.json +7 -7
  708. package/starter/README.md +27 -26
  709. package/starter/astro.config.mjs +5 -5
  710. package/starter/package.json +67 -67
  711. package/starter/public/_redirects +1 -1
  712. package/starter/public/favicon.svg +1 -1
  713. package/starter/public/logo.svg +1 -1
  714. package/starter/src/content/pages/blog.md +8 -8
  715. package/starter/src/content/pages/forside.md +9 -9
  716. package/starter/src/content/pages/kontakt.md +7 -7
  717. package/starter/src/content/pages/privatlivspolitik.md +9 -9
  718. package/starter/src/content/pages/siden-findes-ikke.md +7 -7
  719. package/starter/src/content.config.ts +2 -2
  720. package/starter/src/data/apps.json +1 -1
  721. package/starter/src/data/block-settings.json +3 -3
  722. package/starter/src/data/blocks.json +1 -1
  723. package/starter/src/data/booking.json +6 -6
  724. package/starter/src/data/colors.json +70 -70
  725. package/starter/src/data/contact.json +8 -8
  726. package/starter/src/data/cookies.json +21 -21
  727. package/starter/src/data/csp.json +1 -1
  728. package/starter/src/data/footer.json +15 -15
  729. package/starter/src/data/internal-links.json +4 -4
  730. package/starter/src/data/menu.json +4 -4
  731. package/starter/src/data/mobile-menu.json +4 -4
  732. package/starter/src/data/old-addresses.json +1 -1
  733. package/starter/src/data/redirects.json +1 -1
  734. package/starter/src/data/standard-pages.json +8 -8
  735. package/starter/src/data/theme.json +1 -1
  736. package/starter/src/env.d.ts +2 -2
  737. package/starter/src/site/config.ts +13 -13
  738. package/starter/src/site/worker.js +14 -14
  739. package/starter/tsconfig.json +17 -17
  740. package/starter/worker/apps.js +6 -6
  741. package/starter/wrangler.jsonc +39 -39
  742. package/theme/LICENSE +21 -21
  743. package/theme/blocks/ArrowLink.tsx +14 -14
  744. package/theme/blocks/BlockHead.tsx +26 -26
  745. package/theme/blocks/Panel.tsx +32 -32
  746. package/theme/blocks/Person.tsx +26 -26
  747. package/theme/blocks/accordion/PageRow.tsx +25 -25
  748. package/theme/blocks/accordion/View.tsx +17 -17
  749. package/theme/blocks/buttons/Buttons.tsx +28 -28
  750. package/theme/blocks/buttons/View.tsx +12 -12
  751. package/theme/blocks/cards/LinkCards.tsx +72 -72
  752. package/theme/blocks/cards/View.tsx +20 -20
  753. package/theme/blocks/checklist/Checks.tsx +15 -15
  754. package/theme/blocks/checklist/View.tsx +14 -14
  755. package/theme/blocks/cookies/View.tsx +36 -36
  756. package/theme/blocks/faq/Accordion.tsx +34 -34
  757. package/theme/blocks/faq/View.tsx +16 -16
  758. package/theme/blocks/faq/script.ts +56 -56
  759. package/theme/blocks/hero/View.tsx +44 -44
  760. package/theme/blocks/links/LinkGroups.tsx +24 -24
  761. package/theme/blocks/links/View.tsx +17 -17
  762. package/theme/blocks/list/Rows.tsx +24 -24
  763. package/theme/blocks/list/View.tsx +14 -14
  764. package/theme/blocks/notice/View.tsx +13 -13
  765. package/theme/blocks/points/PointList.tsx +34 -34
  766. package/theme/blocks/points/View.tsx +14 -14
  767. package/theme/blocks/posts/View.tsx +26 -26
  768. package/theme/blocks/pricecards/View.tsx +40 -40
  769. package/theme/blocks/steps/StepList.tsx +23 -23
  770. package/theme/blocks/steps/View.tsx +14 -14
  771. package/theme/blocks/testimonials/List.tsx +46 -46
  772. package/theme/blocks/testimonials/Slider.tsx +44 -44
  773. package/theme/blocks/testimonials/Testimonial.tsx +124 -124
  774. package/theme/blocks/testimonials/View.tsx +21 -21
  775. package/theme/blocks/testimonials/script.ts +52 -52
  776. package/theme/blocks/video/View.tsx +41 -41
  777. package/theme/components/ActionBar.astro +63 -63
  778. package/theme/components/AppSlot.astro +21 -21
  779. package/theme/components/ArrowLink.tsx +21 -21
  780. package/theme/components/BlogIndex.astro +48 -48
  781. package/theme/components/BlogPosts.astro +18 -18
  782. package/theme/components/BlogPosts.tsx +45 -45
  783. package/theme/components/BookButton.tsx +22 -22
  784. package/theme/components/Container.astro +11 -11
  785. package/theme/components/CookieConsent.astro +121 -121
  786. package/theme/components/EndBlock.astro +21 -21
  787. package/theme/components/FontPreload.astro +6 -6
  788. package/theme/components/Footer.astro +85 -85
  789. package/theme/components/Header.astro +186 -186
  790. package/theme/components/Icon.astro +16 -16
  791. package/theme/components/Img.astro +29 -29
  792. package/theme/components/Img.tsx +33 -33
  793. package/theme/components/Inline.astro +11 -11
  794. package/theme/components/Logo.astro +23 -23
  795. package/theme/components/LogoMark.astro +10 -10
  796. package/theme/components/Markdown.astro +17 -17
  797. package/theme/components/MobileMenu.tsx +224 -224
  798. package/theme/components/MobileNav.astro +33 -33
  799. package/theme/components/PageHeader.astro +22 -22
  800. package/theme/components/PageSection.astro +24 -24
  801. package/theme/components/PhoneButton.tsx +11 -11
  802. package/theme/components/Popup.astro +145 -145
  803. package/theme/components/PopupButton.tsx +24 -24
  804. package/theme/components/PopupView.tsx +48 -48
  805. package/theme/components/PostList.tsx +58 -58
  806. package/theme/components/PostMeta.tsx +17 -17
  807. package/theme/components/RichText.astro +28 -28
  808. package/theme/components/Section.astro +29 -29
  809. package/theme/components/SectionHeading.tsx +8 -8
  810. package/theme/components/TextSection.astro +16 -16
  811. package/theme/components/home/PageBlock.astro +19 -19
  812. package/theme/components/home/Text.astro +13 -13
  813. package/theme/components/loadPopup.ts +22 -22
  814. package/theme/components/loadPosts.ts +16 -16
  815. package/theme/components/posts.ts +15 -15
  816. package/theme/editor/looks.mjs +7 -7
  817. package/theme/editor/previews.tsx +56 -56
  818. package/theme/layouts/Base.astro +273 -273
  819. package/theme/layouts/PageLayout.astro +126 -126
  820. package/theme/layouts/PostLayout.astro +137 -137
  821. package/theme/scripts/motion.ts +29 -29
  822. package/theme/styles/base.css +320 -320
  823. package/theme/styles/blocks.css +1808 -1808
  824. package/theme/styles/components.css +206 -206
  825. package/theme/styles/global.css +1302 -1302
  826. package/theme/styles/ui.css +6544 -6544
  827. package/theme/templates/Blog.astro +38 -38
  828. package/theme/templates/Category.astro +19 -19
  829. package/theme/templates/Home.astro +36 -36
  830. package/theme/templates/New.astro +68 -68
  831. package/theme/templates/NotFound.astro +117 -117
  832. package/theme/theme.json +28 -28
  833. package/theme/ui/Badge.tsx +45 -45
  834. package/theme/ui/BottomSheet.tsx +148 -148
  835. package/theme/ui/Button.tsx +79 -79
  836. package/theme/ui/ButtonGroup.tsx +31 -31
  837. package/theme/ui/Card.tsx +45 -45
  838. package/theme/ui/Carousel.tsx +124 -124
  839. package/theme/ui/Checkbox.tsx +59 -59
  840. package/theme/ui/Chip.tsx +101 -101
  841. package/theme/ui/ChipSet.tsx +27 -27
  842. package/theme/ui/DatePicker.tsx +149 -149
  843. package/theme/ui/DatePickerContainer.tsx +316 -316
  844. package/theme/ui/Dialog.tsx +100 -100
  845. package/theme/ui/Divider.tsx +27 -27
  846. package/theme/ui/ExposedDropdownMenu.tsx +77 -77
  847. package/theme/ui/Fab.tsx +88 -88
  848. package/theme/ui/FabMenu.tsx +67 -67
  849. package/theme/ui/IconButton.tsx +78 -78
  850. package/theme/ui/List.tsx +287 -287
  851. package/theme/ui/LoadingIndicator.tsx +29 -29
  852. package/theme/ui/Menu.tsx +278 -278
  853. package/theme/ui/NavigationBar.tsx +62 -62
  854. package/theme/ui/NavigationDrawer.tsx +80 -80
  855. package/theme/ui/NavigationRail.tsx +154 -154
  856. package/theme/ui/ProgressIndicator.tsx +162 -162
  857. package/theme/ui/RadioButton.tsx +50 -50
  858. package/theme/ui/Search.tsx +129 -129
  859. package/theme/ui/SegmentedButton.tsx +49 -49
  860. package/theme/ui/SideSheet.tsx +54 -54
  861. package/theme/ui/Slider.tsx +155 -155
  862. package/theme/ui/Snackbar.tsx +67 -67
  863. package/theme/ui/SplitButton.tsx +67 -67
  864. package/theme/ui/Switch.tsx +60 -60
  865. package/theme/ui/Tabs.tsx +114 -114
  866. package/theme/ui/TextField.tsx +278 -278
  867. package/theme/ui/TimePicker.tsx +128 -128
  868. package/theme/ui/TimePickerContainer.tsx +234 -234
  869. package/theme/ui/Toolbar.tsx +67 -67
  870. package/theme/ui/Tooltip.tsx +58 -58
  871. package/theme/ui/TopAppBar.tsx +77 -77
  872. package/theme/ui/bottomSheet.script.ts +8 -8
  873. package/theme/ui/{bottomSheet.ts → bottomSheetControl.ts} +211 -211
  874. package/theme/ui/carousel.script.ts +28 -28
  875. package/theme/ui/carouselLayout.ts +229 -229
  876. package/theme/ui/checkbox.script.ts +8 -8
  877. package/theme/ui/datePicker.script.ts +302 -302
  878. package/theme/ui/dates.ts +185 -185
  879. package/theme/ui/{dialog.ts → dialogControl.ts} +48 -48
  880. package/theme/ui/dropdownMenu.script.ts +166 -166
  881. package/theme/ui/fabMenu.script.ts +57 -57
  882. package/theme/ui/navigationRail.script.ts +47 -47
  883. package/theme/ui/ripple.script.ts +159 -159
  884. package/theme/ui/search.script.ts +92 -92
  885. package/theme/ui/{sideSheet.ts → sideSheetControl.ts} +48 -48
  886. package/theme/ui/slider.script.ts +12 -12
  887. package/theme/ui/sliderUpdate.ts +52 -52
  888. package/theme/ui/snackbar.script.ts +16 -16
  889. package/theme/ui/splitButton.script.ts +63 -63
  890. package/theme/ui/tabs.script.ts +48 -48
  891. package/theme/ui/textField.script.ts +17 -17
  892. package/theme/ui/textFieldUpdate.ts +44 -44
  893. package/theme/ui/timePicker.script.ts +248 -248
  894. package/theme/ui/toolbar.script.ts +43 -43
  895. package/theme/ui/tooltip.script.ts +68 -68
  896. package/worker/consent.js +141 -141
  897. package/worker/editor-github.js +167 -167
  898. package/worker/editor-host.js +74 -74
  899. package/worker/files.js +401 -401
  900. package/worker/github.js +36 -36
  901. package/worker/index.js +188 -184
  902. package/worker/not-found.js +97 -97
  903. package/worker/oauth.js +95 -95
  904. package/worker/publish.js +52 -52
  905. package/worker/push.js +270 -270
  906. package/worker/r2-status.js +50 -50
  907. package/worker/session.js +58 -58
  908. package/worker/store.js +46 -0
package/src/Media.tsx CHANGED
@@ -1,677 +1,677 @@
1
- // "Filer" under "Indhold": the pictures uploaded to the site (public/images/uploads/<year>/<month>/,
2
- // imported or added in the editor, images.ts) and the PDFs, videos and zip files (public/filer/,
3
- // /filer/ on the site), newest first, with search, the order (newest, oldest, by name), a month or year,
4
- // the type (Billeder, PDF'er, Videoer, Zip-filer) and whether a page uses them (Gallery). A file that is
5
- // not a picture is a square with its icon and name. Tapping one opens a sheet from the bottom with the
6
- // file (FileView.tsx), its file name and where it is used, each a link to that page or post, or
7
- // "Slet billede" (…) when nothing uses it (also not the site's own code, /editor/code-images.json). Its
8
- // name, under the title, can be changed with the pencil after it (not one the site's code uses),
9
- // saved with the tick: the file moves, the pages that use it get the new address in the same commit,
10
- // and the old address sends on to it (addresses.ts). "+" uploads a new file, saved at once. The same
11
- // pictures (not the other files) are the gallery a picture is chosen from in the forms (useMediaPicker).
12
- // The clock in the top bar, "Slettede filer", opens the files that were deleted (Deleted.tsx).
13
- import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
14
- import {
15
- ArrowsDownUp,
16
- CalendarBlank,
17
- Check,
18
- ClockCounterClockwise,
19
- PencilSimple,
20
- Plus,
21
- Trash,
22
- UploadSimple,
23
- X,
24
- } from '@phosphor-icons/react';
25
- import { moveRedirects, REDIRECTS } from './addresses';
26
- import { changesOf, flatten, readAll, REDIRECT_FILES } from './redirects';
27
- import { ask } from './ask';
28
- import { dateName, headed, letterOf } from './Sheet';
29
- import Screen from './Screen';
30
- import {
31
- Button,
32
- InputChip,
33
- Choice,
34
- Filters,
35
- IconButton,
36
- TextField,
37
- listClass,
38
- SupportingText,
39
- SearchRow,
40
- useKept,
41
- BottomSheet,
42
- SheetTitle,
43
- SideSheet,
44
- useWide,
45
- EmptyNote,
46
- SheetBody,
47
- ListItem,
48
- } from './ui';
49
- import type { Env } from './App';
50
- import type { RepoFile } from './github';
51
- import { fixedName, slugify } from './schema';
52
- import { config, siteName } from '@site/editor';
53
- import { collections } from './site';
54
- import { readFields, splitFrontmatter } from './frontmatter.mjs';
55
- import { fileKindOf, pickedKindOf, pickImage, prepareFile, prepareImage, publicPath, showMoved, shownAt, type Uploads } from './images';
56
- import { fileIcons, FileView, KINDS, kindOf, type Kind } from './FileView';
57
- import { kindAndName, savedText } from './deploy';
58
- import { locale, siteT, t } from './i18n';
59
-
60
- export const MEDIA = config.folders.media;
61
-
62
- /** The folder of PDFs, videos and zip files (/filer/ on the site), when the site has one. */
63
- export const FILES: string | undefined = (config.folders as { files?: string }).files;
64
-
65
- /** The uploaded pictures among the files, newest first (by their folder, then name). */
66
- export const mediaOf = (files: RepoFile[]) =>
67
- files.filter((f) => f.path.startsWith(`${MEDIA}/`)).sort((a, b) => b.path.localeCompare(a.path, locale));
68
-
69
- /** The PDFs, videos and zip files among the files, by name. */
70
- export const filesOf = (files: RepoFile[]) =>
71
- FILES
72
- ? files.filter((f) => f.path.startsWith(`${FILES}/`) && fileKindOf(f.path)).sort((a, b) => a.path.localeCompare(b.path, locale))
73
- : [];
74
-
75
- // What a file that uses a picture or another file is called: a page's or post's title, a fixed page's name, an
76
- // entry's as its list shows it (a pop-up); null for a file the editor does not open (the list of
77
- // picture sizes, image-manifest.json).
78
- function nameOf(file: RepoFile): string | null {
79
- for (const c of collections)
80
- if (c.kind === 'files') {
81
- const own = c.files.find((f) => f.file === file.path);
82
- if (own) return fixedName(own, file.text);
83
- } else if (c.kind === 'folder' && file.path.startsWith(`${c.folder}/`) && file.path.endsWith('.json')) {
84
- try {
85
- return c.summary(JSON.parse(file.text)) || fileName(file.path);
86
- } catch {
87
- return null;
88
- }
89
- }
90
- if ([config.folders.pages, config.folders.blog].some((d) => file.path.startsWith(`${d}/`)) && file.path.endsWith('.md'))
91
- return String(readFields(splitFrontmatter(file.text).yaml).title ?? file.path);
92
- return null;
93
- }
94
-
95
- // A picture's file name without its folder, and split from its ending ("foto", ".webp").
96
- const fileName = (src: string) => src.split('/').pop() ?? '';
97
- const stemOf = (src: string) => fileName(src).replace(/\.[^.]*$/, '');
98
- const endingOf = (src: string) => fileName(src).slice(stemOf(src).length);
99
-
100
- // A text with a picture's address changed to another: wherever it stands (Markdown, HTML, JSON), but
101
- // not a longer file name that starts with it.
102
- const readdress = (text: string, from: string, to: string) =>
103
- text.replace(new RegExp(`${from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w.-])`, 'g'), to);
104
-
105
- // The small copies of the pictures the site has (thumbnails.json, made when it is built), by their
106
- // address; {} when they could not be asked (then the pictures themselves are shown).
107
- let small: Promise<Record<string, string>> | null = null;
108
- const thumbnails = () =>
109
- (small ??= fetch('/editor/thumbnails.json')
110
- .then((r) => (r.ok ? (r.json() as Promise<Record<string, string>>) : {}))
111
- .catch(() => ({})));
112
-
113
- // The nearest box around an element that scrolls (the sheet's), or null for the page.
114
- function scrollerOf(el: Element) {
115
- for (let box = el.parentElement; box && box !== document.body; box = box.parentElement)
116
- if (/auto|scroll/.test(getComputedStyle(box).overflowY)) return box;
117
- return null;
118
- }
119
-
120
- // A picture in the grid, fetched only when it is about to be scrolled into view (a screen's height
121
- // before), and as its small copy when there is one: a picture from this visit (shownAt) as it is,
122
- // a renamed one by its old address. The picture itself, if the copy cannot be fetched.
123
- function Thumb({ src, thumbs }: { src: string; thumbs: Record<string, string> | null }) {
124
- const ref = useRef<HTMLImageElement>(null);
125
- const [near, setNear] = useState(false);
126
- const [failed, setFailed] = useState(false);
127
- useEffect(() => {
128
- const img = ref.current;
129
- if (!img || !('IntersectionObserver' in window)) return setNear(true);
130
- const observer = new IntersectionObserver(
131
- (entries) => {
132
- if (!entries.some((e) => e.isIntersecting)) return;
133
- setNear(true);
134
- observer.disconnect();
135
- },
136
- { root: scrollerOf(img), rootMargin: '100% 0px' },
137
- );
138
- observer.observe(img);
139
- return () => observer.disconnect();
140
- }, []);
141
- const shown = shownAt(src);
142
- const thumb = !failed && thumbs?.[shown];
143
- return (
144
- <img
145
- ref={ref}
146
- src={near && thumbs ? thumb || shown : undefined}
147
- alt=""
148
- decoding="async"
149
- className="editor-fade-in size-full object-cover transition-transform duration-(--motion-spatial-duration) ease-(--motion-ease) group-hover:scale-105"
150
- onLoad={(e) => e.currentTarget.setAttribute('data-loaded', '')}
151
- onError={() => thumb && setFailed(true)}
152
- />
153
- );
154
- }
155
-
156
- // A PDF, a video or a zip file in the grid: its icon and its name.
157
- function FileTile({ src }: { src: string }) {
158
- const Icon = fileIcons[fileKindOf(src)!];
159
- return (
160
- <span className="flex size-full flex-col items-center justify-center gap-2 p-2 text-center">
161
- <Icon size={36} className="shrink-0 text-muted" />
162
- <span className="line-clamp-2 text-xs break-all text-muted">{nameOfSrc(src)}</span>
163
- </span>
164
- );
165
- }
166
-
167
- // The pictures (and the other files) as squares, by their address on the site: three to four in a row
168
- // on a phone, as many as there is room for on a computer. picked: the one shown beside the grid (Filer
169
- // on a computer), marked.
170
- function Grid({ srcs, picked, onPick }: { srcs: string[]; picked?: string | null; onPick: (src: string) => void }) {
171
- const [thumbs, setThumbs] = useState<Record<string, string> | null>(null);
172
- useEffect(() => void thumbnails().then(setThumbs), []);
173
- return (
174
- <ul className="grid grid-cols-3 gap-1.5 sm:grid-cols-4 md:grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] md:gap-2">
175
- {srcs.map((src) => (
176
- <li key={src}>
177
- <button
178
- type="button"
179
- className={`group block aspect-square w-full overflow-hidden rounded-lg bg-tint ${src === picked ? 'ring-2 ring-accent ring-offset-2 ring-offset-bg' : ''}`}
180
- aria-label={nameOfSrc(src)}
181
- aria-pressed={picked === undefined ? undefined : src === picked}
182
- onClick={() => onPick(src)}
183
- >
184
- {fileKindOf(src) ? <FileTile src={src} /> : <Thumb src={src} thumbs={thumbs} />}
185
- </button>
186
- </li>
187
- ))}
188
- </ul>
189
- );
190
- }
191
-
192
- type Sort = 'new' | 'old' | 'name';
193
- const SORTS: Sort[] = ['new', 'old', 'name'];
194
- const SORT_KEY = 'editor-media-sort';
195
- const nameOfSrc = (src: string) => src.split('/').pop()!;
196
- // The month a picture was uploaded, as "2025-06" (its folder), or '' outside such a folder.
197
- const monthOf = (src: string) => {
198
- const m = /\/(\d{4})\/(\d{2})\/[^/]+$/.exec(src);
199
- return m ? `${m[1]}-${m[2]}` : '';
200
- };
201
- /** The pictures with search, their order and the filters, and the month between them when they are
202
- * in the order of their dates, the first letter when by name. used: the files a page uses (Filer): then
203
- * the funnel opens a sheet with both filters, by use and by type. picked: the file shown beside it. */
204
- function Gallery({
205
- srcs,
206
- used,
207
- picked,
208
- onPick,
209
- }: {
210
- srcs: string[];
211
- used?: Set<string>;
212
- picked?: string | null;
213
- onPick: (src: string) => void;
214
- }) {
215
- // All the files, not only pictures (Filer), with their type.
216
- const all = !!used;
217
- // In Filer, kept for coming back to it (from a file's page); in a picker, from the start each time.
218
- const [kind, setKind] = useKept<'' | Kind>(all ? 'filer-type' : null, '');
219
- const [query, setQuery] = useKept(all ? 'filer-soeg' : null, '');
220
- const [sort, setSort] = useState<Sort>(() => {
221
- try {
222
- const saved = localStorage.getItem(SORT_KEY);
223
- if (SORTS.some((s) => s === saved)) return saved as Sort;
224
- } catch {}
225
- return 'new';
226
- });
227
- const [date, setDate] = useKept(all ? 'filer-dato' : null, '');
228
- const [use, setUse] = useKept<'' | 'used' | 'unused'>(all ? 'filer-brug' : null, '');
229
-
230
- function pickSort(next: Sort) {
231
- setSort(next);
232
- try {
233
- localStorage.setItem(SORT_KEY, next);
234
- } catch {}
235
- }
236
-
237
- // The years and months there are pictures from, newest first.
238
- const months = [...new Set(srcs.map(monthOf))].sort().reverse();
239
- const years = [...new Set(months.filter(Boolean).map((m) => m.slice(0, 4)))];
240
- // The types there are files of (and the one chosen); the filter by type only when there are two or more.
241
- const kinds = KINDS.filter((k) => k === kind || srcs.some((src) => kindOf(src) === k));
242
- const words = query.toLowerCase().split(/\s+/).filter(Boolean);
243
- const shown = srcs
244
- .filter((src) => words.every((w) => nameOfSrc(src).toLowerCase().includes(w)))
245
- .filter((src) => !date || monthOf(src) === date || monthOf(src).startsWith(`${date}-`))
246
- .filter((src) => !use || used?.has(src) === (use === 'used'))
247
- .filter((src) => !kind || kindOf(src) === kind)
248
- .sort((a, b) =>
249
- sort === 'name'
250
- ? nameOfSrc(a).localeCompare(nameOfSrc(b), locale)
251
- : (sort === 'new' ? -1 : 1) * (monthOf(a).localeCompare(monthOf(b)) || nameOfSrc(a).localeCompare(nameOfSrc(b), locale)),
252
- );
253
- // By month in the order of the dates, by the first letter by name (headed, from HEADINGS_FROM).
254
- const groups = headed(shown, (src) => (sort === 'name' ? letterOf(nameOfSrc(src)) : monthOf(src)));
255
- const chips = [
256
- date && { label: dateName(date), clear: () => setDate('') },
257
- use && { label: use === 'used' ? t.media.inUse : t.common.notInUse, clear: () => setUse('') },
258
- kind && { label: t.media.kinds[kind].plural, clear: () => setKind('') },
259
- ].filter((c) => !!c);
260
-
261
- return (
262
- <div>
263
- <SearchRow
264
- value={query}
265
- onChange={setQuery}
266
- label={all ? t.media.searchAll : t.media.searchLabel}
267
- sort={
268
- <Choice
269
- title={t.media.sort}
270
- icon={ArrowsDownUp}
271
- value={sort}
272
- options={SORTS.map((value) => ({ value, label: t.media.sorts[value] }))}
273
- onChange={pickSort}
274
- />
275
- }
276
- filter={
277
- all && (
278
- <Filters
279
- title={t.media.filter}
280
- selected={!!use || !!kind}
281
- groups={[
282
- {
283
- title: t.media.use,
284
- value: use,
285
- options: [
286
- { value: '', label: t.media.all },
287
- { value: 'used', label: t.media.inUse },
288
- { value: 'unused', label: t.common.notInUse },
289
- ],
290
- onChange: (v: string) => setUse(v as typeof use),
291
- },
292
- kinds.length > 1 && {
293
- title: t.media.kind,
294
- value: kind,
295
- options: [
296
- { value: '', label: t.media.allKinds },
297
- ...kinds.map((value) => ({ value, label: t.media.kinds[value].plural })),
298
- ],
299
- onChange: (v: string) => setKind(v as typeof kind),
300
- },
301
- ].filter((g) => !!g)}
302
- />
303
- )
304
- }
305
- date={
306
- <Choice
307
- title={t.media.date}
308
- icon={CalendarBlank}
309
- value={date}
310
- selected={!!date}
311
- options={[
312
- { value: '', label: t.media.allDates },
313
- ...years.flatMap((year) => [
314
- { value: year, label: t.media.wholeYear(year), group: year },
315
- ...months.filter((m) => m.startsWith(`${year}-`)).map((m) => ({ value: m, label: dateName(m), group: year })),
316
- ]),
317
- ]}
318
- onChange={setDate}
319
- />
320
- }
321
- />
322
- {chips.length > 0 && (
323
- <div className="mt-3 flex flex-wrap gap-2">
324
- {chips.map((c) => (
325
- <InputChip key={c.label} removeLabel={t.media.removeFilter(c.label)} onRemove={c.clear}>
326
- {c.label}
327
- </InputChip>
328
- ))}
329
- </div>
330
- )}
331
- {groups.map((g) => (
332
- <section key={g.key ?? 'all'} className="mt-4">
333
- {g.key !== null && <h2 className="mb-2 text-sm font-medium text-muted">{sort === 'name' ? g.key : dateName(g.key)}</h2>}
334
- <Grid srcs={g.items} picked={picked} onPick={onPick} />
335
- </section>
336
- ))}
337
- {!shown.length && <EmptyNote>{all ? t.media.noFiles : t.media.noPictures}</EmptyNote>}
338
- </div>
339
- );
340
- }
341
-
342
- /** Asks for a picture from the gallery, or a new one (kept with the form's pictures, images.ts, and
343
- * saved with it); its address on the site, or null. The sheet goes on top of a block's sheet. */
344
- export function useMediaPicker(media: RepoFile[], pictures: Uploads): [() => Promise<string | null>, ReactElement] {
345
- const dialog = useRef<HTMLDialogElement>(null);
346
- const answer = useRef<(src: string | null) => void>(() => {});
347
- const srcs = [...new Set([...pictures.added, ...mediaOf(media)].map((f) => publicPath(f.path)))];
348
-
349
- const done = (src: string | null) => {
350
- answer.current(src);
351
- answer.current = () => {};
352
- dialog.current?.close();
353
- };
354
- const ask = () =>
355
- new Promise<string | null>((resolve) => {
356
- answer.current = resolve;
357
- dialog.current?.showModal();
358
- });
359
-
360
- const element = (
361
- <BottomSheet
362
- ref={dialog}
363
- aria-label={t.media.pick}
364
- onClose={(e) => e.target === e.currentTarget && done(null)}
365
- onClick={(e) => e.target === dialog.current && done(null)}
366
- >
367
- <SheetTitle title={t.media.pick} onClose={() => done(null)} />
368
- <SheetBody>
369
- <Button
370
- kind="outlined"
371
-
372
- wide
373
- icon={UploadSimple}
374
- onClick={async () => {
375
- const added = await pictures.upload();
376
- if (added) done(publicPath(added.path));
377
- }}
378
- >
379
- {t.media.uploadNew}
380
- </Button>
381
- <Gallery srcs={srcs} onPick={done} />
382
- </SheetBody>
383
- </BottomSheet>
384
- );
385
- return [ask, element];
386
- }
387
-
388
- // The pictures the site's own code uses (its design, e.g. the front page and a portrait), as it
389
- // was built; null until known, or when it could not be asked (then nothing can be deleted).
390
- let inCode: Promise<string[] | null> | null = null;
391
- const codeImages = () =>
392
- (inCode ??= fetch('/editor/code-images.json')
393
- .then((r) => (r.ok ? (r.json() as Promise<string[]>) : null))
394
- .catch(() => null));
395
-
396
- export default function Media({ files, env }: { files: RepoFile[]; env: Env }) {
397
- // The pictures and the other files.
398
- const pictures = [...mediaOf(files), ...filesOf(files)];
399
- const [code, setCode] = useState<string[] | null>(null);
400
- useEffect(() => void codeImages().then(setCode), []);
401
- // The addresses of the pictures some page, post or fixed page uses (its text has the address), or
402
- // the site's own code.
403
- const used = useMemo(() => {
404
- const texts = files
405
- .filter((f) => f.text && !f.path.startsWith(`${MEDIA}/`) && !(FILES && f.path.startsWith(`${FILES}/`)) && nameOf(f) !== null)
406
- .map((f) => f.text);
407
- return new Set(pictures.map((p) => publicPath(p.path)).filter((src) => code?.includes(src) || texts.some((t) => t.includes(src))));
408
- }, [files, code]);
409
- const dialog = useRef<HTMLDialogElement>(null);
410
- const [src, setSrc] = useState<string | null>(null);
411
- // On a computer the file picked is beside the grid; on a phone in a sheet.
412
- const wide = useWide();
413
- const closeFile = () => {
414
- if (dialog.current?.open) dialog.current.close();
415
- else setSrc(null);
416
- };
417
- // The window gets wide or narrow (a phone turned) while a file is picked: it moves from the sheet to
418
- // the panel or back, and stays picked.
419
- const moving = useRef(false);
420
- useEffect(() => {
421
- if (!src) return;
422
- if (wide && dialog.current?.open) {
423
- moving.current = true;
424
- dialog.current.close();
425
- } else if (!wide && !dialog.current?.open) dialog.current?.showModal();
426
- }, [wide]);
427
- const [adding, setAdding] = useState(false);
428
- const [deleting, setDeleting] = useState(false);
429
- const [name, setName] = useState('');
430
- const [renaming, setRenaming] = useState(false);
431
- // The name being changed: the field shown instead of the pencil after the name.
432
- const [editing, setEditing] = useState(false);
433
- // A new file, saved on its own at once (with a page, a picture is saved with the page instead): a
434
- // PDF, a video or a zip file as it is, a picture made ready as in the forms.
435
- async function add() {
436
- const file = await pickImage(!!FILES);
437
- if (!file) return;
438
- const kind = pickedKindOf(file);
439
- if (!kind && !file.type.startsWith('image/')) return env.notify({ text: t.media.notAFile, error: true });
440
- setAdding(true);
441
- if (!kind) env.notify({ text: t.media.preparing });
442
- try {
443
- const picture = kind
444
- ? await prepareFile(
445
- file,
446
- files.map((f) => f.path),
447
- )
448
- : await prepareImage(file);
449
- env.notify({ text: t.common.saving });
450
- const name = picture.path.split('/').pop()!;
451
- const change = t.app.changes.added(kindAndName(t.media.kinds[kind ?? 'picture'].word, name));
452
- const oids = await env.commit(
453
- siteT.media.commit.add(siteT.media.kinds[kind ?? 'picture'].word, name),
454
- [{ path: picture.path, content: picture.bytes, oid: null }],
455
- change,
456
- );
457
- env.onSaved({ path: picture.path, oid: oids[picture.path], text: '' });
458
- env.notify({ text: savedText(change), kind: 'saved' });
459
- } catch (e) {
460
- env.notify({ text: t.common.notSaved((e as Error).message), error: true, kind: 'failed' });
461
- } finally {
462
- setAdding(false);
463
- }
464
- }
465
-
466
- // A file nothing uses, deleted at once in its own commit (after asking).
467
- async function remove(src: string) {
468
- const file = pictures.find((p) => publicPath(p.path) === src);
469
- if (!file || !(await ask(t.media.kinds[kindOf(src)].deleteAsk, t.common.delete))) return;
470
- setDeleting(true);
471
- env.notify({ text: t.common.deleting });
472
- try {
473
- const change = t.app.changes.deleted(kindAndName(t.media.kinds[kindOf(src)].word, fileName(src)));
474
- await env.commit(
475
- siteT.media.commit.delete(siteT.media.kinds[kindOf(src)].word, src.split('/').pop()!),
476
- [{ path: file.path, content: null, oid: file.oid }],
477
- change,
478
- );
479
- env.forget([file.path]);
480
- closeFile();
481
- env.notify({ text: savedText(change), kind: 'saved' });
482
- } catch (e) {
483
- env.notify({ text: t.media.notDeleted((e as Error).message), error: true, kind: 'failed' });
484
- } finally {
485
- setDeleting(false);
486
- }
487
- }
488
-
489
- // A new name for a picture, saved at once in one commit: the file under its new name (same folder
490
- // and ending), the old one deleted, the texts with its address changed and the redirect.
491
- async function rename(src: string) {
492
- const file = pictures.find((p) => publicPath(p.path) === src);
493
- const stem = slugify(name);
494
- if (!file || !stem || name === stemOf(src) || stem === stemOf(src)) return;
495
- const path = `${file.path.slice(0, file.path.lastIndexOf('/'))}/${stem}${endingOf(src)}`;
496
- const to = publicPath(path);
497
- if (files.some((f) => f.path === path)) return env.notify({ text: t.media.nameTaken, error: true });
498
- const texts = files
499
- .filter((f) => f.text && f.path !== REDIRECTS && !f.path.startsWith(`${MEDIA}/`) && f.text.includes(src))
500
- .map((f) => ({ ...f, text: readdress(f.text, src, to) }));
501
- setRenaming(true);
502
- env.notify({ text: t.common.saving });
503
- try {
504
- // The redirects (redirects.ts): the old address sends on to the new one, and those that sent to
505
- // the old address send straight to the new one (no chain).
506
- const loaded = await env.load(REDIRECT_FILES);
507
- const all = readAll(loaded);
508
- const own = moveRedirects(all.own, src, to);
509
- if (typeof own === 'string') return env.notify({ text: own, error: true });
510
- const flat = flatten({ ...all, own });
511
- if (typeof flat === 'string') return env.notify({ text: flat, error: true });
512
- const redirectChanges = changesOf(flat, loaded);
513
- const moved = redirectChanges.filter((c) => c.path === REDIRECTS).map((c) => ({ path: REDIRECTS, oid: '', text: String(c.content) }));
514
- const bytes = await env.bytes(file.oid, shownAt(src));
515
- const change = t.app.changes.renamed(kindAndName(t.media.kinds[kindOf(src)].word, fileName(src)), fileName(to));
516
- const oids = await env.commit(
517
- siteT.media.commit.rename(siteT.media.kinds[kindOf(src)].word, fileName(src), fileName(to)),
518
- [
519
- { path, content: bytes, oid: null },
520
- { path: file.path, content: null, oid: file.oid },
521
- ...texts.map((f) => ({ path: f.path, content: f.text, oid: files.find((x) => x.path === f.path)!.oid })),
522
- ...redirectChanges,
523
- ],
524
- change,
525
- );
526
- showMoved(src, to);
527
- env.onSaved(
528
- { path, oid: oids[path], text: '' },
529
- { files: [...texts, ...moved].map((f) => ({ ...f, oid: oids[f.path] })), removed: [file.path] },
530
- );
531
- setSrc(to);
532
- setName(stem);
533
- setEditing(false);
534
- env.notify({ text: savedText(change), kind: 'saved' });
535
- } catch (e) {
536
- env.notify({ text: t.common.notSaved((e as Error).message), error: true, kind: 'failed' });
537
- } finally {
538
- setRenaming(false);
539
- }
540
- }
541
-
542
- // The pages, posts and fixed pages whose text has the picture's address.
543
- const usedBy = src
544
- ? files.flatMap((f) => {
545
- const name = f.text.includes(src) && nameOf(f);
546
- return name ? [{ path: f.path, name }] : [];
547
- })
548
- : [];
549
-
550
- // What is shown about a file: its name (changed with the pencil), the file, where it is used and
551
- // "Slet". On a computer beside the grid (SideSheet), on a phone in the sheet.
552
- const details = (src: string) => (
553
- <>
554
- <form
555
- onSubmit={(e) => {
556
- e.preventDefault();
557
- rename(src);
558
- }}
559
- >
560
- <label htmlFor="media-name" className="block font-medium">
561
- {t.media.name}
562
- </label>
563
- <div className="flex min-h-11 items-center gap-1">
564
- {editing ? (
565
- <>
566
- <TextField
567
- id="media-name"
568
- className="min-w-0 flex-1"
569
- value={name}
570
- autoFocus
571
- autoComplete="off"
572
- onChange={(e) => setName(e.target.value)}
573
- />
574
- <span className="mr-1 text-muted">{endingOf(src)}</span>
575
- <IconButton
576
- icon={X}
577
- label={t.common.cancel}
578
- onClick={() => {
579
- setName(stemOf(src));
580
- setEditing(false);
581
- }}
582
- />
583
- <IconButton
584
- type="submit"
585
- icon={Check}
586
- label={t.media.saveName}
587
- kind="filled"
588
- disabled={renaming || name === stemOf(src) || !slugify(name) || slugify(name) === stemOf(src)}
589
- />
590
- </>
591
- ) : (
592
- <>
593
- <span className="min-w-0 flex-1 break-words text-muted">{fileName(src)}</span>
594
- {code && !code.includes(src) && (
595
- <IconButton icon={PencilSimple} label={t.media.kinds[kindOf(src)].rename} onClick={() => setEditing(true)} />
596
- )}
597
- </>
598
- )}
599
- </div>
600
- {editing && name !== stemOf(src) && slugify(name) && slugify(name) !== name && (
601
- <SupportingText className="mt-1">{t.media.savedAs(`${slugify(name)}${endingOf(src)}`)}</SupportingText>
602
- )}
603
- </form>
604
- <FileView src={shownAt(src)} name={fileName(src)} />
605
- <div>
606
- <p className="mb-1 font-medium">{t.media.usedOn}</p>
607
- {usedBy.length || code?.includes(src) ? (
608
- <ul className={listClass}>
609
- {usedBy.map((f) => (
610
- <ListItem key={f.path} href={`#${f.path}`} onClick={closeFile} headline={f.name} />
611
- ))}
612
- {code?.includes(src) && <ListItem tone="muted" headline={t.media.siteDesign} />}
613
- </ul>
614
- ) : (
615
- <p className="text-muted">{t.media.noPages}</p>
616
- )}
617
- </div>
618
- {code && !used.has(src) && (
619
- <Button kind="outlined" wide icon={Trash} disabled={deleting} onClick={() => remove(src)}>
620
- {deleting ? t.common.deleting : t.media.kinds[kindOf(src)].delete}
621
- </Button>
622
- )}
623
- </>
624
- );
625
-
626
- return (
627
- <Screen
628
- title={t.common.parts.media}
629
- wide
630
- back={{ href: '#', label: siteName }}
631
- actions={
632
- <>
633
- <IconButton
634
- icon={ClockCounterClockwise}
635
- label={t.history.deletedOf(t.history.files)}
636
-
637
- onClick={() => (location.hash = 'slettet/filer')}
638
- />
639
- <IconButton icon={Plus} label={t.media.add} disabled={adding} onClick={add} />
640
- </>
641
- }
642
- >
643
- <div className="md:flex md:items-start md:gap-6">
644
- <div className="min-w-0 flex-1">
645
- <Gallery
646
- srcs={pictures.map((p) => publicPath(p.path))}
647
- used={used}
648
- picked={wide ? src : undefined}
649
- onPick={(src) => {
650
- setSrc(src);
651
- setName(stemOf(src));
652
- setEditing(false);
653
- if (!wide) dialog.current?.showModal();
654
- }}
655
- />
656
- </div>
657
- {wide && src && (
658
- <SideSheet title={fileName(src)} onClose={closeFile}>
659
- {details(src)}
660
- </SideSheet>
661
- )}
662
- </div>
663
- <BottomSheet
664
- ref={dialog}
665
- onClose={(e) => {
666
- if (e.target !== e.currentTarget) return;
667
- if (moving.current) moving.current = false;
668
- else setSrc(null);
669
- }}
670
- onClick={(e) => e.target === dialog.current && dialog.current?.close()}
671
- >
672
- <SheetTitle title={src ? fileName(src) : ''} onClose={() => dialog.current?.close()} />
673
- {!wide && src && <SheetBody>{details(src)}</SheetBody>}
674
- </BottomSheet>
675
- </Screen>
676
- );
677
- }
1
+ // "Filer" under "Indhold": the pictures uploaded to the site (public/images/uploads/<year>/<month>/,
2
+ // imported or added in the editor, images.ts) and the PDFs, videos and zip files (public/filer/,
3
+ // /filer/ on the site), newest first, with search, the order (newest, oldest, by name), a month or year,
4
+ // the type (Billeder, PDF'er, Videoer, Zip-filer) and whether a page uses them (Gallery). A file that is
5
+ // not a picture is a square with its icon and name. Tapping one opens a sheet from the bottom with the
6
+ // file (FileView.tsx), its file name and where it is used, each a link to that page or post, or
7
+ // "Slet billede" (…) when nothing uses it (also not the site's own code, /editor/code-images.json). Its
8
+ // name, under the title, can be changed with the pencil after it (not one the site's code uses),
9
+ // saved with the tick: the file moves, the pages that use it get the new address in the same commit,
10
+ // and the old address sends on to it (addresses.ts). "+" uploads a new file, saved at once. The same
11
+ // pictures (not the other files) are the gallery a picture is chosen from in the forms (useMediaPicker).
12
+ // The clock in the top bar, "Slettede filer", opens the files that were deleted (Deleted.tsx).
13
+ import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
14
+ import {
15
+ ArrowsDownUp,
16
+ CalendarBlank,
17
+ Check,
18
+ ClockCounterClockwise,
19
+ PencilSimple,
20
+ Plus,
21
+ Trash,
22
+ UploadSimple,
23
+ X,
24
+ } from '@phosphor-icons/react';
25
+ import { moveRedirects, REDIRECTS } from './addresses';
26
+ import { changesOf, flatten, readAll, REDIRECT_FILES } from './redirectData';
27
+ import { ask } from './ask';
28
+ import { dateName, headed, letterOf } from './Sheet';
29
+ import Screen from './Screen';
30
+ import {
31
+ Button,
32
+ InputChip,
33
+ Choice,
34
+ Filters,
35
+ IconButton,
36
+ TextField,
37
+ listClass,
38
+ SupportingText,
39
+ SearchRow,
40
+ useKept,
41
+ BottomSheet,
42
+ SheetTitle,
43
+ SideSheet,
44
+ useWide,
45
+ EmptyNote,
46
+ SheetBody,
47
+ ListItem,
48
+ } from './ui';
49
+ import type { Env } from './App';
50
+ import type { RepoFile } from './github';
51
+ import { fixedName, slugify } from './schema';
52
+ import { config, siteName } from '@site/editor';
53
+ import { collections } from './site';
54
+ import { readFields, splitFrontmatter } from './frontmatter.mjs';
55
+ import { fileKindOf, pickedKindOf, pickImage, prepareFile, prepareImage, publicPath, showMoved, shownAt, type Uploads } from './images';
56
+ import { fileIcons, FileView, KINDS, kindOf, type Kind } from './FileView';
57
+ import { kindAndName, savedText } from './deploy';
58
+ import { locale, siteT, t } from './i18n';
59
+
60
+ export const MEDIA = config.folders.media;
61
+
62
+ /** The folder of PDFs, videos and zip files (/filer/ on the site), when the site has one. */
63
+ export const FILES: string | undefined = (config.folders as { files?: string }).files;
64
+
65
+ /** The uploaded pictures among the files, newest first (by their folder, then name). */
66
+ export const mediaOf = (files: RepoFile[]) =>
67
+ files.filter((f) => f.path.startsWith(`${MEDIA}/`)).sort((a, b) => b.path.localeCompare(a.path, locale));
68
+
69
+ /** The PDFs, videos and zip files among the files, by name. */
70
+ export const filesOf = (files: RepoFile[]) =>
71
+ FILES
72
+ ? files.filter((f) => f.path.startsWith(`${FILES}/`) && fileKindOf(f.path)).sort((a, b) => a.path.localeCompare(b.path, locale))
73
+ : [];
74
+
75
+ // What a file that uses a picture or another file is called: a page's or post's title, a fixed page's name, an
76
+ // entry's as its list shows it (a pop-up); null for a file the editor does not open (the list of
77
+ // picture sizes, image-manifest.json).
78
+ function nameOf(file: RepoFile): string | null {
79
+ for (const c of collections)
80
+ if (c.kind === 'files') {
81
+ const own = c.files.find((f) => f.file === file.path);
82
+ if (own) return fixedName(own, file.text);
83
+ } else if (c.kind === 'folder' && file.path.startsWith(`${c.folder}/`) && file.path.endsWith('.json')) {
84
+ try {
85
+ return c.summary(JSON.parse(file.text)) || fileName(file.path);
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+ if ([config.folders.pages, config.folders.blog].some((d) => file.path.startsWith(`${d}/`)) && file.path.endsWith('.md'))
91
+ return String(readFields(splitFrontmatter(file.text).yaml).title ?? file.path);
92
+ return null;
93
+ }
94
+
95
+ // A picture's file name without its folder, and split from its ending ("foto", ".webp").
96
+ const fileName = (src: string) => src.split('/').pop() ?? '';
97
+ const stemOf = (src: string) => fileName(src).replace(/\.[^.]*$/, '');
98
+ const endingOf = (src: string) => fileName(src).slice(stemOf(src).length);
99
+
100
+ // A text with a picture's address changed to another: wherever it stands (Markdown, HTML, JSON), but
101
+ // not a longer file name that starts with it.
102
+ const readdress = (text: string, from: string, to: string) =>
103
+ text.replace(new RegExp(`${from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w.-])`, 'g'), to);
104
+
105
+ // The small copies of the pictures the site has (thumbnails.json, made when it is built), by their
106
+ // address; {} when they could not be asked (then the pictures themselves are shown).
107
+ let small: Promise<Record<string, string>> | null = null;
108
+ const thumbnails = () =>
109
+ (small ??= fetch('/editor/thumbnails.json')
110
+ .then((r) => (r.ok ? (r.json() as Promise<Record<string, string>>) : {}))
111
+ .catch(() => ({})));
112
+
113
+ // The nearest box around an element that scrolls (the sheet's), or null for the page.
114
+ function scrollerOf(el: Element) {
115
+ for (let box = el.parentElement; box && box !== document.body; box = box.parentElement)
116
+ if (/auto|scroll/.test(getComputedStyle(box).overflowY)) return box;
117
+ return null;
118
+ }
119
+
120
+ // A picture in the grid, fetched only when it is about to be scrolled into view (a screen's height
121
+ // before), and as its small copy when there is one: a picture from this visit (shownAt) as it is,
122
+ // a renamed one by its old address. The picture itself, if the copy cannot be fetched.
123
+ function Thumb({ src, thumbs }: { src: string; thumbs: Record<string, string> | null }) {
124
+ const ref = useRef<HTMLImageElement>(null);
125
+ const [near, setNear] = useState(false);
126
+ const [failed, setFailed] = useState(false);
127
+ useEffect(() => {
128
+ const img = ref.current;
129
+ if (!img || !('IntersectionObserver' in window)) return setNear(true);
130
+ const observer = new IntersectionObserver(
131
+ (entries) => {
132
+ if (!entries.some((e) => e.isIntersecting)) return;
133
+ setNear(true);
134
+ observer.disconnect();
135
+ },
136
+ { root: scrollerOf(img), rootMargin: '100% 0px' },
137
+ );
138
+ observer.observe(img);
139
+ return () => observer.disconnect();
140
+ }, []);
141
+ const shown = shownAt(src);
142
+ const thumb = !failed && thumbs?.[shown];
143
+ return (
144
+ <img
145
+ ref={ref}
146
+ src={near && thumbs ? thumb || shown : undefined}
147
+ alt=""
148
+ decoding="async"
149
+ className="editor-fade-in size-full object-cover transition-transform duration-(--motion-spatial-duration) ease-(--motion-ease) group-hover:scale-105"
150
+ onLoad={(e) => e.currentTarget.setAttribute('data-loaded', '')}
151
+ onError={() => thumb && setFailed(true)}
152
+ />
153
+ );
154
+ }
155
+
156
+ // A PDF, a video or a zip file in the grid: its icon and its name.
157
+ function FileTile({ src }: { src: string }) {
158
+ const Icon = fileIcons[fileKindOf(src)!];
159
+ return (
160
+ <span className="flex size-full flex-col items-center justify-center gap-2 p-2 text-center">
161
+ <Icon size={36} className="shrink-0 text-muted" />
162
+ <span className="line-clamp-2 text-xs break-all text-muted">{nameOfSrc(src)}</span>
163
+ </span>
164
+ );
165
+ }
166
+
167
+ // The pictures (and the other files) as squares, by their address on the site: three to four in a row
168
+ // on a phone, as many as there is room for on a computer. picked: the one shown beside the grid (Filer
169
+ // on a computer), marked.
170
+ function Grid({ srcs, picked, onPick }: { srcs: string[]; picked?: string | null; onPick: (src: string) => void }) {
171
+ const [thumbs, setThumbs] = useState<Record<string, string> | null>(null);
172
+ useEffect(() => void thumbnails().then(setThumbs), []);
173
+ return (
174
+ <ul className="grid grid-cols-3 gap-1.5 sm:grid-cols-4 md:grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] md:gap-2">
175
+ {srcs.map((src) => (
176
+ <li key={src}>
177
+ <button
178
+ type="button"
179
+ className={`group block aspect-square w-full overflow-hidden rounded-lg bg-tint ${src === picked ? 'ring-2 ring-accent ring-offset-2 ring-offset-bg' : ''}`}
180
+ aria-label={nameOfSrc(src)}
181
+ aria-pressed={picked === undefined ? undefined : src === picked}
182
+ onClick={() => onPick(src)}
183
+ >
184
+ {fileKindOf(src) ? <FileTile src={src} /> : <Thumb src={src} thumbs={thumbs} />}
185
+ </button>
186
+ </li>
187
+ ))}
188
+ </ul>
189
+ );
190
+ }
191
+
192
+ type Sort = 'new' | 'old' | 'name';
193
+ const SORTS: Sort[] = ['new', 'old', 'name'];
194
+ const SORT_KEY = 'editor-media-sort';
195
+ const nameOfSrc = (src: string) => src.split('/').pop()!;
196
+ // The month a picture was uploaded, as "2025-06" (its folder), or '' outside such a folder.
197
+ const monthOf = (src: string) => {
198
+ const m = /\/(\d{4})\/(\d{2})\/[^/]+$/.exec(src);
199
+ return m ? `${m[1]}-${m[2]}` : '';
200
+ };
201
+ /** The pictures with search, their order and the filters, and the month between them when they are
202
+ * in the order of their dates, the first letter when by name. used: the files a page uses (Filer): then
203
+ * the funnel opens a sheet with both filters, by use and by type. picked: the file shown beside it. */
204
+ function Gallery({
205
+ srcs,
206
+ used,
207
+ picked,
208
+ onPick,
209
+ }: {
210
+ srcs: string[];
211
+ used?: Set<string>;
212
+ picked?: string | null;
213
+ onPick: (src: string) => void;
214
+ }) {
215
+ // All the files, not only pictures (Filer), with their type.
216
+ const all = !!used;
217
+ // In Filer, kept for coming back to it (from a file's page); in a picker, from the start each time.
218
+ const [kind, setKind] = useKept<'' | Kind>(all ? 'filer-type' : null, '');
219
+ const [query, setQuery] = useKept(all ? 'filer-soeg' : null, '');
220
+ const [sort, setSort] = useState<Sort>(() => {
221
+ try {
222
+ const saved = localStorage.getItem(SORT_KEY);
223
+ if (SORTS.some((s) => s === saved)) return saved as Sort;
224
+ } catch {}
225
+ return 'new';
226
+ });
227
+ const [date, setDate] = useKept(all ? 'filer-dato' : null, '');
228
+ const [use, setUse] = useKept<'' | 'used' | 'unused'>(all ? 'filer-brug' : null, '');
229
+
230
+ function pickSort(next: Sort) {
231
+ setSort(next);
232
+ try {
233
+ localStorage.setItem(SORT_KEY, next);
234
+ } catch {}
235
+ }
236
+
237
+ // The years and months there are pictures from, newest first.
238
+ const months = [...new Set(srcs.map(monthOf))].sort().reverse();
239
+ const years = [...new Set(months.filter(Boolean).map((m) => m.slice(0, 4)))];
240
+ // The types there are files of (and the one chosen); the filter by type only when there are two or more.
241
+ const kinds = KINDS.filter((k) => k === kind || srcs.some((src) => kindOf(src) === k));
242
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
243
+ const shown = srcs
244
+ .filter((src) => words.every((w) => nameOfSrc(src).toLowerCase().includes(w)))
245
+ .filter((src) => !date || monthOf(src) === date || monthOf(src).startsWith(`${date}-`))
246
+ .filter((src) => !use || used?.has(src) === (use === 'used'))
247
+ .filter((src) => !kind || kindOf(src) === kind)
248
+ .sort((a, b) =>
249
+ sort === 'name'
250
+ ? nameOfSrc(a).localeCompare(nameOfSrc(b), locale)
251
+ : (sort === 'new' ? -1 : 1) * (monthOf(a).localeCompare(monthOf(b)) || nameOfSrc(a).localeCompare(nameOfSrc(b), locale)),
252
+ );
253
+ // By month in the order of the dates, by the first letter by name (headed, from HEADINGS_FROM).
254
+ const groups = headed(shown, (src) => (sort === 'name' ? letterOf(nameOfSrc(src)) : monthOf(src)));
255
+ const chips = [
256
+ date && { label: dateName(date), clear: () => setDate('') },
257
+ use && { label: use === 'used' ? t.media.inUse : t.common.notInUse, clear: () => setUse('') },
258
+ kind && { label: t.media.kinds[kind].plural, clear: () => setKind('') },
259
+ ].filter((c) => !!c);
260
+
261
+ return (
262
+ <div>
263
+ <SearchRow
264
+ value={query}
265
+ onChange={setQuery}
266
+ label={all ? t.media.searchAll : t.media.searchLabel}
267
+ sort={
268
+ <Choice
269
+ title={t.media.sort}
270
+ icon={ArrowsDownUp}
271
+ value={sort}
272
+ options={SORTS.map((value) => ({ value, label: t.media.sorts[value] }))}
273
+ onChange={pickSort}
274
+ />
275
+ }
276
+ filter={
277
+ all && (
278
+ <Filters
279
+ title={t.media.filter}
280
+ selected={!!use || !!kind}
281
+ groups={[
282
+ {
283
+ title: t.media.use,
284
+ value: use,
285
+ options: [
286
+ { value: '', label: t.media.all },
287
+ { value: 'used', label: t.media.inUse },
288
+ { value: 'unused', label: t.common.notInUse },
289
+ ],
290
+ onChange: (v: string) => setUse(v as typeof use),
291
+ },
292
+ kinds.length > 1 && {
293
+ title: t.media.kind,
294
+ value: kind,
295
+ options: [
296
+ { value: '', label: t.media.allKinds },
297
+ ...kinds.map((value) => ({ value, label: t.media.kinds[value].plural })),
298
+ ],
299
+ onChange: (v: string) => setKind(v as typeof kind),
300
+ },
301
+ ].filter((g) => !!g)}
302
+ />
303
+ )
304
+ }
305
+ date={
306
+ <Choice
307
+ title={t.media.date}
308
+ icon={CalendarBlank}
309
+ value={date}
310
+ selected={!!date}
311
+ options={[
312
+ { value: '', label: t.media.allDates },
313
+ ...years.flatMap((year) => [
314
+ { value: year, label: t.media.wholeYear(year), group: year },
315
+ ...months.filter((m) => m.startsWith(`${year}-`)).map((m) => ({ value: m, label: dateName(m), group: year })),
316
+ ]),
317
+ ]}
318
+ onChange={setDate}
319
+ />
320
+ }
321
+ />
322
+ {chips.length > 0 && (
323
+ <div className="mt-3 flex flex-wrap gap-2">
324
+ {chips.map((c) => (
325
+ <InputChip key={c.label} removeLabel={t.media.removeFilter(c.label)} onRemove={c.clear}>
326
+ {c.label}
327
+ </InputChip>
328
+ ))}
329
+ </div>
330
+ )}
331
+ {groups.map((g) => (
332
+ <section key={g.key ?? 'all'} className="mt-4">
333
+ {g.key !== null && <h2 className="mb-2 text-sm font-medium text-muted">{sort === 'name' ? g.key : dateName(g.key)}</h2>}
334
+ <Grid srcs={g.items} picked={picked} onPick={onPick} />
335
+ </section>
336
+ ))}
337
+ {!shown.length && <EmptyNote>{all ? t.media.noFiles : t.media.noPictures}</EmptyNote>}
338
+ </div>
339
+ );
340
+ }
341
+
342
+ /** Asks for a picture from the gallery, or a new one (kept with the form's pictures, images.ts, and
343
+ * saved with it); its address on the site, or null. The sheet goes on top of a block's sheet. */
344
+ export function useMediaPicker(media: RepoFile[], pictures: Uploads): [() => Promise<string | null>, ReactElement] {
345
+ const dialog = useRef<HTMLDialogElement>(null);
346
+ const answer = useRef<(src: string | null) => void>(() => {});
347
+ const srcs = [...new Set([...pictures.added, ...mediaOf(media)].map((f) => publicPath(f.path)))];
348
+
349
+ const done = (src: string | null) => {
350
+ answer.current(src);
351
+ answer.current = () => {};
352
+ dialog.current?.close();
353
+ };
354
+ const ask = () =>
355
+ new Promise<string | null>((resolve) => {
356
+ answer.current = resolve;
357
+ dialog.current?.showModal();
358
+ });
359
+
360
+ const element = (
361
+ <BottomSheet
362
+ ref={dialog}
363
+ aria-label={t.media.pick}
364
+ onClose={(e) => e.target === e.currentTarget && done(null)}
365
+ onClick={(e) => e.target === dialog.current && done(null)}
366
+ >
367
+ <SheetTitle title={t.media.pick} onClose={() => done(null)} />
368
+ <SheetBody>
369
+ <Button
370
+ kind="outlined"
371
+
372
+ wide
373
+ icon={UploadSimple}
374
+ onClick={async () => {
375
+ const added = await pictures.upload();
376
+ if (added) done(publicPath(added.path));
377
+ }}
378
+ >
379
+ {t.media.uploadNew}
380
+ </Button>
381
+ <Gallery srcs={srcs} onPick={done} />
382
+ </SheetBody>
383
+ </BottomSheet>
384
+ );
385
+ return [ask, element];
386
+ }
387
+
388
+ // The pictures the site's own code uses (its design, e.g. the front page and a portrait), as it
389
+ // was built; null until known, or when it could not be asked (then nothing can be deleted).
390
+ let inCode: Promise<string[] | null> | null = null;
391
+ const codeImages = () =>
392
+ (inCode ??= fetch('/editor/code-images.json')
393
+ .then((r) => (r.ok ? (r.json() as Promise<string[]>) : null))
394
+ .catch(() => null));
395
+
396
+ export default function Media({ files, env }: { files: RepoFile[]; env: Env }) {
397
+ // The pictures and the other files.
398
+ const pictures = [...mediaOf(files), ...filesOf(files)];
399
+ const [code, setCode] = useState<string[] | null>(null);
400
+ useEffect(() => void codeImages().then(setCode), []);
401
+ // The addresses of the pictures some page, post or fixed page uses (its text has the address), or
402
+ // the site's own code.
403
+ const used = useMemo(() => {
404
+ const texts = files
405
+ .filter((f) => f.text && !f.path.startsWith(`${MEDIA}/`) && !(FILES && f.path.startsWith(`${FILES}/`)) && nameOf(f) !== null)
406
+ .map((f) => f.text);
407
+ return new Set(pictures.map((p) => publicPath(p.path)).filter((src) => code?.includes(src) || texts.some((t) => t.includes(src))));
408
+ }, [files, code]);
409
+ const dialog = useRef<HTMLDialogElement>(null);
410
+ const [src, setSrc] = useState<string | null>(null);
411
+ // On a computer the file picked is beside the grid; on a phone in a sheet.
412
+ const wide = useWide();
413
+ const closeFile = () => {
414
+ if (dialog.current?.open) dialog.current.close();
415
+ else setSrc(null);
416
+ };
417
+ // The window gets wide or narrow (a phone turned) while a file is picked: it moves from the sheet to
418
+ // the panel or back, and stays picked.
419
+ const moving = useRef(false);
420
+ useEffect(() => {
421
+ if (!src) return;
422
+ if (wide && dialog.current?.open) {
423
+ moving.current = true;
424
+ dialog.current.close();
425
+ } else if (!wide && !dialog.current?.open) dialog.current?.showModal();
426
+ }, [wide]);
427
+ const [adding, setAdding] = useState(false);
428
+ const [deleting, setDeleting] = useState(false);
429
+ const [name, setName] = useState('');
430
+ const [renaming, setRenaming] = useState(false);
431
+ // The name being changed: the field shown instead of the pencil after the name.
432
+ const [editing, setEditing] = useState(false);
433
+ // A new file, saved on its own at once (with a page, a picture is saved with the page instead): a
434
+ // PDF, a video or a zip file as it is, a picture made ready as in the forms.
435
+ async function add() {
436
+ const file = await pickImage(!!FILES);
437
+ if (!file) return;
438
+ const kind = pickedKindOf(file);
439
+ if (!kind && !file.type.startsWith('image/')) return env.notify({ text: t.media.notAFile, error: true });
440
+ setAdding(true);
441
+ if (!kind) env.notify({ text: t.media.preparing });
442
+ try {
443
+ const picture = kind
444
+ ? await prepareFile(
445
+ file,
446
+ files.map((f) => f.path),
447
+ )
448
+ : await prepareImage(file);
449
+ env.notify({ text: t.common.saving });
450
+ const name = picture.path.split('/').pop()!;
451
+ const change = t.app.changes.added(kindAndName(t.media.kinds[kind ?? 'picture'].word, name));
452
+ const oids = await env.commit(
453
+ siteT.media.commit.add(siteT.media.kinds[kind ?? 'picture'].word, name),
454
+ [{ path: picture.path, content: picture.bytes, oid: null }],
455
+ change,
456
+ );
457
+ env.onSaved({ path: picture.path, oid: oids[picture.path], text: '' });
458
+ env.notify({ text: savedText(change), kind: 'saved' });
459
+ } catch (e) {
460
+ env.notify({ text: t.common.notSaved((e as Error).message), error: true, kind: 'failed' });
461
+ } finally {
462
+ setAdding(false);
463
+ }
464
+ }
465
+
466
+ // A file nothing uses, deleted at once in its own commit (after asking).
467
+ async function remove(src: string) {
468
+ const file = pictures.find((p) => publicPath(p.path) === src);
469
+ if (!file || !(await ask(t.media.kinds[kindOf(src)].deleteAsk, t.common.delete))) return;
470
+ setDeleting(true);
471
+ env.notify({ text: t.common.deleting });
472
+ try {
473
+ const change = t.app.changes.deleted(kindAndName(t.media.kinds[kindOf(src)].word, fileName(src)));
474
+ await env.commit(
475
+ siteT.media.commit.delete(siteT.media.kinds[kindOf(src)].word, src.split('/').pop()!),
476
+ [{ path: file.path, content: null, oid: file.oid }],
477
+ change,
478
+ );
479
+ env.forget([file.path]);
480
+ closeFile();
481
+ env.notify({ text: savedText(change), kind: 'saved' });
482
+ } catch (e) {
483
+ env.notify({ text: t.media.notDeleted((e as Error).message), error: true, kind: 'failed' });
484
+ } finally {
485
+ setDeleting(false);
486
+ }
487
+ }
488
+
489
+ // A new name for a picture, saved at once in one commit: the file under its new name (same folder
490
+ // and ending), the old one deleted, the texts with its address changed and the redirect.
491
+ async function rename(src: string) {
492
+ const file = pictures.find((p) => publicPath(p.path) === src);
493
+ const stem = slugify(name);
494
+ if (!file || !stem || name === stemOf(src) || stem === stemOf(src)) return;
495
+ const path = `${file.path.slice(0, file.path.lastIndexOf('/'))}/${stem}${endingOf(src)}`;
496
+ const to = publicPath(path);
497
+ if (files.some((f) => f.path === path)) return env.notify({ text: t.media.nameTaken, error: true });
498
+ const texts = files
499
+ .filter((f) => f.text && f.path !== REDIRECTS && !f.path.startsWith(`${MEDIA}/`) && f.text.includes(src))
500
+ .map((f) => ({ ...f, text: readdress(f.text, src, to) }));
501
+ setRenaming(true);
502
+ env.notify({ text: t.common.saving });
503
+ try {
504
+ // The redirects (redirectData.ts): the old address sends on to the new one, and those that sent to
505
+ // the old address send straight to the new one (no chain).
506
+ const loaded = await env.load(REDIRECT_FILES);
507
+ const all = readAll(loaded);
508
+ const own = moveRedirects(all.own, src, to);
509
+ if (typeof own === 'string') return env.notify({ text: own, error: true });
510
+ const flat = flatten({ ...all, own });
511
+ if (typeof flat === 'string') return env.notify({ text: flat, error: true });
512
+ const redirectChanges = changesOf(flat, loaded);
513
+ const moved = redirectChanges.filter((c) => c.path === REDIRECTS).map((c) => ({ path: REDIRECTS, oid: '', text: String(c.content) }));
514
+ const bytes = await env.bytes(file.oid, shownAt(src));
515
+ const change = t.app.changes.renamed(kindAndName(t.media.kinds[kindOf(src)].word, fileName(src)), fileName(to));
516
+ const oids = await env.commit(
517
+ siteT.media.commit.rename(siteT.media.kinds[kindOf(src)].word, fileName(src), fileName(to)),
518
+ [
519
+ { path, content: bytes, oid: null },
520
+ { path: file.path, content: null, oid: file.oid },
521
+ ...texts.map((f) => ({ path: f.path, content: f.text, oid: files.find((x) => x.path === f.path)!.oid })),
522
+ ...redirectChanges,
523
+ ],
524
+ change,
525
+ );
526
+ showMoved(src, to);
527
+ env.onSaved(
528
+ { path, oid: oids[path], text: '' },
529
+ { files: [...texts, ...moved].map((f) => ({ ...f, oid: oids[f.path] })), removed: [file.path] },
530
+ );
531
+ setSrc(to);
532
+ setName(stem);
533
+ setEditing(false);
534
+ env.notify({ text: savedText(change), kind: 'saved' });
535
+ } catch (e) {
536
+ env.notify({ text: t.common.notSaved((e as Error).message), error: true, kind: 'failed' });
537
+ } finally {
538
+ setRenaming(false);
539
+ }
540
+ }
541
+
542
+ // The pages, posts and fixed pages whose text has the picture's address.
543
+ const usedBy = src
544
+ ? files.flatMap((f) => {
545
+ const name = f.text.includes(src) && nameOf(f);
546
+ return name ? [{ path: f.path, name }] : [];
547
+ })
548
+ : [];
549
+
550
+ // What is shown about a file: its name (changed with the pencil), the file, where it is used and
551
+ // "Slet". On a computer beside the grid (SideSheet), on a phone in the sheet.
552
+ const details = (src: string) => (
553
+ <>
554
+ <form
555
+ onSubmit={(e) => {
556
+ e.preventDefault();
557
+ rename(src);
558
+ }}
559
+ >
560
+ <label htmlFor="media-name" className="block font-medium">
561
+ {t.media.name}
562
+ </label>
563
+ <div className="flex min-h-11 items-center gap-1">
564
+ {editing ? (
565
+ <>
566
+ <TextField
567
+ id="media-name"
568
+ className="min-w-0 flex-1"
569
+ value={name}
570
+ autoFocus
571
+ autoComplete="off"
572
+ onChange={(e) => setName(e.target.value)}
573
+ />
574
+ <span className="mr-1 text-muted">{endingOf(src)}</span>
575
+ <IconButton
576
+ icon={X}
577
+ label={t.common.cancel}
578
+ onClick={() => {
579
+ setName(stemOf(src));
580
+ setEditing(false);
581
+ }}
582
+ />
583
+ <IconButton
584
+ type="submit"
585
+ icon={Check}
586
+ label={t.media.saveName}
587
+ kind="filled"
588
+ disabled={renaming || name === stemOf(src) || !slugify(name) || slugify(name) === stemOf(src)}
589
+ />
590
+ </>
591
+ ) : (
592
+ <>
593
+ <span className="min-w-0 flex-1 break-words text-muted">{fileName(src)}</span>
594
+ {code && !code.includes(src) && (
595
+ <IconButton icon={PencilSimple} label={t.media.kinds[kindOf(src)].rename} onClick={() => setEditing(true)} />
596
+ )}
597
+ </>
598
+ )}
599
+ </div>
600
+ {editing && name !== stemOf(src) && slugify(name) && slugify(name) !== name && (
601
+ <SupportingText className="mt-1">{t.media.savedAs(`${slugify(name)}${endingOf(src)}`)}</SupportingText>
602
+ )}
603
+ </form>
604
+ <FileView src={shownAt(src)} name={fileName(src)} />
605
+ <div>
606
+ <p className="mb-1 font-medium">{t.media.usedOn}</p>
607
+ {usedBy.length || code?.includes(src) ? (
608
+ <ul className={listClass}>
609
+ {usedBy.map((f) => (
610
+ <ListItem key={f.path} href={`#${f.path}`} onClick={closeFile} headline={f.name} />
611
+ ))}
612
+ {code?.includes(src) && <ListItem tone="muted" headline={t.media.siteDesign} />}
613
+ </ul>
614
+ ) : (
615
+ <p className="text-muted">{t.media.noPages}</p>
616
+ )}
617
+ </div>
618
+ {code && !used.has(src) && (
619
+ <Button kind="outlined" wide icon={Trash} disabled={deleting} onClick={() => remove(src)}>
620
+ {deleting ? t.common.deleting : t.media.kinds[kindOf(src)].delete}
621
+ </Button>
622
+ )}
623
+ </>
624
+ );
625
+
626
+ return (
627
+ <Screen
628
+ title={t.common.parts.media}
629
+ wide
630
+ back={{ href: '#', label: siteName }}
631
+ actions={
632
+ <>
633
+ <IconButton
634
+ icon={ClockCounterClockwise}
635
+ label={t.history.deletedOf(t.history.files)}
636
+
637
+ onClick={() => (location.hash = 'slettet/filer')}
638
+ />
639
+ <IconButton icon={Plus} label={t.media.add} disabled={adding} onClick={add} />
640
+ </>
641
+ }
642
+ >
643
+ <div className="md:flex md:items-start md:gap-6">
644
+ <div className="min-w-0 flex-1">
645
+ <Gallery
646
+ srcs={pictures.map((p) => publicPath(p.path))}
647
+ used={used}
648
+ picked={wide ? src : undefined}
649
+ onPick={(src) => {
650
+ setSrc(src);
651
+ setName(stemOf(src));
652
+ setEditing(false);
653
+ if (!wide) dialog.current?.showModal();
654
+ }}
655
+ />
656
+ </div>
657
+ {wide && src && (
658
+ <SideSheet title={fileName(src)} onClose={closeFile}>
659
+ {details(src)}
660
+ </SideSheet>
661
+ )}
662
+ </div>
663
+ <BottomSheet
664
+ ref={dialog}
665
+ onClose={(e) => {
666
+ if (e.target !== e.currentTarget) return;
667
+ if (moving.current) moving.current = false;
668
+ else setSrc(null);
669
+ }}
670
+ onClick={(e) => e.target === dialog.current && dialog.current?.close()}
671
+ >
672
+ <SheetTitle title={src ? fileName(src) : ''} onClose={() => dialog.current?.close()} />
673
+ {!wide && src && <SheetBody>{details(src)}</SheetBody>}
674
+ </BottomSheet>
675
+ </Screen>
676
+ );
677
+ }