@jant/core 0.6.15 → 0.7.0
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/README.md +11 -11
- package/bin/commands/assets/upload.js +192 -15
- package/bin/commands/import-site.js +67 -4
- package/bin/commands/reset-password.js +21 -8
- package/bin/commands/site/snapshot/export.js +1 -1
- package/bin/commands/site/snapshot/import.js +1 -1
- package/bin/lib/d1-query.js +5 -16
- package/bin/lib/node-env.js +40 -4
- package/bin/lib/site-selection.js +85 -29
- package/bin/lib/wrangler-cli.js +28 -2
- package/bin/lib/wrangler-json.js +35 -0
- package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
- package/dist/client/.vite/manifest.json +255 -90
- package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
- package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
- package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
- package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
- package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
- package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
- package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
- package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
- package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
- package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
- package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
- package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
- package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
- package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
- package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
- package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
- package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
- package/dist/client/_assets/client-GiYENVw8.css +2 -0
- package/dist/client/_assets/client-VnFxJN7G.js +295 -0
- package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
- package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
- package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
- package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
- package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
- package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
- package/dist/index.js +2 -5
- package/dist/node.js +5 -7
- package/package.json +3 -3
- package/src/__tests__/auth-session-freshness.test.ts +57 -0
- package/src/__tests__/basecoat-variants.test.ts +121 -0
- package/src/__tests__/bin/assets-upload.test.ts +68 -0
- package/src/__tests__/export-hugo-build.test.ts +260 -0
- package/src/__tests__/export-service.test.ts +231 -0
- package/src/__tests__/helpers/component-css.ts +91 -0
- package/src/__tests__/helpers/db.ts +33 -0
- package/src/__tests__/helpers/export-fixtures.ts +3 -0
- package/src/__tests__/mise-config.test.ts +22 -0
- package/src/__tests__/stylesheet-audience.test.ts +159 -0
- package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
- package/src/app.tsx +71 -16
- package/src/auth.ts +18 -1
- package/src/client/__tests__/collection-page-actions.test.ts +51 -2
- package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
- package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
- package/src/client/__tests__/compose-bridge.test.ts +524 -2
- package/src/client/__tests__/compose-launch.test.ts +62 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
- package/src/client/__tests__/compose-triggers.test.ts +90 -0
- package/src/client/__tests__/copy-field.test.ts +162 -0
- package/src/client/__tests__/custom-url-menu.test.ts +127 -6
- package/src/client/__tests__/image-processor.test.ts +324 -2
- package/src/client/__tests__/lazy-entries.test.ts +92 -0
- package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
- package/src/client/__tests__/runtime-paths.test.ts +84 -0
- package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
- package/src/client/__tests__/toast.test.ts +88 -1
- package/src/client/__tests__/video-processor-probe.test.ts +175 -0
- package/src/client/__tests__/video-processor.test.ts +181 -0
- package/src/client/audio-processor.ts +24 -15
- package/src/client/audio-waveform.ts +49 -0
- package/src/client/collection-dialog-host.ts +72 -0
- package/src/client/collection-navigation.ts +51 -16
- package/src/client/collection-page-actions.ts +26 -0
- package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
- package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
- package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
- package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
- package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
- package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
- package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
- package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
- package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
- package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
- package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
- package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
- package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
- package/src/client/components/collection-dialog-types.ts +49 -0
- package/src/client/components/collection-manager-types.ts +32 -1
- package/src/client/components/collection-types.ts +1 -15
- package/src/client/components/compose-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +121 -3
- package/src/client/components/jant-collection-dialog.ts +722 -0
- package/src/client/components/jant-collection-directory.ts +567 -182
- package/src/client/components/jant-collection-form.ts +53 -232
- package/src/client/components/jant-command-palette.ts +106 -49
- package/src/client/components/jant-compose-dialog.ts +2757 -1194
- package/src/client/components/jant-compose-editor.ts +866 -397
- package/src/client/components/jant-compose-fullscreen.ts +53 -47
- package/src/client/components/jant-config-editor.ts +65 -53
- package/src/client/components/jant-confirm-dialog.ts +13 -11
- package/src/client/components/jant-locale-picker.ts +418 -0
- package/src/client/components/jant-media-lightbox.ts +146 -136
- package/src/client/components/jant-nav-manager.ts +690 -366
- package/src/client/components/jant-post-menu.ts +1142 -268
- package/src/client/components/jant-post-picker.ts +324 -0
- package/src/client/components/jant-repo-picker.ts +138 -106
- package/src/client/components/jant-settings-avatar.ts +31 -27
- package/src/client/components/jant-settings-general.ts +617 -543
- package/src/client/components/jant-settings-language.ts +1087 -0
- package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
- package/src/client/components/jant-text-preview.ts +94 -86
- package/src/client/components/nav-manager-types.ts +62 -1
- package/src/client/components/settings-types.ts +14 -29
- package/src/client/components/smart-collection-conditions.ts +285 -0
- package/src/client/components/smart-collection-dialog-types.ts +68 -0
- package/src/client/compose-bridge.ts +336 -242
- package/src/client/compose-launch.ts +89 -2
- package/src/client/compose-shortcuts.ts +9 -7
- package/src/client/compose-triggers.ts +50 -0
- package/src/client/copy-field.ts +108 -0
- package/src/client/custom-url-menu.ts +60 -4
- package/src/client/icons.ts +14 -0
- package/src/client/image-plan.ts +165 -0
- package/src/client/image-processor.ts +195 -117
- package/src/client/image-worker-client.ts +45 -0
- package/src/client/image-worker.ts +135 -0
- package/src/client/lazy-entries.ts +95 -0
- package/src/client/lazy-slugify.ts +2 -2
- package/src/client/media-scroll-hint.ts +43 -2
- package/src/client/mediabunny.ts +29 -0
- package/src/client/post-picker.ts +41 -0
- package/src/client/post-refresh.ts +135 -0
- package/src/client/runtime-paths.ts +74 -1
- package/src/client/settings-bridge.ts +4 -10
- package/src/client/site-header-nav.js +18 -6
- package/src/client/smart-collection-dialog-host.ts +116 -0
- package/src/client/smart-collection-page-actions.ts +208 -0
- package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
- package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
- package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/bubble-menu.ts +47 -16
- package/src/client/tiptap/floating-position.ts +216 -2
- package/src/client/tiptap/inline-image-upload.ts +7 -3
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/tiptap/link-toolbar.ts +22 -9
- package/src/client/toast.ts +143 -38
- package/src/client/types/vite-worker.d.ts +12 -0
- package/src/client/upload-with-metadata.ts +3 -13
- package/src/client/video-processor.ts +351 -107
- package/src/client-auth.ts +14 -31
- package/src/client-compose.ts +19 -0
- package/src/client-manage.ts +13 -0
- package/src/client-settings.ts +14 -0
- package/src/client-site.ts +10 -3
- package/src/client.ts +4 -0
- package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
- package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
- package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
- package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
- package/src/db/__tests__/thread-activity.test.ts +113 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
- package/src/db/migrations/0031_many_ego.sql +3 -0
- package/src/db/migrations/0032_married_tomas.sql +3 -0
- package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
- package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
- package/src/db/migrations/meta/0031_snapshot.json +2562 -0
- package/src/db/migrations/meta/0032_snapshot.json +2582 -0
- package/src/db/migrations/meta/0033_snapshot.json +2816 -0
- package/src/db/migrations/meta/0034_snapshot.json +2816 -0
- package/src/db/migrations/meta/_journal.json +28 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
- package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
- package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
- package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
- package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
- package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
- package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/_journal.json +28 -0
- package/src/db/pg/schema.ts +213 -27
- package/src/db/post-visibility.ts +117 -0
- package/src/db/schema.ts +242 -27
- package/src/db/thread-activity.ts +101 -0
- package/src/i18n/__tests__/detect.test.ts +26 -81
- package/src/i18n/__tests__/supported-locales.test.ts +83 -0
- package/src/i18n/context.tsx +12 -2
- package/src/i18n/detect.ts +7 -66
- package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
- package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
- package/src/i18n/locales/public/en.po +568 -168
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +562 -162
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +562 -162
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +551 -69
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +597 -115
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +610 -128
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/locales.ts +94 -0
- package/src/i18n/middleware.ts +12 -2
- package/src/i18n/supported-locales.ts +80 -0
- package/src/lib/__tests__/api-settings.test.ts +11 -2
- package/src/lib/__tests__/asset-path.test.ts +92 -0
- package/src/lib/__tests__/deferred.test.ts +90 -0
- package/src/lib/__tests__/discover-ping.test.ts +89 -0
- package/src/lib/__tests__/discover.test.ts +410 -0
- package/src/lib/__tests__/feed-policy.test.ts +26 -0
- package/src/lib/__tests__/feed.test.ts +1691 -12
- package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
- package/src/lib/__tests__/hosted-signin.test.ts +1 -1
- package/src/lib/__tests__/http-cache.test.ts +162 -0
- package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
- package/src/lib/__tests__/jant-docs.test.ts +23 -0
- package/src/lib/__tests__/lang-detect.test.ts +231 -0
- package/src/lib/__tests__/markdown.test.ts +16 -0
- package/src/lib/__tests__/media-helpers.test.ts +81 -0
- package/src/lib/__tests__/navigation.test.ts +135 -1
- package/src/lib/__tests__/resolve-config.test.ts +64 -3
- package/src/lib/__tests__/schemas.test.ts +42 -4
- package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
- package/src/lib/__tests__/slug.test.ts +88 -0
- package/src/lib/__tests__/slugify.test.ts +85 -0
- package/src/lib/__tests__/storage.test.ts +32 -0
- package/src/lib/__tests__/thread-fold.test.ts +113 -0
- package/src/lib/__tests__/translit.test.ts +87 -0
- package/src/lib/__tests__/upload-sideload.test.ts +14 -0
- package/src/lib/__tests__/url-fetch.test.ts +1 -2
- package/src/lib/__tests__/url.test.ts +93 -34
- package/src/lib/__tests__/view-language.test.ts +413 -0
- package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
- package/src/lib/address.ts +57 -0
- package/src/lib/api-posts.ts +12 -0
- package/src/lib/asset-path.ts +98 -1
- package/src/lib/build-env.ts +16 -0
- package/src/lib/collection-groups.ts +5 -1
- package/src/lib/collection-paths.ts +3 -10
- package/src/lib/constants.ts +28 -3
- package/src/lib/copy-field.ts +36 -0
- package/src/lib/crypto.ts +40 -0
- package/src/lib/csp-builder.ts +2 -1
- package/src/lib/deferred.ts +79 -0
- package/src/lib/discover-ping.ts +141 -0
- package/src/lib/discover.ts +437 -0
- package/src/lib/env.ts +134 -0
- package/src/lib/errors.ts +33 -0
- package/src/lib/feed-policy.ts +236 -2
- package/src/lib/feed.ts +1021 -66
- package/src/lib/filter-dimensions.ts +1349 -0
- package/src/lib/github-sync-site-config.ts +3 -0
- package/src/lib/hosted-signin.ts +1 -1
- package/src/lib/http-cache.ts +148 -0
- package/src/lib/hugo-markdown.ts +21 -0
- package/src/lib/icons.ts +9 -2
- package/src/lib/ids.ts +3 -0
- package/src/lib/jant-docs.ts +42 -0
- package/src/lib/lang-detect.ts +308 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +6 -1
- package/src/lib/media-helpers.ts +13 -1
- package/src/lib/navigation.ts +78 -11
- package/src/lib/pagination.ts +28 -24
- package/src/lib/per-language-surfaces.ts +67 -0
- package/src/lib/post-display.ts +42 -9
- package/src/lib/post-meta.ts +19 -0
- package/src/lib/render.tsx +55 -7
- package/src/lib/resolve-config.ts +53 -5
- package/src/lib/schemas.ts +201 -10
- package/src/lib/site-header-fragment.tsx +2 -1
- package/src/lib/site-unavailable-page.ts +47 -0
- package/src/lib/slug.ts +51 -7
- package/src/lib/slugify.ts +113 -0
- package/src/lib/startup-config.ts +4 -6
- package/src/lib/storage.ts +7 -0
- package/src/lib/summary.ts +50 -0
- package/src/lib/thread-fold.ts +161 -0
- package/src/lib/time.ts +32 -0
- package/src/lib/timeline.ts +81 -10
- package/src/lib/tiptap-render.ts +5 -4
- package/src/lib/translit.ts +288 -0
- package/src/lib/upload.ts +16 -13
- package/src/lib/url.ts +188 -24
- package/src/lib/version.ts +10 -8
- package/src/lib/view-language.ts +436 -0
- package/src/lib/view.ts +79 -23
- package/src/lib/viewer-context.ts +62 -0
- package/src/middleware/__tests__/auth.test.ts +70 -2
- package/src/middleware/__tests__/cache-control.test.ts +71 -3
- package/src/middleware/__tests__/onboarding.test.ts +76 -5
- package/src/middleware/__tests__/secure-headers.test.ts +1 -1
- package/src/middleware/__tests__/session.test.ts +181 -33
- package/src/middleware/auth.ts +50 -31
- package/src/middleware/cache-control.ts +75 -10
- package/src/middleware/config.ts +6 -6
- package/src/middleware/error-handler.ts +12 -0
- package/src/middleware/onboarding.ts +32 -10
- package/src/middleware/secure-headers.ts +1 -1
- package/src/middleware/session.ts +96 -8
- package/src/node/__tests__/cli-migrate.test.ts +21 -19
- package/src/node/__tests__/cli-reset-password.test.ts +81 -8
- package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
- package/src/node/__tests__/cli-site-selection.test.ts +128 -38
- package/src/node/__tests__/runtime.test.ts +37 -0
- package/src/node/index.ts +1 -0
- package/src/preset.css +76 -22
- package/src/routes/__tests__/feed-validator.test.ts +156 -0
- package/src/routes/api/__tests__/collections.test.ts +114 -0
- package/src/routes/api/__tests__/discover.test.ts +222 -0
- package/src/routes/api/__tests__/nav-items.test.ts +212 -2
- package/src/routes/api/__tests__/palette.test.ts +44 -0
- package/src/routes/api/__tests__/post-translations.test.ts +829 -0
- package/src/routes/api/__tests__/settings.test.ts +24 -3
- package/src/routes/api/__tests__/telegram.test.ts +6 -5
- package/src/routes/api/collections.ts +49 -6
- package/src/routes/api/custom-urls.ts +0 -1
- package/src/routes/api/discover.ts +95 -0
- package/src/routes/api/export.ts +3 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
- package/src/routes/api/nav-items.ts +55 -8
- package/src/routes/api/posts.ts +172 -9
- package/src/routes/api/public/__tests__/archive.test.ts +111 -0
- package/src/routes/api/public/__tests__/posts.test.ts +38 -0
- package/src/routes/api/public/archive.ts +77 -81
- package/src/routes/api/public/posts.ts +30 -6
- package/src/routes/api/smart-collections.ts +132 -0
- package/src/routes/api/telegram.ts +2 -40
- package/src/routes/api/uploads.ts +1 -2
- package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
- package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
- package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
- package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
- package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
- package/src/routes/auth/__tests__/setup.test.ts +278 -26
- package/src/routes/auth/__tests__/signin.test.ts +135 -0
- package/src/routes/auth/setup.tsx +934 -186
- package/src/routes/auth/signin.tsx +10 -8
- package/src/routes/compose.tsx +11 -2
- package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
- package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
- package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
- package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
- package/src/routes/dash/custom-urls.tsx +1 -37
- package/src/routes/dash/settings.tsx +502 -41
- package/src/routes/discover-announce.ts +96 -0
- package/src/routes/feed/__tests__/feed.test.ts +165 -2
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- package/src/routes/feed/feed.ts +69 -40
- package/src/routes/feed/sitemap.ts +95 -11
- package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
- package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
- package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
- package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
- package/src/routes/pages/__tests__/collections.test.ts +9 -3
- package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
- package/src/routes/pages/__tests__/featured.test.ts +6 -2
- package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
- package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
- package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
- package/src/routes/pages/__tests__/preview.test.ts +3 -1
- package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
- package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
- package/src/routes/pages/archive.tsx +507 -357
- package/src/routes/pages/collection.tsx +94 -96
- package/src/routes/pages/collections.tsx +38 -50
- package/src/routes/pages/featured.tsx +32 -13
- package/src/routes/pages/home.tsx +25 -6
- package/src/routes/pages/language.tsx +113 -0
- package/src/routes/pages/latest.tsx +26 -16
- package/src/routes/pages/new.tsx +3 -0
- package/src/routes/pages/page.tsx +183 -19
- package/src/routes/pages/partials.tsx +41 -0
- package/src/routes/pages/search.tsx +18 -3
- package/src/routes/pages/smart-collection.tsx +340 -0
- package/src/routes/pages/subscribe.tsx +182 -0
- package/src/runtime/__tests__/index.test.ts +19 -0
- package/src/runtime/__tests__/node.test.ts +129 -4
- package/src/runtime/cloudflare.ts +21 -2
- package/src/runtime/index.ts +27 -1
- package/src/runtime/node.ts +18 -1
- package/src/runtime/site.ts +95 -24
- package/src/services/__tests__/auth.test.ts +116 -7
- package/src/services/__tests__/collection.test.ts +139 -4
- package/src/services/__tests__/language.test.ts +495 -0
- package/src/services/__tests__/navigation.test.ts +213 -10
- package/src/services/__tests__/path.test.ts +180 -0
- package/src/services/__tests__/post-language.test.ts +830 -0
- package/src/services/__tests__/post-timeline.test.ts +142 -11
- package/src/services/__tests__/post.test.ts +323 -9
- package/src/services/__tests__/settings.test.ts +228 -17
- package/src/services/__tests__/site-admin.test.ts +5 -5
- package/src/services/__tests__/smart-collection.test.ts +651 -0
- package/src/services/about-page.ts +22 -1
- package/src/services/auth.ts +51 -49
- package/src/services/bootstrap.ts +188 -38
- package/src/services/collection-directory-position.ts +76 -0
- package/src/services/collection.ts +330 -47
- package/src/services/custom-url.ts +27 -22
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +123 -123
- package/src/services/export-theme/layouts/_default/rss.xml +77 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
- package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
- package/src/services/export-theme/layouts/partials/pagination.html +14 -13
- package/src/services/export-theme/layouts/partials/post-card.html +8 -4
- package/src/services/export-theme/layouts/partials/reply.html +2 -1
- package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
- package/src/services/export-theme/styles/main.css +13 -1
- package/src/services/export.ts +281 -24
- package/src/services/index.ts +34 -3
- package/src/services/language.ts +412 -0
- package/src/services/mcp.ts +3 -1
- package/src/services/media.ts +14 -6
- package/src/services/navigation.ts +429 -67
- package/src/services/path.ts +225 -7
- package/src/services/post.ts +1318 -252
- package/src/services/search.ts +48 -2
- package/src/services/settings.ts +238 -43
- package/src/services/site-admin.ts +24 -7
- package/src/services/site.ts +77 -1
- package/src/services/smart-collection.ts +636 -0
- package/src/services/telegram.ts +20 -22
- package/src/services/upload-session.ts +1 -3
- package/src/style-author.css +27 -0
- package/src/styles/components-author.css +1622 -0
- package/src/styles/components.css +24 -1585
- package/src/styles/site-media.css +20 -6
- package/src/styles/tokens.css +144 -13
- package/src/styles/ui-author.css +7602 -0
- package/src/styles/ui.css +1224 -7276
- package/src/types/app-context.ts +7 -0
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +94 -15
- package/src/types/constants.ts +188 -16
- package/src/types/entities.ts +92 -1
- package/src/types/operations.ts +64 -2
- package/src/types/props.ts +168 -30
- package/src/types/views.ts +61 -1
- package/src/ui/__tests__/font-themes.test.ts +52 -12
- package/src/ui/compose/ComposeDialog.tsx +215 -21
- package/src/ui/compose/ComposePrompt.tsx +1 -5
- package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
- package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
- package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
- package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
- package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
- package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
- package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
- package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
- package/src/ui/dash/settings/GeneralContent.tsx +275 -113
- package/src/ui/dash/settings/LanguageContent.tsx +409 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
- package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
- package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
- package/src/ui/dash/settings/discover-copy.ts +116 -0
- package/src/ui/feed/LinkCard.tsx +55 -45
- package/src/ui/feed/LinkPreview.tsx +2 -7
- package/src/ui/feed/NoteCard.tsx +9 -4
- package/src/ui/feed/PostStatusBadges.tsx +103 -17
- package/src/ui/feed/QuoteCard.tsx +55 -30
- package/src/ui/feed/ThreadPreview.tsx +6 -2
- package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
- package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
- package/src/ui/feed/thread-preview-state.ts +20 -6
- package/src/ui/font-themes.ts +39 -29
- package/src/ui/layouts/BaseLayout.tsx +155 -10
- package/src/ui/layouts/SiteLayout.tsx +192 -34
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
- package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
- package/src/ui/pages/ArchivePage.tsx +401 -301
- package/src/ui/pages/CollectionPage.tsx +86 -76
- package/src/ui/pages/CollectionsPage.tsx +5 -0
- package/src/ui/pages/ComposePage.tsx +8 -0
- package/src/ui/pages/PostPage.tsx +47 -0
- package/src/ui/pages/SearchPage.tsx +3 -3
- package/src/ui/pages/SmartCollectionPage.tsx +434 -0
- package/src/ui/pages/SubscribePage.tsx +236 -0
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
- package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
- package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
- package/src/ui/shared/CollectionDirectory.tsx +178 -3
- package/src/ui/shared/CollectionsManager.tsx +70 -17
- package/src/ui/shared/CopyField.tsx +127 -0
- package/src/ui/shared/DraftPreviewBar.tsx +25 -9
- package/src/ui/shared/EmptyPostContent.tsx +23 -0
- package/src/ui/shared/MediaGallery.tsx +125 -46
- package/src/ui/shared/Pagination.tsx +20 -5
- package/src/ui/shared/PostFooter.tsx +55 -23
- package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
- package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
- package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
- package/src/ui/shared/__tests__/pagination.test.ts +38 -24
- package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
- package/src/ui/shared/archive-labels.ts +98 -0
- package/src/ui/shared/collection-management-labels.ts +94 -16
- package/src/ui/shared/custom-icons.ts +5 -0
- package/src/ui/shared/navigation-labels.ts +45 -71
- package/src/ui/shared/post-article-attributes.ts +8 -0
- package/src/ui/shared/smart-collection-labels.ts +343 -0
- package/dist/app-BxCOR3Uc.js +0 -6
- package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
- package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
- package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
- package/dist/client/_assets/client-B_eGKN5p.css +0 -2
- package/dist/client/_assets/client-DxY5BFe8.js +0 -296
- package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
- package/dist/env-OHRKGcMj.js +0 -281
- package/dist/github-api-BgSiE71w.js +0 -176
- package/dist/github-app-BbklkFmU.js +0 -275
- package/dist/github-sync-Cq1pzzOI.js +0 -431
- package/dist/github-sync-ppWXN3jb.js +0 -4
- package/dist/url-BMYO-Zlt.js +0 -395
- package/src/client/__tests__/collection-created-notice.test.ts +0 -36
- package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
- package/src/client/__tests__/post-form-bridge.test.ts +0 -82
- package/src/client/collection-created-notice.ts +0 -66
- package/src/client/collection-form-bridge.ts +0 -191
- package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
- package/src/client/components/jant-post-form.ts +0 -422
- package/src/client/components/post-form-template.ts +0 -352
- package/src/client/components/post-form-types.ts +0 -96
- package/src/client/media-metadata.ts +0 -247
- package/src/client/post-form-bridge.ts +0 -193
- package/src/ui/pages/CollectionEditorPage.tsx +0 -175
|
@@ -15,11 +15,16 @@ import {
|
|
|
15
15
|
supportsDrizzleTransaction,
|
|
16
16
|
} from "../db/index.js";
|
|
17
17
|
import type { DatabaseDialect } from "../db/dialect.js";
|
|
18
|
+
import {
|
|
19
|
+
buildRootActivityExpr,
|
|
20
|
+
rootActivityColumns,
|
|
21
|
+
} from "../db/thread-activity.js";
|
|
22
|
+
import { buildReaderVisibilityConditions } from "../db/post-visibility.js";
|
|
18
23
|
import {
|
|
19
24
|
sqliteSchemaBundle,
|
|
20
25
|
type DatabaseSchema,
|
|
21
26
|
} from "../db/schema-bundle.js";
|
|
22
|
-
import { createEntityId } from "../lib/ids.js";
|
|
27
|
+
import { createEntityId, isTypeId, ID_PREFIX } from "../lib/ids.js";
|
|
23
28
|
import { now } from "../lib/time.js";
|
|
24
29
|
import type {
|
|
25
30
|
Collection,
|
|
@@ -30,6 +35,7 @@ import type {
|
|
|
30
35
|
CollectionsDirectoryData,
|
|
31
36
|
CreateCollection,
|
|
32
37
|
CreateCollectionDirectoryEntry,
|
|
38
|
+
SmartCollectionDirectoryEntry,
|
|
33
39
|
UpdateCollection,
|
|
34
40
|
UpdateCollectionDirectoryEntry,
|
|
35
41
|
CollectionSortOrder,
|
|
@@ -45,6 +51,13 @@ import {
|
|
|
45
51
|
type PathService,
|
|
46
52
|
} from "./path.js";
|
|
47
53
|
import { getCollectionPagePath } from "../lib/collection-paths.js";
|
|
54
|
+
import { getSlugValidationIssue } from "../lib/slug-format.js";
|
|
55
|
+
import type { SmartCollectionService } from "./smart-collection.js";
|
|
56
|
+
import {
|
|
57
|
+
DIRECTORY_POSITION_RETRY_ATTEMPTS as POSITION_RETRY_ATTEMPTS,
|
|
58
|
+
getAppendDirectoryPosition as appendDirectoryPosition,
|
|
59
|
+
getLastDirectoryPosition as lastDirectoryPosition,
|
|
60
|
+
} from "./collection-directory-position.js";
|
|
48
61
|
import {
|
|
49
62
|
CreateCollectionDirectoryItemSchema,
|
|
50
63
|
CollectionDirectoryLabelSchema,
|
|
@@ -56,8 +69,6 @@ import {
|
|
|
56
69
|
parseValidated,
|
|
57
70
|
} from "../lib/schemas.js";
|
|
58
71
|
|
|
59
|
-
const POSITION_RETRY_ATTEMPTS = 5;
|
|
60
|
-
|
|
61
72
|
// Re-export shared constraint detection — see db/dialect.ts
|
|
62
73
|
import { isUniqueConstraintError } from "../db/dialect.js";
|
|
63
74
|
|
|
@@ -87,6 +98,21 @@ function parseCollectionSelectionSlugs(
|
|
|
87
98
|
return slugs.length > 0 ? slugs : null;
|
|
88
99
|
}
|
|
89
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Who is looking at the collections directory.
|
|
103
|
+
*
|
|
104
|
+
* The directory lists every collection in every language view — that is site
|
|
105
|
+
* skeleton, and it does not depend on the reader. Its *numbers* do: they answer
|
|
106
|
+
* "how many threads will I see if I click this", so they carry the same
|
|
107
|
+
* visibility and language narrowing the collection page applies.
|
|
108
|
+
*/
|
|
109
|
+
export interface CollectionDirectoryViewer {
|
|
110
|
+
/** Signed-in authors see their private threads counted; readers do not. */
|
|
111
|
+
isAuthenticated: boolean;
|
|
112
|
+
/** Content language of the current view, when the site has more than one. */
|
|
113
|
+
lang?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
90
116
|
export interface CollectionService {
|
|
91
117
|
getById(id: string): Promise<Collection | null>;
|
|
92
118
|
getBySlug(slug: string): Promise<Collection | null>;
|
|
@@ -95,12 +121,25 @@ export interface CollectionService {
|
|
|
95
121
|
slugExpression: string,
|
|
96
122
|
): Promise<ResolvedCollectionSelection | null>;
|
|
97
123
|
list(): Promise<Collection[]>;
|
|
98
|
-
listDirectoryData(
|
|
124
|
+
listDirectoryData(
|
|
125
|
+
viewer: CollectionDirectoryViewer,
|
|
126
|
+
): Promise<CollectionsDirectoryData>;
|
|
99
127
|
/** List collections sorted by most recent Thread addition (for compose dialog) */
|
|
100
128
|
listByRecentActivity(): Promise<Collection[]>;
|
|
101
129
|
create(data: CreateCollection): Promise<Collection>;
|
|
102
130
|
update(id: string, data: UpdateCollection): Promise<Collection | null>;
|
|
103
131
|
delete(id: string): Promise<boolean>;
|
|
132
|
+
/**
|
|
133
|
+
* Whether a collection can take this address.
|
|
134
|
+
*
|
|
135
|
+
* A collection shares the root namespace with posts and smart collections,
|
|
136
|
+
* so the whole path registry answers this, not the collection table.
|
|
137
|
+
*
|
|
138
|
+
* @param slug - Address to test, already lowercased
|
|
139
|
+
* @param excludeId - Ignore the address this collection already holds
|
|
140
|
+
* @returns True when nothing else holds the address
|
|
141
|
+
*/
|
|
142
|
+
checkSlugAvailability(slug: string, excludeId?: string): Promise<boolean>;
|
|
104
143
|
/** List all collection directory items ordered by position */
|
|
105
144
|
listDirectoryItems(): Promise<CollectionDirectoryEntry[]>;
|
|
106
145
|
/** Create a collection directory item (collection, divider, or link) */
|
|
@@ -114,7 +153,14 @@ export interface CollectionService {
|
|
|
114
153
|
id: string,
|
|
115
154
|
data: UpdateCollectionDirectoryEntry,
|
|
116
155
|
): Promise<CollectionDirectoryEntry | null>;
|
|
117
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* Move a collection directory row between two neighbors.
|
|
158
|
+
*
|
|
159
|
+
* Each id is a directory row id, or — for a collection or smart collection
|
|
160
|
+
* the directory renders without one — that entry's own id. Naming one of
|
|
161
|
+
* those writes rows for everything unplaced, in the order the directory
|
|
162
|
+
* already shows, and then moves within them.
|
|
163
|
+
*/
|
|
118
164
|
moveDirectoryItem(
|
|
119
165
|
id: string,
|
|
120
166
|
after: string | null,
|
|
@@ -209,12 +255,22 @@ export function createCollectionService(
|
|
|
209
255
|
paths: PathService | undefined,
|
|
210
256
|
databaseSchema: DatabaseSchema = sqliteSchemaBundle,
|
|
211
257
|
databaseDialect: DatabaseDialect = "sqlite",
|
|
258
|
+
/**
|
|
259
|
+
* Consulted before a collection is deleted, so a smart collection that
|
|
260
|
+
* filters by it is named rather than silently widened.
|
|
261
|
+
*
|
|
262
|
+
* Passed as a dependency rather than reached for, following the same shape
|
|
263
|
+
* `storage?: StorageDriver` uses for media cleanup — the route stays out of
|
|
264
|
+
* cross-service orchestration.
|
|
265
|
+
*/
|
|
266
|
+
smartCollections?: SmartCollectionService,
|
|
212
267
|
): CollectionService {
|
|
213
268
|
const resolvedPaths = paths ?? createPathService(db, siteId, databaseSchema);
|
|
214
269
|
const {
|
|
215
270
|
collections,
|
|
216
271
|
pathRegistry,
|
|
217
272
|
collectionDirectoryItems: directoryItemsTable,
|
|
273
|
+
smartCollections: smartCollectionsTable,
|
|
218
274
|
threadCollections,
|
|
219
275
|
posts,
|
|
220
276
|
navItems,
|
|
@@ -283,6 +339,7 @@ export function createCollectionService(
|
|
|
283
339
|
siteId: row.siteId,
|
|
284
340
|
type: row.type as CollectionDirectoryEntryType,
|
|
285
341
|
collectionId: row.collectionId,
|
|
342
|
+
smartCollectionId: row.smartCollectionId,
|
|
286
343
|
label: row.label,
|
|
287
344
|
url: row.url,
|
|
288
345
|
description: row.description,
|
|
@@ -346,13 +403,7 @@ export function createCollectionService(
|
|
|
346
403
|
}
|
|
347
404
|
|
|
348
405
|
async function getLastDirectoryPosition(): Promise<string | null> {
|
|
349
|
-
|
|
350
|
-
.select({ position: directoryItemsTable.position })
|
|
351
|
-
.from(directoryItemsTable)
|
|
352
|
-
.where(eq(directoryItemsTable.siteId, siteId))
|
|
353
|
-
.orderBy(sql`${directoryItemsTable.position} DESC`)
|
|
354
|
-
.limit(1);
|
|
355
|
-
return rows[0]?.position ?? null;
|
|
406
|
+
return lastDirectoryPosition(db, directoryItemsTable, siteId);
|
|
356
407
|
}
|
|
357
408
|
|
|
358
409
|
async function listOrderedDirectoryPositions(excludeId?: string) {
|
|
@@ -368,8 +419,7 @@ export function createCollectionService(
|
|
|
368
419
|
}
|
|
369
420
|
|
|
370
421
|
async function getAppendDirectoryPosition(): Promise<string> {
|
|
371
|
-
|
|
372
|
-
return generateKeyBetween(lastPos, null);
|
|
422
|
+
return appendDirectoryPosition(db, directoryItemsTable, siteId);
|
|
373
423
|
}
|
|
374
424
|
|
|
375
425
|
async function pathExists(path: string): Promise<boolean> {
|
|
@@ -421,6 +471,140 @@ export function createCollectionService(
|
|
|
421
471
|
return generateKeyBetween(rows.at(-1)?.position ?? null, null);
|
|
422
472
|
}
|
|
423
473
|
|
|
474
|
+
/**
|
|
475
|
+
* Write rows for the entries the directory only shows, in the order it shows
|
|
476
|
+
* them.
|
|
477
|
+
*
|
|
478
|
+
* A directory row is a position, not a membership: `buildDirectoryItems`
|
|
479
|
+
* appends every collection and smart collection that has none, so an unplaced
|
|
480
|
+
* entry is visible but has nothing to move and cannot be named as a
|
|
481
|
+
* neighbour. Materializing in exactly the render order is invisible — nothing
|
|
482
|
+
* on screen changes — and afterwards every row the author can see is a row
|
|
483
|
+
* the author can drag.
|
|
484
|
+
*
|
|
485
|
+
* @returns Directory row id, keyed by the collection or smart collection it places
|
|
486
|
+
*/
|
|
487
|
+
async function materializeDirectoryRows(): Promise<Map<string, string>> {
|
|
488
|
+
const [directoryRows, collectionRows, smartCollectionRows] =
|
|
489
|
+
await Promise.all([
|
|
490
|
+
db
|
|
491
|
+
.select({
|
|
492
|
+
id: directoryItemsTable.id,
|
|
493
|
+
collectionId: directoryItemsTable.collectionId,
|
|
494
|
+
smartCollectionId: directoryItemsTable.smartCollectionId,
|
|
495
|
+
position: directoryItemsTable.position,
|
|
496
|
+
})
|
|
497
|
+
.from(directoryItemsTable)
|
|
498
|
+
.where(eq(directoryItemsTable.siteId, siteId))
|
|
499
|
+
.orderBy(asc(directoryItemsTable.position)),
|
|
500
|
+
db
|
|
501
|
+
.select({ id: collections.id })
|
|
502
|
+
.from(collections)
|
|
503
|
+
.where(eq(collections.siteId, siteId))
|
|
504
|
+
.orderBy(asc(collections.createdAt)),
|
|
505
|
+
db
|
|
506
|
+
.select({ id: smartCollectionsTable.id })
|
|
507
|
+
.from(smartCollectionsTable)
|
|
508
|
+
.where(eq(smartCollectionsTable.siteId, siteId))
|
|
509
|
+
.orderBy(asc(smartCollectionsTable.createdAt)),
|
|
510
|
+
]);
|
|
511
|
+
|
|
512
|
+
const placed = new Map<string, string>();
|
|
513
|
+
let lastPosition: string | null = null;
|
|
514
|
+
for (const row of directoryRows) {
|
|
515
|
+
const entityId = row.collectionId ?? row.smartCollectionId;
|
|
516
|
+
if (entityId) placed.set(entityId, row.id);
|
|
517
|
+
lastPosition = row.position;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Manual collections before smart ones, matching the two fallback loops in
|
|
521
|
+
// `buildDirectoryItems`. Read them in the other order and the first drag
|
|
522
|
+
// would reshuffle rows the author never touched.
|
|
523
|
+
const unplaced: { type: CollectionDirectoryEntryType; entityId: string }[] =
|
|
524
|
+
[
|
|
525
|
+
...collectionRows
|
|
526
|
+
.filter((row) => !placed.has(row.id))
|
|
527
|
+
.map((row) => ({ type: "collection" as const, entityId: row.id })),
|
|
528
|
+
...smartCollectionRows
|
|
529
|
+
.filter((row) => !placed.has(row.id))
|
|
530
|
+
.map((row) => ({
|
|
531
|
+
type: "smart_collection" as const,
|
|
532
|
+
entityId: row.id,
|
|
533
|
+
})),
|
|
534
|
+
];
|
|
535
|
+
|
|
536
|
+
const timestamp = now();
|
|
537
|
+
for (const entry of unplaced) {
|
|
538
|
+
for (let attempt = 0; attempt < POSITION_RETRY_ATTEMPTS; attempt += 1) {
|
|
539
|
+
const id = createEntityId("collectionDirectoryItem");
|
|
540
|
+
const position = generateKeyBetween(lastPosition, null);
|
|
541
|
+
try {
|
|
542
|
+
await db.insert(directoryItemsTable).values({
|
|
543
|
+
id,
|
|
544
|
+
siteId,
|
|
545
|
+
type: entry.type,
|
|
546
|
+
collectionId: entry.type === "collection" ? entry.entityId : null,
|
|
547
|
+
smartCollectionId:
|
|
548
|
+
entry.type === "smart_collection" ? entry.entityId : null,
|
|
549
|
+
label: null,
|
|
550
|
+
url: null,
|
|
551
|
+
position,
|
|
552
|
+
createdAt: timestamp,
|
|
553
|
+
updatedAt: timestamp,
|
|
554
|
+
});
|
|
555
|
+
placed.set(entry.entityId, id);
|
|
556
|
+
lastPosition = position;
|
|
557
|
+
break;
|
|
558
|
+
} catch (err) {
|
|
559
|
+
if (
|
|
560
|
+
!isUniqueConstraintError(err) ||
|
|
561
|
+
attempt === POSITION_RETRY_ATTEMPTS - 1
|
|
562
|
+
) {
|
|
563
|
+
throw err;
|
|
564
|
+
}
|
|
565
|
+
lastPosition = await getLastDirectoryPosition();
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return placed;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Name the three rows a move talks about by their directory row id.
|
|
575
|
+
*
|
|
576
|
+
* The drag surface names an unplaced collection or smart collection by its
|
|
577
|
+
* own id, because that is all it has to render. Any such id here means the
|
|
578
|
+
* directory has to become explicit before a position can be computed.
|
|
579
|
+
*
|
|
580
|
+
* @returns Row ids, or `null` when the moved row no longer exists
|
|
581
|
+
*/
|
|
582
|
+
async function resolveDirectoryMoveRows(
|
|
583
|
+
id: string,
|
|
584
|
+
afterId: string | null,
|
|
585
|
+
beforeId: string | null,
|
|
586
|
+
): Promise<{
|
|
587
|
+
id: string;
|
|
588
|
+
after: string | null;
|
|
589
|
+
before: string | null;
|
|
590
|
+
} | null> {
|
|
591
|
+
const isRowId = (value: string | null) =>
|
|
592
|
+
value === null || isTypeId(value, ID_PREFIX.collectionDirectoryItem);
|
|
593
|
+
|
|
594
|
+
if (isRowId(id) && isRowId(afterId) && isRowId(beforeId)) {
|
|
595
|
+
return { id, after: afterId, before: beforeId };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const placed = await materializeDirectoryRows();
|
|
599
|
+
const toRowId = (value: string | null) =>
|
|
600
|
+
value === null || isRowId(value) ? value : (placed.get(value) ?? null);
|
|
601
|
+
|
|
602
|
+
const rowId = toRowId(id);
|
|
603
|
+
if (!rowId) return null;
|
|
604
|
+
|
|
605
|
+
return { id: rowId, after: toRowId(afterId), before: toRowId(beforeId) };
|
|
606
|
+
}
|
|
607
|
+
|
|
424
608
|
async function hydrateCollection(
|
|
425
609
|
row: typeof collections.$inferSelect | undefined,
|
|
426
610
|
): Promise<Collection | null> {
|
|
@@ -445,34 +629,38 @@ export function createCollectionService(
|
|
|
445
629
|
.filter((row): row is Collection => row !== null);
|
|
446
630
|
}
|
|
447
631
|
|
|
448
|
-
async function listDirectoryCollections(
|
|
449
|
-
|
|
450
|
-
> {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
632
|
+
async function listDirectoryCollections(
|
|
633
|
+
viewer: CollectionDirectoryViewer,
|
|
634
|
+
): Promise<CollectionDirectoryCollection[]> {
|
|
635
|
+
// Joined on thread_id rather than id, so a Thread counts once no matter
|
|
636
|
+
// which of its posts matched — the same shape
|
|
637
|
+
// `posts.countCollectionThreadRootsForCollections` uses for the collection
|
|
638
|
+
// page. The two numbers are shown one click apart and have to agree.
|
|
639
|
+
const readerConditions = buildReaderVisibilityConditions(posts, siteId, {
|
|
640
|
+
status: "published",
|
|
641
|
+
excludePrivate: !viewer.isAuthenticated,
|
|
642
|
+
lang: viewer.lang,
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
const threadCount =
|
|
646
|
+
sql<number>`CAST(COUNT(DISTINCT ${posts.threadId}) AS INTEGER)`.as(
|
|
647
|
+
"thread_count",
|
|
648
|
+
);
|
|
649
|
+
// Rows here are Thread *members*, so the root's activity is reached
|
|
650
|
+
// through a correlated subquery; the outer MAX only collapses the group.
|
|
651
|
+
// A member whose root row is missing falls back to its own timestamps.
|
|
652
|
+
const recentActivityAt = sql<number | null>`MAX(
|
|
653
|
+
COALESCE(
|
|
654
|
+
(
|
|
655
|
+
SELECT ${buildRootActivityExpr(rootActivityColumns("root"))}
|
|
656
|
+
FROM post AS root
|
|
657
|
+
WHERE root.site_id = ${siteId}
|
|
658
|
+
AND root.id = ${posts.threadId}
|
|
659
|
+
),
|
|
457
660
|
${posts.publishedAt},
|
|
458
661
|
${posts.updatedAt}
|
|
459
662
|
)
|
|
460
|
-
|
|
461
|
-
const threadCount = sql<number>`
|
|
462
|
-
CAST(COUNT(
|
|
463
|
-
CASE
|
|
464
|
-
WHEN ${posts.id} IS NOT NULL THEN 1
|
|
465
|
-
END
|
|
466
|
-
) AS INTEGER)
|
|
467
|
-
`.as("thread_count");
|
|
468
|
-
const recentActivityAt = sql<number | null>`
|
|
469
|
-
MAX(
|
|
470
|
-
CASE
|
|
471
|
-
WHEN ${posts.id} IS NOT NULL
|
|
472
|
-
THEN ${threadActivityAt}
|
|
473
|
-
END
|
|
474
|
-
)
|
|
475
|
-
`.as("recent_activity_at");
|
|
663
|
+
)`.as("recent_activity_at");
|
|
476
664
|
|
|
477
665
|
const rows = await db
|
|
478
666
|
.select({
|
|
@@ -488,11 +676,15 @@ export function createCollectionService(
|
|
|
488
676
|
eq(collections.id, threadCollections.collectionId),
|
|
489
677
|
),
|
|
490
678
|
)
|
|
679
|
+
// The reader's conditions belong in ON, not WHERE: a collection this
|
|
680
|
+
// reader can see nothing in stays listed and reads `0 threads`, which is
|
|
681
|
+
// what the directory promises — every collection in every view.
|
|
491
682
|
.leftJoin(
|
|
492
683
|
posts,
|
|
493
684
|
and(
|
|
494
685
|
eq(posts.siteId, threadCollections.siteId),
|
|
495
|
-
eq(
|
|
686
|
+
eq(posts.threadId, threadCollections.threadId),
|
|
687
|
+
...readerConditions,
|
|
496
688
|
),
|
|
497
689
|
)
|
|
498
690
|
.where(eq(collections.siteId, siteId))
|
|
@@ -519,14 +711,33 @@ export function createCollectionService(
|
|
|
519
711
|
.filter((row): row is CollectionDirectoryCollection => row !== null);
|
|
520
712
|
}
|
|
521
713
|
|
|
714
|
+
/**
|
|
715
|
+
* Order the directory: explicitly placed rows first, then everything else.
|
|
716
|
+
*
|
|
717
|
+
* A directory row is a *position*, not a membership. Both kinds of collection
|
|
718
|
+
* appear whether or not one exists — anything unplaced is appended here — so
|
|
719
|
+
* there is no such thing as a collection missing from `/collections`, and no
|
|
720
|
+
* "add to directory" picker to build. The rows exist only so a collection can
|
|
721
|
+
* be interleaved with dividers and links and dragged around.
|
|
722
|
+
*
|
|
723
|
+
* Creating either kind writes its row, so the appended tail is for rows from
|
|
724
|
+
* before that was true and for imports. An entry in it is rendered under its
|
|
725
|
+
* own id, because there is no row id to render; moving one places it first —
|
|
726
|
+
* see `materializeDirectoryRows`.
|
|
727
|
+
*/
|
|
522
728
|
function buildDirectoryItems(
|
|
523
729
|
directoryCollections: CollectionDirectoryCollection[],
|
|
730
|
+
directorySmartCollections: SmartCollectionDirectoryEntry[],
|
|
524
731
|
orderedDirectoryItems: CollectionDirectoryEntry[],
|
|
525
732
|
): CollectionDirectoryItem[] {
|
|
526
733
|
const collectionMap = new Map(
|
|
527
734
|
directoryCollections.map((collection) => [collection.id, collection]),
|
|
528
735
|
);
|
|
736
|
+
const smartCollectionMap = new Map(
|
|
737
|
+
directorySmartCollections.map((entry) => [entry.id, entry]),
|
|
738
|
+
);
|
|
529
739
|
const seenCollections = new Set<string>();
|
|
740
|
+
const seenSmartCollections = new Set<string>();
|
|
530
741
|
const items: CollectionDirectoryItem[] = [];
|
|
531
742
|
|
|
532
743
|
for (const item of orderedDirectoryItems) {
|
|
@@ -551,6 +762,21 @@ export function createCollectionService(
|
|
|
551
762
|
continue;
|
|
552
763
|
}
|
|
553
764
|
|
|
765
|
+
if (item.type === "smart_collection") {
|
|
766
|
+
const smartCollection = item.smartCollectionId
|
|
767
|
+
? smartCollectionMap.get(item.smartCollectionId)
|
|
768
|
+
: undefined;
|
|
769
|
+
if (!smartCollection) continue;
|
|
770
|
+
|
|
771
|
+
seenSmartCollections.add(smartCollection.id);
|
|
772
|
+
items.push({
|
|
773
|
+
id: item.id,
|
|
774
|
+
type: "smart_collection",
|
|
775
|
+
smartCollection,
|
|
776
|
+
});
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
|
|
554
780
|
const collection = item.collectionId
|
|
555
781
|
? collectionMap.get(item.collectionId)
|
|
556
782
|
: undefined;
|
|
@@ -573,6 +799,15 @@ export function createCollectionService(
|
|
|
573
799
|
});
|
|
574
800
|
}
|
|
575
801
|
|
|
802
|
+
for (const smartCollection of directorySmartCollections) {
|
|
803
|
+
if (seenSmartCollections.has(smartCollection.id)) continue;
|
|
804
|
+
items.push({
|
|
805
|
+
id: smartCollection.id,
|
|
806
|
+
type: "smart_collection",
|
|
807
|
+
smartCollection,
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
576
811
|
return items;
|
|
577
812
|
}
|
|
578
813
|
|
|
@@ -628,15 +863,31 @@ export function createCollectionService(
|
|
|
628
863
|
return hydrateCollections(rows);
|
|
629
864
|
},
|
|
630
865
|
|
|
631
|
-
async listDirectoryData() {
|
|
632
|
-
const [
|
|
633
|
-
|
|
866
|
+
async listDirectoryData(viewer) {
|
|
867
|
+
const [
|
|
868
|
+
directoryCollections,
|
|
869
|
+
directorySmartCollections,
|
|
870
|
+
orderedDirectoryItems,
|
|
871
|
+
] = await Promise.all([
|
|
872
|
+
listDirectoryCollections(viewer),
|
|
873
|
+
// One pass for every smart collection, not one query each — see
|
|
874
|
+
// `posts.aggregateMany`. Both kinds of row are measured through the
|
|
875
|
+
// same reader predicate, so a signed-out visitor sees a directory
|
|
876
|
+
// whose counts and dates agree with the pages behind them.
|
|
877
|
+
smartCollections
|
|
878
|
+
? smartCollections.listDirectoryEntries(viewer)
|
|
879
|
+
: Promise.resolve([] as SmartCollectionDirectoryEntry[]),
|
|
634
880
|
this.listDirectoryItems(),
|
|
635
881
|
]);
|
|
636
882
|
|
|
637
883
|
return {
|
|
638
884
|
collections: directoryCollections,
|
|
639
|
-
|
|
885
|
+
smartCollections: directorySmartCollections,
|
|
886
|
+
items: buildDirectoryItems(
|
|
887
|
+
directoryCollections,
|
|
888
|
+
directorySmartCollections,
|
|
889
|
+
orderedDirectoryItems,
|
|
890
|
+
),
|
|
640
891
|
directoryItems: orderedDirectoryItems,
|
|
641
892
|
};
|
|
642
893
|
},
|
|
@@ -841,6 +1092,13 @@ export function createCollectionService(
|
|
|
841
1092
|
},
|
|
842
1093
|
|
|
843
1094
|
async delete(id) {
|
|
1095
|
+
// Refused, not repaired: clearing the condition would quietly widen a
|
|
1096
|
+
// smart collection the author never edited. This is the only place that
|
|
1097
|
+
// refusal lives — `smart_collection.collection_id` carries no foreign key,
|
|
1098
|
+
// precisely so the answer can name the smart collections in the way
|
|
1099
|
+
// instead of failing a constraint.
|
|
1100
|
+
await smartCollections?.assertCollectionUnused(id);
|
|
1101
|
+
|
|
844
1102
|
await db
|
|
845
1103
|
.delete(threadCollections)
|
|
846
1104
|
.where(
|
|
@@ -867,6 +1125,24 @@ export function createCollectionService(
|
|
|
867
1125
|
return result.length > 0;
|
|
868
1126
|
},
|
|
869
1127
|
|
|
1128
|
+
async checkSlugAvailability(slug, excludeId) {
|
|
1129
|
+
const issue = getSlugValidationIssue(slug);
|
|
1130
|
+
if (issue === "invalid") {
|
|
1131
|
+
throw new ValidationError("Slug contains invalid characters");
|
|
1132
|
+
}
|
|
1133
|
+
if (issue === "reserved") {
|
|
1134
|
+
throw new ValidationError("Slug is reserved");
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const resolved = await resolvedPaths.resolve(toCollectionPath(slug));
|
|
1138
|
+
if (!resolved) return true;
|
|
1139
|
+
return Boolean(
|
|
1140
|
+
excludeId &&
|
|
1141
|
+
resolved.kind === "slug" &&
|
|
1142
|
+
resolved.collectionId === excludeId,
|
|
1143
|
+
);
|
|
1144
|
+
},
|
|
1145
|
+
|
|
870
1146
|
async listDirectoryItems() {
|
|
871
1147
|
const rows = await db
|
|
872
1148
|
.select()
|
|
@@ -1026,13 +1302,16 @@ export function createCollectionService(
|
|
|
1026
1302
|
},
|
|
1027
1303
|
|
|
1028
1304
|
async moveDirectoryItem(id, afterId, beforeId) {
|
|
1305
|
+
const rows = await resolveDirectoryMoveRows(id, afterId, beforeId);
|
|
1306
|
+
if (!rows) return null;
|
|
1307
|
+
|
|
1029
1308
|
const items = await db
|
|
1030
1309
|
.select()
|
|
1031
1310
|
.from(directoryItemsTable)
|
|
1032
1311
|
.where(
|
|
1033
1312
|
and(
|
|
1034
1313
|
eq(directoryItemsTable.siteId, siteId),
|
|
1035
|
-
eq(directoryItemsTable.id, id),
|
|
1314
|
+
eq(directoryItemsTable.id, rows.id),
|
|
1036
1315
|
),
|
|
1037
1316
|
)
|
|
1038
1317
|
.limit(1);
|
|
@@ -1044,13 +1323,17 @@ export function createCollectionService(
|
|
|
1044
1323
|
const result = await db
|
|
1045
1324
|
.update(directoryItemsTable)
|
|
1046
1325
|
.set({
|
|
1047
|
-
position: await getDirectoryMovePosition(
|
|
1326
|
+
position: await getDirectoryMovePosition(
|
|
1327
|
+
rows.id,
|
|
1328
|
+
rows.after,
|
|
1329
|
+
rows.before,
|
|
1330
|
+
),
|
|
1048
1331
|
updatedAt: timestamp,
|
|
1049
1332
|
})
|
|
1050
1333
|
.where(
|
|
1051
1334
|
and(
|
|
1052
1335
|
eq(directoryItemsTable.siteId, siteId),
|
|
1053
|
-
eq(directoryItemsTable.id, id),
|
|
1336
|
+
eq(directoryItemsTable.id, rows.id),
|
|
1054
1337
|
),
|
|
1055
1338
|
)
|
|
1056
1339
|
.returning();
|
|
@@ -15,10 +15,15 @@ import { isReservedPath } from "../lib/constants.js";
|
|
|
15
15
|
import { ConflictError, ValidationError } from "../lib/errors.js";
|
|
16
16
|
import { normalizePath } from "../lib/url.js";
|
|
17
17
|
import type { CustomUrl } from "../types.js";
|
|
18
|
+
import { readLanguageSettings } from "./language.js";
|
|
18
19
|
import { createPathService, type PathService } from "./path.js";
|
|
19
20
|
|
|
20
21
|
export interface CreateCustomUrl {
|
|
21
22
|
path: string;
|
|
23
|
+
/**
|
|
24
|
+
* `archive` is readable but no longer creatable — {@link CustomUrlService.create}
|
|
25
|
+
* refuses it. Stored ones predate smart collections and keep working.
|
|
26
|
+
*/
|
|
22
27
|
targetType: "post" | "collection" | "redirect" | "archive";
|
|
23
28
|
targetId?: string;
|
|
24
29
|
toPath?: string;
|
|
@@ -111,7 +116,12 @@ export function createCustomUrlService(
|
|
|
111
116
|
async create(data) {
|
|
112
117
|
const normalized = normalizeInputPath(data.path);
|
|
113
118
|
|
|
114
|
-
|
|
119
|
+
const { reservedPrefixes } = await readLanguageSettings(
|
|
120
|
+
db,
|
|
121
|
+
siteId,
|
|
122
|
+
databaseSchema,
|
|
123
|
+
);
|
|
124
|
+
if (isReservedPath(normalized, reservedPrefixes)) {
|
|
115
125
|
throw new ValidationError(
|
|
116
126
|
`Path "${normalized}" is reserved and cannot be used`,
|
|
117
127
|
);
|
|
@@ -128,26 +138,16 @@ export function createCustomUrlService(
|
|
|
128
138
|
}
|
|
129
139
|
|
|
130
140
|
if (data.targetType === "archive") {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
.from(pathRegistry)
|
|
142
|
-
.where(
|
|
143
|
-
and(
|
|
144
|
-
eq(pathRegistry.siteId, siteId),
|
|
145
|
-
eq(pathRegistry.id, record.id),
|
|
146
|
-
),
|
|
147
|
-
)
|
|
148
|
-
.limit(1);
|
|
149
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- freshly inserted row exists
|
|
150
|
-
return toCustomUrl(row[0]!);
|
|
141
|
+
// Refused here, not only hidden in the settings form. Typing
|
|
142
|
+
// `format=note&title=none` into a text field was the problem smart
|
|
143
|
+
// collections were built to replace, and a UI that stops offering it
|
|
144
|
+
// while the API still accepts it has not stopped offering it.
|
|
145
|
+
//
|
|
146
|
+
// Reading, listing, and deleting these paths all stay: existing ones
|
|
147
|
+
// keep working indefinitely, and each carries an upgrade button.
|
|
148
|
+
throw new ValidationError(
|
|
149
|
+
"Archive addresses are no longer created here. Make a smart collection instead.",
|
|
150
|
+
);
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
if (data.targetType === "redirect") {
|
|
@@ -238,7 +238,12 @@ export function createCustomUrlService(
|
|
|
238
238
|
|
|
239
239
|
async isPathAvailable(path) {
|
|
240
240
|
const normalized = normalizeInputPath(path);
|
|
241
|
-
|
|
241
|
+
const { reservedPrefixes } = await readLanguageSettings(
|
|
242
|
+
db,
|
|
243
|
+
siteId,
|
|
244
|
+
databaseSchema,
|
|
245
|
+
);
|
|
246
|
+
if (isReservedPath(normalized, reservedPrefixes)) return false;
|
|
242
247
|
return resolvedPaths.isPathAvailable(normalized);
|
|
243
248
|
},
|
|
244
249
|
};
|