@jant/core 0.3.35 → 0.3.36

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 (156) hide show
  1. package/dist/client/assets/module-RjUF93sV.js +716 -0
  2. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  3. package/dist/client/assets/url-8Dj-5CLW.js +1 -0
  4. package/dist/client/client.css +1 -1
  5. package/dist/client/client.js +3109 -2294
  6. package/dist/index.js +3026 -2778
  7. package/package.json +13 -4
  8. package/src/__tests__/helpers/app.ts +1 -1
  9. package/src/__tests__/helpers/db.ts +6 -0
  10. package/src/app.tsx +1 -5
  11. package/src/{lib → client}/avatar-upload.ts +1 -1
  12. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  13. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  14. package/src/{ui → client}/components/__tests__/jant-compose-dialog.test.ts +46 -14
  15. package/src/{ui → client}/components/__tests__/jant-compose-editor.test.ts +64 -24
  16. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +24 -14
  17. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  18. package/src/client/components/collection-sidebar-types.ts +45 -0
  19. package/src/{ui → client}/components/collection-types.ts +3 -4
  20. package/src/{ui → client}/components/compose-types.ts +3 -1
  21. package/src/{ui → client}/components/jant-collection-form.ts +301 -182
  22. package/src/client/components/jant-collection-sidebar.ts +801 -0
  23. package/src/{ui → client}/components/jant-compose-dialog.ts +231 -1
  24. package/src/client/components/jant-compose-editor.ts +1249 -0
  25. package/src/client/components/jant-compose-fullscreen.ts +338 -0
  26. package/src/client/components/jant-media-lightbox.ts +257 -0
  27. package/src/{ui → client}/components/jant-nav-manager.ts +143 -84
  28. package/src/{ui → client}/components/jant-post-form.ts +57 -8
  29. package/src/{ui → client}/components/jant-settings-general.ts +2 -2
  30. package/src/{ui → client}/components/nav-manager-types.ts +3 -0
  31. package/src/{ui → client}/components/post-form-template.ts +35 -31
  32. package/src/{ui → client}/components/post-form-types.ts +7 -3
  33. package/src/{lib → client}/compose-bridge.ts +9 -7
  34. package/src/client/lazy-slugify.ts +51 -0
  35. package/src/{lib → client}/media-upload.ts +16 -3
  36. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  37. package/src/client/page-slug-bridge.ts +42 -0
  38. package/src/{lib → client}/post-form-bridge.ts +2 -2
  39. package/src/{lib → client}/settings-bridge.ts +3 -3
  40. package/src/client/tiptap/bubble-menu.ts +205 -0
  41. package/src/client/tiptap/create-editor.ts +40 -0
  42. package/src/client/tiptap/exitable-marks.ts +73 -0
  43. package/src/client/tiptap/extensions.ts +60 -0
  44. package/src/client/tiptap/image-node.ts +488 -0
  45. package/src/client/tiptap/link-toolbar.ts +371 -0
  46. package/src/client/tiptap/more-break.ts +50 -0
  47. package/src/client/tiptap/paste-image.ts +140 -0
  48. package/src/client/tiptap/slash-commands.ts +328 -0
  49. package/src/{types → client/types}/sortablejs.d.ts +1 -1
  50. package/src/client.ts +24 -17
  51. package/src/db/migrations/0012_add_tiptap_columns.sql +2 -0
  52. package/src/db/migrations/0013_replace_featured_with_visibility.sql +8 -0
  53. package/src/db/schema.ts +6 -1
  54. package/src/i18n/locales/en.po +641 -215
  55. package/src/i18n/locales/en.ts +1 -1
  56. package/src/i18n/locales/zh-Hans.po +642 -204
  57. package/src/i18n/locales/zh-Hans.ts +1 -1
  58. package/src/i18n/locales/zh-Hant.po +642 -204
  59. package/src/i18n/locales/zh-Hant.ts +1 -1
  60. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  61. package/src/lib/__tests__/schemas.test.ts +9 -6
  62. package/src/lib/__tests__/url.test.ts +2 -2
  63. package/src/lib/__tests__/view.test.ts +9 -9
  64. package/src/lib/emoji-catalog.ts +146 -0
  65. package/src/lib/feed.ts +1 -1
  66. package/src/lib/media-helpers.ts +10 -9
  67. package/src/lib/render.tsx +4 -3
  68. package/src/lib/resolve-config.ts +8 -1
  69. package/src/lib/schemas.ts +2 -3
  70. package/src/lib/summary.ts +92 -0
  71. package/src/lib/timeline.ts +2 -0
  72. package/src/lib/tiptap-render.ts +196 -0
  73. package/src/lib/upload.ts +97 -9
  74. package/src/lib/url.ts +7 -23
  75. package/src/lib/view.ts +33 -19
  76. package/src/middleware/error-handler.ts +3 -3
  77. package/src/preset.css +38 -0
  78. package/src/routes/api/collections.ts +20 -3
  79. package/src/routes/api/posts.ts +48 -33
  80. package/src/routes/api/upload.ts +7 -5
  81. package/src/routes/auth/reset.tsx +5 -4
  82. package/src/routes/auth/setup.tsx +26 -11
  83. package/src/routes/auth/signin.tsx +10 -7
  84. package/src/routes/compose.tsx +20 -11
  85. package/src/routes/dash/__tests__/settings-avatar.test.ts +43 -8
  86. package/src/routes/dash/index.tsx +7 -1
  87. package/src/routes/dash/media.tsx +3 -0
  88. package/src/routes/dash/pages.tsx +8 -2
  89. package/src/routes/dash/posts.tsx +6 -2
  90. package/src/routes/dash/redirects.tsx +15 -9
  91. package/src/routes/dash/settings.tsx +336 -32
  92. package/src/routes/feed/__tests__/rss.test.ts +7 -7
  93. package/src/routes/feed/rss.ts +8 -6
  94. package/src/routes/pages/__tests__/featured.test.ts +6 -7
  95. package/src/routes/pages/archive.tsx +11 -7
  96. package/src/routes/pages/collection.tsx +32 -15
  97. package/src/routes/pages/collections.tsx +11 -2
  98. package/src/routes/pages/featured.tsx +1 -1
  99. package/src/routes/pages/home.tsx +1 -1
  100. package/src/services/__tests__/post.test.ts +124 -33
  101. package/src/services/__tests__/settings.test.ts +3 -3
  102. package/src/services/page.ts +16 -3
  103. package/src/services/post.ts +96 -37
  104. package/src/services/search.ts +4 -2
  105. package/src/services/settings.ts +6 -2
  106. package/src/styles/components.css +240 -60
  107. package/src/styles/tokens.css +10 -0
  108. package/src/styles/ui.css +1157 -81
  109. package/src/types/bindings.ts +5 -0
  110. package/src/types/config.ts +23 -1
  111. package/src/types/constants.ts +3 -0
  112. package/src/types/entities.ts +9 -2
  113. package/src/types/operations.ts +9 -3
  114. package/src/types/props.ts +3 -3
  115. package/src/types/views.ts +3 -2
  116. package/src/ui/compose/ComposeDialog.tsx +24 -7
  117. package/src/ui/dash/PageForm.tsx +2 -0
  118. package/src/ui/dash/PostList.tsx +5 -5
  119. package/src/ui/dash/StatusBadge.tsx +13 -5
  120. package/src/ui/dash/appearance/AdvancedContent.tsx +52 -61
  121. package/src/ui/dash/appearance/ColorThemeContent.tsx +30 -35
  122. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  123. package/src/ui/dash/appearance/NavigationContent.tsx +107 -96
  124. package/src/ui/dash/media/MediaListContent.tsx +9 -4
  125. package/src/ui/dash/media/ViewMediaContent.tsx +2 -2
  126. package/src/ui/dash/pages/PagesContent.tsx +2 -1
  127. package/src/ui/dash/posts/PostForm.tsx +19 -7
  128. package/src/ui/dash/settings/AccountContent.tsx +133 -138
  129. package/src/ui/dash/settings/AvatarContent.tsx +70 -0
  130. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  131. package/src/ui/dash/settings/SettingsRootContent.tsx +236 -0
  132. package/src/ui/layouts/DashLayout.tsx +157 -75
  133. package/src/ui/layouts/SiteLayout.tsx +13 -13
  134. package/src/ui/pages/ArchivePage.tsx +10 -7
  135. package/src/ui/pages/CollectionPage.tsx +6 -35
  136. package/src/ui/pages/CollectionsPage.tsx +2 -1
  137. package/src/ui/pages/FeaturedPage.tsx +2 -1
  138. package/src/ui/pages/HomePage.tsx +1 -1
  139. package/src/ui/pages/SearchPage.tsx +1 -1
  140. package/src/ui/shared/CollectionsSidebar.tsx +228 -3
  141. package/src/ui/shared/MediaGallery.tsx +179 -41
  142. package/src/lib/collections-reorder.ts +0 -28
  143. package/src/routes/dash/appearance.tsx +0 -240
  144. package/src/routes/dash/collections.tsx +0 -211
  145. package/src/ui/components/jant-compose-editor.ts +0 -814
  146. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  147. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  148. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  149. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  150. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  151. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  152. /package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +0 -0
  153. /package/src/{ui → client}/components/jant-settings-avatar.ts +0 -0
  154. /package/src/{ui → client}/components/settings-types.ts +0 -0
  155. /package/src/{lib → client}/image-processor.ts +0 -0
  156. /package/src/{lib → client}/toast.ts +0 -0
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Compose Fullscreen (Zen Mode)
3
+ *
4
+ * Full-screen overlay editor with its own Tiptap instance.
5
+ * Opens from compose editor via jant:fullscreen-open event,
6
+ * returns content via jant:fullscreen-close event.
7
+ *
8
+ * Light DOM only — BaseCoat and Tailwind classes apply directly.
9
+ */
10
+
11
+ import { LitElement, html, nothing } from "lit";
12
+ import type { Editor, JSONContent } from "@tiptap/core";
13
+ import type { ComposeLabels } from "./compose-types.js";
14
+ import { createTiptapEditor } from "../tiptap/create-editor.js";
15
+ import { getSlashCommands } from "../tiptap/slash-commands.js";
16
+
17
+ export class JantComposeFullscreen extends LitElement {
18
+ static properties = {
19
+ labels: { type: Object },
20
+ _open: { state: true },
21
+ _title: { state: true },
22
+ _showTitle: { state: true },
23
+ _actionsOpen: { state: true },
24
+ };
25
+
26
+ declare labels: ComposeLabels;
27
+ declare _open: boolean;
28
+ declare _title: string;
29
+ declare _showTitle: boolean;
30
+ declare _actionsOpen: boolean;
31
+
32
+ private _editor: Editor | null = null;
33
+ private _content: JSONContent | null = null;
34
+ private _fileInput: HTMLInputElement | null = null;
35
+
36
+ createRenderRoot() {
37
+ return this;
38
+ }
39
+
40
+ constructor() {
41
+ super();
42
+ this.labels = {} as ComposeLabels;
43
+ this._open = false;
44
+ this._title = "";
45
+ this._showTitle = false;
46
+ this._actionsOpen = false;
47
+ }
48
+
49
+ connectedCallback() {
50
+ super.connectedCallback();
51
+ document.addEventListener(
52
+ "jant:fullscreen-open",
53
+ this._onOpen as EventListener,
54
+ );
55
+ document.addEventListener("jant:slash-image", this._onSlashImage);
56
+ }
57
+
58
+ disconnectedCallback() {
59
+ super.disconnectedCallback();
60
+ document.removeEventListener(
61
+ "jant:fullscreen-open",
62
+ this._onOpen as EventListener,
63
+ );
64
+ document.removeEventListener("jant:slash-image", this._onSlashImage);
65
+ this._fileInput?.remove();
66
+ this._destroyEditor();
67
+ }
68
+
69
+ private _onSlashImage = () => {
70
+ if (!this._open || !this._editor) return;
71
+ this._triggerImagePicker();
72
+ };
73
+
74
+ private _triggerImagePicker() {
75
+ if (!this._fileInput) {
76
+ this._fileInput = document.createElement("input");
77
+ this._fileInput.type = "file";
78
+ this._fileInput.accept = "image/*";
79
+ this._fileInput.style.display = "none";
80
+ this._fileInput.addEventListener("change", () => {
81
+ const file = this._fileInput?.files?.[0];
82
+ if (file && this._editor) {
83
+ this._uploadAndInsertImage(file);
84
+ }
85
+ if (this._fileInput) this._fileInput.value = "";
86
+ });
87
+ document.body.appendChild(this._fileInput);
88
+ }
89
+ this._fileInput.click();
90
+ }
91
+
92
+ private async _uploadAndInsertImage(file: File) {
93
+ if (!this._editor) return;
94
+
95
+ const placeholderUrl = URL.createObjectURL(file);
96
+ this._editor.chain().focus().setImage({ src: placeholderUrl }).run();
97
+
98
+ try {
99
+ const formData = new FormData();
100
+ formData.append("file", file);
101
+ const response = await fetch("/api/upload", {
102
+ method: "POST",
103
+ body: formData,
104
+ });
105
+ if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
106
+ const data = (await response.json()) as { url: string };
107
+
108
+ // Replace placeholder with real URL
109
+ const { doc } = this._editor.state;
110
+ let replaced = false;
111
+ doc.descendants((node, pos) => {
112
+ if (
113
+ replaced ||
114
+ node.type.name !== "image" ||
115
+ node.attrs.src !== placeholderUrl
116
+ )
117
+ return;
118
+ this._editor
119
+ ?.chain()
120
+ .focus()
121
+ .command(({ tr }) => {
122
+ tr.setNodeMarkup(pos, undefined, { ...node.attrs, src: data.url });
123
+ return true;
124
+ })
125
+ .run();
126
+ replaced = true;
127
+ });
128
+ } catch {
129
+ // Remove placeholder on failure
130
+ const { doc } = this._editor.state;
131
+ doc.descendants((node, pos) => {
132
+ if (node.type.name === "image" && node.attrs.src === placeholderUrl) {
133
+ this._editor
134
+ ?.chain()
135
+ .command(({ tr }) => {
136
+ tr.delete(pos, pos + node.nodeSize);
137
+ return true;
138
+ })
139
+ .run();
140
+ }
141
+ });
142
+ } finally {
143
+ URL.revokeObjectURL(placeholderUrl);
144
+ }
145
+ }
146
+
147
+ private _onOpen = (
148
+ e: CustomEvent<{
149
+ json: JSONContent | null;
150
+ title: string;
151
+ showTitle: boolean;
152
+ format?: string;
153
+ labels?: ComposeLabels;
154
+ }>,
155
+ ) => {
156
+ this._content = e.detail.json;
157
+ this._title = e.detail.title;
158
+ if (e.detail.labels) {
159
+ this.labels = e.detail.labels;
160
+ }
161
+ // Always show title in fullscreen — it's the primary editing surface
162
+ this._showTitle = true;
163
+ this._open = true;
164
+ this._actionsOpen = false;
165
+ // Show as modal (top layer) and init editor after render
166
+ this.updateComplete.then(() => {
167
+ const dialog = this.querySelector<HTMLDialogElement>(
168
+ ".compose-fullscreen-dialog",
169
+ );
170
+ if (dialog && !dialog.open) {
171
+ dialog.showModal();
172
+ }
173
+ this._initEditor();
174
+ });
175
+ };
176
+
177
+ private _initEditor() {
178
+ const container = this.querySelector<HTMLElement>(
179
+ ".compose-fullscreen .compose-tiptap-body",
180
+ );
181
+ if (!container || this._editor) return;
182
+
183
+ this._editor = createTiptapEditor({
184
+ element: container,
185
+ placeholder: this.labels.bodyPlaceholder ?? "Write something…",
186
+ content: this._content,
187
+ onUpdate: (json) => {
188
+ this._content = json;
189
+ },
190
+ });
191
+ }
192
+
193
+ private _destroyEditor() {
194
+ this._editor?.destroy();
195
+ this._editor = null;
196
+ }
197
+
198
+ private _onDialogCancel = (e: Event) => {
199
+ // Intercept Escape key to save content back instead of just closing
200
+ e.preventDefault();
201
+ this._close();
202
+ };
203
+
204
+ private _close() {
205
+ const json = this._editor?.getJSON() ?? this._content;
206
+ this._destroyEditor();
207
+
208
+ // Close the modal dialog before Lit removes it from DOM
209
+ const dialog = this.querySelector<HTMLDialogElement>(
210
+ ".compose-fullscreen-dialog",
211
+ );
212
+ dialog?.close();
213
+ this._open = false;
214
+
215
+ // Dispatch on document so the compose dialog (a separate subtree) receives it
216
+ document.dispatchEvent(
217
+ new CustomEvent("jant:fullscreen-close", {
218
+ bubbles: true,
219
+ detail: { json, title: this._title },
220
+ }),
221
+ );
222
+ }
223
+
224
+ private _toggleActions() {
225
+ this._actionsOpen = !this._actionsOpen;
226
+ }
227
+
228
+ private _executeCommand(index: number) {
229
+ const commands = getSlashCommands();
230
+ const item = commands[index];
231
+ if (!item || !this._editor) {
232
+ this._actionsOpen = false;
233
+ return;
234
+ }
235
+
236
+ // Image command: trigger file picker directly
237
+ if (item.label === "Image") {
238
+ this._actionsOpen = false;
239
+ this._triggerImagePicker();
240
+ return;
241
+ }
242
+
243
+ const { from, to } = this._editor.state.selection;
244
+ item.command(this._editor, { from, to });
245
+ this._actionsOpen = false;
246
+ }
247
+
248
+ private _renderActionsMenu() {
249
+ if (!this._actionsOpen) return nothing;
250
+ const commands = getSlashCommands();
251
+ return html`
252
+ <div class="tiptap-slash-menu compose-fullscreen-plus-dropdown">
253
+ ${commands.map(
254
+ (item, i) => html`
255
+ <div
256
+ class="tiptap-slash-item"
257
+ @mousedown=${(e: Event) => {
258
+ e.preventDefault();
259
+ this._executeCommand(i);
260
+ }}
261
+ >
262
+ <span class="tiptap-slash-item-icon">${item.icon}</span>
263
+ <span class="tiptap-slash-item-label">${item.label}</span>
264
+ </div>
265
+ `,
266
+ )}
267
+ </div>
268
+ `;
269
+ }
270
+
271
+ render() {
272
+ if (!this._open) return nothing;
273
+
274
+ return html`
275
+ <dialog class="compose-fullscreen-dialog" @cancel=${this._onDialogCancel}>
276
+ <div class="compose-fullscreen">
277
+ <div class="compose-fullscreen-toolbar">
278
+ <div class="compose-fullscreen-plus-menu">
279
+ <button
280
+ type="button"
281
+ class="compose-tool-btn"
282
+ @click=${() => this._toggleActions()}
283
+ >
284
+ <svg
285
+ width="18"
286
+ height="18"
287
+ viewBox="0 0 18 18"
288
+ fill="none"
289
+ stroke="currentColor"
290
+ stroke-width="2"
291
+ stroke-linecap="round"
292
+ >
293
+ <line x1="9" y1="3" x2="9" y2="15" />
294
+ <line x1="3" y1="9" x2="15" y2="9" />
295
+ </svg>
296
+ </button>
297
+ ${this._renderActionsMenu()}
298
+ </div>
299
+ <div class="flex-1"></div>
300
+ <button
301
+ type="button"
302
+ class="compose-tool-btn"
303
+ @click=${() => this._close()}
304
+ >
305
+ ${this.labels.done || "Done"}
306
+ </button>
307
+ </div>
308
+ <div class="compose-fullscreen-content">
309
+ <div class="compose-fullscreen-inner">
310
+ ${this._showTitle
311
+ ? html`
312
+ <input
313
+ type="text"
314
+ .value=${this._title}
315
+ @input=${(e: Event) => {
316
+ this._title = (e.target as HTMLInputElement).value;
317
+ }}
318
+ @keydown=${(e: globalThis.KeyboardEvent) => {
319
+ if (e.key === "Enter") {
320
+ e.preventDefault();
321
+ this._editor?.commands.focus("start");
322
+ }
323
+ }}
324
+ class="compose-fullscreen-title"
325
+ placeholder=${this.labels.titlePlaceholder ?? "Title"}
326
+ />
327
+ `
328
+ : nothing}
329
+ <div class="compose-tiptap-body"></div>
330
+ </div>
331
+ </div>
332
+ </div>
333
+ </dialog>
334
+ `;
335
+ }
336
+ }
337
+
338
+ customElements.define("jant-compose-fullscreen", JantComposeFullscreen);
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Media Lightbox
3
+ *
4
+ * Fullscreen overlay carousel for post media galleries.
5
+ * Intercepts clicks on [data-post-media] a[data-lightbox-index] via
6
+ * delegated listener, reads image data from [data-lightbox-group],
7
+ * and displays images in a native <dialog>.
8
+ *
9
+ * Light DOM only — BaseCoat and Tailwind classes apply directly.
10
+ */
11
+
12
+ import { LitElement, html, nothing } from "lit";
13
+
14
+ interface LightboxImage {
15
+ url: string;
16
+ alt: string;
17
+ width?: number;
18
+ height?: number;
19
+ mimeType?: string;
20
+ }
21
+
22
+ export class JantMediaLightbox extends LitElement {
23
+ static properties = {
24
+ _images: { state: true },
25
+ _currentIndex: { state: true },
26
+ _open: { state: true },
27
+ };
28
+
29
+ declare _images: LightboxImage[];
30
+ declare _currentIndex: number;
31
+ declare _open: boolean;
32
+
33
+ createRenderRoot() {
34
+ this.innerHTML = "";
35
+ return this;
36
+ }
37
+
38
+ constructor() {
39
+ super();
40
+ this._images = [];
41
+ this._currentIndex = 0;
42
+ this._open = false;
43
+ }
44
+
45
+ connectedCallback() {
46
+ super.connectedCallback();
47
+ document.addEventListener("click", this.#handleDocumentClick);
48
+ }
49
+
50
+ disconnectedCallback() {
51
+ super.disconnectedCallback();
52
+ document.removeEventListener("click", this.#handleDocumentClick);
53
+ }
54
+
55
+ open(images: LightboxImage[], index: number) {
56
+ this._images = images;
57
+ this._currentIndex = Math.max(0, Math.min(index, images.length - 1));
58
+ this._open = true;
59
+ this.updateComplete.then(() => {
60
+ const dialog = this.querySelector<HTMLDialogElement>(".media-lightbox");
61
+ dialog?.showModal();
62
+ // Focus the content wrapper instead of letting the browser auto-focus
63
+ // the close button, which would show a focus ring on arrow-key nav.
64
+ this.querySelector<HTMLElement>(".media-lightbox-content")?.focus();
65
+ });
66
+ }
67
+
68
+ close() {
69
+ this.querySelector<HTMLDialogElement>(".media-lightbox")?.close();
70
+ this._open = false;
71
+ }
72
+
73
+ #handleDocumentClick = (e: Event) => {
74
+ const target = e.target as HTMLElement;
75
+
76
+ // Find the closest anchor with data-lightbox-index inside [data-post-media]
77
+ // Media gallery lightbox (existing)
78
+ const anchor = target.closest<HTMLAnchorElement>(
79
+ "[data-post-media] a[data-lightbox-index]",
80
+ );
81
+ if (anchor) {
82
+ const group = anchor.closest<HTMLElement>("[data-lightbox-group]");
83
+ if (!group) return;
84
+
85
+ e.preventDefault();
86
+
87
+ const index = parseInt(anchor.dataset.lightboxIndex ?? "0", 10);
88
+ try {
89
+ const images: LightboxImage[] = JSON.parse(
90
+ group.dataset.lightboxGroup ?? "[]",
91
+ );
92
+ if (images.length > 0) {
93
+ this.open(images, index);
94
+ }
95
+ } catch {
96
+ // JSON parse failed — fall through to default link behavior
97
+ }
98
+ return;
99
+ }
100
+
101
+ // Inline body images — collect all <img> within the same [data-post-body]
102
+ const img = target.closest<HTMLImageElement>("[data-post-body] img");
103
+ if (img) {
104
+ e.preventDefault();
105
+ const container = img.closest<HTMLElement>("[data-post-body]");
106
+ if (!container) return;
107
+ const allImages = Array.from(
108
+ container.querySelectorAll<HTMLImageElement>("img"),
109
+ );
110
+ const images: LightboxImage[] = allImages.map((i) => ({
111
+ url: i.src,
112
+ alt: i.alt || "",
113
+ }));
114
+ const index = allImages.indexOf(img);
115
+ if (images.length > 0) this.open(images, Math.max(0, index));
116
+ }
117
+ };
118
+
119
+ #prev() {
120
+ if (this._images.length <= 1) return;
121
+ this._currentIndex =
122
+ (this._currentIndex - 1 + this._images.length) % this._images.length;
123
+ }
124
+
125
+ #next() {
126
+ if (this._images.length <= 1) return;
127
+ this._currentIndex = (this._currentIndex + 1) % this._images.length;
128
+ }
129
+
130
+ #handleKeydown = (e: Event) => {
131
+ const ke = e as globalThis.KeyboardEvent;
132
+ if (ke.key === "ArrowLeft") {
133
+ e.preventDefault();
134
+ this.#prev();
135
+ } else if (ke.key === "ArrowRight") {
136
+ e.preventDefault();
137
+ this.#next();
138
+ }
139
+ };
140
+
141
+ #handleDialogClick = (e: Event) => {
142
+ const target = e.target as HTMLElement;
143
+ // Close on backdrop click (dialog itself or the content wrapper, not media/buttons)
144
+ if (
145
+ target === e.currentTarget ||
146
+ target.classList.contains("media-lightbox-content")
147
+ ) {
148
+ this.close();
149
+ }
150
+ };
151
+
152
+ #handleClose = () => {
153
+ this._open = false;
154
+ };
155
+
156
+ render() {
157
+ if (!this._open) return nothing;
158
+
159
+ const img = this._images[this._currentIndex];
160
+ const multiple = this._images.length > 1;
161
+
162
+ return html`
163
+ <dialog
164
+ class="media-lightbox"
165
+ @keydown=${this.#handleKeydown}
166
+ @click=${this.#handleDialogClick}
167
+ @close=${this.#handleClose}
168
+ >
169
+ <div class="media-lightbox-content" tabindex="-1">
170
+ <button
171
+ type="button"
172
+ class="media-lightbox-close"
173
+ @click=${() => this.close()}
174
+ aria-label="Close"
175
+ >
176
+ <svg
177
+ width="20"
178
+ height="20"
179
+ viewBox="0 0 24 24"
180
+ fill="none"
181
+ stroke="currentColor"
182
+ stroke-width="2"
183
+ stroke-linecap="round"
184
+ stroke-linejoin="round"
185
+ >
186
+ <path d="M18 6 6 18" />
187
+ <path d="m6 6 12 12" />
188
+ </svg>
189
+ </button>
190
+
191
+ ${multiple
192
+ ? html`<div class="media-lightbox-counter">
193
+ ${this._currentIndex + 1} / ${this._images.length}
194
+ </div>`
195
+ : nothing}
196
+ ${img?.mimeType?.startsWith("video/")
197
+ ? html`<video
198
+ class="media-lightbox-video"
199
+ src=${img.url}
200
+ controls
201
+ autoplay
202
+ playsinline
203
+ ></video>`
204
+ : html`<img
205
+ class="media-lightbox-img"
206
+ src=${img?.url ?? ""}
207
+ alt=${img?.alt ?? ""}
208
+ />`}
209
+ ${multiple
210
+ ? html`
211
+ <button
212
+ type="button"
213
+ class="media-lightbox-nav media-lightbox-nav-prev"
214
+ @click=${() => this.#prev()}
215
+ aria-label="Previous"
216
+ >
217
+ <svg
218
+ width="24"
219
+ height="24"
220
+ viewBox="0 0 24 24"
221
+ fill="none"
222
+ stroke="currentColor"
223
+ stroke-width="2"
224
+ stroke-linecap="round"
225
+ stroke-linejoin="round"
226
+ >
227
+ <path d="m15 18-6-6 6-6" />
228
+ </svg>
229
+ </button>
230
+ <button
231
+ type="button"
232
+ class="media-lightbox-nav media-lightbox-nav-next"
233
+ @click=${() => this.#next()}
234
+ aria-label="Next"
235
+ >
236
+ <svg
237
+ width="24"
238
+ height="24"
239
+ viewBox="0 0 24 24"
240
+ fill="none"
241
+ stroke="currentColor"
242
+ stroke-width="2"
243
+ stroke-linecap="round"
244
+ stroke-linejoin="round"
245
+ >
246
+ <path d="m9 18 6-6-6-6" />
247
+ </svg>
248
+ </button>
249
+ `
250
+ : nothing}
251
+ </div>
252
+ </dialog>
253
+ `;
254
+ }
255
+ }
256
+
257
+ customElements.define("jant-media-lightbox", JantMediaLightbox);