@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/App.tsx CHANGED
@@ -1,904 +1,901 @@
1
- // The editor at /editor/ (docs/editor.md): sign in with GitHub, pick a part of the site, edit and
2
- // save. The address after # says what is open, so reloading keeps it:
3
- // (empty) the start page
4
- // liste/<id> the list of a part, e.g. liste/priser, and liste/<id>/<app> a tab an installed app adds to it
5
- // (AppDefinition.tabs): liste/cookies/cookiescanner
6
- // header what is in the header on every page (the menu)
7
- // lister the short lists (the testimonials, the apps' lists)
8
- // indstillinger the settings of the whole site (Library.tsx Settings), opened by the gear in the top bar:
9
- // indstillinger/appstore the app store, its apps, and indstillinger/appstore/temaer its themes (AppList.tsx StorePage)
10
- // ny/<id> a new entry, ny/<id>/<kind> one of a kind (a pop-up: standard, or an app's)
11
- // virksomheden the business, and virksomheden/<part> its parts: kontakt, betaling; and
12
- // virksomheden/betaling/<part> its own parts: mobilepay, bank
13
- // <screen> a screen with parts of the site's own (partScreens), and <screen>/<part> its parts:
14
- // privatliv/cookiebanner, privatliv/privatlivssider, or one of the editor's own screens (Part.opens):
15
- // privatliv/samtykker the visitors' answers in the cookie banner (Consents.tsx); and the settings of the apps installed with
16
- // its category: maaling/google-analytics, promovering/calendly
17
- // filer the uploaded pictures, PDFs, videos and zip files (Media.tsx)
18
- // seo "Søgemaskineoptimering" (Library.tsx Seo): seo/videresendelser the redirects' two lists
19
- // (Redirects.tsx): seo/videresendelser/alle the redirects, and seo/videresendelser/ikke-fundet
20
- // the addresses visitors came to that do not exist; seo/vurdering the SEO rating of every page and
21
- // post (SeoScores.tsx); and seo/<part> the site's own (seoParts)
22
- // udseende how the site looks (Library.tsx Theme): udseende/temaer the themes installed (Themes) and udseende/temaer/<theme>
23
- // a theme's page, with the button that swaps to it (ThemePage), udseende/ikonpakker the icon packs (IconPacks),
24
- // udseende/bevaegelse how it moves (MotionSchemes),
25
- // udseende/farver the colours (Colors.tsx;
26
- // udseende/farver/reservefarver the site's own while an app changes the pages'), udseende/logo the logo
27
- // and icon (BrandForm.tsx), udseende/dele the parts (Library.tsx PartsHome): udseende/dele/editor the editor's, every
28
- // one in every state (Parts.tsx), udseende/dele/ui the theme's UI components, udseende/dele/blokke the
29
- // blocks, the standard ones and the apps', and udseende/dele/liste/<list> each kind of a list of kinds
30
- // ("Promovering"), its own and the apps' (BlockParts.tsx), and udseende/dele/vaerdier the
31
- // theme's design tokens, "Værdier" (Tokens.tsx)
32
- // blokvalg the blocks that can be put in, "Blokindstillinger" (BlockSettings.tsx)
33
- // avanceret "Avanceret", under "Indstillinger": the copy of the site's files in R2 (Advanced.tsx)
34
- // licenser "Licenser", under "Indstillinger": the licences of what the site and the editor are made with, and
35
- // licenser/<id> one with its text (Licenses.tsx)
36
- // apps the apps that come with the site, apps/<app> one with its button to install it and, while it
37
- // is installed, a row to its settings: on the screen with its category (maaling/<app>), else
38
- // apps/<app>/indstillinger (AppList.tsx settingsRoute)
39
- // <form>/<field> the page of a field of a part's form with its choices on a page of their own (Field.page), over the
40
- // form, which keeps what is in it (formAt): apps/content-colors/indstillinger/variant
41
- // blokke the blocks, and blokke/<block> the texts all its copies have (blockTexts.ts)
42
- // slettet/<id> what was deleted from a list, or slettet/filer the files (Deleted.tsx)
43
- // <file path> a file being edited, e.g. src/content/blog/mit-foerste-indlaeg.md
44
- import { useEffect, useMemo, useRef, useState } from 'react';
45
- import {
46
- AuthError,
47
- commit,
48
- deletedFiles,
49
- loadBytes,
50
- loadFiles,
51
- head,
52
- loadFolders,
53
- login,
54
- logout,
55
- rememberTexts,
56
- savedAt,
57
- versionsOf,
58
- versionText,
59
- viewer,
60
- type Change,
61
- type DeletedFile,
62
- type Version,
63
- type RepoFile,
64
- type Viewer,
65
- } from './github';
66
- import {
67
- changesText,
68
- hasUnsaved,
69
- kindAndName,
70
- lower,
71
- nameOf,
72
- savedText,
73
- setUnsaved,
74
- keepCurrent,
75
- registerWorker,
76
- waitForSite,
77
- } from './deploy';
78
- import { readCache, writeCache } from './cache';
79
- import { forgetNotFound, loadNotFound, type Missing } from './notFound';
80
- import { collectionOf, slugOf, type Collection, type FolderCollection, type Part, type Relation } from './schema';
81
- import EntryForm, { newMarkdown } from './EntryForm';
82
- import DataForm from './DataForm';
83
- import CompanyForm from './CompanyForm';
84
- import Media, { mediaOf } from './Media';
85
- import Redirects, { REDIRECTS_ROUTE, TAB_ROUTES } from './Redirects';
86
- import Consents from './Consents';
87
- import SeoScores, { SEO_SCORES_ROUTE } from './SeoScores';
88
- import Deleted from './Deleted';
89
- import Parts from './Parts';
90
- import { BlocksParts, ComponentParts, KindParts } from './BlockParts';
91
- import Tokens from './Tokens';
92
- import BrandForm from './BrandForm';
93
- import BlockSettings from './BlockSettings';
94
- import Advanced, { ADVANCED_ROUTE } from './Advanced';
95
- import Licenses, { LICENSES_ROUTE, LicensePage } from './Licenses';
96
- import { StoreThemePage } from './ThemeStore';
97
- import {
98
- AppPage,
99
- AppScreenPage,
100
- Apps,
101
- appPartsIn,
102
- autoUpdate,
103
- DeveloperPage,
104
- appSettingsPart,
105
- settingsRoute,
106
- StoreAppPage,
107
- StorePage,
108
- } from './AppList';
109
- import { installedApps } from './apps';
110
- import {
111
- DEVELOPER_ROUTE,
112
- readApp,
113
- readCatalog,
114
- readScreenshots,
115
- readThemes,
116
- readThemeScreenshots,
117
- STORE_BEFORE,
118
- STORE_ROUTE,
119
- STORE_THEMES,
120
- type ShownApp,
121
- type ShownTheme,
122
- type StoreApp,
123
- type StoreFile,
124
- type StoreTheme,
125
- } from './store';
126
- import Colors, { colorsOf, FALLBACK_ROUTE } from './Colors';
127
- import { applyColors, applyMotion } from './theme';
128
- import { apps, notOffered } from './registry';
129
- import {
130
- Account,
131
- Blocks,
132
- Company,
133
- Header,
134
- List,
135
- Lists,
136
- PartsHome,
137
- partsIn,
138
- PartsScreen,
139
- Seo,
140
- Settings,
141
- Start,
142
- Theme,
143
- ThemePage,
144
- Themes,
145
- IconPacks,
146
- MotionSchemes,
147
- motionOf,
148
- } from './Library';
149
- import {
150
- appearance,
151
- asWritten,
152
- blockParts,
153
- collections,
154
- companyParts,
155
- groups,
156
- icons,
157
- listRowsOf,
158
- lists,
159
- partScreens,
160
- seoParts,
161
- } from './site';
162
- import { config, header, relationsOf, type SiteData, siteName, siteOf, testimonialsOf, usedBy, whatIsAt } from '@site/editor';
163
- import { themes } from './themes';
164
- import Screen from './Screen';
165
- import { Button, EmptyNote, NavigationRail, NavigationRailItem, NavigationRailSection, ProblemNote, startRipples } from './ui';
166
- import { setSidebar, useSidebar } from './sidebar';
167
- import { Ask, ask } from './ask';
168
- import { LinkAsk } from './LinkSheet';
169
- import { type ListRows, type Option, type TestimonialRow } from './fields';
170
- import { endOf, entriesOf, lookup, readAll } from './redirects';
171
- import { inForm, leaveTo, type Status } from './FormFrame';
172
- import { arrived, screenOf, up } from './nav';
173
- import { siteT, siteText, t } from './i18n';
174
- import { snackbar } from '../lib/snackbar';
175
- import { appType, clearNotifications, isOn, remember, signOff, stopServerWait, syncDevice, told, waitOnServer } from './notifications';
176
- import Notifications from './Notifications';
177
- import { EditorOwn, InEditor } from './standard/view';
178
- import './editor.css';
179
-
180
- // What the forms and lists need from the app.
181
- export type Env = {
182
- relations: Record<Relation, Option[]>;
183
- /** The rows of the lists (the apps' and the standard blocks'), by the list's id, and the testimonials, in their
184
- * order, for the cards of the blocks. */
185
- lists: ListRows;
186
- testimonials: TestimonialRow[];
187
- /** The rest of the site, for the previews of the blocks (sitedata.ts). */
188
- site: SiteData;
189
- /** The pictures uploaded to the site (Media.tsx), for the gallery a picture is chosen from. */
190
- media: RepoFile[];
191
- /** All the files the editor has, for what links to a page that moves (addresses.ts). */
192
- files: RepoFile[];
193
- /** Shows a message at the bottom of the screen; null hides it. */
194
- notify: (status: Status) => void;
195
- /** Saves files as one commit (github.ts) and later tells when the site has the change. `change`: what it changed, in
196
- * the editor's language (t.app.changes, deploy.ts), for that message. Returns the new blob ids. */
197
- commit: (message: string, changes: Change[], change: string) => Promise<Record<string, string>>;
198
- /** When some files were last saved (github.ts); a new file is left out. */
199
- savedAt: (paths: string[]) => Promise<Record<string, Date>>;
200
- /** The commits that changed a file, and its text in one of them (github.ts; Versions.tsx). */
201
- versions: (path: string, after?: string) => Promise<{ versions: Version[]; more: string | null }>;
202
- versionText: (version: Version, path: string) => Promise<string | null>;
203
- /** The files deleted from a folder (github.ts deletedFiles; Deleted.tsx). */
204
- deleted: (folder: string, options?: { text?: boolean }) => Promise<DeletedFile[]>;
205
- /** Why a new page or post cannot have this address (it is taken), or null; redirects: the files of
206
- * the redirects as they are now (redirects.ts, loaded with `load`). */
207
- taken: (address: string, redirects: RepoFile[]) => Taken | null;
208
- /** Single files outside the folders the app loads (github.ts loadFiles). */
209
- load: (paths: string[]) => Promise<RepoFile[]>;
210
- /** All the files in a folder of the site, at any depth (an app's, to install, update or uninstall it). */
211
- folder: (path: string) => Promise<RepoFile[]>;
212
- /** The app store (store.ts): its apps and its themes, an app's or a theme's files from it, and their pictures (as
213
- * addresses in this browser). */
214
- store: {
215
- catalog: () => Promise<ShownApp[]>;
216
- themes: () => Promise<ShownTheme[]>;
217
- files: (of: {
218
- path: string;
219
- ref: string;
220
- files: StoreFile[];
221
- label?: string;
222
- name?: string;
223
- }) => Promise<{ path: string; content: string | Uint8Array }[]>;
224
- screenshots: (app: StoreApp) => Promise<{ src: string; label: string }[]>;
225
- themeScreenshots: (theme: Pick<StoreTheme, 'screenshots' | 'files'>) => Promise<{ key: string; src: string }[]>;
226
- };
227
- /** A picture's bytes by its blob id (github.ts loadBytes). */
228
- bytes: (oid: string, address?: string) => Promise<Uint8Array>;
229
- /** The addresses visitors came to that do not exist (notFound.ts; null: the site does not keep
230
- * them), and taking some off that list. */
231
- notFound: { list: () => Promise<Missing[] | null>; forget: (paths: string[]) => Promise<void> };
232
- /** An app's own addresses in the site's Worker (/api/<app>/<path>, its worker.js), asked with the GitHub sign-in, for
233
- * its page (AppDefinition.page). */
234
- appApi: (app: string, path?: string, init?: RequestInit) => Promise<Response>;
235
- /** Files deleted on their own (a file in Filer). */
236
- forget: (paths: string[]) => void;
237
- /** A file was saved; more: other files saved with it, and files it removed (a post that moved). */
238
- onSaved: (file: RepoFile, more?: { files: RepoFile[]; removed: string[] }) => void;
239
- remove: (collection: Collection & { folder: string }, file: RepoFile, deleting: (b: boolean) => void) => void;
240
- nextOrder: (collection: FolderCollection) => number;
241
- /** Saves a list in a new order; says what changed (deploy.ts). */
242
- reorder: (collection: Collection, files: RepoFile[]) => Promise<string>;
243
- };
244
-
245
- // The uploaded pictures only by their paths (no text), for "Filer" (Media.tsx).
246
- const FOLDERS = config.folders;
247
- const FOLDERS_KEY = JSON.stringify(FOLDERS);
248
-
249
- const json = (file: RepoFile) => JSON.parse(file.text) as Record<string, unknown>;
250
-
251
- // Addresses of the site that are not pages.
252
- const RESERVED = config.reserved;
253
-
254
- /** Why a new page or post cannot have an address (/<slug>/): a message; with `redirect` when the site
255
- * sends it on elsewhere, which the site's owner may remove (EntryForm.tsx): where it ends after every redirect
256
- * (redirects.ts), its name, and how many other addresses send on through it. */
257
- export type Taken = { text: string; redirect?: { end: string; name: string; via: number } };
258
-
259
- // Why a new page or post cannot have an address (/<slug>/), or null. redirects: the files of the
260
- // redirects (redirects.ts), as they are now.
261
- function taken(address: string, relations: Record<Relation, Option[]>, redirects: RepoFile[]): Taken | null {
262
- const pages = [...relations.pages, ...relations.fixed].map((o) => o.value);
263
- const posts = relations.blog.map((o) => `/${o.value}/`);
264
- if (pages.includes(address) || posts.includes(address) || RESERVED.includes(address)) return { text: t.app.addressTaken(address) };
265
- const all = readAll(redirects);
266
- const look = lookup(all);
267
- if (look(address) === undefined) return null;
268
- const { end } = endOf(address, look);
269
- const via = entriesOf(all).filter((e) => e.from !== address && e.to.split('#')[0] === address).length;
270
- const name = whatIsAt(end, relations);
271
- return { text: t.app.addressRedirects(address, name), redirect: { end, name, via } };
272
- }
273
-
274
- // Screens that have moved, by their old address: a link or a bookmark to one opens it where it is now.
275
- const MOVED: Record<string, string> = {
276
- farver: 'udseende/farver',
277
- 'virksomheden/logo': 'udseende/logo',
278
- 'tema/blokvalg': 'blokvalg',
279
- dele: 'udseende/dele',
280
- tema: 'udseende',
281
- 'tema/farver': 'udseende/farver',
282
- 'tema/logo': 'udseende/logo',
283
- 'tema/dele': 'udseende/dele',
284
- 'tema/dele/editor': 'udseende/dele/editor',
285
- 'virksomheden/bank': 'virksomheden/betaling/bank',
286
- videresendelser: 'seo/videresendelser',
287
- 'videresendelser/alle': 'seo/videresendelser/alle',
288
- 'videresendelser/ikke-fundet': 'seo/videresendelser/ikke-fundet',
289
- [STORE_BEFORE]: STORE_ROUTE,
290
- [`${STORE_BEFORE}/temaer`]: STORE_THEMES,
291
- };
292
-
293
- // The editor's address after the "#" (nav.ts screenOf).
294
- function routeOf(hash: string) {
295
- const route = MOVED[screenOf(hash)] ?? screenOf(hash);
296
- // An app's settings: on the screen with its category, when it has one ("Analytics", AppList.tsx settingsRoute), and so
297
- // the pages of their fields (Field.page) after them.
298
- const app = apps.find((a) => route === `apps/${a.id}/indstillinger` || route.startsWith(`apps/${a.id}/indstillinger/`));
299
- const rest = app ? route.slice(`apps/${app.id}/indstillinger`.length) : '';
300
- const moved = (app && settingsRoute(app) && `${settingsRoute(app)}${rest}`) || route;
301
- if (moved !== screenOf(hash)) history.replaceState(history.state, '', `#${moved}`);
302
- return moved;
303
- }
304
-
305
- // The editor; what it draws of the site is in the editor (InEditor: a link only drawn …), its own screens are its own
306
- // (EditorOwn: the theme's UI parts on them are links that lead somewhere).
307
- export default function App() {
308
- return (
309
- <InEditor>
310
- <EditorOwn>
311
- <Editor />
312
- </EditorOwn>
313
- </InEditor>
314
- );
315
- }
316
-
317
- function Editor() {
318
- // Whether someone is signed in (the Worker keeps the sign-in, packages/editor/worker/session.js): null until the Worker or this
319
- // browser's copy of the files (cache.ts) has said.
320
- const [signedIn, setSignedIn] = useState<boolean | null>(null);
321
- // Whether the sidebar is open on a computer (sidebar.ts).
322
- const sidebar = useSidebar();
323
- // While it is closed: the mouse over the rail, which then shows the whole sidebar over the content.
324
- const [peek, setPeek] = useState(false);
325
- const peekTimer = useRef(0);
326
- const [user, setUser] = useState<Viewer | null>(null);
327
- const [files, setFiles] = useState<RepoFile[] | null>(null);
328
- const [error, setError] = useState('');
329
- const [route, setRoute] = useState(() => routeOf(location.hash));
330
- // Counts the visits to an address: a form starts fresh on each, but not when a new entry that was
331
- // just saved gets its own address.
332
- const [visit, setVisit] = useState(0);
333
- // The messages at the bottom stand in the middle of the content, next to the sidebar (editor.css).
334
- useEffect(() => {
335
- document.documentElement.dataset.sidebar = sidebar ? 'open' : 'closed';
336
- }, [sidebar]);
337
- // Stops waiting for the last save to be on the site.
338
- const stopWaiting = useRef<() => void>(undefined);
339
- // What the saves the editor waits for the website to have changed (save).
340
- const waitingFor = useRef<string[]>([]);
341
-
342
- useEffect(registerWorker, []);
343
- useEffect(startRipples, []);
344
- // Signed up for the notifications on the phone from the site's Worker, also when the editor is closed, while
345
- // "På telefonen" is on (notifications.ts).
346
- useEffect(() => {
347
- if (signedIn) syncDevice();
348
- }, [signedIn]);
349
- // A tap on such a notification while the editor is open: to the screen it is about (packages/editor/pages/editor/sw.js).
350
- useEffect(() => {
351
- const onMessage = (e: MessageEvent) => typeof e.data?.to === 'string' && leaveTo(`#${e.data.to}`);
352
- navigator.serviceWorker?.addEventListener('message', onMessage);
353
- return () => navigator.serviceWorker?.removeEventListener('message', onMessage);
354
- }, []);
355
- // When the site was last updated (built on Cloudflare), for the start screen.
356
- const [builtAt, setBuiltAt] = useState<Date | null>(null);
357
- useEffect(() => keepCurrent((v) => v.builtAt && setBuiltAt(new Date(v.builtAt))), []);
358
-
359
- // Where the editor is, to come back to when leaving is asked about.
360
- const here = useRef(route);
361
- here.current = route;
362
- useEffect(() => {
363
- const onHash = () => {
364
- // The history's step (nav.ts): null when it undoes the one below.
365
- const step = arrived(here.current);
366
- if (!step) return;
367
- // To or from a page of the open form's own (a field's, Field.page): the form stays, with what is in it, and
368
- // scrolls itself (CompanyForm.tsx).
369
- const next = routeOf(location.hash);
370
- if (inForm(here.current, next)) return setRoute(next);
371
- // While something is unsaved, another address (the phone's back gesture, the sidebar, a link)
372
- // is first undone, and taken again only when the owner leaves without saving (leaveTo).
373
- if (hasUnsaved()) {
374
- step.undo();
375
- return leaveTo(step.redo);
376
- }
377
- setRoute(next);
378
- setVisit((v) => v + 1);
379
- scrollTo(0, 0);
380
- };
381
- window.addEventListener('hashchange', onHash);
382
- return () => window.removeEventListener('hashchange', onHash);
383
- }, []);
384
-
385
- // The commit the files are from. Not moved on by the owner's own saves, so the next check loads the
386
- // files again with those changes and anything else that came in.
387
- const commitOf = useRef('');
388
- // Counts the owner's saves, so that files loaded while one was made (from before it) are not shown.
389
- const saves = useRef(0);
390
-
391
- // The files from this browser at once (cache.ts), then from GitHub only when the branch has moved
392
- // on: checked on opening, and when the app is shown again, at most every half minute, and not
393
- // while something is unsaved. Only the files whose text this browser does not have are loaded (github.ts). The open
394
- // form keeps the file it opened with; saving checks it. Signed out (the Worker says so), the sign-in is shown.
395
- const signedOut = signedIn === false;
396
- useEffect(() => {
397
- if (signedOut) return;
398
- let stopped = false;
399
- let checked = 0;
400
- async function refresh() {
401
- if (stopped || hasUnsaved() || Date.now() - checked < 30_000) return;
402
- checked = Date.now();
403
- try {
404
- const [user, at] = await Promise.all([viewer(), head()]);
405
- if (stopped) return;
406
- setUser(user);
407
- setSignedIn(true);
408
- if (at.commit !== commitOf.current) {
409
- const before = saves.current;
410
- const loaded = await loadFolders(FOLDERS, 3, at);
411
- if (stopped) return;
412
- // Saved meanwhile: these files are older than that save; the next check loads them again.
413
- if (saves.current !== before) {
414
- checked = 0;
415
- return;
416
- }
417
- commitOf.current = loaded.commit;
418
- setFiles(Object.values(loaded.folders).flat());
419
- }
420
- } catch (e) {
421
- if (e instanceof AuthError) signOut();
422
- // With files from this browser, the editor goes on; the next check tries again.
423
- else if (!commitOf.current) setError((e as Error).message);
424
- checked = 0;
425
- }
426
- }
427
- readCache().then((cached) => {
428
- if (stopped) return;
429
- if (cached) {
430
- // Kept from other folders than these (an older editor): shown, and loaded again at once.
431
- commitOf.current = cached.folders === FOLDERS_KEY ? cached.commit : '';
432
- rememberTexts(cached.files);
433
- setUser(cached.user);
434
- setFiles(cached.files);
435
- setSignedIn(true);
436
- }
437
- refresh();
438
- });
439
- const onShow = () => document.visibilityState === 'visible' && refresh();
440
- document.addEventListener('visibilitychange', onShow);
441
- return () => {
442
- stopped = true;
443
- document.removeEventListener('visibilitychange', onShow);
444
- };
445
- }, [signedOut]);
446
-
447
- // Keeps what is shown, with the owner's saves, for the next time the editor opens.
448
- useEffect(() => {
449
- if (files && user && commitOf.current) writeCache({ commit: commitOf.current, files, user, folders: FOLDERS_KEY });
450
- }, [files, user]);
451
-
452
- const relations = useMemo(() => relationsOf(files ?? []), [files]);
453
- // What a link in the text can go to (LinkSheet.tsx): the fixed pages (not those that are also a
454
- // page), the other pages and the posts, by their addresses.
455
- const linkGroups = useMemo(() => {
456
- const fixed = relations.fixed.filter((f) => !relations.pages.some((o) => o.value === f.value));
457
- return [
458
- { title: t.forms.fixedPages, options: fixed },
459
- { title: t.forms.pages, options: relations.pages },
460
- { title: t.text.linkPosts, options: relations.blog.map((o) => ({ ...o, value: `/${o.value}/` })) },
461
- ].filter((g) => g.options.length);
462
- }, [relations]);
463
- const listRows = useMemo(() => listRowsOf(files ?? []), [files]);
464
- const testimonials = useMemo(() => testimonialsOf(files ?? []), [files]);
465
-
466
- // The site's colours as loaded and saved ("Farver"), also before the site is built again with them.
467
- const colorsFile = files?.find((f) => f.path === config.colors)?.text;
468
- useEffect(() => {
469
- if (colorsFile !== undefined) applyColors(colorsOf(files!));
470
- }, [colorsFile]);
471
- // How the site moves ("Bevægelse") as loaded and saved, also before the site is built again with it.
472
- const motionFile = files?.find((f) => f.path === config.motion)?.text;
473
- useEffect(() => {
474
- if (files) applyMotion(motionOf(files));
475
- }, [motionFile, files === null]);
476
-
477
- const siteData = useMemo(() => siteOf(files ?? []), [files]);
478
- const media = useMemo(() => mediaOf(files ?? []), [files]);
479
-
480
- // A message at the bottom (snackbar in lib/snackbar.ts, the same as on the site), with a link to what it
481
- // is about; one of a kind kept in the notifications too (notifications.ts).
482
- function notify(next: Status, device = true) {
483
- const edit = next?.edit;
484
- if (next?.kind)
485
- remember({ text: next.text, type: next.kind, error: next.error, link: edit && { text: edit.text, href: `#${edit.to}` } }, device);
486
- snackbar(
487
- next && {
488
- text: next.text,
489
- error: next.error,
490
- links: [
491
- ...(edit
492
- ? [
493
- {
494
- text: edit.text,
495
- href: `#${edit.to}`,
496
- onClick: (e: MouseEvent) => {
497
- e.preventDefault();
498
- leaveTo(`#${edit.to}`);
499
- },
500
- },
501
- ]
502
- : []),
503
- ],
504
- },
505
- );
506
- }
507
-
508
- async function save(message: string, changes: Change[], change: string) {
509
- const saved = await commit(message, changes);
510
- // One message for all saves: a later save is on the site together with or after the ones before. It says what
511
- // each of them changed (each once).
512
- stopWaiting.current?.();
513
- if (!waitingFor.current.includes(change)) waitingFor.current.push(change);
514
- const what = changesText(waitingFor.current);
515
- // The Worker says it on the phone too, also when the editor is closed by then (notifications.ts).
516
- const onServer = waitOnServer(saved.commit, t.app.siteUpdated(what), t.app.siteNotUpdated(what));
517
- stopWaiting.current = waitForSite(saved.commit, async (online) => {
518
- waitingFor.current = [];
519
- const text = online ? t.app.siteUpdated(what) : t.app.siteNotUpdated(what);
520
- // On screen the editor says it, and the Worker does not; else only the Worker sends it to the phone.
521
- const byServer = document.visibilityState !== 'visible' && (await onServer);
522
- if (!byServer) stopServerWait();
523
- notify({ text, error: !online, kind: 'site' }, !byServer);
524
- });
525
- return saved.blobs;
526
- }
527
-
528
- function signOut() {
529
- stopWaiting.current?.();
530
- waitingFor.current = [];
531
- // No notifications on this phone for someone signed out: signed off at the Worker while the sign-in is still there.
532
- void signOff()
533
- .catch(() => {})
534
- .then(logout);
535
- commitOf.current = '';
536
- writeCache(null);
537
- clearNotifications();
538
- setSignedIn(false);
539
- setUser(null);
540
- setFiles(null);
541
- }
542
-
543
- const replace = (saved: RepoFile[], removed: string[] = []) => {
544
- saves.current++;
545
- setFiles((all) => [...(all ?? []).filter((f) => !saved.some((s) => s.path === f.path) && !removed.includes(f.path)), ...saved]);
546
- };
547
-
548
- const env: Env = {
549
- relations,
550
- lists: listRows,
551
- testimonials,
552
- site: siteData,
553
- media,
554
- files: files ?? [],
555
- forget: (paths) => replace([], paths),
556
- notify,
557
- commit: save,
558
- savedAt,
559
- versions: versionsOf,
560
- versionText,
561
- deleted: deletedFiles,
562
- load: (paths) => loadFiles(paths),
563
- folder: (path) => loadFolders({ all: path }, 8).then((r) => r.folders.all),
564
- store: {
565
- catalog: readCatalog,
566
- themes: readThemes,
567
- files: readApp,
568
- screenshots: readScreenshots,
569
- themeScreenshots: readThemeScreenshots,
570
- },
571
- bytes: (oid, address) => loadBytes(oid, address),
572
- notFound: { list: loadNotFound, forget: forgetNotFound },
573
- // The sign-in's cookie goes with the request (packages/editor/worker/session.js).
574
- appApi: (app, path = '', init) => fetch(`/api/${app}/${path}`, { cache: 'no-store', ...init }),
575
- taken: (address, redirects) => taken(address, relations, redirects),
576
- onSaved(file, more) {
577
- replace([file, ...(more?.files ?? [])], more?.removed);
578
- // A new entry, or a post that got a new address: from now on it is edited as the file it
579
- // became. A form that saves another file than the one at its address (Virksomheden) stays where it is.
580
- if (route.startsWith('ny/') || more?.removed.includes(route)) {
581
- history.replaceState(history.state, '', `#${file.path}`);
582
- setRoute(file.path);
583
- }
584
- },
585
- async remove(collection, file, deleting) {
586
- const reason = usedBy(collection, file, files ?? []);
587
- if (reason) return notify(reason);
588
- if (!(await ask(t.app.deleteEntry(collection.singular), t.common.delete))) return;
589
- deleting(true);
590
- notify({ text: t.common.deleting });
591
- const change = t.app.changes.deleted(kindAndName(collection.singular, nameOf(collection, file)));
592
- try {
593
- await save(
594
- siteT.app.commit.delete(asWritten(collection).singular, slugOf(file.path, collection.folder)),
595
- [{ path: file.path, content: null, oid: file.oid }],
596
- change,
597
- );
598
- replace([], [file.path]);
599
- // Back to the list, as with the back arrow: the deleted entry is not left behind it in the history.
600
- setUnsaved(false);
601
- up(`#liste/${collection.id}`);
602
- notify({ text: savedText(change), kind: 'saved' });
603
- } catch (e) {
604
- notify({ text: t.app.notDeleted((e as Error).message), error: true, kind: 'failed' });
605
- deleting(false);
606
- }
607
- },
608
- nextOrder(collection) {
609
- const own = (files ?? []).filter((f) => f.path.startsWith(`${collection.folder}/`) && f.path.endsWith('.json'));
610
- return Math.max(0, ...own.map((f) => Number(json(f).order) || 0)) + 1;
611
- },
612
- // Numbers the entries 1, 2, 3… in the new order and saves the files whose number changed.
613
- async reorder(collection, ordered) {
614
- const changes = ordered.flatMap((file, i) => {
615
- const { order, ...rest } = json(file);
616
- if (order === i + 1) return [];
617
- const text = `${JSON.stringify({ order: i + 1, ...rest }, null, 2)}\n`;
618
- return [{ path: file.path, content: text, oid: file.oid }];
619
- });
620
- const change = t.app.changes.order(lower(collection.label));
621
- if (!changes.length) return change;
622
- const oids = await save(siteT.app.commit.reorder(asWritten(collection).label.toLowerCase()), changes, change);
623
- replace(changes.map((c) => ({ path: c.path, oid: oids[c.path], text: c.content })));
624
- return change;
625
- },
626
- };
627
-
628
- // The apps from the store updated on their own, once the files are there (autoUpdate in AppList.tsx).
629
- const loaded = !!files;
630
- useEffect(() => {
631
- if (signedIn && loaded) autoUpdate(env, files!);
632
- }, [signedIn, loaded]);
633
-
634
- // What the installed apps have to say (AppNotifications in apps.ts): asked when the editor opens and every 15 minutes
635
- // while it is open; each comes once, at the bottom and under "Notifikationer", while its type is on (notifications.ts).
636
- const notifying = installedApps(files ?? [])
637
- .filter((id) => apps.some((a) => a.id === id && a.notifications))
638
- .join();
639
- useEffect(() => {
640
- if (!signedIn || !notifying) return;
641
- const ask = () => {
642
- for (const app of apps.filter((a) => notifying.split(',').includes(a.id))) {
643
- const { types, check } = app.notifications!;
644
- check({ api: (path, init) => env.appApi(app.id, path, init) })
645
- .then((found) => {
646
- for (const n of found) {
647
- const type = appType(app.id, n.type);
648
- if (!(n.type in types) || told(type, n.id) || !isOn(type)) continue;
649
- const to = siteText(n.link ?? app.label);
650
- const link = n.to ? { text: to, href: `#${n.to}` } : undefined;
651
- remember({ text: n.text, type, error: n.error, link });
652
- notify({ text: n.text, error: n.error, edit: n.to ? { to: n.to, text: to } : undefined });
653
- }
654
- })
655
- .catch(() => {});
656
- }
657
- };
658
- ask();
659
- const timer = setInterval(ask, 15 * 60_000);
660
- return () => clearInterval(timer);
661
- }, [signedIn, notifying]);
662
-
663
- // Signed out, or not yet known with nothing from this browser to show: the sign-in (while the Worker is asked, the
664
- // icon and the name alone).
665
- if (signedIn === false || (signedIn === null && !files)) {
666
- return (
667
- <main className="mx-auto flex min-h-dvh max-w-sm flex-col items-center justify-center gap-5 px-6 pb-[env(safe-area-inset-bottom)] text-center">
668
- <img src="/editor/icon-192.png" alt="" className="size-20 rounded-[1.375rem] shadow-soft" />
669
- <h1 className="text-2xl font-semibold">{siteName}</h1>
670
- {signedIn === null && !error ? (
671
- <EmptyNote busy>{t.app.loading}</EmptyNote>
672
- ) : (
673
- <Button wide onClick={login}>
674
- {t.app.logIn}
675
- </Button>
676
- )}
677
- {error && <ProblemNote>{error}</ProblemNote>}
678
- </main>
679
- );
680
- }
681
-
682
- // The part of the site that is open, for the sidebar: the menu under "Header", the lists and testimonials under
683
- // "Lister", the front page under "Udseende".
684
- const open = collectionOf(collections, route)?.collection;
685
- const part = open?.id || route.replace(/^(liste|ny|slettet)\/([^/]+).*/, '$2');
686
- const current = lists.includes(part)
687
- ? 'lister'
688
- : header.includes(part)
689
- ? 'header'
690
- : part.startsWith('virksomheden')
691
- ? 'virksomheden'
692
- : part.startsWith('blokke')
693
- ? 'blokke'
694
- : part.startsWith('udseende')
695
- ? 'udseende'
696
- : part.startsWith('seo')
697
- ? 'seo'
698
- : part.startsWith('apps')
699
- ? 'apps'
700
- : (partScreens.find((s) => part.startsWith(`${s.id}/`))?.id ?? part);
701
-
702
- return (
703
- <>
704
- <div className="md:flex">
705
- {/* On a computer: the parts of the site in the theme's navigation rail, expanded and collapsed with its menu
706
- button; under it, while expanded, the logo and the name, which go to the start screen (the account is in the
707
- top bar, Screen.tsx). Collapsed, the mouse over it shows it expanded over the content for a moment (a short wait
708
- first, so passing over it does not), until the mouse leaves. On a phone, the start screen is the menu. */}
709
- <aside
710
- className="sticky top-0 z-30 hidden h-dvh shrink-0 pt-[env(safe-area-inset-top)] md:flex"
711
- onPointerEnter={(e) => {
712
- if (sidebar || e.pointerType !== 'mouse') return;
713
- peekTimer.current = window.setTimeout(() => setPeek(true), 150);
714
- }}
715
- onPointerLeave={() => {
716
- clearTimeout(peekTimer.current);
717
- setPeek(false);
718
- }}
719
- >
720
- <NavigationRail
721
- label={t.library.sidebar}
722
- menuLabel={sidebar ? t.library.closeSidebar : t.library.openSidebar}
723
- expanded={sidebar}
724
- peek={peek}
725
- onExpandedChange={(open) => {
726
- setPeek(false);
727
- setSidebar(open);
728
- }}
729
- header={
730
- <a href="#" className="flex min-w-0 items-center gap-3 font-semibold">
731
- <img src="/editor/icon-192.png" alt="" className="size-7 shrink-0 rounded-lg" />
732
- <span className="truncate">{siteName}</span>
733
- </a>
734
- }
735
- >
736
- {groups.map((g) => (
737
- <NavigationRailSection key={g.title} label={g.title}>
738
- {partsIn(g.ids, files ?? []).map(({ id, label, href, count }) => (
739
- <NavigationRailItem
740
- key={id}
741
- href={href}
742
- icon={icons[id]}
743
- label={label}
744
- count={files ? count : null}
745
- selected={current === id}
746
- />
747
- ))}
748
- </NavigationRailSection>
749
- ))}
750
- </NavigationRail>
751
- </aside>
752
-
753
- <div className="min-w-0 flex-1">
754
- {files ? (
755
- <>
756
- {user && !user.canSave && (
757
- <p className="bg-accent px-4 py-3 pt-[calc(0.75rem+env(safe-area-inset-top))] text-center text-sm text-on-accent">
758
- {t.app.cannotSave(user.login)}
759
- </p>
760
- )}
761
- <Page key={visit} route={route} files={files} env={env} user={user} builtAt={builtAt} onLogout={signOut} />
762
- </>
763
- ) : (
764
- <Screen title={siteName}>
765
- <EmptyNote busy={!error}>{error || t.app.loading}</EmptyNote>
766
- </Screen>
767
- )}
768
- </div>
769
-
770
- <LinkAsk groups={linkGroups} />
771
- <Ask />
772
- </div>
773
- </>
774
- );
775
- }
776
-
777
- /** The part whose form is at an address (CompanyForm.tsx): an app's settings on a screen of their own, one of
778
- * "Virksomheden"'s (or of its parts', with the settings of the apps of their kinds), "Søgemaskineoptimering"'s, "Udseende"'s, or one on a screen of parts with
779
- * the settings of the apps of its kinds ("Analytics"). */
780
- function partAt(route: string, files: RepoFile[]): Part | null {
781
- // The settings of an app installed without a screen with its kind, on a screen of their own.
782
- const settingsOf = apps.find((a) => route === `apps/${a.id}/indstillinger`);
783
- if (settingsOf) return (installedApps(files).includes(settingsOf.id) && appSettingsPart(settingsOf)) || null;
784
- // virksomheden/<part>, or virksomheden/<part>/<its part> (Part.parts).
785
- const [, companyId, ownId, ...more] = route.split('/');
786
- const company = route.startsWith('virksomheden/') && !more.length && companyParts.find((p) => p.id === companyId);
787
- return (
788
- (company &&
789
- (ownId ? [...(company.parts ?? []), ...appPartsIn(company, files)].find((p) => p.id === ownId) : company.file && company)) ||
790
- (route.startsWith('blokke/') && blockParts.find((p) => p.id === route.slice(7))) ||
791
- (route.startsWith('seo/') && seoParts.find((p) => p.id === route.slice(4))) ||
792
- (route.startsWith('udseende/') && appearance.find((p) => p.id === route.slice(9))) ||
793
- partScreens.flatMap((s) => [...s.parts, ...appPartsIn(s, files)].filter((p) => route === `${s.id}/${p.id}`))[0] ||
794
- null
795
- );
796
- }
797
-
798
- /** The form at an address: a part's (partAt, `at` its address), or the page of one of its fields with its choices on a
799
- * page of their own (Field.page: the form's address with the field's name after it, `page`). */
800
- function formAt(route: string, files: RepoFile[]): { part: Part; at: string; page?: string } | null {
801
- const part = partAt(route, files);
802
- if (part) return { part, at: route };
803
- const cut = route.lastIndexOf('/');
804
- const above = cut > 0 ? partAt(route.slice(0, cut), files) : null;
805
- const page = route.slice(cut + 1);
806
- return above?.fields?.some((f) => f.name === page && f.page) ? { part: above, at: route.slice(0, cut), page } : null;
807
- }
808
-
809
- type PageProps = { route: string; files: RepoFile[]; env: Env; user: Viewer | null; builtAt: Date | null; onLogout: () => void };
810
-
811
- function Page({ route, files, env, user, builtAt, onLogout }: PageProps) {
812
- if (!route) return <Start files={files} builtAt={builtAt} />;
813
- if (route === 'konto') return <Account user={user} files={files} onLogout={onLogout} />;
814
- if (route === 'notifikationer') return <Notifications />;
815
- if (route === 'indstillinger') return <Settings files={files} />;
816
- if (route === 'header') return <Header files={files} />;
817
- if (route === 'lister') return <Lists files={files} />;
818
- if (route === 'virksomheden') return <Company files={files} />;
819
- if (route === 'blokke') return <Blocks files={files} />;
820
- if (route === 'filer') return <Media files={files} env={env} />;
821
- if (route === 'seo') return <Seo />;
822
- if (route === REDIRECTS_ROUTE) return <Redirects env={env} />;
823
- if (route === SEO_SCORES_ROUTE) return <SeoScores files={files} env={env} />;
824
- if (route === TAB_ROUTES.redirects) return <Redirects env={env} tab="redirects" />;
825
- if (route === TAB_ROUTES.missing) return <Redirects env={env} tab="missing" />;
826
- const screen = partScreens.find((s) => s.id === route);
827
- if (screen) return <PartsScreen screen={screen} files={files} />;
828
- // A part of a screen of parts that is one of the editor's own screens (Part.opens), going back to that screen.
829
- const opening = partScreens.flatMap((s) => s.parts.filter((p) => p.opens && route === `${s.id}/${p.id}`).map((p) => ({ s, p })))[0];
830
- if (opening?.p.opens === 'consents') return <Consents env={env} back={{ href: `#${opening.s.id}`, label: opening.s.label }} />;
831
- if (route === 'udseende') return <Theme files={files} />;
832
- if (route === 'udseende/temaer') return <Themes env={env} files={files} />;
833
- if (route === 'udseende/ikonpakker') return <IconPacks env={env} files={files} />;
834
- if (route === 'udseende/bevaegelse') return <MotionSchemes env={env} files={files} />;
835
- // A theme installed has its page; one from the store that is not (or not yet built), its page in the store.
836
- const themeShown = route.startsWith('udseende/temaer/') && themes.find((x) => x.id === route.slice(16));
837
- if (themeShown) return <ThemePage key={themeShown.id} of={themeShown} env={env} files={files} />;
838
- if (/^udseende\/temaer\/[^/]+$/.test(route)) return <StoreThemePage key={route} id={route.slice(16)} env={env} />;
839
- // The colours, or the site's own on a page of the same form while an app changes the pages' (FALLBACK_ROUTE).
840
- if (route === 'udseende/farver' || route === FALLBACK_ROUTE)
841
- return <Colors env={env} files={files} fallback={route === FALLBACK_ROUTE} />;
842
- if (route === 'udseende/logo') return <BrandForm env={env} />;
843
- if (route === 'blokvalg') return <BlockSettings env={env} files={files} />;
844
- if (route === ADVANCED_ROUTE) return <Advanced env={env} />;
845
- if (route === LICENSES_ROUTE) return <Licenses />;
846
- if (route.startsWith(`${LICENSES_ROUTE}/`)) return <LicensePage key={route} id={route.slice(LICENSES_ROUTE.length + 1)} />;
847
- if (route === 'apps') return <Apps env={env} files={files} />;
848
- if (route === STORE_ROUTE) return <StorePage env={env} files={files} tab="apps" />;
849
- if (route === STORE_THEMES) return <StorePage env={env} files={files} tab="themes" />;
850
- if (route.startsWith(`${DEVELOPER_ROUTE}/`))
851
- return <DeveloperPage key={route} slug={route.slice(DEVELOPER_ROUTE.length + 1)} env={env} files={files} />;
852
- // An app in the site (its own, or one from the store while it is installed) has its page; one from the store that is
853
- // not (or no longer) installed, its page in the store.
854
- if (/^apps\/[^/]+$/.test(route)) {
855
- const app = apps.find((a) => a.id === route.slice(5));
856
- if (app && (!app.store || installedApps(files).includes(app.id))) return <AppPage app={app} env={env} files={files} />;
857
- return <StoreAppPage key={route} id={route.slice(5)} env={env} files={files} />;
858
- }
859
- // A screen of an app's own under its page, while it is installed (Cookiescanner's "Blokeret adresse").
860
- const [, screenApp, screenId, ...deeper] = route.split('/');
861
- const ownScreen =
862
- route.startsWith('apps/') && !deeper.length && apps.find((a) => a.id === screenApp && screenId && a.screens?.[screenId]);
863
- if (ownScreen && installedApps(files).includes(ownScreen.id))
864
- return <AppScreenPage key={route} app={ownScreen} id={screenId} env={env} />;
865
- if (route === 'udseende/dele') return <PartsHome />;
866
- if (route === 'udseende/dele/editor') return <Parts env={env} />;
867
- if (route === 'udseende/dele/ui') return <ComponentParts />;
868
- if (route === 'udseende/dele/blokke') return <BlocksParts />;
869
- if (route.startsWith('udseende/dele/liste/')) return <KindParts key={route} id={route.slice(20)} />;
870
- if (route === 'udseende/dele/vaerdier') return <Tokens />;
871
- if (route.startsWith('slettet/')) return <Deleted id={route.slice(8)} files={files} env={env} />;
872
- // virksomheden/<part>, or virksomheden/<part>/<its part> (Part.parts).
873
- const [, companyId, ownId, ...more] = route.split('/');
874
- const company = route.startsWith('virksomheden/') && !more.length && companyParts.find((p) => p.id === companyId);
875
- if (company && company.parts && !ownId) return <Company part={company} files={files} />;
876
- // A part's form, or the page of one of its fields (Field.page) with the form under it: the same form, so it keeps what
877
- // is in it while the editor goes between them (useFormPages, inForm).
878
- const form = formAt(route, files);
879
- if (form) return <CompanyForm part={form.part} at={form.at} page={form.page} env={env} files={files} />;
880
- // A list, or a tab an installed app adds to it (liste/<id>/<app>).
881
- const [, listId, listTab, ...below] = route.split('/');
882
- const listed = route.startsWith('liste/') && !below.length && collections.find((c) => c.id === listId);
883
- if (listed) return <List key={listed.id} collection={listed} tab={listTab} files={files} env={env} />;
884
-
885
- // ny/<list>, or ny/<list>/<kind> for one of its kinds (a pop-up: standard, mailchimp).
886
- const [, newIn, newKind] = route.split('/');
887
- const creating = route.startsWith('ny/') && collections.find((c) => c.id === newIn && c.create);
888
- if (creating) {
889
- if (creating.kind === 'markdown') return <EntryForm collection={creating} file={newMarkdown(creating, notOffered(files))} env={env} />;
890
- if (creating.kind === 'folder')
891
- return <DataForm key={route} collection={creating} kind={newKind} file={{ path: '', oid: '', text: '' }} env={env} />;
892
- }
893
-
894
- const found = collectionOf(collections, route);
895
- const file = files.find((f) => f.path === route);
896
- if (!found || !file)
897
- return (
898
- <Screen title={t.app.notFound} back={{ href: '#', label: siteName }}>
899
- <EmptyNote>{t.app.nothingAt(route)}</EmptyNote>
900
- </Screen>
901
- );
902
- if (found.collection.kind === 'markdown') return <EntryForm collection={found.collection} file={file} env={env} />;
903
- return <DataForm collection={found.collection} dataFile={found.dataFile} file={file} env={env} />;
904
- }
1
+ // The editor at /editor/ (docs/editor.md): sign in with GitHub, pick a part of the site, edit and
2
+ // save. The address after # says what is open, so reloading keeps it:
3
+ // (empty) the start page
4
+ // liste/<id> the list of a part, e.g. liste/priser, and liste/<id>/<app> a tab an installed app adds to it
5
+ // (AppDefinition.tabs): liste/cookies/cookiescanner
6
+ // header what is in the header on every page (the menu)
7
+ // lister the short lists (the testimonials, the apps' lists)
8
+ // indstillinger the settings of the whole site (Library.tsx Settings), opened by the gear in the top bar:
9
+ // indstillinger/appstore the app store, its apps, and indstillinger/appstore/temaer its themes (AppList.tsx StorePage)
10
+ // ny/<id> a new entry, ny/<id>/<kind> one of a kind (a pop-up: standard, or an app's)
11
+ // virksomheden the business, and virksomheden/<part> its parts: kontakt, betaling; and
12
+ // virksomheden/betaling/<part> its own parts: mobilepay, bank
13
+ // <screen> a screen with parts of the site's own (partScreens), and <screen>/<part> its parts:
14
+ // privatliv/cookiebanner, privatliv/privatlivssider, or one of the editor's own screens (Part.opens):
15
+ // privatliv/samtykker the visitors' answers in the cookie banner (Consents.tsx); and the settings of the apps installed with
16
+ // its category: maaling/google-analytics, promovering/calendly
17
+ // filer the uploaded pictures, PDFs, videos and zip files (Media.tsx)
18
+ // seo "Søgemaskineoptimering" (Library.tsx Seo): seo/videresendelser the redirects' two lists
19
+ // (Redirects.tsx): seo/videresendelser/alle the redirects, and seo/videresendelser/ikke-fundet
20
+ // the addresses visitors came to that do not exist; seo/vurdering the SEO rating of every page and
21
+ // post (SeoScores.tsx); and seo/<part> the site's own (seoParts)
22
+ // udseende how the site looks (Library.tsx Theme): udseende/temaer the themes installed (Themes) and udseende/temaer/<theme>
23
+ // a theme's page, with the button that swaps to it (ThemePage), udseende/ikonpakker the icon packs (IconPacks),
24
+ // udseende/bevaegelse how it moves (MotionSchemes),
25
+ // udseende/farver the colours (Colors.tsx;
26
+ // udseende/farver/reservefarver the site's own while an app changes the pages'), udseende/logo the logo
27
+ // and icon (BrandForm.tsx), udseende/dele the parts (Library.tsx PartsHome): udseende/dele/editor the editor's, every
28
+ // one in every state (Parts.tsx), udseende/dele/ui the theme's UI components, udseende/dele/blokke the
29
+ // blocks, the standard ones and the apps', and udseende/dele/liste/<list> each kind of a list of kinds
30
+ // ("Promovering"), its own and the apps' (BlockParts.tsx), and udseende/dele/vaerdier the
31
+ // theme's design tokens, "Værdier" (Tokens.tsx)
32
+ // blokvalg the blocks that can be put in, "Blokindstillinger" (BlockSettings.tsx)
33
+ // avanceret "Avanceret", under "Indstillinger": the copy of the site's files in R2 (Advanced.tsx)
34
+ // licenser "Licenser", under "Indstillinger": the licences of what the site and the editor are made with, and
35
+ // licenser/<id> one with its text (Licenses.tsx)
36
+ // apps the apps that come with the site, apps/<app> one with its button to install it and, while it
37
+ // is installed, a row to its settings: on the screen with its category (maaling/<app>), else
38
+ // apps/<app>/indstillinger (AppList.tsx settingsRoute)
39
+ // <form>/<field> the page of a field of a part's form with its choices on a page of their own (Field.page), over the
40
+ // form, which keeps what is in it (formAt): apps/content-colors/indstillinger/variant
41
+ // blokke the blocks, and blokke/<block> the texts all its copies have (blockTexts.ts)
42
+ // slettet/<id> what was deleted from a list, or slettet/filer the files (Deleted.tsx)
43
+ // <file path> a file being edited, e.g. src/content/blog/mit-foerste-indlaeg.md
44
+ import { useEffect, useMemo, useRef, useState } from 'react';
45
+ import {
46
+ AuthError,
47
+ commit,
48
+ deletedFiles,
49
+ loadBytes,
50
+ loadFiles,
51
+ head,
52
+ loadFolders,
53
+ login,
54
+ logout,
55
+ rememberTexts,
56
+ savedAt,
57
+ versionsOf,
58
+ versionText,
59
+ viewer,
60
+ type Change,
61
+ type DeletedFile,
62
+ type Version,
63
+ type RepoFile,
64
+ type Viewer,
65
+ } from './github';
66
+ import {
67
+ changesText,
68
+ hasUnsaved,
69
+ kindAndName,
70
+ lower,
71
+ nameOf,
72
+ savedText,
73
+ setUnsaved,
74
+ keepCurrent,
75
+ registerWorker,
76
+ waitForSite,
77
+ } from './deploy';
78
+ import { readCache, writeCache } from './cache';
79
+ import { forgetNotFound, loadNotFound, type Missing } from './notFound';
80
+ import { collectionOf, slugOf, type Collection, type FolderCollection, type Part, type Relation } from './schema';
81
+ import EntryForm, { newMarkdown } from './EntryForm';
82
+ import DataForm from './DataForm';
83
+ import CompanyForm from './CompanyForm';
84
+ import Media, { mediaOf } from './Media';
85
+ import Redirects, { REDIRECTS_ROUTE, TAB_ROUTES } from './Redirects';
86
+ import Consents from './Consents';
87
+ import SeoScores, { SEO_SCORES_ROUTE } from './SeoScores';
88
+ import Deleted from './Deleted';
89
+ import Parts from './Parts';
90
+ import { BlocksParts, ComponentParts, KindParts } from './BlockParts';
91
+ import Tokens from './Tokens';
92
+ import BrandForm from './BrandForm';
93
+ import BlockSettings from './BlockSettings';
94
+ import Advanced, { ADVANCED_ROUTE } from './Advanced';
95
+ import Licenses, { LICENSES_ROUTE, LicensePage } from './Licenses';
96
+ import { StoreThemePage } from './ThemeStore';
97
+ import {
98
+ AppPage,
99
+ AppScreenPage,
100
+ Apps,
101
+ appPartsIn,
102
+ autoUpdate,
103
+ DeveloperPage,
104
+ appSettingsPart,
105
+ settingsRoute,
106
+ StoreAppPage,
107
+ StorePage,
108
+ } from './AppList';
109
+ import { installedApps } from './apps';
110
+ import {
111
+ DEVELOPER_ROUTE,
112
+ readApp,
113
+ readCatalog,
114
+ readScreenshots,
115
+ storeConnected,
116
+ readThemes,
117
+ readThemeScreenshots,
118
+ STORE_BEFORE,
119
+ STORE_ROUTE,
120
+ STORE_THEMES,
121
+ type ShownApp,
122
+ type ShownTheme,
123
+ type StoreApp,
124
+ type StoreFile,
125
+ type StoreTheme,
126
+ } from './store';
127
+ import Colors, { colorsOf, FALLBACK_ROUTE } from './Colors';
128
+ import { applyColors, applyMotion } from './theme';
129
+ import { apps, notOffered } from './registry';
130
+ import {
131
+ Account,
132
+ Blocks,
133
+ Company,
134
+ Header,
135
+ List,
136
+ Lists,
137
+ PartsHome,
138
+ partsIn,
139
+ PartsScreen,
140
+ Seo,
141
+ Settings,
142
+ Start,
143
+ Theme,
144
+ ThemePage,
145
+ Themes,
146
+ IconPacks,
147
+ MotionSchemes,
148
+ motionOf,
149
+ } from './Library';
150
+ import {
151
+ appearance,
152
+ asWritten,
153
+ blockParts,
154
+ collections,
155
+ companyParts,
156
+ groups,
157
+ icons,
158
+ listRowsOf,
159
+ lists,
160
+ partScreens,
161
+ seoParts,
162
+ } from './site';
163
+ import { config, header, relationsOf, type SiteData, siteName, siteOf, testimonialsOf, usedBy, whatIsAt } from '@site/editor';
164
+ import { themes } from './themes';
165
+ import Screen from './Screen';
166
+ import { Button, EmptyNote, NavigationRail, NavigationRailItem, NavigationRailSection, ProblemNote, startRipples } from './ui';
167
+ import { setSidebar, useSidebar } from './sidebar';
168
+ import { Ask, ask } from './ask';
169
+ import { LinkAsk } from './LinkSheet';
170
+ import { type ListRows, type Option, type TestimonialRow } from './fields';
171
+ import { endOf, entriesOf, lookup, readAll } from './redirectData';
172
+ import { inForm, leaveTo, type Status } from './FormFrame';
173
+ import { arrived, screenOf, up } from './nav';
174
+ import { siteT, siteText, t } from './i18n';
175
+ import { snackbar } from '../lib/snackbar';
176
+ import { appType, clearNotifications, isOn, remember, signOff, stopServerWait, syncDevice, told, waitOnServer } from './notificationData';
177
+ import Notifications from './Notifications';
178
+ import { EditorOwn, InEditor } from './standard/view';
179
+ import './editor.css';
180
+
181
+ // What the forms and lists need from the app.
182
+ export type Env = {
183
+ relations: Record<Relation, Option[]>;
184
+ /** The rows of the lists (the apps' and the standard blocks'), by the list's id, and the testimonials, in their
185
+ * order, for the cards of the blocks. */
186
+ lists: ListRows;
187
+ testimonials: TestimonialRow[];
188
+ /** The rest of the site, for the previews of the blocks (sitedata.ts). */
189
+ site: SiteData;
190
+ /** The pictures uploaded to the site (Media.tsx), for the gallery a picture is chosen from. */
191
+ media: RepoFile[];
192
+ /** All the files the editor has, for what links to a page that moves (addresses.ts). */
193
+ files: RepoFile[];
194
+ /** Shows a message at the bottom of the screen; null hides it. */
195
+ notify: (status: Status) => void;
196
+ /** Saves files as one commit (github.ts) and later tells when the site has the change. `change`: what it changed, in
197
+ * the editor's language (t.app.changes, deploy.ts), for that message. Returns the new blob ids. */
198
+ commit: (message: string, changes: Change[], change: string) => Promise<Record<string, string>>;
199
+ /** When some files were last saved (github.ts); a new file is left out. */
200
+ savedAt: (paths: string[]) => Promise<Record<string, Date>>;
201
+ /** The commits that changed a file, and its text in one of them (github.ts; Versions.tsx). */
202
+ versions: (path: string, after?: string) => Promise<{ versions: Version[]; more: string | null }>;
203
+ versionText: (version: Version, path: string) => Promise<string | null>;
204
+ /** The files deleted from a folder (github.ts deletedFiles; Deleted.tsx). */
205
+ deleted: (folder: string, options?: { text?: boolean }) => Promise<DeletedFile[]>;
206
+ /** Why a new page or post cannot have this address (it is taken), or null; redirects: the files of
207
+ * the redirects as they are now (redirectData.ts, loaded with `load`). */
208
+ taken: (address: string, redirects: RepoFile[]) => Taken | null;
209
+ /** Single files outside the folders the app loads (github.ts loadFiles). */
210
+ load: (paths: string[]) => Promise<RepoFile[]>;
211
+ /** All the files in a folder of the site, at any depth (an app's, to install, update or uninstall it). */
212
+ folder: (path: string) => Promise<RepoFile[]>;
213
+ /** The app store (store.ts): its apps and its themes, an app's or a theme's files from it, and their pictures (as
214
+ * addresses in this browser). */
215
+ store: {
216
+ connected: () => Promise<boolean>;
217
+ catalog: () => Promise<ShownApp[]>;
218
+ themes: () => Promise<ShownTheme[]>;
219
+ files: (of: { files: StoreFile[]; label?: string; name?: string }) => Promise<{ path: string; content: string | Uint8Array }[]>;
220
+ screenshots: (app: StoreApp) => Promise<{ src: string; label: string }[]>;
221
+ themeScreenshots: (theme: Pick<StoreTheme, 'screenshots' | 'files'>) => Promise<{ key: string; src: string }[]>;
222
+ };
223
+ /** A picture's bytes by its blob id (github.ts loadBytes). */
224
+ bytes: (oid: string, address?: string) => Promise<Uint8Array>;
225
+ /** The addresses visitors came to that do not exist (notFound.ts; null: the site does not keep
226
+ * them), and taking some off that list. */
227
+ notFound: { list: () => Promise<Missing[] | null>; forget: (paths: string[]) => Promise<void> };
228
+ /** An app's own addresses in the site's Worker (/api/<app>/<path>, its worker.js), asked with the GitHub sign-in, for
229
+ * its page (AppDefinition.page). */
230
+ appApi: (app: string, path?: string, init?: RequestInit) => Promise<Response>;
231
+ /** Files deleted on their own (a file in Filer). */
232
+ forget: (paths: string[]) => void;
233
+ /** A file was saved; more: other files saved with it, and files it removed (a post that moved). */
234
+ onSaved: (file: RepoFile, more?: { files: RepoFile[]; removed: string[] }) => void;
235
+ remove: (collection: Collection & { folder: string }, file: RepoFile, deleting: (b: boolean) => void) => void;
236
+ nextOrder: (collection: FolderCollection) => number;
237
+ /** Saves a list in a new order; says what changed (deploy.ts). */
238
+ reorder: (collection: Collection, files: RepoFile[]) => Promise<string>;
239
+ };
240
+
241
+ // The uploaded pictures only by their paths (no text), for "Filer" (Media.tsx).
242
+ const FOLDERS = config.folders;
243
+ const FOLDERS_KEY = JSON.stringify(FOLDERS);
244
+
245
+ const json = (file: RepoFile) => JSON.parse(file.text) as Record<string, unknown>;
246
+
247
+ // Addresses of the site that are not pages.
248
+ const RESERVED = config.reserved;
249
+
250
+ /** Why a new page or post cannot have an address (/<slug>/): a message; with `redirect` when the site
251
+ * sends it on elsewhere, which the site's owner may remove (EntryForm.tsx): where it ends after every redirect
252
+ * (redirectData.ts), its name, and how many other addresses send on through it. */
253
+ export type Taken = { text: string; redirect?: { end: string; name: string; via: number } };
254
+
255
+ // Why a new page or post cannot have an address (/<slug>/), or null. redirects: the files of the
256
+ // redirects (redirectData.ts), as they are now.
257
+ function taken(address: string, relations: Record<Relation, Option[]>, redirects: RepoFile[]): Taken | null {
258
+ const pages = [...relations.pages, ...relations.fixed].map((o) => o.value);
259
+ const posts = relations.blog.map((o) => `/${o.value}/`);
260
+ if (pages.includes(address) || posts.includes(address) || RESERVED.includes(address)) return { text: t.app.addressTaken(address) };
261
+ const all = readAll(redirects);
262
+ const look = lookup(all);
263
+ if (look(address) === undefined) return null;
264
+ const { end } = endOf(address, look);
265
+ const via = entriesOf(all).filter((e) => e.from !== address && e.to.split('#')[0] === address).length;
266
+ const name = whatIsAt(end, relations);
267
+ return { text: t.app.addressRedirects(address, name), redirect: { end, name, via } };
268
+ }
269
+
270
+ // Screens that have moved, by their old address: a link or a bookmark to one opens it where it is now.
271
+ const MOVED: Record<string, string> = {
272
+ farver: 'udseende/farver',
273
+ 'virksomheden/logo': 'udseende/logo',
274
+ 'tema/blokvalg': 'blokvalg',
275
+ dele: 'udseende/dele',
276
+ tema: 'udseende',
277
+ 'tema/farver': 'udseende/farver',
278
+ 'tema/logo': 'udseende/logo',
279
+ 'tema/dele': 'udseende/dele',
280
+ 'tema/dele/editor': 'udseende/dele/editor',
281
+ 'virksomheden/bank': 'virksomheden/betaling/bank',
282
+ videresendelser: 'seo/videresendelser',
283
+ 'videresendelser/alle': 'seo/videresendelser/alle',
284
+ 'videresendelser/ikke-fundet': 'seo/videresendelser/ikke-fundet',
285
+ [STORE_BEFORE]: STORE_ROUTE,
286
+ [`${STORE_BEFORE}/temaer`]: STORE_THEMES,
287
+ };
288
+
289
+ // The editor's address after the "#" (nav.ts screenOf).
290
+ function routeOf(hash: string) {
291
+ const route = MOVED[screenOf(hash)] ?? screenOf(hash);
292
+ // An app's settings: on the screen with its category, when it has one ("Analytics", AppList.tsx settingsRoute), and so
293
+ // the pages of their fields (Field.page) after them.
294
+ const app = apps.find((a) => route === `apps/${a.id}/indstillinger` || route.startsWith(`apps/${a.id}/indstillinger/`));
295
+ const rest = app ? route.slice(`apps/${app.id}/indstillinger`.length) : '';
296
+ const moved = (app && settingsRoute(app) && `${settingsRoute(app)}${rest}`) || route;
297
+ if (moved !== screenOf(hash)) history.replaceState(history.state, '', `#${moved}`);
298
+ return moved;
299
+ }
300
+
301
+ // The editor; what it draws of the site is in the editor (InEditor: a link only drawn …), its own screens are its own
302
+ // (EditorOwn: the theme's UI parts on them are links that lead somewhere).
303
+ export default function App() {
304
+ return (
305
+ <InEditor>
306
+ <EditorOwn>
307
+ <Editor />
308
+ </EditorOwn>
309
+ </InEditor>
310
+ );
311
+ }
312
+
313
+ function Editor() {
314
+ // Whether someone is signed in (the Worker keeps the sign-in, packages/editor/worker/session.js): null until the Worker or this
315
+ // browser's copy of the files (cache.ts) has said.
316
+ const [signedIn, setSignedIn] = useState<boolean | null>(null);
317
+ // Whether the sidebar is open on a computer (sidebar.ts).
318
+ const sidebar = useSidebar();
319
+ // While it is closed: the mouse over the rail, which then shows the whole sidebar over the content.
320
+ const [peek, setPeek] = useState(false);
321
+ const peekTimer = useRef(0);
322
+ const [user, setUser] = useState<Viewer | null>(null);
323
+ const [files, setFiles] = useState<RepoFile[] | null>(null);
324
+ const [error, setError] = useState('');
325
+ const [route, setRoute] = useState(() => routeOf(location.hash));
326
+ // Counts the visits to an address: a form starts fresh on each, but not when a new entry that was
327
+ // just saved gets its own address.
328
+ const [visit, setVisit] = useState(0);
329
+ // The messages at the bottom stand in the middle of the content, next to the sidebar (editor.css).
330
+ useEffect(() => {
331
+ document.documentElement.dataset.sidebar = sidebar ? 'open' : 'closed';
332
+ }, [sidebar]);
333
+ // Stops waiting for the last save to be on the site.
334
+ const stopWaiting = useRef<() => void>(undefined);
335
+ // What the saves the editor waits for the website to have changed (save).
336
+ const waitingFor = useRef<string[]>([]);
337
+
338
+ useEffect(registerWorker, []);
339
+ useEffect(startRipples, []);
340
+ // Signed up for the notifications on the phone from the site's Worker, also when the editor is closed, while
341
+ // "På telefonen" is on (notificationData.ts).
342
+ useEffect(() => {
343
+ if (signedIn) syncDevice();
344
+ }, [signedIn]);
345
+ // A tap on such a notification while the editor is open: to the screen it is about (packages/editor/pages/editor/sw.js).
346
+ useEffect(() => {
347
+ const onMessage = (e: MessageEvent) => typeof e.data?.to === 'string' && leaveTo(`#${e.data.to}`);
348
+ navigator.serviceWorker?.addEventListener('message', onMessage);
349
+ return () => navigator.serviceWorker?.removeEventListener('message', onMessage);
350
+ }, []);
351
+ // When the site was last updated (built on Cloudflare), for the start screen.
352
+ const [builtAt, setBuiltAt] = useState<Date | null>(null);
353
+ useEffect(() => keepCurrent((v) => v.builtAt && setBuiltAt(new Date(v.builtAt))), []);
354
+
355
+ // Where the editor is, to come back to when leaving is asked about.
356
+ const here = useRef(route);
357
+ here.current = route;
358
+ useEffect(() => {
359
+ const onHash = () => {
360
+ // The history's step (nav.ts): null when it undoes the one below.
361
+ const step = arrived(here.current);
362
+ if (!step) return;
363
+ // To or from a page of the open form's own (a field's, Field.page): the form stays, with what is in it, and
364
+ // scrolls itself (CompanyForm.tsx).
365
+ const next = routeOf(location.hash);
366
+ if (inForm(here.current, next)) return setRoute(next);
367
+ // While something is unsaved, another address (the phone's back gesture, the sidebar, a link)
368
+ // is first undone, and taken again only when the owner leaves without saving (leaveTo).
369
+ if (hasUnsaved()) {
370
+ step.undo();
371
+ return leaveTo(step.redo);
372
+ }
373
+ setRoute(next);
374
+ setVisit((v) => v + 1);
375
+ scrollTo(0, 0);
376
+ };
377
+ window.addEventListener('hashchange', onHash);
378
+ return () => window.removeEventListener('hashchange', onHash);
379
+ }, []);
380
+
381
+ // The commit the files are from. Not moved on by the owner's own saves, so the next check loads the
382
+ // files again with those changes and anything else that came in.
383
+ const commitOf = useRef('');
384
+ // Counts the owner's saves, so that files loaded while one was made (from before it) are not shown.
385
+ const saves = useRef(0);
386
+
387
+ // The files from this browser at once (cache.ts), then from GitHub only when the branch has moved
388
+ // on: checked on opening, and when the app is shown again, at most every half minute, and not
389
+ // while something is unsaved. Only the files whose text this browser does not have are loaded (github.ts). The open
390
+ // form keeps the file it opened with; saving checks it. Signed out (the Worker says so), the sign-in is shown.
391
+ const signedOut = signedIn === false;
392
+ useEffect(() => {
393
+ if (signedOut) return;
394
+ let stopped = false;
395
+ let checked = 0;
396
+ async function refresh() {
397
+ if (stopped || hasUnsaved() || Date.now() - checked < 30_000) return;
398
+ checked = Date.now();
399
+ try {
400
+ const [user, at] = await Promise.all([viewer(), head()]);
401
+ if (stopped) return;
402
+ setUser(user);
403
+ setSignedIn(true);
404
+ if (at.commit !== commitOf.current) {
405
+ const before = saves.current;
406
+ const loaded = await loadFolders(FOLDERS, 3, at);
407
+ if (stopped) return;
408
+ // Saved meanwhile: these files are older than that save; the next check loads them again.
409
+ if (saves.current !== before) {
410
+ checked = 0;
411
+ return;
412
+ }
413
+ commitOf.current = loaded.commit;
414
+ setFiles(Object.values(loaded.folders).flat());
415
+ }
416
+ } catch (e) {
417
+ if (e instanceof AuthError) signOut();
418
+ // With files from this browser, the editor goes on; the next check tries again.
419
+ else if (!commitOf.current) setError((e as Error).message);
420
+ checked = 0;
421
+ }
422
+ }
423
+ readCache().then((cached) => {
424
+ if (stopped) return;
425
+ if (cached) {
426
+ // Kept from other folders than these (an older editor): shown, and loaded again at once.
427
+ commitOf.current = cached.folders === FOLDERS_KEY ? cached.commit : '';
428
+ rememberTexts(cached.files);
429
+ setUser(cached.user);
430
+ setFiles(cached.files);
431
+ setSignedIn(true);
432
+ }
433
+ refresh();
434
+ });
435
+ const onShow = () => document.visibilityState === 'visible' && refresh();
436
+ document.addEventListener('visibilitychange', onShow);
437
+ return () => {
438
+ stopped = true;
439
+ document.removeEventListener('visibilitychange', onShow);
440
+ };
441
+ }, [signedOut]);
442
+
443
+ // Keeps what is shown, with the owner's saves, for the next time the editor opens.
444
+ useEffect(() => {
445
+ if (files && user && commitOf.current) writeCache({ commit: commitOf.current, files, user, folders: FOLDERS_KEY });
446
+ }, [files, user]);
447
+
448
+ const relations = useMemo(() => relationsOf(files ?? []), [files]);
449
+ // What a link in the text can go to (LinkSheet.tsx): the fixed pages (not those that are also a
450
+ // page), the other pages and the posts, by their addresses.
451
+ const linkGroups = useMemo(() => {
452
+ const fixed = relations.fixed.filter((f) => !relations.pages.some((o) => o.value === f.value));
453
+ return [
454
+ { title: t.forms.fixedPages, options: fixed },
455
+ { title: t.forms.pages, options: relations.pages },
456
+ { title: t.text.linkPosts, options: relations.blog.map((o) => ({ ...o, value: `/${o.value}/` })) },
457
+ ].filter((g) => g.options.length);
458
+ }, [relations]);
459
+ const listRows = useMemo(() => listRowsOf(files ?? []), [files]);
460
+ const testimonials = useMemo(() => testimonialsOf(files ?? []), [files]);
461
+
462
+ // The site's colours as loaded and saved ("Farver"), also before the site is built again with them.
463
+ const colorsFile = files?.find((f) => f.path === config.colors)?.text;
464
+ useEffect(() => {
465
+ if (colorsFile !== undefined) applyColors(colorsOf(files!));
466
+ }, [colorsFile]);
467
+ // How the site moves ("Bevægelse") as loaded and saved, also before the site is built again with it.
468
+ const motionFile = files?.find((f) => f.path === config.motion)?.text;
469
+ useEffect(() => {
470
+ if (files) applyMotion(motionOf(files));
471
+ }, [motionFile, files === null]);
472
+
473
+ const siteData = useMemo(() => siteOf(files ?? []), [files]);
474
+ const media = useMemo(() => mediaOf(files ?? []), [files]);
475
+
476
+ // A message at the bottom (snackbar in lib/snackbar.ts, the same as on the site), with a link to what it
477
+ // is about; one of a kind kept in the notifications too (notificationData.ts).
478
+ function notify(next: Status, device = true) {
479
+ const edit = next?.edit;
480
+ if (next?.kind)
481
+ remember({ text: next.text, type: next.kind, error: next.error, link: edit && { text: edit.text, href: `#${edit.to}` } }, device);
482
+ snackbar(
483
+ next && {
484
+ text: next.text,
485
+ error: next.error,
486
+ links: [
487
+ ...(edit
488
+ ? [
489
+ {
490
+ text: edit.text,
491
+ href: `#${edit.to}`,
492
+ onClick: (e: MouseEvent) => {
493
+ e.preventDefault();
494
+ leaveTo(`#${edit.to}`);
495
+ },
496
+ },
497
+ ]
498
+ : []),
499
+ ],
500
+ },
501
+ );
502
+ }
503
+
504
+ async function save(message: string, changes: Change[], change: string) {
505
+ const saved = await commit(message, changes);
506
+ // One message for all saves: a later save is on the site together with or after the ones before. It says what
507
+ // each of them changed (each once).
508
+ stopWaiting.current?.();
509
+ if (!waitingFor.current.includes(change)) waitingFor.current.push(change);
510
+ const what = changesText(waitingFor.current);
511
+ // The Worker says it on the phone too, also when the editor is closed by then (notificationData.ts).
512
+ const onServer = waitOnServer(saved.commit, t.app.siteUpdated(what), t.app.siteNotUpdated(what));
513
+ stopWaiting.current = waitForSite(saved.commit, async (online) => {
514
+ waitingFor.current = [];
515
+ const text = online ? t.app.siteUpdated(what) : t.app.siteNotUpdated(what);
516
+ // On screen the editor says it, and the Worker does not; else only the Worker sends it to the phone.
517
+ const byServer = document.visibilityState !== 'visible' && (await onServer);
518
+ if (!byServer) stopServerWait();
519
+ notify({ text, error: !online, kind: 'site' }, !byServer);
520
+ });
521
+ return saved.blobs;
522
+ }
523
+
524
+ function signOut() {
525
+ stopWaiting.current?.();
526
+ waitingFor.current = [];
527
+ // No notifications on this phone for someone signed out: signed off at the Worker while the sign-in is still there.
528
+ void signOff()
529
+ .catch(() => {})
530
+ .then(logout);
531
+ commitOf.current = '';
532
+ writeCache(null);
533
+ clearNotifications();
534
+ setSignedIn(false);
535
+ setUser(null);
536
+ setFiles(null);
537
+ }
538
+
539
+ const replace = (saved: RepoFile[], removed: string[] = []) => {
540
+ saves.current++;
541
+ setFiles((all) => [...(all ?? []).filter((f) => !saved.some((s) => s.path === f.path) && !removed.includes(f.path)), ...saved]);
542
+ };
543
+
544
+ const env: Env = {
545
+ relations,
546
+ lists: listRows,
547
+ testimonials,
548
+ site: siteData,
549
+ media,
550
+ files: files ?? [],
551
+ forget: (paths) => replace([], paths),
552
+ notify,
553
+ commit: save,
554
+ savedAt,
555
+ versions: versionsOf,
556
+ versionText,
557
+ deleted: deletedFiles,
558
+ load: (paths) => loadFiles(paths),
559
+ folder: (path) => loadFolders({ all: path }, 8).then((r) => r.folders.all),
560
+ store: {
561
+ connected: storeConnected,
562
+ catalog: readCatalog,
563
+ themes: readThemes,
564
+ files: readApp,
565
+ screenshots: readScreenshots,
566
+ themeScreenshots: readThemeScreenshots,
567
+ },
568
+ bytes: (oid, address) => loadBytes(oid, address),
569
+ notFound: { list: loadNotFound, forget: forgetNotFound },
570
+ // The sign-in's cookie goes with the request (packages/editor/worker/session.js).
571
+ appApi: (app, path = '', init) => fetch(`/api/${app}/${path}`, { cache: 'no-store', ...init }),
572
+ taken: (address, redirects) => taken(address, relations, redirects),
573
+ onSaved(file, more) {
574
+ replace([file, ...(more?.files ?? [])], more?.removed);
575
+ // A new entry, or a post that got a new address: from now on it is edited as the file it
576
+ // became. A form that saves another file than the one at its address (Virksomheden) stays where it is.
577
+ if (route.startsWith('ny/') || more?.removed.includes(route)) {
578
+ history.replaceState(history.state, '', `#${file.path}`);
579
+ setRoute(file.path);
580
+ }
581
+ },
582
+ async remove(collection, file, deleting) {
583
+ const reason = usedBy(collection, file, files ?? []);
584
+ if (reason) return notify(reason);
585
+ if (!(await ask(t.app.deleteEntry(collection.singular), t.common.delete))) return;
586
+ deleting(true);
587
+ notify({ text: t.common.deleting });
588
+ const change = t.app.changes.deleted(kindAndName(collection.singular, nameOf(collection, file)));
589
+ try {
590
+ await save(
591
+ siteT.app.commit.delete(asWritten(collection).singular, slugOf(file.path, collection.folder)),
592
+ [{ path: file.path, content: null, oid: file.oid }],
593
+ change,
594
+ );
595
+ replace([], [file.path]);
596
+ // Back to the list, as with the back arrow: the deleted entry is not left behind it in the history.
597
+ setUnsaved(false);
598
+ up(`#liste/${collection.id}`);
599
+ notify({ text: savedText(change), kind: 'saved' });
600
+ } catch (e) {
601
+ notify({ text: t.app.notDeleted((e as Error).message), error: true, kind: 'failed' });
602
+ deleting(false);
603
+ }
604
+ },
605
+ nextOrder(collection) {
606
+ const own = (files ?? []).filter((f) => f.path.startsWith(`${collection.folder}/`) && f.path.endsWith('.json'));
607
+ return Math.max(0, ...own.map((f) => Number(json(f).order) || 0)) + 1;
608
+ },
609
+ // Numbers the entries 1, 2, 3… in the new order and saves the files whose number changed.
610
+ async reorder(collection, ordered) {
611
+ const changes = ordered.flatMap((file, i) => {
612
+ const { order, ...rest } = json(file);
613
+ if (order === i + 1) return [];
614
+ const text = `${JSON.stringify({ order: i + 1, ...rest }, null, 2)}\n`;
615
+ return [{ path: file.path, content: text, oid: file.oid }];
616
+ });
617
+ const change = t.app.changes.order(lower(collection.label));
618
+ if (!changes.length) return change;
619
+ const oids = await save(siteT.app.commit.reorder(asWritten(collection).label.toLowerCase()), changes, change);
620
+ replace(changes.map((c) => ({ path: c.path, oid: oids[c.path], text: c.content })));
621
+ return change;
622
+ },
623
+ };
624
+
625
+ // The apps from the store updated on their own, once the files are there (autoUpdate in AppList.tsx).
626
+ const loaded = !!files;
627
+ useEffect(() => {
628
+ if (signedIn && loaded) autoUpdate(env, files!);
629
+ }, [signedIn, loaded]);
630
+
631
+ // What the installed apps have to say (AppNotifications in apps.ts): asked when the editor opens and every 15 minutes
632
+ // while it is open; each comes once, at the bottom and under "Notifikationer", while its type is on (notificationData.ts).
633
+ const notifying = installedApps(files ?? [])
634
+ .filter((id) => apps.some((a) => a.id === id && a.notifications))
635
+ .join();
636
+ useEffect(() => {
637
+ if (!signedIn || !notifying) return;
638
+ const ask = () => {
639
+ for (const app of apps.filter((a) => notifying.split(',').includes(a.id))) {
640
+ const { types, check } = app.notifications!;
641
+ check({ api: (path, init) => env.appApi(app.id, path, init) })
642
+ .then((found) => {
643
+ for (const n of found) {
644
+ const type = appType(app.id, n.type);
645
+ if (!(n.type in types) || told(type, n.id) || !isOn(type)) continue;
646
+ const to = siteText(n.link ?? app.label);
647
+ const link = n.to ? { text: to, href: `#${n.to}` } : undefined;
648
+ remember({ text: n.text, type, error: n.error, link });
649
+ notify({ text: n.text, error: n.error, edit: n.to ? { to: n.to, text: to } : undefined });
650
+ }
651
+ })
652
+ .catch(() => {});
653
+ }
654
+ };
655
+ ask();
656
+ const timer = setInterval(ask, 15 * 60_000);
657
+ return () => clearInterval(timer);
658
+ }, [signedIn, notifying]);
659
+
660
+ // Signed out, or not yet known with nothing from this browser to show: the sign-in (while the Worker is asked, the
661
+ // icon and the name alone).
662
+ if (signedIn === false || (signedIn === null && !files)) {
663
+ return (
664
+ <main className="mx-auto flex min-h-dvh max-w-sm flex-col items-center justify-center gap-5 px-6 pb-[env(safe-area-inset-bottom)] text-center">
665
+ <img src="/editor/icon-192.png" alt="" className="size-20 rounded-[1.375rem] shadow-soft" />
666
+ <h1 className="text-2xl font-semibold">{siteName}</h1>
667
+ {signedIn === null && !error ? (
668
+ <EmptyNote busy>{t.app.loading}</EmptyNote>
669
+ ) : (
670
+ <Button wide onClick={login}>
671
+ {t.app.logIn}
672
+ </Button>
673
+ )}
674
+ {error && <ProblemNote>{error}</ProblemNote>}
675
+ </main>
676
+ );
677
+ }
678
+
679
+ // The part of the site that is open, for the sidebar: the menu under "Header", the lists and testimonials under
680
+ // "Lister", the front page under "Udseende".
681
+ const open = collectionOf(collections, route)?.collection;
682
+ const part = open?.id || route.replace(/^(liste|ny|slettet)\/([^/]+).*/, '$2');
683
+ const current = lists.includes(part)
684
+ ? 'lister'
685
+ : header.includes(part)
686
+ ? 'header'
687
+ : part.startsWith('virksomheden')
688
+ ? 'virksomheden'
689
+ : part.startsWith('blokke')
690
+ ? 'blokke'
691
+ : part.startsWith('udseende')
692
+ ? 'udseende'
693
+ : part.startsWith('seo')
694
+ ? 'seo'
695
+ : part.startsWith('apps')
696
+ ? 'apps'
697
+ : (partScreens.find((s) => part.startsWith(`${s.id}/`))?.id ?? part);
698
+
699
+ return (
700
+ <>
701
+ <div className="md:flex">
702
+ {/* On a computer: the parts of the site in the theme's navigation rail, expanded and collapsed with its menu
703
+ button; under it, while expanded, the logo and the name, which go to the start screen (the account is in the
704
+ top bar, Screen.tsx). Collapsed, the mouse over it shows it expanded over the content for a moment (a short wait
705
+ first, so passing over it does not), until the mouse leaves. On a phone, the start screen is the menu. */}
706
+ <aside
707
+ className="sticky top-0 z-30 hidden h-dvh shrink-0 pt-[env(safe-area-inset-top)] md:flex"
708
+ onPointerEnter={(e) => {
709
+ if (sidebar || e.pointerType !== 'mouse') return;
710
+ peekTimer.current = window.setTimeout(() => setPeek(true), 150);
711
+ }}
712
+ onPointerLeave={() => {
713
+ clearTimeout(peekTimer.current);
714
+ setPeek(false);
715
+ }}
716
+ >
717
+ <NavigationRail
718
+ label={t.library.sidebar}
719
+ menuLabel={sidebar ? t.library.closeSidebar : t.library.openSidebar}
720
+ expanded={sidebar}
721
+ peek={peek}
722
+ onExpandedChange={(open) => {
723
+ setPeek(false);
724
+ setSidebar(open);
725
+ }}
726
+ header={
727
+ <a href="#" className="flex min-w-0 items-center gap-3 font-semibold">
728
+ <img src="/editor/icon-192.png" alt="" className="size-7 shrink-0 rounded-lg" />
729
+ <span className="truncate">{siteName}</span>
730
+ </a>
731
+ }
732
+ >
733
+ {groups.map((g) => (
734
+ <NavigationRailSection key={g.title} label={g.title}>
735
+ {partsIn(g.ids, files ?? []).map(({ id, label, href, count }) => (
736
+ <NavigationRailItem
737
+ key={id}
738
+ href={href}
739
+ icon={icons[id]}
740
+ label={label}
741
+ count={files ? count : null}
742
+ selected={current === id}
743
+ />
744
+ ))}
745
+ </NavigationRailSection>
746
+ ))}
747
+ </NavigationRail>
748
+ </aside>
749
+
750
+ <div className="min-w-0 flex-1">
751
+ {files ? (
752
+ <>
753
+ {user && !user.canSave && (
754
+ <p className="bg-accent px-4 py-3 pt-[calc(0.75rem+env(safe-area-inset-top))] text-center text-sm text-on-accent">
755
+ {t.app.cannotSave(user.login)}
756
+ </p>
757
+ )}
758
+ <Page key={visit} route={route} files={files} env={env} user={user} builtAt={builtAt} onLogout={signOut} />
759
+ </>
760
+ ) : (
761
+ <Screen title={siteName}>
762
+ <EmptyNote busy={!error}>{error || t.app.loading}</EmptyNote>
763
+ </Screen>
764
+ )}
765
+ </div>
766
+
767
+ <LinkAsk groups={linkGroups} />
768
+ <Ask />
769
+ </div>
770
+ </>
771
+ );
772
+ }
773
+
774
+ /** The part whose form is at an address (CompanyForm.tsx): an app's settings on a screen of their own, one of
775
+ * "Virksomheden"'s (or of its parts', with the settings of the apps of their kinds), "Søgemaskineoptimering"'s, "Udseende"'s, or one on a screen of parts with
776
+ * the settings of the apps of its kinds ("Analytics"). */
777
+ function partAt(route: string, files: RepoFile[]): Part | null {
778
+ // The settings of an app installed without a screen with its kind, on a screen of their own.
779
+ const settingsOf = apps.find((a) => route === `apps/${a.id}/indstillinger`);
780
+ if (settingsOf) return (installedApps(files).includes(settingsOf.id) && appSettingsPart(settingsOf)) || null;
781
+ // virksomheden/<part>, or virksomheden/<part>/<its part> (Part.parts).
782
+ const [, companyId, ownId, ...more] = route.split('/');
783
+ const company = route.startsWith('virksomheden/') && !more.length && companyParts.find((p) => p.id === companyId);
784
+ return (
785
+ (company &&
786
+ (ownId ? [...(company.parts ?? []), ...appPartsIn(company, files)].find((p) => p.id === ownId) : company.file && company)) ||
787
+ (route.startsWith('blokke/') && blockParts.find((p) => p.id === route.slice(7))) ||
788
+ (route.startsWith('seo/') && seoParts.find((p) => p.id === route.slice(4))) ||
789
+ (route.startsWith('udseende/') && appearance.find((p) => p.id === route.slice(9))) ||
790
+ partScreens.flatMap((s) => [...s.parts, ...appPartsIn(s, files)].filter((p) => route === `${s.id}/${p.id}`))[0] ||
791
+ null
792
+ );
793
+ }
794
+
795
+ /** The form at an address: a part's (partAt, `at` its address), or the page of one of its fields with its choices on a
796
+ * page of their own (Field.page: the form's address with the field's name after it, `page`). */
797
+ function formAt(route: string, files: RepoFile[]): { part: Part; at: string; page?: string } | null {
798
+ const part = partAt(route, files);
799
+ if (part) return { part, at: route };
800
+ const cut = route.lastIndexOf('/');
801
+ const above = cut > 0 ? partAt(route.slice(0, cut), files) : null;
802
+ const page = route.slice(cut + 1);
803
+ return above?.fields?.some((f) => f.name === page && f.page) ? { part: above, at: route.slice(0, cut), page } : null;
804
+ }
805
+
806
+ type PageProps = { route: string; files: RepoFile[]; env: Env; user: Viewer | null; builtAt: Date | null; onLogout: () => void };
807
+
808
+ function Page({ route, files, env, user, builtAt, onLogout }: PageProps) {
809
+ if (!route) return <Start files={files} builtAt={builtAt} />;
810
+ if (route === 'konto') return <Account user={user} files={files} onLogout={onLogout} />;
811
+ if (route === 'notifikationer') return <Notifications />;
812
+ if (route === 'indstillinger') return <Settings files={files} />;
813
+ if (route === 'header') return <Header files={files} />;
814
+ if (route === 'lister') return <Lists files={files} />;
815
+ if (route === 'virksomheden') return <Company files={files} />;
816
+ if (route === 'blokke') return <Blocks files={files} />;
817
+ if (route === 'filer') return <Media files={files} env={env} />;
818
+ if (route === 'seo') return <Seo />;
819
+ if (route === REDIRECTS_ROUTE) return <Redirects env={env} />;
820
+ if (route === SEO_SCORES_ROUTE) return <SeoScores files={files} env={env} />;
821
+ if (route === TAB_ROUTES.redirects) return <Redirects env={env} tab="redirects" />;
822
+ if (route === TAB_ROUTES.missing) return <Redirects env={env} tab="missing" />;
823
+ const screen = partScreens.find((s) => s.id === route);
824
+ if (screen) return <PartsScreen screen={screen} files={files} />;
825
+ // A part of a screen of parts that is one of the editor's own screens (Part.opens), going back to that screen.
826
+ const opening = partScreens.flatMap((s) => s.parts.filter((p) => p.opens && route === `${s.id}/${p.id}`).map((p) => ({ s, p })))[0];
827
+ if (opening?.p.opens === 'consents') return <Consents env={env} back={{ href: `#${opening.s.id}`, label: opening.s.label }} />;
828
+ if (route === 'udseende') return <Theme files={files} />;
829
+ if (route === 'udseende/temaer') return <Themes env={env} files={files} />;
830
+ if (route === 'udseende/ikonpakker') return <IconPacks env={env} files={files} />;
831
+ if (route === 'udseende/bevaegelse') return <MotionSchemes env={env} files={files} />;
832
+ // A theme installed has its page; one from the store that is not (or not yet built), its page in the store.
833
+ const themeShown = route.startsWith('udseende/temaer/') && themes.find((x) => x.id === route.slice(16));
834
+ if (themeShown) return <ThemePage key={themeShown.id} of={themeShown} env={env} files={files} />;
835
+ if (/^udseende\/temaer\/[^/]+$/.test(route)) return <StoreThemePage key={route} id={route.slice(16)} env={env} />;
836
+ // The colours, or the site's own on a page of the same form while an app changes the pages' (FALLBACK_ROUTE).
837
+ if (route === 'udseende/farver' || route === FALLBACK_ROUTE)
838
+ return <Colors env={env} files={files} fallback={route === FALLBACK_ROUTE} />;
839
+ if (route === 'udseende/logo') return <BrandForm env={env} />;
840
+ if (route === 'blokvalg') return <BlockSettings env={env} files={files} />;
841
+ if (route === ADVANCED_ROUTE) return <Advanced env={env} />;
842
+ if (route === LICENSES_ROUTE) return <Licenses />;
843
+ if (route.startsWith(`${LICENSES_ROUTE}/`)) return <LicensePage key={route} id={route.slice(LICENSES_ROUTE.length + 1)} />;
844
+ if (route === 'apps') return <Apps env={env} files={files} />;
845
+ if (route === STORE_ROUTE) return <StorePage env={env} files={files} tab="apps" />;
846
+ if (route === STORE_THEMES) return <StorePage env={env} files={files} tab="themes" />;
847
+ if (route.startsWith(`${DEVELOPER_ROUTE}/`))
848
+ return <DeveloperPage key={route} slug={route.slice(DEVELOPER_ROUTE.length + 1)} env={env} files={files} />;
849
+ // An app in the site (its own, or one from the store while it is installed) has its page; one from the store that is
850
+ // not (or no longer) installed, its page in the store.
851
+ if (/^apps\/[^/]+$/.test(route)) {
852
+ const app = apps.find((a) => a.id === route.slice(5));
853
+ if (app && (!app.store || installedApps(files).includes(app.id))) return <AppPage app={app} env={env} files={files} />;
854
+ return <StoreAppPage key={route} id={route.slice(5)} env={env} files={files} />;
855
+ }
856
+ // A screen of an app's own under its page, while it is installed (Cookiescanner's "Blokeret adresse").
857
+ const [, screenApp, screenId, ...deeper] = route.split('/');
858
+ const ownScreen =
859
+ route.startsWith('apps/') && !deeper.length && apps.find((a) => a.id === screenApp && screenId && a.screens?.[screenId]);
860
+ if (ownScreen && installedApps(files).includes(ownScreen.id))
861
+ return <AppScreenPage key={route} app={ownScreen} id={screenId} env={env} />;
862
+ if (route === 'udseende/dele') return <PartsHome />;
863
+ if (route === 'udseende/dele/editor') return <Parts env={env} />;
864
+ if (route === 'udseende/dele/ui') return <ComponentParts />;
865
+ if (route === 'udseende/dele/blokke') return <BlocksParts />;
866
+ if (route.startsWith('udseende/dele/liste/')) return <KindParts key={route} id={route.slice(20)} />;
867
+ if (route === 'udseende/dele/vaerdier') return <Tokens />;
868
+ if (route.startsWith('slettet/')) return <Deleted id={route.slice(8)} files={files} env={env} />;
869
+ // virksomheden/<part>, or virksomheden/<part>/<its part> (Part.parts).
870
+ const [, companyId, ownId, ...more] = route.split('/');
871
+ const company = route.startsWith('virksomheden/') && !more.length && companyParts.find((p) => p.id === companyId);
872
+ if (company && company.parts && !ownId) return <Company part={company} files={files} />;
873
+ // A part's form, or the page of one of its fields (Field.page) with the form under it: the same form, so it keeps what
874
+ // is in it while the editor goes between them (useFormPages, inForm).
875
+ const form = formAt(route, files);
876
+ if (form) return <CompanyForm part={form.part} at={form.at} page={form.page} env={env} files={files} />;
877
+ // A list, or a tab an installed app adds to it (liste/<id>/<app>).
878
+ const [, listId, listTab, ...below] = route.split('/');
879
+ const listed = route.startsWith('liste/') && !below.length && collections.find((c) => c.id === listId);
880
+ if (listed) return <List key={listed.id} collection={listed} tab={listTab} files={files} env={env} />;
881
+
882
+ // ny/<list>, or ny/<list>/<kind> for one of its kinds (a pop-up: standard, mailchimp).
883
+ const [, newIn, newKind] = route.split('/');
884
+ const creating = route.startsWith('ny/') && collections.find((c) => c.id === newIn && c.create);
885
+ if (creating) {
886
+ if (creating.kind === 'markdown') return <EntryForm collection={creating} file={newMarkdown(creating, notOffered(files))} env={env} />;
887
+ if (creating.kind === 'folder')
888
+ return <DataForm key={route} collection={creating} kind={newKind} file={{ path: '', oid: '', text: '' }} env={env} />;
889
+ }
890
+
891
+ const found = collectionOf(collections, route);
892
+ const file = files.find((f) => f.path === route);
893
+ if (!found || !file)
894
+ return (
895
+ <Screen title={t.app.notFound} back={{ href: '#', label: siteName }}>
896
+ <EmptyNote>{t.app.nothingAt(route)}</EmptyNote>
897
+ </Screen>
898
+ );
899
+ if (found.collection.kind === 'markdown') return <EntryForm collection={found.collection} file={file} env={env} />;
900
+ return <DataForm collection={found.collection} dataFile={found.dataFile} file={file} env={env} />;
901
+ }