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