@jant/core 0.3.35 → 0.3.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/module-RjUF93sV.js +716 -0
  6. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  7. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  8. package/dist/client/client.css +1 -1
  9. package/dist/client/client.js +4564 -3013
  10. package/dist/index.js +12885 -8161
  11. package/package.json +23 -6
  12. package/src/__tests__/helpers/app.ts +10 -10
  13. package/src/__tests__/helpers/db.ts +91 -87
  14. package/src/app.tsx +157 -31
  15. package/src/auth.ts +20 -2
  16. package/src/client/archive-nav.js +187 -0
  17. package/src/client/audio-player.ts +478 -0
  18. package/src/client/audio-processor.ts +84 -0
  19. package/src/{lib → client}/avatar-upload.ts +4 -3
  20. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  21. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  22. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
  23. package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
  24. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
  25. package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
  26. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  27. package/src/client/components/collection-sidebar-types.ts +43 -0
  28. package/src/{ui → client}/components/collection-types.ts +3 -4
  29. package/src/client/components/compose-types.ts +174 -0
  30. package/src/client/components/jant-collection-form.ts +667 -0
  31. package/src/client/components/jant-collection-sidebar.ts +805 -0
  32. package/src/client/components/jant-compose-dialog.ts +2161 -0
  33. package/src/client/components/jant-compose-editor.ts +1813 -0
  34. package/src/client/components/jant-compose-fullscreen.ts +283 -0
  35. package/src/client/components/jant-media-lightbox.ts +259 -0
  36. package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
  37. package/src/{ui → client}/components/jant-post-form.ts +141 -12
  38. package/src/client/components/jant-post-menu.ts +1019 -0
  39. package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
  40. package/src/{ui → client}/components/jant-settings-general.ts +38 -4
  41. package/src/client/components/jant-text-preview.ts +232 -0
  42. package/src/{ui → client}/components/nav-manager-types.ts +6 -18
  43. package/src/{ui → client}/components/post-form-template.ts +137 -38
  44. package/src/{ui → client}/components/post-form-types.ts +15 -4
  45. package/src/client/compose-bridge.ts +583 -0
  46. package/src/{lib → client}/image-processor.ts +26 -8
  47. package/src/client/lazy-slugify.ts +51 -0
  48. package/src/client/media-metadata.ts +247 -0
  49. package/src/client/multipart-upload.ts +160 -0
  50. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  51. package/src/{lib → client}/post-form-bridge.ts +53 -2
  52. package/src/{lib → client}/settings-bridge.ts +3 -15
  53. package/src/client/thread-context.ts +140 -0
  54. package/src/client/tiptap/bubble-menu.ts +205 -0
  55. package/src/client/tiptap/create-editor.ts +86 -0
  56. package/src/client/tiptap/exitable-marks.ts +73 -0
  57. package/src/client/tiptap/extensions.ts +65 -0
  58. package/src/client/tiptap/image-node.ts +482 -0
  59. package/src/client/tiptap/link-toolbar.ts +371 -0
  60. package/src/client/tiptap/more-break.ts +50 -0
  61. package/src/client/tiptap/paste-image.ts +129 -0
  62. package/src/client/tiptap/slash-commands.ts +438 -0
  63. package/src/{lib → client}/toast.ts +101 -3
  64. package/src/client/types/sortablejs.d.ts +44 -0
  65. package/src/client/upload-with-metadata.ts +54 -0
  66. package/src/client/video-processor.ts +207 -0
  67. package/src/client.ts +27 -17
  68. package/src/db/__tests__/migrations.test.ts +118 -0
  69. package/src/db/index.ts +52 -0
  70. package/src/db/migrations/0000_baseline.sql +269 -0
  71. package/src/db/migrations/0001_fts_setup.sql +31 -0
  72. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  73. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  74. package/src/db/migrations/meta/_journal.json +4 -39
  75. package/src/db/schema.ts +409 -140
  76. package/src/i18n/__tests__/detect.test.ts +115 -0
  77. package/src/i18n/context.tsx +2 -2
  78. package/src/i18n/detect.ts +85 -1
  79. package/src/i18n/i18n.ts +1 -1
  80. package/src/i18n/index.ts +2 -1
  81. package/src/i18n/locales/en.po +783 -1087
  82. package/src/i18n/locales/en.ts +1 -1
  83. package/src/i18n/locales/zh-Hans.po +867 -812
  84. package/src/i18n/locales/zh-Hans.ts +1 -1
  85. package/src/i18n/locales/zh-Hant.po +878 -823
  86. package/src/i18n/locales/zh-Hant.ts +1 -1
  87. package/src/i18n/middleware.ts +6 -0
  88. package/src/index.ts +5 -7
  89. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  90. package/src/lib/__tests__/constants.test.ts +0 -1
  91. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  92. package/src/lib/__tests__/nanoid.test.ts +26 -0
  93. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  94. package/src/lib/__tests__/schemas.test.ts +186 -65
  95. package/src/lib/__tests__/slug.test.ts +126 -0
  96. package/src/lib/__tests__/sse.test.ts +6 -6
  97. package/src/lib/__tests__/summary.test.ts +264 -0
  98. package/src/lib/__tests__/theme.test.ts +1 -1
  99. package/src/lib/__tests__/timeline.test.ts +33 -30
  100. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  101. package/src/lib/__tests__/url.test.ts +2 -2
  102. package/src/lib/__tests__/view.test.ts +140 -65
  103. package/src/lib/blurhash-placeholder.ts +102 -0
  104. package/src/lib/constants.ts +3 -1
  105. package/src/lib/emoji-catalog.ts +963 -0
  106. package/src/lib/errors.ts +11 -8
  107. package/src/lib/feed.ts +77 -31
  108. package/src/lib/html.ts +2 -1
  109. package/src/lib/icon-catalog.ts +5033 -1
  110. package/src/lib/icons.ts +3 -2
  111. package/src/lib/index.ts +0 -1
  112. package/src/lib/markdown-to-tiptap.ts +286 -0
  113. package/src/lib/media-helpers.ts +22 -12
  114. package/src/lib/nanoid.ts +29 -0
  115. package/src/lib/navigation.ts +1 -1
  116. package/src/lib/render.tsx +24 -5
  117. package/src/lib/resolve-config.ts +13 -2
  118. package/src/lib/schemas.ts +226 -58
  119. package/src/lib/search-snippet.ts +34 -0
  120. package/src/lib/slug.ts +96 -0
  121. package/src/lib/sse.ts +6 -6
  122. package/src/lib/storage.ts +115 -7
  123. package/src/lib/summary.ts +158 -0
  124. package/src/lib/theme.ts +11 -8
  125. package/src/lib/timeline.ts +76 -34
  126. package/src/lib/tiptap-render.ts +191 -0
  127. package/src/lib/tiptap-to-markdown.ts +305 -0
  128. package/src/lib/upload.ts +263 -14
  129. package/src/lib/url.ts +37 -22
  130. package/src/lib/view.ts +236 -55
  131. package/src/middleware/__tests__/auth.test.ts +191 -11
  132. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  133. package/src/middleware/auth.ts +63 -9
  134. package/src/middleware/error-handler.ts +3 -3
  135. package/src/middleware/onboarding.ts +1 -1
  136. package/src/middleware/secure-headers.ts +40 -0
  137. package/src/preset.css +83 -2
  138. package/src/routes/__tests__/compose.test.ts +17 -24
  139. package/src/routes/api/__tests__/collections.test.ts +109 -61
  140. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  141. package/src/routes/api/__tests__/posts.test.ts +132 -68
  142. package/src/routes/api/__tests__/search.test.ts +15 -2
  143. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  144. package/src/routes/api/collections.ts +57 -31
  145. package/src/routes/api/custom-urls.ts +80 -0
  146. package/src/routes/api/export.ts +31 -0
  147. package/src/routes/api/nav-items.ts +23 -19
  148. package/src/routes/api/posts.ts +81 -62
  149. package/src/routes/api/search.ts +3 -4
  150. package/src/routes/api/upload-multipart.ts +245 -0
  151. package/src/routes/api/upload.ts +92 -24
  152. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  153. package/src/routes/auth/reset.tsx +5 -4
  154. package/src/routes/auth/setup.tsx +39 -31
  155. package/src/routes/auth/signin.tsx +13 -14
  156. package/src/routes/compose.tsx +27 -63
  157. package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
  158. package/src/routes/dash/custom-urls.tsx +414 -0
  159. package/src/routes/dash/settings.tsx +475 -99
  160. package/src/routes/feed/__tests__/rss.test.ts +22 -23
  161. package/src/routes/feed/rss.ts +6 -2
  162. package/src/routes/feed/sitemap.ts +2 -12
  163. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  164. package/src/routes/pages/__tests__/featured.test.ts +36 -18
  165. package/src/routes/pages/archive.tsx +177 -37
  166. package/src/routes/pages/collection.tsx +43 -14
  167. package/src/routes/pages/collections.tsx +11 -2
  168. package/src/routes/pages/featured.tsx +27 -3
  169. package/src/routes/pages/home.tsx +15 -14
  170. package/src/routes/pages/latest.tsx +1 -11
  171. package/src/routes/pages/new.tsx +39 -0
  172. package/src/routes/pages/page.tsx +95 -49
  173. package/src/routes/pages/search.tsx +1 -1
  174. package/src/services/__tests__/api-token.test.ts +135 -0
  175. package/src/services/__tests__/collection.test.ts +275 -227
  176. package/src/services/__tests__/custom-url.test.ts +213 -0
  177. package/src/services/__tests__/media.test.ts +162 -22
  178. package/src/services/__tests__/navigation.test.ts +109 -68
  179. package/src/services/__tests__/post-timeline.test.ts +205 -32
  180. package/src/services/__tests__/post.test.ts +800 -230
  181. package/src/services/__tests__/search.test.ts +67 -10
  182. package/src/services/__tests__/settings.test.ts +3 -3
  183. package/src/services/api-token.ts +166 -0
  184. package/src/services/auth.ts +17 -2
  185. package/src/services/collection.ts +397 -131
  186. package/src/services/custom-url.ts +188 -0
  187. package/src/services/export.ts +802 -0
  188. package/src/services/index.ts +26 -19
  189. package/src/services/media.ts +100 -22
  190. package/src/services/navigation.ts +158 -47
  191. package/src/services/path.ts +339 -0
  192. package/src/services/post.ts +764 -172
  193. package/src/services/search.ts +161 -74
  194. package/src/services/settings.ts +6 -2
  195. package/src/styles/components.css +293 -62
  196. package/src/styles/tokens.css +93 -5
  197. package/src/styles/ui.css +4349 -766
  198. package/src/types/bindings.ts +8 -0
  199. package/src/types/config.ts +34 -4
  200. package/src/types/constants.ts +17 -2
  201. package/src/types/entities.ts +83 -37
  202. package/src/types/operations.ts +20 -27
  203. package/src/types/props.ts +52 -17
  204. package/src/types/views.ts +48 -24
  205. package/src/ui/color-themes.ts +133 -23
  206. package/src/ui/compose/ComposeDialog.tsx +255 -16
  207. package/src/ui/compose/ComposePrompt.tsx +1 -1
  208. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  209. package/src/ui/dash/ListItemRow.tsx +1 -1
  210. package/src/ui/dash/StatusBadge.tsx +12 -2
  211. package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
  212. package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
  213. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  214. package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
  215. package/src/ui/dash/index.ts +0 -3
  216. package/src/ui/dash/settings/AccountContent.tsx +87 -146
  217. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  218. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  219. package/src/ui/dash/settings/AvatarContent.tsx +78 -0
  220. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  221. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  222. package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
  223. package/src/ui/feed/LinkCard.tsx +89 -40
  224. package/src/ui/feed/NoteCard.tsx +39 -25
  225. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  226. package/src/ui/feed/QuoteCard.tsx +38 -23
  227. package/src/ui/feed/ThreadPreview.tsx +90 -26
  228. package/src/ui/feed/TimelineFeed.tsx +3 -2
  229. package/src/ui/feed/TimelineItem.tsx +15 -6
  230. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  231. package/src/ui/feed/thread-preview-state.ts +61 -0
  232. package/src/ui/font-themes.ts +2 -2
  233. package/src/ui/layouts/BaseLayout.tsx +2 -2
  234. package/src/ui/layouts/SiteLayout.tsx +116 -103
  235. package/src/ui/pages/ArchivePage.tsx +923 -95
  236. package/src/ui/pages/CollectionPage.tsx +6 -35
  237. package/src/ui/pages/CollectionsPage.tsx +2 -1
  238. package/src/ui/pages/ComposePage.tsx +54 -0
  239. package/src/ui/pages/FeaturedPage.tsx +2 -1
  240. package/src/ui/pages/HomePage.tsx +1 -1
  241. package/src/ui/pages/PostPage.tsx +30 -45
  242. package/src/ui/pages/SearchPage.tsx +182 -38
  243. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  244. package/src/ui/shared/CollectionsSidebar.tsx +239 -4
  245. package/src/ui/shared/MediaGallery.tsx +475 -41
  246. package/src/ui/shared/PostFooter.tsx +204 -0
  247. package/src/ui/shared/StarRating.tsx +27 -0
  248. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  249. package/src/ui/shared/index.ts +0 -1
  250. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  251. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  252. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  253. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  254. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  255. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  256. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  257. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  258. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  259. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  260. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  261. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  262. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  263. package/src/lib/__tests__/sqid.test.ts +0 -65
  264. package/src/lib/collections-reorder.ts +0 -28
  265. package/src/lib/compose-bridge.ts +0 -280
  266. package/src/lib/media-upload.ts +0 -148
  267. package/src/lib/sqid.ts +0 -79
  268. package/src/routes/api/__tests__/pages.test.ts +0 -218
  269. package/src/routes/api/pages.ts +0 -73
  270. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  271. package/src/routes/dash/appearance.tsx +0 -240
  272. package/src/routes/dash/collections.tsx +0 -211
  273. package/src/routes/dash/index.tsx +0 -103
  274. package/src/routes/dash/media.tsx +0 -132
  275. package/src/routes/dash/pages.tsx +0 -239
  276. package/src/routes/dash/posts.tsx +0 -334
  277. package/src/routes/dash/redirects.tsx +0 -257
  278. package/src/routes/pages/post.tsx +0 -59
  279. package/src/services/__tests__/page.test.ts +0 -298
  280. package/src/services/__tests__/path-registry.test.ts +0 -165
  281. package/src/services/__tests__/redirect.test.ts +0 -159
  282. package/src/services/page.ts +0 -203
  283. package/src/services/path-registry.ts +0 -160
  284. package/src/services/redirect.ts +0 -97
  285. package/src/types/sortablejs.d.ts +0 -29
  286. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
  287. package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
  288. package/src/ui/components/compose-types.ts +0 -75
  289. package/src/ui/components/jant-collection-form.ts +0 -512
  290. package/src/ui/components/jant-compose-dialog.ts +0 -495
  291. package/src/ui/components/jant-compose-editor.ts +0 -814
  292. package/src/ui/dash/PageForm.tsx +0 -185
  293. package/src/ui/dash/PostList.tsx +0 -95
  294. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  295. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  296. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  297. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  298. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  299. package/src/ui/dash/media/MediaListContent.tsx +0 -201
  300. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  301. package/src/ui/dash/pages/PagesContent.tsx +0 -74
  302. package/src/ui/dash/posts/PostForm.tsx +0 -248
  303. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  304. package/src/ui/layouts/DashLayout.tsx +0 -165
  305. package/src/ui/pages/SinglePage.tsx +0 -23
  306. package/src/ui/shared/ThreadView.tsx +0 -136
  307. /package/src/{ui → client}/components/settings-types.ts +0 -0
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Slash Commands Extension
3
+ *
4
+ * Provides a "/" command menu for block formatting.
5
+ * Built on @tiptap/suggestion for cursor tracking and filtering.
6
+ */
7
+
8
+ import { Extension } from "@tiptap/core";
9
+ import Suggestion, {
10
+ type SuggestionOptions,
11
+ type SuggestionProps,
12
+ type SuggestionKeyDownProps,
13
+ } from "@tiptap/suggestion";
14
+ import type { Editor, Range } from "@tiptap/core";
15
+
16
+ // SVG icons (18×18, stroke-based, Lucide style)
17
+ const ICONS = {
18
+ image: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>`,
19
+ divider: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h18"/></svg>`,
20
+ readMore: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h18"/><path d="m9 18 3 3 3-3"/><path d="m9 6-3-3-3 3"/><path d="M3 6h18"/></svg>`,
21
+ table: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/></svg>`,
22
+ code: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`,
23
+ blockquote: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>`,
24
+ bulletList: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg>`,
25
+ orderedList: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="10" x2="21" y1="6" y2="6"/><line x1="10" x2="21" y1="12" y2="12"/><line x1="10" x2="21" y1="18" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>`,
26
+ h1: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M17 12l3-2v10"/></svg>`,
27
+ h2: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1"/></svg>`,
28
+ h3: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 0 1-2 2"/><path d="M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2"/></svg>`,
29
+ } as const;
30
+
31
+ interface SlashCommandItem {
32
+ label: string;
33
+ icon: string;
34
+ command: (editor: Editor, range: Range) => void;
35
+ }
36
+
37
+ const SLASH_COMMANDS: SlashCommandItem[] = [
38
+ {
39
+ label: "Media",
40
+ icon: ICONS.image,
41
+ command: (editor, range) => {
42
+ editor.chain().focus().deleteRange(range).run();
43
+ document.dispatchEvent(
44
+ new CustomEvent("jant:slash-image", { bubbles: true }),
45
+ );
46
+ },
47
+ },
48
+ {
49
+ label: "Divider",
50
+ icon: ICONS.divider,
51
+ command: (editor, range) => {
52
+ editor.chain().focus().deleteRange(range).setHorizontalRule().run();
53
+ },
54
+ },
55
+ {
56
+ label: "Read More",
57
+ icon: ICONS.readMore,
58
+ command: (editor, range) => {
59
+ editor
60
+ .chain()
61
+ .focus()
62
+ .deleteRange(range)
63
+ .insertContent({ type: "moreBreak" })
64
+ .run();
65
+ },
66
+ },
67
+ {
68
+ label: "Table",
69
+ icon: ICONS.table,
70
+ command: (editor, range) => {
71
+ editor
72
+ .chain()
73
+ .focus()
74
+ .deleteRange(range)
75
+ .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
76
+ .run();
77
+ },
78
+ },
79
+ {
80
+ label: "Code Block",
81
+ icon: ICONS.code,
82
+ command: (editor, range) => {
83
+ editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
84
+ },
85
+ },
86
+ {
87
+ label: "Blockquote",
88
+ icon: ICONS.blockquote,
89
+ command: (editor, range) => {
90
+ editor.chain().focus().deleteRange(range).toggleBlockquote().run();
91
+ },
92
+ },
93
+ {
94
+ label: "Bullet List",
95
+ icon: ICONS.bulletList,
96
+ command: (editor, range) => {
97
+ editor.chain().focus().deleteRange(range).toggleBulletList().run();
98
+ },
99
+ },
100
+ {
101
+ label: "Ordered List",
102
+ icon: ICONS.orderedList,
103
+ command: (editor, range) => {
104
+ editor.chain().focus().deleteRange(range).toggleOrderedList().run();
105
+ },
106
+ },
107
+ {
108
+ label: "Heading 1",
109
+ icon: ICONS.h1,
110
+ command: (editor, range) => {
111
+ editor
112
+ .chain()
113
+ .focus()
114
+ .deleteRange(range)
115
+ .toggleHeading({ level: 1 })
116
+ .run();
117
+ },
118
+ },
119
+ {
120
+ label: "Heading 2",
121
+ icon: ICONS.h2,
122
+ command: (editor, range) => {
123
+ editor
124
+ .chain()
125
+ .focus()
126
+ .deleteRange(range)
127
+ .toggleHeading({ level: 2 })
128
+ .run();
129
+ },
130
+ },
131
+ {
132
+ label: "Heading 3",
133
+ icon: ICONS.h3,
134
+ command: (editor, range) => {
135
+ editor
136
+ .chain()
137
+ .focus()
138
+ .deleteRange(range)
139
+ .toggleHeading({ level: 3 })
140
+ .run();
141
+ },
142
+ },
143
+ ];
144
+
145
+ /** Check whether a document already contains a moreBreak node */
146
+ function hasMoreBreak(editor: Editor): boolean {
147
+ let found = false;
148
+ editor.state.doc.descendants((node) => {
149
+ if (node.type.name === "moreBreak") {
150
+ found = true;
151
+ return false; // stop traversal
152
+ }
153
+ return !found;
154
+ });
155
+ return found;
156
+ }
157
+
158
+ /**
159
+ * Returns the slash commands list, used by both the extension and the + menu.
160
+ * Omits "Read More" when the document already contains one.
161
+ */
162
+ export function getSlashCommands(editor?: Editor): SlashCommandItem[] {
163
+ if (editor && hasMoreBreak(editor)) {
164
+ return SLASH_COMMANDS.filter((item) => item.label !== "Read More");
165
+ }
166
+ return SLASH_COMMANDS;
167
+ }
168
+
169
+ // Popup element management
170
+ let popupEl: HTMLElement | null = null;
171
+ let selectedIndex = 0;
172
+ let filteredItems: SlashCommandItem[] = [];
173
+ let commandFn: ((item: { index: number }) => void) | null = null;
174
+ let editorRef: Editor | null = null;
175
+ let currentRange: Range | null = null;
176
+ let outsideClickHandler: ((e: MouseEvent) => void) | null = null;
177
+
178
+ function createPopup(): HTMLElement {
179
+ const el = document.createElement("div");
180
+ el.className = "tiptap-slash-menu";
181
+ el.style.position = "fixed";
182
+ return el;
183
+ }
184
+
185
+ /** Scroll the selected item into view within the popup only (no ancestor scroll) */
186
+ function scrollSelectedIntoView() {
187
+ if (!popupEl) return;
188
+ const selected = popupEl.querySelector(
189
+ ".tiptap-slash-item.is-selected",
190
+ ) as HTMLElement | null;
191
+ if (!selected) return;
192
+ const itemTop = selected.offsetTop;
193
+ const itemBottom = itemTop + selected.offsetHeight;
194
+ const scrollTop = popupEl.scrollTop;
195
+ const viewBottom = scrollTop + popupEl.clientHeight;
196
+ if (itemTop < scrollTop) {
197
+ popupEl.scrollTop = itemTop;
198
+ } else if (itemBottom > viewBottom) {
199
+ popupEl.scrollTop = itemBottom - popupEl.clientHeight;
200
+ }
201
+ }
202
+
203
+ /** Update selection highlight and scroll into view */
204
+ function updateSelection() {
205
+ popupEl
206
+ ?.querySelectorAll(".tiptap-slash-item")
207
+ .forEach((item, i) =>
208
+ item.classList.toggle("is-selected", i === selectedIndex),
209
+ );
210
+ scrollSelectedIntoView();
211
+ }
212
+
213
+ function renderPopup(
214
+ items: SlashCommandItem[],
215
+ onSelect: (index: number) => void,
216
+ ) {
217
+ if (!popupEl) return;
218
+
219
+ filteredItems = items;
220
+ if (selectedIndex >= items.length) selectedIndex = 0;
221
+
222
+ popupEl.innerHTML = items
223
+ .map(
224
+ (item, i) =>
225
+ `<div class="tiptap-slash-item${i === selectedIndex ? " is-selected" : ""}" data-index="${i}">
226
+ <span class="tiptap-slash-item-icon">${item.icon}</span>
227
+ <span class="tiptap-slash-item-label">${item.label}</span>
228
+ </div>`,
229
+ )
230
+ .join("");
231
+
232
+ // Click handlers
233
+ popupEl.querySelectorAll<HTMLElement>(".tiptap-slash-item").forEach((el) => {
234
+ el.addEventListener("mousedown", (e) => {
235
+ e.preventDefault();
236
+ const idx = parseInt(el.dataset.index ?? "0", 10);
237
+ onSelect(idx);
238
+ });
239
+ el.addEventListener("mouseenter", () => {
240
+ selectedIndex = parseInt(el.dataset.index ?? "0", 10);
241
+ updateSelection();
242
+ });
243
+ });
244
+ }
245
+
246
+ function destroyPopup() {
247
+ if (outsideClickHandler) {
248
+ document.removeEventListener("mousedown", outsideClickHandler, true);
249
+ outsideClickHandler = null;
250
+ }
251
+ popupEl?.remove();
252
+ popupEl = null;
253
+ selectedIndex = 0;
254
+ filteredItems = [];
255
+ commandFn = null;
256
+ editorRef = null;
257
+ currentRange = null;
258
+ }
259
+
260
+ /**
261
+ * Position the popup relative to the cursor, accounting for dialog containing block.
262
+ * When a `<dialog>` has CSS animation, it creates a containing block that makes
263
+ * `position: fixed` relative to the dialog instead of the viewport.
264
+ * Flips above the cursor when there isn't enough space below.
265
+ */
266
+ function positionPopup(
267
+ rect: globalThis.DOMRect,
268
+ container: HTMLElement | null,
269
+ ) {
270
+ if (!popupEl) return;
271
+
272
+ // Reset inline max-height so offsetHeight reflects the natural size
273
+ popupEl.style.maxHeight = "";
274
+
275
+ const offsetX = container?.getBoundingClientRect().left ?? 0;
276
+ const offsetY = container?.getBoundingClientRect().top ?? 0;
277
+ const containerHeight = container?.clientHeight ?? window.innerHeight;
278
+ const popupHeight = popupEl.offsetHeight;
279
+ const gap = 4;
280
+
281
+ const left = rect.left - offsetX;
282
+ const belowTop = rect.bottom + gap - offsetY;
283
+ const spaceBelow = containerHeight - belowTop;
284
+ const spaceAbove = rect.top - offsetY - gap;
285
+
286
+ popupEl.style.left = `${left}px`;
287
+
288
+ if (popupHeight > spaceBelow && spaceAbove > spaceBelow) {
289
+ // Not enough space below and more room above — flip
290
+ const maxH = Math.min(popupHeight, spaceAbove);
291
+ if (popupHeight > spaceAbove) {
292
+ popupEl.style.maxHeight = `${spaceAbove}px`;
293
+ }
294
+ popupEl.style.top = `${rect.top - offsetY - maxH - gap}px`;
295
+ } else {
296
+ // Show below (constrain if needed)
297
+ if (popupHeight > spaceBelow) {
298
+ popupEl.style.maxHeight = `${spaceBelow}px`;
299
+ }
300
+ popupEl.style.top = `${belowTop}px`;
301
+ }
302
+ }
303
+
304
+ /** Install a click-outside handler to dismiss the suggestion on external clicks */
305
+ function installClickOutside() {
306
+ outsideClickHandler = (e: MouseEvent) => {
307
+ if (!popupEl || popupEl.contains(e.target as Node)) return;
308
+ // Click anywhere outside the popup (including inside the editor) — dismiss
309
+ // by deleting the trigger text so the suggestion plugin deactivates via onExit
310
+ if (editorRef && currentRange) {
311
+ const { state, view } = editorRef;
312
+ view.dispatch(state.tr.delete(currentRange.from, currentRange.to));
313
+ }
314
+ };
315
+ document.addEventListener("mousedown", outsideClickHandler, true);
316
+ }
317
+
318
+ /**
319
+ * Slash commands Tiptap extension.
320
+ */
321
+ export const SlashCommands = Extension.create({
322
+ name: "slashCommands",
323
+
324
+ addOptions() {
325
+ return {
326
+ suggestion: {
327
+ char: "/",
328
+ startOfLine: false,
329
+ items: ({ query, editor }: { query: string; editor: Editor }) => {
330
+ const q = query.toLowerCase();
331
+ return getSlashCommands(editor).filter((item) =>
332
+ item.label.toLowerCase().includes(q),
333
+ );
334
+ },
335
+ render: () => {
336
+ function getEditorElement(editor: Editor): globalThis.Element | null {
337
+ const el = editor.options.element;
338
+ return el instanceof globalThis.Element ? el : null;
339
+ }
340
+
341
+ return {
342
+ onStart: (
343
+ props: SuggestionProps<SlashCommandItem, { index: number }>,
344
+ ) => {
345
+ popupEl = createPopup();
346
+ selectedIndex = 0;
347
+ commandFn = props.command;
348
+ editorRef = props.editor;
349
+ currentRange = props.range;
350
+ renderPopup(props.items, (index) => props.command({ index }));
351
+
352
+ // Append inside the closest dialog (top-layer) or body
353
+ const editorEl = getEditorElement(props.editor);
354
+ const dialog = editorEl?.closest("dialog") ?? null;
355
+ (dialog ?? document.body).appendChild(popupEl);
356
+
357
+ const rect = props.clientRect?.();
358
+ if (rect) {
359
+ positionPopup(rect, dialog);
360
+ }
361
+
362
+ installClickOutside();
363
+ },
364
+ onUpdate: (
365
+ props: SuggestionProps<SlashCommandItem, { index: number }>,
366
+ ) => {
367
+ commandFn = props.command;
368
+ currentRange = props.range;
369
+ renderPopup(props.items, (index) => props.command({ index }));
370
+ const rect = props.clientRect?.();
371
+ if (rect) {
372
+ const editorEl = getEditorElement(props.editor);
373
+ const dialog = editorEl?.closest("dialog") ?? null;
374
+ positionPopup(rect, dialog);
375
+ }
376
+ },
377
+ onKeyDown: (props: SuggestionKeyDownProps) => {
378
+ const { event } = props;
379
+ if (event.key === "ArrowDown") {
380
+ event.preventDefault();
381
+ selectedIndex = (selectedIndex + 1) % filteredItems.length;
382
+ updateSelection();
383
+ return true;
384
+ }
385
+ if (event.key === "ArrowUp") {
386
+ event.preventDefault();
387
+ selectedIndex =
388
+ (selectedIndex - 1 + filteredItems.length) %
389
+ filteredItems.length;
390
+ updateSelection();
391
+ return true;
392
+ }
393
+ if (event.key === "Enter") {
394
+ event.preventDefault();
395
+ commandFn?.({ index: selectedIndex });
396
+ return true;
397
+ }
398
+ if (event.key === "Escape") {
399
+ // Stop propagation to prevent parent dialog from closing
400
+ event.stopPropagation();
401
+ event.preventDefault();
402
+ destroyPopup();
403
+ return true;
404
+ }
405
+ return false;
406
+ },
407
+ onExit: () => {
408
+ destroyPopup();
409
+ },
410
+ };
411
+ },
412
+ command: ({
413
+ editor,
414
+ range,
415
+ props,
416
+ }: {
417
+ editor: Editor;
418
+ range: Range;
419
+ props: { index: number };
420
+ }) => {
421
+ const item = filteredItems[props.index];
422
+ if (item) {
423
+ item.command(editor, range);
424
+ }
425
+ },
426
+ } satisfies Partial<SuggestionOptions>,
427
+ };
428
+ },
429
+
430
+ addProseMirrorPlugins() {
431
+ return [
432
+ Suggestion({
433
+ editor: this.editor,
434
+ ...this.options.suggestion,
435
+ }),
436
+ ];
437
+ },
438
+ });
@@ -5,6 +5,13 @@
5
5
  * Appends a temporary notification to `#toast-container`.
6
6
  */
7
7
 
8
+ /** Ensure the toast container is in the top layer (above <dialog> etc.) */
9
+ function ensureTopLayer(container: HTMLElement): void {
10
+ if (!container.matches(":popover-open")) {
11
+ container.showPopover();
12
+ }
13
+ }
14
+
8
15
  const TOAST_ICONS = {
9
16
  success:
10
17
  '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/></svg>',
@@ -12,6 +19,26 @@ const TOAST_ICONS = {
12
19
  '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6M9 9l6 6"/></svg>',
13
20
  };
14
21
 
22
+ /** Build toast inner content using safe DOM APIs (icon is trusted, text uses textContent). */
23
+ function setToastContent(
24
+ toast: HTMLElement,
25
+ type: "success" | "error",
26
+ message: string,
27
+ action?: { label: string; href: string },
28
+ ): void {
29
+ toast.innerHTML = TOAST_ICONS[type];
30
+ const span = document.createElement("span");
31
+ span.textContent = message;
32
+ toast.appendChild(span);
33
+ if (action) {
34
+ const a = document.createElement("a");
35
+ a.href = action.href;
36
+ a.className = "toast-action";
37
+ a.textContent = action.label;
38
+ toast.appendChild(a);
39
+ }
40
+ }
41
+
15
42
  /**
16
43
  * Show a toast notification.
17
44
  *
@@ -31,9 +58,44 @@ export function showToast(
31
58
  const container = document.getElementById("toast-container");
32
59
  if (!container) return;
33
60
 
61
+ ensureTopLayer(container);
62
+
34
63
  const toast = document.createElement("div");
35
64
  toast.className = `toast toast-${type}`;
36
- toast.innerHTML = `${TOAST_ICONS[type]}<span>${message}</span>`;
65
+ setToastContent(toast, type, message);
66
+ container.appendChild(toast);
67
+
68
+ setTimeout(() => {
69
+ toast.classList.add("toast-out");
70
+ toast.addEventListener("animationend", () => toast.remove());
71
+ }, 3000);
72
+ }
73
+
74
+ /**
75
+ * Show a toast with an action link.
76
+ *
77
+ * @param message - Text to display
78
+ * @param action - Action link with label and href
79
+ * @param type - Visual style: "success" (default) or "error"
80
+ *
81
+ * @example
82
+ * showToastWithAction("Post published.", { label: "View", href: "/p/abc" });
83
+ */
84
+ export function showToastWithAction(
85
+ message: string,
86
+ action: { label: string; href: string },
87
+ type: "success" | "error" = "success",
88
+ ): void {
89
+ if (!message) return;
90
+
91
+ const container = document.getElementById("toast-container");
92
+ if (!container) return;
93
+
94
+ ensureTopLayer(container);
95
+
96
+ const toast = document.createElement("div");
97
+ toast.className = `toast toast-${type}`;
98
+ setToastContent(toast, type, message, action);
37
99
  container.appendChild(toast);
38
100
 
39
101
  setTimeout(() => {
@@ -61,10 +123,12 @@ export function showPersistentToast(
61
123
  const container = document.getElementById("toast-container");
62
124
  if (!container) return null;
63
125
 
126
+ ensureTopLayer(container);
127
+
64
128
  const toast = document.createElement("div");
65
129
  toast.className = `toast toast-${type}`;
66
130
  toast.id = `toast-${id}`;
67
- toast.innerHTML = `${TOAST_ICONS[type]}<span>${message}</span>`;
131
+ setToastContent(toast, type, message);
68
132
  container.appendChild(toast);
69
133
 
70
134
  return toast;
@@ -125,7 +189,41 @@ export function replaceWithAutoClose(
125
189
  }
126
190
 
127
191
  toast.className = `toast toast-${type}`;
128
- toast.innerHTML = `${TOAST_ICONS[type]}<span>${message}</span>`;
192
+ toast.replaceChildren();
193
+ setToastContent(toast, type, message);
194
+
195
+ setTimeout(() => {
196
+ toast.classList.add("toast-out");
197
+ toast.addEventListener("animationend", () => toast.remove());
198
+ }, 3000);
199
+ }
200
+
201
+ /**
202
+ * Replace a persistent toast with an auto-dismissing one that has an action link.
203
+ *
204
+ * @param id - The toast identifier
205
+ * @param message - New message text
206
+ * @param action - Action link with label and href
207
+ * @param type - Visual style: "success" (default) or "error"
208
+ *
209
+ * @example
210
+ * replaceWithAutoCloseAction("upload", "Post published.", { label: "View", href: "/p/abc" });
211
+ */
212
+ export function replaceWithAutoCloseAction(
213
+ id: string,
214
+ message: string,
215
+ action: { label: string; href: string },
216
+ type: "success" | "error" = "success",
217
+ ): void {
218
+ const toast = document.getElementById(`toast-${id}`);
219
+ if (!toast) {
220
+ showToastWithAction(message, action, type);
221
+ return;
222
+ }
223
+
224
+ toast.className = `toast toast-${type}`;
225
+ toast.replaceChildren();
226
+ setToastContent(toast, type, message, action);
129
227
 
130
228
  setTimeout(() => {
131
229
  toast.classList.add("toast-out");
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Minimal type declarations for sortablejs
3
+ *
4
+ * Only covers the API surface used by jant-nav-manager and jant-collection-sidebar.
5
+ */
6
+
7
+ declare module "sortablejs" {
8
+ interface SortableEvent {
9
+ oldIndex?: number;
10
+ newIndex?: number;
11
+ item: HTMLElement;
12
+ }
13
+
14
+ interface SortableOptions {
15
+ animation?: number;
16
+ bubbleScroll?: boolean;
17
+ chosenClass?: string;
18
+ direction?: "horizontal" | "vertical";
19
+ dragClass?: string;
20
+ fallbackTolerance?: number;
21
+ filter?: string;
22
+ forceAutoScrollFallback?: boolean;
23
+ ghostClass?: string;
24
+ handle?: string;
25
+ onChoose?: (event: SortableEvent) => void;
26
+ onStart?: (event: SortableEvent) => void;
27
+ onUnchoose?: (event: SortableEvent) => void;
28
+ onEnd?: (event: SortableEvent) => void;
29
+ preventOnFilter?: boolean;
30
+ scroll?: boolean | HTMLElement;
31
+ scrollSensitivity?: number;
32
+ scrollSpeed?: number;
33
+ }
34
+
35
+ interface SortableInstance {
36
+ destroy(): void;
37
+ }
38
+
39
+ const Sortable: {
40
+ create(el: HTMLElement, options?: SortableOptions): SortableInstance;
41
+ };
42
+
43
+ export default Sortable;
44
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Shared Upload Helper with Metadata
3
+ *
4
+ * Processes images via ImageProcessor, extracts dimensions + blurhash,
5
+ * and uploads with metadata attached to the FormData.
6
+ * Used by paste-image, image-node replace, and fullscreen compose.
7
+ */
8
+
9
+ import { ImageProcessor } from "./image-processor.js";
10
+ import { extractImageMetadata } from "./media-metadata.js";
11
+
12
+ /**
13
+ * Process an image file and upload it with dimension/blurhash metadata.
14
+ *
15
+ * @returns The server response with url and id
16
+ */
17
+ export async function uploadWithMetadata(
18
+ file: File,
19
+ ): Promise<{ url: string; id: string }> {
20
+ // Process image (resize, convert to WebP)
21
+ const {
22
+ file: processed,
23
+ width,
24
+ height,
25
+ } = await ImageProcessor.processToFile(file);
26
+
27
+ // Extract blurhash from the processed file
28
+ let blurhash: string | undefined;
29
+ try {
30
+ const meta = await extractImageMetadata(processed);
31
+ blurhash = meta.blurhash;
32
+ } catch {
33
+ // Blurhash extraction failed — upload without it
34
+ }
35
+
36
+ const formData = new FormData();
37
+ formData.append("file", processed);
38
+ formData.append("width", String(width));
39
+ formData.append("height", String(height));
40
+ if (blurhash) {
41
+ formData.append("blurhash", blurhash);
42
+ }
43
+
44
+ const response = await fetch("/api/upload", {
45
+ method: "POST",
46
+ body: formData,
47
+ });
48
+
49
+ if (!response.ok) {
50
+ throw new Error(`Upload failed: ${response.status}`);
51
+ }
52
+
53
+ return (await response.json()) as { url: string; id: string };
54
+ }