@jant/core 0.3.36 → 0.3.38
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/bin/commands/export.js +1 -1
- package/bin/commands/import-site.js +529 -0
- package/bin/commands/reset-password.js +3 -2
- package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
- package/dist/client/assets/url-FWFqPJPb.js +1 -0
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +4012 -3276
- package/dist/index.js +10285 -5809
- package/package.json +11 -3
- package/src/__tests__/helpers/app.ts +9 -9
- package/src/__tests__/helpers/db.ts +91 -93
- package/src/app.tsx +157 -27
- package/src/auth.ts +20 -2
- package/src/client/archive-nav.js +187 -0
- package/src/client/audio-player.ts +478 -0
- package/src/client/audio-processor.ts +84 -0
- package/src/client/avatar-upload.ts +3 -2
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
- package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
- package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-sidebar-types.ts +7 -9
- package/src/client/components/compose-types.ts +101 -4
- package/src/client/components/jant-collection-form.ts +43 -7
- package/src/client/components/jant-collection-sidebar.ts +88 -84
- package/src/client/components/jant-compose-dialog.ts +1655 -219
- package/src/client/components/jant-compose-editor.ts +732 -168
- package/src/client/components/jant-compose-fullscreen.ts +23 -78
- package/src/client/components/jant-media-lightbox.ts +2 -0
- package/src/client/components/jant-nav-manager.ts +24 -284
- package/src/client/components/jant-post-form.ts +89 -9
- package/src/client/components/jant-post-menu.ts +1019 -0
- package/src/client/components/jant-settings-avatar.ts +3 -3
- package/src/client/components/jant-settings-general.ts +38 -4
- package/src/client/components/jant-text-preview.ts +232 -0
- package/src/client/components/nav-manager-types.ts +4 -19
- package/src/client/components/post-form-template.ts +107 -12
- package/src/client/components/post-form-types.ts +11 -4
- package/src/client/compose-bridge.ts +410 -109
- package/src/client/image-processor.ts +26 -8
- package/src/client/media-metadata.ts +247 -0
- package/src/client/multipart-upload.ts +160 -0
- package/src/client/post-form-bridge.ts +52 -1
- package/src/client/settings-bridge.ts +0 -12
- package/src/client/thread-context.ts +140 -0
- package/src/client/tiptap/create-editor.ts +46 -0
- package/src/client/tiptap/extensions.ts +5 -0
- package/src/client/tiptap/image-node.ts +2 -8
- package/src/client/tiptap/paste-image.ts +2 -13
- package/src/client/tiptap/slash-commands.ts +173 -63
- package/src/client/toast.ts +101 -3
- package/src/client/types/sortablejs.d.ts +15 -0
- package/src/client/upload-with-metadata.ts +54 -0
- package/src/client/video-processor.ts +207 -0
- package/src/client.ts +5 -2
- package/src/db/__tests__/migrations.test.ts +118 -0
- package/src/db/index.ts +52 -0
- package/src/db/migrations/0000_baseline.sql +269 -0
- package/src/db/migrations/0001_fts_setup.sql +31 -0
- package/src/db/migrations/meta/0000_snapshot.json +703 -119
- package/src/db/migrations/meta/0001_snapshot.json +1337 -0
- package/src/db/migrations/meta/_journal.json +4 -39
- package/src/db/schema.ts +409 -145
- package/src/i18n/__tests__/detect.test.ts +115 -0
- package/src/i18n/context.tsx +2 -2
- package/src/i18n/detect.ts +85 -1
- package/src/i18n/i18n.ts +1 -1
- package/src/i18n/index.ts +2 -1
- package/src/i18n/locales/en.po +487 -1217
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +613 -996
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +624 -1007
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/i18n/middleware.ts +6 -0
- package/src/index.ts +5 -7
- package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
- package/src/lib/__tests__/constants.test.ts +0 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
- package/src/lib/__tests__/nanoid.test.ts +26 -0
- package/src/lib/__tests__/schemas.test.ts +181 -63
- package/src/lib/__tests__/slug.test.ts +126 -0
- package/src/lib/__tests__/sse.test.ts +6 -6
- package/src/lib/__tests__/summary.test.ts +264 -0
- package/src/lib/__tests__/theme.test.ts +1 -1
- package/src/lib/__tests__/timeline.test.ts +33 -30
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
- package/src/lib/__tests__/view.test.ts +141 -66
- package/src/lib/blurhash-placeholder.ts +102 -0
- package/src/lib/constants.ts +3 -1
- package/src/lib/emoji-catalog.ts +885 -68
- package/src/lib/errors.ts +11 -8
- package/src/lib/feed.ts +78 -32
- package/src/lib/html.ts +2 -1
- package/src/lib/icon-catalog.ts +5033 -1
- package/src/lib/icons.ts +3 -2
- package/src/lib/index.ts +0 -1
- package/src/lib/markdown-to-tiptap.ts +286 -0
- package/src/lib/media-helpers.ts +12 -3
- package/src/lib/nanoid.ts +29 -0
- package/src/lib/navigation.ts +1 -1
- package/src/lib/render.tsx +20 -2
- package/src/lib/resolve-config.ts +6 -2
- package/src/lib/schemas.ts +224 -55
- package/src/lib/search-snippet.ts +34 -0
- package/src/lib/slug.ts +96 -0
- package/src/lib/sse.ts +6 -6
- package/src/lib/storage.ts +115 -7
- package/src/lib/summary.ts +66 -0
- package/src/lib/theme.ts +11 -8
- package/src/lib/timeline.ts +74 -34
- package/src/lib/tiptap-render.ts +5 -10
- package/src/lib/tiptap-to-markdown.ts +305 -0
- package/src/lib/upload.ts +190 -29
- package/src/lib/url.ts +31 -0
- package/src/lib/view.ts +204 -37
- package/src/middleware/__tests__/auth.test.ts +191 -11
- package/src/middleware/__tests__/onboarding.test.ts +12 -10
- package/src/middleware/auth.ts +63 -9
- package/src/middleware/onboarding.ts +1 -1
- package/src/middleware/secure-headers.ts +40 -0
- package/src/preset.css +45 -2
- package/src/routes/__tests__/compose.test.ts +17 -24
- package/src/routes/api/__tests__/collections.test.ts +109 -61
- package/src/routes/api/__tests__/nav-items.test.ts +46 -29
- package/src/routes/api/__tests__/posts.test.ts +132 -68
- package/src/routes/api/__tests__/search.test.ts +15 -2
- package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
- package/src/routes/api/collections.ts +51 -42
- package/src/routes/api/custom-urls.ts +80 -0
- package/src/routes/api/export.ts +31 -0
- package/src/routes/api/nav-items.ts +23 -19
- package/src/routes/api/posts.ts +43 -39
- package/src/routes/api/search.ts +3 -4
- package/src/routes/api/upload-multipart.ts +245 -0
- package/src/routes/api/upload.ts +85 -19
- package/src/routes/auth/__tests__/setup.test.ts +20 -60
- package/src/routes/auth/setup.tsx +26 -33
- package/src/routes/auth/signin.tsx +3 -7
- package/src/routes/compose.tsx +10 -55
- package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
- package/src/routes/dash/custom-urls.tsx +414 -0
- package/src/routes/dash/settings.tsx +304 -232
- package/src/routes/feed/__tests__/rss.test.ts +27 -28
- package/src/routes/feed/rss.ts +6 -4
- package/src/routes/feed/sitemap.ts +2 -12
- package/src/routes/pages/__tests__/collections.test.ts +5 -6
- package/src/routes/pages/__tests__/featured.test.ts +41 -22
- package/src/routes/pages/archive.tsx +175 -39
- package/src/routes/pages/collection.tsx +22 -10
- package/src/routes/pages/collections.tsx +3 -3
- package/src/routes/pages/featured.tsx +28 -4
- package/src/routes/pages/home.tsx +16 -15
- package/src/routes/pages/latest.tsx +1 -11
- package/src/routes/pages/new.tsx +39 -0
- package/src/routes/pages/page.tsx +95 -49
- package/src/routes/pages/search.tsx +1 -1
- package/src/services/__tests__/api-token.test.ts +135 -0
- package/src/services/__tests__/collection.test.ts +275 -227
- package/src/services/__tests__/custom-url.test.ts +213 -0
- package/src/services/__tests__/media.test.ts +162 -22
- package/src/services/__tests__/navigation.test.ts +109 -68
- package/src/services/__tests__/post-timeline.test.ts +205 -32
- package/src/services/__tests__/post.test.ts +713 -234
- package/src/services/__tests__/search.test.ts +67 -10
- package/src/services/api-token.ts +166 -0
- package/src/services/auth.ts +17 -2
- package/src/services/collection.ts +397 -131
- package/src/services/custom-url.ts +188 -0
- package/src/services/export.ts +802 -0
- package/src/services/index.ts +26 -19
- package/src/services/media.ts +100 -22
- package/src/services/navigation.ts +158 -47
- package/src/services/path.ts +339 -0
- package/src/services/post.ts +687 -154
- package/src/services/search.ts +160 -75
- package/src/styles/components.css +58 -7
- package/src/styles/tokens.css +84 -6
- package/src/styles/ui.css +2964 -457
- package/src/types/bindings.ts +4 -1
- package/src/types/config.ts +12 -4
- package/src/types/constants.ts +15 -3
- package/src/types/entities.ts +74 -35
- package/src/types/operations.ts +11 -24
- package/src/types/props.ts +51 -16
- package/src/types/views.ts +45 -22
- package/src/ui/color-themes.ts +133 -23
- package/src/ui/compose/ComposeDialog.tsx +239 -17
- package/src/ui/compose/ComposePrompt.tsx +1 -1
- package/src/ui/dash/CrudPageHeader.tsx +1 -1
- package/src/ui/dash/ListItemRow.tsx +1 -1
- package/src/ui/dash/StatusBadge.tsx +3 -1
- package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
- package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
- package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
- package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
- package/src/ui/dash/index.ts +0 -3
- package/src/ui/dash/settings/AccountContent.tsx +3 -57
- package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
- package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
- package/src/ui/dash/settings/AvatarContent.tsx +8 -0
- package/src/ui/dash/settings/SessionsContent.tsx +159 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
- package/src/ui/feed/LinkCard.tsx +89 -40
- package/src/ui/feed/NoteCard.tsx +39 -25
- package/src/ui/feed/PostStatusBadges.tsx +67 -0
- package/src/ui/feed/QuoteCard.tsx +38 -23
- package/src/ui/feed/ThreadPreview.tsx +90 -26
- package/src/ui/feed/TimelineFeed.tsx +3 -2
- package/src/ui/feed/TimelineItem.tsx +15 -6
- package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
- package/src/ui/feed/thread-preview-state.ts +61 -0
- package/src/ui/font-themes.ts +2 -2
- package/src/ui/layouts/BaseLayout.tsx +2 -2
- package/src/ui/layouts/SiteLayout.tsx +105 -92
- package/src/ui/pages/ArchivePage.tsx +923 -98
- package/src/ui/pages/ComposePage.tsx +54 -0
- package/src/ui/pages/PostPage.tsx +30 -45
- package/src/ui/pages/SearchPage.tsx +181 -37
- package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
- package/src/ui/shared/CollectionsSidebar.tsx +47 -37
- package/src/ui/shared/MediaGallery.tsx +445 -149
- package/src/ui/shared/PostFooter.tsx +204 -0
- package/src/ui/shared/StarRating.tsx +27 -0
- package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
- package/src/ui/shared/index.ts +0 -1
- package/dist/client/assets/url-8Dj-5CLW.js +0 -1
- package/src/client/media-upload.ts +0 -161
- package/src/client/page-slug-bridge.ts +0 -42
- package/src/db/migrations/0000_square_wallflower.sql +0 -118
- package/src/db/migrations/0001_add_search_fts.sql +0 -34
- package/src/db/migrations/0002_add_media_attachments.sql +0 -3
- package/src/db/migrations/0003_add_navigation_links.sql +0 -8
- package/src/db/migrations/0004_add_storage_provider.sql +0 -3
- package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
- package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
- package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
- package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
- package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
- package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
- package/src/db/migrations/0011_add_path_registry.sql +0 -23
- package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
- package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
- package/src/db/migrations/meta/0003_snapshot.json +0 -821
- package/src/lib/__tests__/sqid.test.ts +0 -65
- package/src/lib/sqid.ts +0 -79
- package/src/routes/api/__tests__/pages.test.ts +0 -218
- package/src/routes/api/pages.ts +0 -73
- package/src/routes/dash/__tests__/pages.test.ts +0 -226
- package/src/routes/dash/index.tsx +0 -109
- package/src/routes/dash/media.tsx +0 -135
- package/src/routes/dash/pages.tsx +0 -245
- package/src/routes/dash/posts.tsx +0 -338
- package/src/routes/dash/redirects.tsx +0 -263
- package/src/routes/pages/post.tsx +0 -59
- package/src/services/__tests__/page.test.ts +0 -298
- package/src/services/__tests__/path-registry.test.ts +0 -165
- package/src/services/__tests__/redirect.test.ts +0 -159
- package/src/services/page.ts +0 -216
- package/src/services/path-registry.ts +0 -160
- package/src/services/redirect.ts +0 -97
- package/src/ui/dash/PageForm.tsx +0 -187
- package/src/ui/dash/PostList.tsx +0 -95
- package/src/ui/dash/media/MediaListContent.tsx +0 -206
- package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
- package/src/ui/dash/pages/PagesContent.tsx +0 -75
- package/src/ui/dash/posts/PostForm.tsx +0 -260
- package/src/ui/layouts/DashLayout.tsx +0 -247
- package/src/ui/pages/SinglePage.tsx +0 -23
- package/src/ui/shared/ThreadView.tsx +0 -136
|
@@ -10,21 +10,68 @@
|
|
|
10
10
|
import { LitElement, html, nothing } from "lit";
|
|
11
11
|
import { classMap } from "lit/directives/class-map.js";
|
|
12
12
|
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
|
13
|
+
import type { Editor, JSONContent } from "@tiptap/core";
|
|
13
14
|
import type {
|
|
14
15
|
ComposeFormat,
|
|
16
|
+
ComposeVisibility,
|
|
15
17
|
ComposeLabels,
|
|
16
18
|
ComposeCollection,
|
|
17
19
|
ComposeSubmitDetail,
|
|
18
20
|
ComposeAttachment,
|
|
21
|
+
DraftItem,
|
|
22
|
+
LocalDraft,
|
|
19
23
|
} from "./compose-types.js";
|
|
24
|
+
import type { CollectionSubmitDetail } from "./collection-types.js";
|
|
25
|
+
import { showToast } from "../toast.js";
|
|
20
26
|
import type { JantComposeEditor } from "./jant-compose-editor.js";
|
|
21
27
|
import { getMediaCategory } from "../../lib/upload.js";
|
|
28
|
+
import { createTiptapEditor } from "../tiptap/create-editor.js";
|
|
29
|
+
import { renderCollectionIcon } from "../../lib/icons.js";
|
|
30
|
+
|
|
31
|
+
interface ReplyToData {
|
|
32
|
+
contentHtml: string;
|
|
33
|
+
dateText: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ComposeStateSnapshot {
|
|
37
|
+
format: ComposeFormat;
|
|
38
|
+
collectionIds: string[];
|
|
39
|
+
title: string;
|
|
40
|
+
bodyJson: JSONContent | null;
|
|
41
|
+
url: string;
|
|
42
|
+
quoteText: string;
|
|
43
|
+
quoteAuthor: string;
|
|
44
|
+
rating: number;
|
|
45
|
+
showTitle: boolean;
|
|
46
|
+
showRating: boolean;
|
|
47
|
+
attachments: Array<{
|
|
48
|
+
clientId: string;
|
|
49
|
+
mediaId: string | null;
|
|
50
|
+
previewUrl: string;
|
|
51
|
+
mimeType: string;
|
|
52
|
+
alt: string;
|
|
53
|
+
status: ComposeAttachment["status"];
|
|
54
|
+
summary: string | null;
|
|
55
|
+
chars: number | null;
|
|
56
|
+
}>;
|
|
57
|
+
attachedTexts: Array<{
|
|
58
|
+
clientId: string;
|
|
59
|
+
mediaId: string | null;
|
|
60
|
+
bodyJson: JSONContent | null;
|
|
61
|
+
bodyHtml: string;
|
|
62
|
+
summary: string;
|
|
63
|
+
}>;
|
|
64
|
+
attachmentOrder: string[];
|
|
65
|
+
}
|
|
22
66
|
|
|
23
67
|
export class JantComposeDialog extends LitElement {
|
|
24
68
|
static properties = {
|
|
25
69
|
collections: { type: Array },
|
|
26
70
|
labels: { type: Object },
|
|
27
71
|
uploadMaxFileSize: { type: Number, attribute: "upload-max-file-size" },
|
|
72
|
+
pageMode: { type: Boolean, attribute: "page-mode" },
|
|
73
|
+
closeHref: { type: String, attribute: "close-href" },
|
|
74
|
+
autoRestoreDraft: { type: Boolean, attribute: "auto-restore-draft" },
|
|
28
75
|
_format: { state: true },
|
|
29
76
|
_status: { state: true },
|
|
30
77
|
_loading: { state: true },
|
|
@@ -35,21 +82,66 @@ export class JantComposeDialog extends LitElement {
|
|
|
35
82
|
_altPanelOpen: { state: true },
|
|
36
83
|
_altPanelIndex: { state: true },
|
|
37
84
|
_attachedPanelOpen: { state: true },
|
|
85
|
+
_attachedTextIndex: { state: true },
|
|
86
|
+
_confirmPanelOpen: { state: true },
|
|
87
|
+
_editPostId: { state: true },
|
|
88
|
+
_draftSourceId: { state: true },
|
|
89
|
+
_draftsPanelOpen: { state: true },
|
|
90
|
+
_drafts: { state: true },
|
|
91
|
+
_draftsLoading: { state: true },
|
|
92
|
+
_draftsError: { state: true },
|
|
93
|
+
_draftMenuOpenId: { state: true },
|
|
94
|
+
_addCollectionPanelOpen: { state: true },
|
|
95
|
+
_replyToId: { state: true },
|
|
96
|
+
_replyToData: { state: true },
|
|
97
|
+
_replyExpanded: { state: true },
|
|
98
|
+
_visibility: { state: true },
|
|
99
|
+
_featured: { state: true },
|
|
100
|
+
_showVisibilityMenu: { state: true },
|
|
38
101
|
};
|
|
39
102
|
|
|
40
103
|
declare collections: ComposeCollection[];
|
|
41
104
|
declare labels: ComposeLabels;
|
|
42
105
|
declare uploadMaxFileSize: number;
|
|
106
|
+
declare pageMode: boolean;
|
|
107
|
+
declare closeHref: string;
|
|
108
|
+
declare autoRestoreDraft: boolean;
|
|
43
109
|
declare _format: ComposeFormat;
|
|
44
110
|
declare _status: "published" | "draft";
|
|
45
111
|
declare _loading: boolean;
|
|
46
|
-
declare _collectionIds:
|
|
112
|
+
declare _collectionIds: string[];
|
|
47
113
|
declare _showCollection: boolean;
|
|
48
114
|
declare _showMoreMenu: boolean;
|
|
49
115
|
declare _collectionSearch: string;
|
|
50
116
|
declare _altPanelOpen: boolean;
|
|
51
117
|
declare _altPanelIndex: number;
|
|
52
118
|
declare _attachedPanelOpen: boolean;
|
|
119
|
+
declare _attachedTextIndex: number;
|
|
120
|
+
declare _confirmPanelOpen: boolean;
|
|
121
|
+
declare _editPostId: string | null;
|
|
122
|
+
declare _draftSourceId: string | null;
|
|
123
|
+
declare _draftsPanelOpen: boolean;
|
|
124
|
+
declare _drafts: DraftItem[];
|
|
125
|
+
declare _draftsLoading: boolean;
|
|
126
|
+
declare _draftsError: string | null;
|
|
127
|
+
declare _draftMenuOpenId: string | null;
|
|
128
|
+
declare _addCollectionPanelOpen: boolean;
|
|
129
|
+
declare _replyToId: string | null;
|
|
130
|
+
declare _replyToData: ReplyToData | null;
|
|
131
|
+
declare _replyExpanded: boolean;
|
|
132
|
+
declare _visibility: ComposeVisibility;
|
|
133
|
+
declare _featured: boolean;
|
|
134
|
+
declare _showVisibilityMenu: boolean;
|
|
135
|
+
|
|
136
|
+
private _attachedEditor: Editor | null = null;
|
|
137
|
+
private _attachedTextSnapshot: JSONContent | null = null;
|
|
138
|
+
private _confirmForDrafts = false;
|
|
139
|
+
private _draftSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
140
|
+
private _draftRestored = false;
|
|
141
|
+
private _initialSnapshot: string | null = null;
|
|
142
|
+
private _pageFocusApplied = false;
|
|
143
|
+
private _pageLeaveRequested = false;
|
|
144
|
+
private _suppressBeforeUnload = false;
|
|
53
145
|
|
|
54
146
|
createRenderRoot() {
|
|
55
147
|
this.innerHTML = "";
|
|
@@ -61,6 +153,9 @@ export class JantComposeDialog extends LitElement {
|
|
|
61
153
|
this.collections = [];
|
|
62
154
|
this.labels = {} as ComposeLabels;
|
|
63
155
|
this.uploadMaxFileSize = 500;
|
|
156
|
+
this.pageMode = false;
|
|
157
|
+
this.closeHref = "/";
|
|
158
|
+
this.autoRestoreDraft = false;
|
|
64
159
|
this._format = "note";
|
|
65
160
|
this._status = "published";
|
|
66
161
|
this._loading = false;
|
|
@@ -71,12 +166,41 @@ export class JantComposeDialog extends LitElement {
|
|
|
71
166
|
this._altPanelOpen = false;
|
|
72
167
|
this._altPanelIndex = 0;
|
|
73
168
|
this._attachedPanelOpen = false;
|
|
169
|
+
this._attachedTextIndex = 0;
|
|
170
|
+
this._confirmPanelOpen = false;
|
|
171
|
+
this._editPostId = null;
|
|
172
|
+
this._draftSourceId = null;
|
|
173
|
+
this._draftsPanelOpen = false;
|
|
174
|
+
this._drafts = [];
|
|
175
|
+
this._draftsLoading = false;
|
|
176
|
+
this._draftsError = null;
|
|
177
|
+
this._draftMenuOpenId = null;
|
|
178
|
+
this._addCollectionPanelOpen = false;
|
|
179
|
+
this._replyToId = null;
|
|
180
|
+
this._replyToData = null;
|
|
181
|
+
this._replyExpanded = false;
|
|
182
|
+
this._visibility = "public";
|
|
183
|
+
this._featured = false;
|
|
184
|
+
this._showVisibilityMenu = false;
|
|
74
185
|
}
|
|
75
186
|
|
|
76
187
|
private get _editor(): JantComposeEditor | null {
|
|
77
188
|
return this.querySelector("jant-compose-editor");
|
|
78
189
|
}
|
|
79
190
|
|
|
191
|
+
protected updated(changed: Map<string, unknown>) {
|
|
192
|
+
super.updated(changed);
|
|
193
|
+
if (this._initialSnapshot === null && this._editor) {
|
|
194
|
+
this._captureInitialSnapshot();
|
|
195
|
+
}
|
|
196
|
+
if (changed.has("_format") || changed.has("_collectionIds")) {
|
|
197
|
+
// Schedule draft auto-save for new-post mode only
|
|
198
|
+
if (!this._editPostId && !this._draftSourceId) {
|
|
199
|
+
this._scheduleDraftSave();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
80
204
|
reset() {
|
|
81
205
|
this._format = "note";
|
|
82
206
|
this._status = "published";
|
|
@@ -88,7 +212,164 @@ export class JantComposeDialog extends LitElement {
|
|
|
88
212
|
this._altPanelOpen = false;
|
|
89
213
|
this._altPanelIndex = 0;
|
|
90
214
|
this._attachedPanelOpen = false;
|
|
215
|
+
this._attachedTextIndex = 0;
|
|
216
|
+
this._confirmPanelOpen = false;
|
|
217
|
+
this._editPostId = null;
|
|
218
|
+
this._draftSourceId = null;
|
|
219
|
+
this._draftsPanelOpen = false;
|
|
220
|
+
this._drafts = [];
|
|
221
|
+
this._draftsLoading = false;
|
|
222
|
+
this._draftsError = null;
|
|
223
|
+
this._draftMenuOpenId = null;
|
|
224
|
+
this._addCollectionPanelOpen = false;
|
|
225
|
+
this._replyToId = null;
|
|
226
|
+
this._replyToData = null;
|
|
227
|
+
this._replyExpanded = false;
|
|
228
|
+
this._visibility = "public";
|
|
229
|
+
this._featured = false;
|
|
230
|
+
this._showVisibilityMenu = false;
|
|
231
|
+
this._confirmForDrafts = false;
|
|
232
|
+
this._initialSnapshot = null;
|
|
233
|
+
this._pageFocusApplied = false;
|
|
234
|
+
this._pageLeaveRequested = false;
|
|
235
|
+
this._suppressBeforeUnload = false;
|
|
236
|
+
this._destroyAttachedEditor();
|
|
91
237
|
this._editor?.reset();
|
|
238
|
+
this._captureInitialSnapshot();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async openEdit(id: string) {
|
|
242
|
+
this.reset();
|
|
243
|
+
|
|
244
|
+
const res = await fetch(`/api/posts/${id}`);
|
|
245
|
+
if (!res.ok) return;
|
|
246
|
+
const post = await res.json();
|
|
247
|
+
|
|
248
|
+
this._editPostId = id;
|
|
249
|
+
this._format = post.format;
|
|
250
|
+
|
|
251
|
+
// Pre-fill collection memberships if present
|
|
252
|
+
if (post.collectionIds?.length) {
|
|
253
|
+
this._collectionIds = post.collectionIds;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Wait for Lit to render with the new format before populating editor
|
|
257
|
+
await this.updateComplete;
|
|
258
|
+
|
|
259
|
+
// Separate text media items from other media attachments
|
|
260
|
+
const allMedia = post.mediaAttachments ?? [];
|
|
261
|
+
const nonTextMedia = allMedia.filter(
|
|
262
|
+
(m: { mimeType: string }) => !m.mimeType.startsWith("text/"),
|
|
263
|
+
);
|
|
264
|
+
const textMedia = allMedia.filter(
|
|
265
|
+
(m: { mimeType: string }) => m.mimeType === "text/x-tiptap+json",
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
// Fetch text content for TipTap text media items (stored as { json, html } envelope)
|
|
269
|
+
const textAttachments = await Promise.all(
|
|
270
|
+
textMedia.map(
|
|
271
|
+
async (m: { id: string; url: string; summary?: string }) => {
|
|
272
|
+
try {
|
|
273
|
+
const textRes = await fetch(`/api/media/${m.id}/content`);
|
|
274
|
+
if (textRes.ok) {
|
|
275
|
+
const raw = await textRes.text();
|
|
276
|
+
const envelope = JSON.parse(raw) as {
|
|
277
|
+
json?: unknown;
|
|
278
|
+
html?: string;
|
|
279
|
+
};
|
|
280
|
+
return {
|
|
281
|
+
bodyJson: JSON.stringify(envelope.json ?? {}),
|
|
282
|
+
bodyHtml: envelope.html ?? "",
|
|
283
|
+
summary: m.summary ?? "",
|
|
284
|
+
mediaId: m.id,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
} catch {
|
|
288
|
+
// Fetch failed — skip
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
bodyJson: "{}",
|
|
292
|
+
bodyHtml: "",
|
|
293
|
+
summary: m.summary ?? "",
|
|
294
|
+
mediaId: m.id,
|
|
295
|
+
};
|
|
296
|
+
},
|
|
297
|
+
),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
this._editor?.populate({
|
|
301
|
+
format: post.format,
|
|
302
|
+
title: post.title ?? undefined,
|
|
303
|
+
bodyJson: post.body ?? undefined,
|
|
304
|
+
url: post.url ?? undefined,
|
|
305
|
+
quoteText: post.quoteText ?? undefined,
|
|
306
|
+
quoteAuthor:
|
|
307
|
+
post.format === "quote" ? (post.title ?? undefined) : undefined,
|
|
308
|
+
rating: post.rating ?? undefined,
|
|
309
|
+
media: nonTextMedia.map(
|
|
310
|
+
(m: {
|
|
311
|
+
id: string;
|
|
312
|
+
previewUrl: string;
|
|
313
|
+
alt?: string;
|
|
314
|
+
mimeType: string;
|
|
315
|
+
}) => ({
|
|
316
|
+
id: m.id,
|
|
317
|
+
previewUrl: m.previewUrl,
|
|
318
|
+
alt: m.alt,
|
|
319
|
+
mimeType: m.mimeType,
|
|
320
|
+
}),
|
|
321
|
+
),
|
|
322
|
+
textAttachments,
|
|
323
|
+
attachmentOrder: allMedia.map((m: { id: string }) => m.id),
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
this.closest("dialog")?.showModal();
|
|
327
|
+
globalThis.requestAnimationFrame(() => {
|
|
328
|
+
this._editor?.focusInput();
|
|
329
|
+
this._captureInitialSnapshot();
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Open compose dialog in reply mode.
|
|
335
|
+
*
|
|
336
|
+
* @param id - UUID of the post being replied to
|
|
337
|
+
* @param replyData - Pre-captured content from the DOM (avoids API fetch)
|
|
338
|
+
*/
|
|
339
|
+
async openReply(id: string, replyData?: ReplyToData) {
|
|
340
|
+
this.reset();
|
|
341
|
+
this._replyToId = id;
|
|
342
|
+
this._replyToData = replyData ?? null;
|
|
343
|
+
this._format = "note";
|
|
344
|
+
|
|
345
|
+
this.closest("dialog")?.showModal();
|
|
346
|
+
await this.updateComplete;
|
|
347
|
+
this._editor?.focusInput();
|
|
348
|
+
this._captureInitialSnapshot();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Fetch parent post from API to populate reply context preview.
|
|
353
|
+
* Falls back gracefully if the parent is unavailable (deleted, etc.).
|
|
354
|
+
*/
|
|
355
|
+
private async _fetchReplyContext(replyToId: string) {
|
|
356
|
+
try {
|
|
357
|
+
const res = await fetch(`/api/posts/${replyToId}`);
|
|
358
|
+
if (!res.ok) return;
|
|
359
|
+
const post = await res.json();
|
|
360
|
+
const dateText = post.publishedAt
|
|
361
|
+
? new Date(post.publishedAt * 1000).toLocaleDateString(undefined, {
|
|
362
|
+
month: "short",
|
|
363
|
+
day: "numeric",
|
|
364
|
+
})
|
|
365
|
+
: "";
|
|
366
|
+
this._replyToData = {
|
|
367
|
+
contentHtml: (post.bodyHtml as string) ?? "",
|
|
368
|
+
dateText,
|
|
369
|
+
};
|
|
370
|
+
} catch {
|
|
371
|
+
// Parent unavailable — reply mode still works, just no preview
|
|
372
|
+
}
|
|
92
373
|
}
|
|
93
374
|
|
|
94
375
|
set loading(v: boolean) {
|
|
@@ -96,7 +377,192 @@ export class JantComposeDialog extends LitElement {
|
|
|
96
377
|
}
|
|
97
378
|
|
|
98
379
|
private _closeDialog() {
|
|
99
|
-
this.closest("dialog")
|
|
380
|
+
const dialog = this.closest("dialog");
|
|
381
|
+
if (dialog) {
|
|
382
|
+
dialog.close();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (this.pageMode) {
|
|
387
|
+
this._suppressBeforeUnload = true;
|
|
388
|
+
globalThis.location.assign(this.closeHref || "/");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
requestCloseAndLeave() {
|
|
393
|
+
this._pageLeaveRequested = true;
|
|
394
|
+
this.requestClose();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
consumePageLeaveRequest(): boolean {
|
|
398
|
+
const shouldLeave = this._pageLeaveRequested;
|
|
399
|
+
this._pageLeaveRequested = false;
|
|
400
|
+
return shouldLeave;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
preparePageLeave() {
|
|
404
|
+
this._suppressBeforeUnload = true;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private _hasContent(): boolean {
|
|
408
|
+
const editor = this._editor;
|
|
409
|
+
if (!editor) return false;
|
|
410
|
+
|
|
411
|
+
const data = editor.getData();
|
|
412
|
+
if (data.body) return true;
|
|
413
|
+
if (data.title.trim()) return true;
|
|
414
|
+
if (data.url.trim()) return true;
|
|
415
|
+
if (data.quoteText.trim()) return true;
|
|
416
|
+
if (data.quoteAuthor.trim()) return true;
|
|
417
|
+
if (data.attachedTexts.some((t) => t.bodyJson !== null)) return true;
|
|
418
|
+
if (data.rating > 0) return true;
|
|
419
|
+
if (data.attachments.length > 0) return true;
|
|
420
|
+
// Collection selection alone isn't content — it's metadata that
|
|
421
|
+
// only matters when paired with actual post content above.
|
|
422
|
+
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private _buildSnapshot(): ComposeStateSnapshot | null {
|
|
427
|
+
const editor = this._editor;
|
|
428
|
+
if (!editor) return null;
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
format: this._format,
|
|
432
|
+
collectionIds: [...this._collectionIds],
|
|
433
|
+
title: editor._title,
|
|
434
|
+
bodyJson: editor._bodyJson,
|
|
435
|
+
url: editor._url,
|
|
436
|
+
quoteText: editor._quoteText,
|
|
437
|
+
quoteAuthor: editor._quoteAuthor,
|
|
438
|
+
rating: editor._rating,
|
|
439
|
+
showTitle: editor._showTitle,
|
|
440
|
+
showRating: editor._showRating,
|
|
441
|
+
attachments: editor._attachments.map((attachment) => ({
|
|
442
|
+
clientId: attachment.clientId,
|
|
443
|
+
mediaId: attachment.mediaId,
|
|
444
|
+
previewUrl: attachment.previewUrl,
|
|
445
|
+
mimeType: attachment.file.type,
|
|
446
|
+
alt: attachment.alt,
|
|
447
|
+
status: attachment.status,
|
|
448
|
+
summary: attachment.summary,
|
|
449
|
+
chars: attachment.chars,
|
|
450
|
+
})),
|
|
451
|
+
attachedTexts: editor._attachedTexts.map((item) => ({
|
|
452
|
+
clientId: item.clientId,
|
|
453
|
+
mediaId: item.mediaId ?? null,
|
|
454
|
+
bodyJson: item.bodyJson,
|
|
455
|
+
bodyHtml: item.bodyHtml,
|
|
456
|
+
summary: item.summary,
|
|
457
|
+
})),
|
|
458
|
+
attachmentOrder: [...editor._attachmentOrder],
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private _serializeSnapshot(
|
|
463
|
+
snapshot: ComposeStateSnapshot | null,
|
|
464
|
+
): string | null {
|
|
465
|
+
if (!snapshot) return null;
|
|
466
|
+
return JSON.stringify(snapshot);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private _captureInitialSnapshot() {
|
|
470
|
+
this._initialSnapshot = this._serializeSnapshot(this._buildSnapshot());
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private _hasUnsavedChanges(): boolean {
|
|
474
|
+
const currentSnapshot = this._serializeSnapshot(this._buildSnapshot());
|
|
475
|
+
if (currentSnapshot === null) return false;
|
|
476
|
+
if (this._initialSnapshot === null) return this._hasContent();
|
|
477
|
+
return currentSnapshot !== this._initialSnapshot;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
requestClose() {
|
|
481
|
+
if (this._loading) return;
|
|
482
|
+
|
|
483
|
+
// Dismiss any open dropdowns first
|
|
484
|
+
if (this._showCollection) {
|
|
485
|
+
this._showCollection = false;
|
|
486
|
+
this._collectionSearch = "";
|
|
487
|
+
}
|
|
488
|
+
if (this._showMoreMenu) {
|
|
489
|
+
this._showMoreMenu = false;
|
|
490
|
+
}
|
|
491
|
+
if (this._showVisibilityMenu) {
|
|
492
|
+
this._showVisibilityMenu = false;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (this._confirmPanelOpen) {
|
|
496
|
+
this._confirmPanelOpen = false;
|
|
497
|
+
this._confirmForDrafts = false;
|
|
498
|
+
this._pageLeaveRequested = false;
|
|
499
|
+
this.updateComplete.then(() => this._editor?.focusInput());
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// In edit mode, only prompt if actual changes were made
|
|
504
|
+
if (this._editPostId) {
|
|
505
|
+
if (this._hasUnsavedChanges()) {
|
|
506
|
+
this._confirmForDrafts = false;
|
|
507
|
+
this._confirmPanelOpen = true;
|
|
508
|
+
} else {
|
|
509
|
+
this._closeDialog();
|
|
510
|
+
this.reset();
|
|
511
|
+
}
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (this._hasContent()) {
|
|
516
|
+
this._confirmForDrafts = false;
|
|
517
|
+
this._confirmPanelOpen = true;
|
|
518
|
+
} else {
|
|
519
|
+
this._closeDialog();
|
|
520
|
+
this.reset();
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private _discardAndClose() {
|
|
525
|
+
if (this._draftSourceId) {
|
|
526
|
+
const id = this._draftSourceId;
|
|
527
|
+
fetch(`/api/posts/${id}`, { method: "DELETE" }).catch(() => {});
|
|
528
|
+
showToast(this.labels.draftDeleted);
|
|
529
|
+
}
|
|
530
|
+
this._clearDraftFromStorage();
|
|
531
|
+
this._confirmPanelOpen = false;
|
|
532
|
+
this._closeDialog();
|
|
533
|
+
(document.activeElement as HTMLElement)?.blur();
|
|
534
|
+
this.reset();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private _handleConfirmSave() {
|
|
538
|
+
if (this._confirmForDrafts) {
|
|
539
|
+
this._dispatchSubmit("draft");
|
|
540
|
+
this._confirmPanelOpen = false;
|
|
541
|
+
this.reset();
|
|
542
|
+
this._openDraftsPanel();
|
|
543
|
+
} else if (this._editPostId) {
|
|
544
|
+
// Editing a published post — publish the update directly
|
|
545
|
+
this._confirmPanelOpen = false;
|
|
546
|
+
this._submit("published");
|
|
547
|
+
} else {
|
|
548
|
+
this._confirmPanelOpen = false;
|
|
549
|
+
this._submit("draft");
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
private _handleConfirmDiscard() {
|
|
554
|
+
if (this._confirmForDrafts) {
|
|
555
|
+
if (this._draftSourceId) {
|
|
556
|
+
const id = this._draftSourceId;
|
|
557
|
+
fetch(`/api/posts/${id}`, { method: "DELETE" }).catch(() => {});
|
|
558
|
+
showToast(this.labels.draftDeleted);
|
|
559
|
+
}
|
|
560
|
+
this._confirmPanelOpen = false;
|
|
561
|
+
this.reset();
|
|
562
|
+
this._openDraftsPanel();
|
|
563
|
+
} else {
|
|
564
|
+
this._discardAndClose();
|
|
565
|
+
}
|
|
100
566
|
}
|
|
101
567
|
|
|
102
568
|
private _buildSubmitDetail(
|
|
@@ -121,6 +587,15 @@ export class JantComposeDialog extends LitElement {
|
|
|
121
587
|
}
|
|
122
588
|
}
|
|
123
589
|
|
|
590
|
+
// Capture clientId → mediaId for all done attachments now,
|
|
591
|
+
// because the editor will be reset before the deferred handler runs
|
|
592
|
+
const mediaClientMap: Record<string, string> = {};
|
|
593
|
+
for (const a of attachments) {
|
|
594
|
+
if (a.mediaId) {
|
|
595
|
+
mediaClientMap[a.clientId] = a.mediaId;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
124
599
|
return {
|
|
125
600
|
format: this._format,
|
|
126
601
|
title: editorData.title,
|
|
@@ -129,55 +604,59 @@ export class JantComposeDialog extends LitElement {
|
|
|
129
604
|
quoteText: editorData.quoteText,
|
|
130
605
|
quoteAuthor: editorData.quoteAuthor,
|
|
131
606
|
status,
|
|
607
|
+
visibility: this._visibility,
|
|
608
|
+
featured: this._featured || undefined,
|
|
132
609
|
rating: editorData.rating,
|
|
133
610
|
collectionIds: [...this._collectionIds],
|
|
134
611
|
mediaIds,
|
|
135
612
|
mediaAlts,
|
|
136
|
-
|
|
613
|
+
attachedTexts: editorData.attachedTexts,
|
|
614
|
+
attachmentOrder: editorData.attachmentOrder ?? [],
|
|
615
|
+
mediaClientMap,
|
|
616
|
+
editPostId: this._editPostId ?? this._draftSourceId ?? undefined,
|
|
617
|
+
replyToId: this._replyToId ?? undefined,
|
|
137
618
|
};
|
|
138
619
|
}
|
|
139
620
|
|
|
140
|
-
private
|
|
141
|
-
if (this._loading) return;
|
|
621
|
+
private _dispatchSubmit(status: "published" | "draft"): boolean {
|
|
622
|
+
if (this._loading) return false;
|
|
142
623
|
const editor = this._editor;
|
|
143
|
-
if (!editor) return;
|
|
624
|
+
if (!editor) return false;
|
|
625
|
+
|
|
626
|
+
const detail = this._buildSubmitDetail(status);
|
|
627
|
+
if (!detail) return false;
|
|
144
628
|
|
|
145
629
|
const attachments = editor._attachments ?? [];
|
|
146
|
-
const
|
|
147
|
-
(a) =>
|
|
630
|
+
const pendingAttachments = attachments.filter(
|
|
631
|
+
(a) =>
|
|
632
|
+
a.status === "pending" ||
|
|
633
|
+
a.status === "processing" ||
|
|
634
|
+
a.status === "uploading",
|
|
148
635
|
);
|
|
149
636
|
|
|
150
|
-
|
|
151
|
-
|
|
637
|
+
this.dispatchEvent(
|
|
638
|
+
new CustomEvent("jant:compose-submit-deferred", {
|
|
639
|
+
bubbles: true,
|
|
640
|
+
detail: { ...detail, pendingAttachments },
|
|
641
|
+
}),
|
|
642
|
+
);
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
152
645
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
...detail,
|
|
160
|
-
pendingAttachments: attachments.filter(
|
|
161
|
-
(a) => a.status === "pending" || a.status === "uploading",
|
|
162
|
-
),
|
|
163
|
-
},
|
|
164
|
-
}),
|
|
165
|
-
);
|
|
166
|
-
this._closeDialog();
|
|
167
|
-
// Prevent browser from restoring focus to the trigger button
|
|
168
|
-
(document.activeElement as HTMLElement)?.blur();
|
|
169
|
-
this.reset();
|
|
170
|
-
} else {
|
|
171
|
-
this.dispatchEvent(
|
|
172
|
-
new CustomEvent("jant:compose-submit", {
|
|
173
|
-
bubbles: true,
|
|
174
|
-
detail,
|
|
175
|
-
}),
|
|
176
|
-
);
|
|
646
|
+
private _submit(status: "published" | "draft") {
|
|
647
|
+
this._clearDraftFromStorage();
|
|
648
|
+
if (!this._dispatchSubmit(status)) return;
|
|
649
|
+
if (this.pageMode) {
|
|
650
|
+
this._loading = true;
|
|
651
|
+
return;
|
|
177
652
|
}
|
|
653
|
+
this._closeDialog();
|
|
654
|
+
// Prevent browser from restoring focus to the trigger button
|
|
655
|
+
(document.activeElement as HTMLElement)?.blur();
|
|
656
|
+
this.reset();
|
|
178
657
|
}
|
|
179
658
|
|
|
180
|
-
private _toggleCollection(id:
|
|
659
|
+
private _toggleCollection(id: string) {
|
|
181
660
|
if (this._collectionIds.includes(id)) {
|
|
182
661
|
this._collectionIds = this._collectionIds.filter((cid) => cid !== id);
|
|
183
662
|
} else {
|
|
@@ -185,6 +664,16 @@ export class JantComposeDialog extends LitElement {
|
|
|
185
664
|
}
|
|
186
665
|
}
|
|
187
666
|
|
|
667
|
+
private _selectedCollectionLabel(collections: ComposeCollection[]): string {
|
|
668
|
+
const ids = this._collectionIds;
|
|
669
|
+
const first = collections.find((c) => c.id === ids[0]);
|
|
670
|
+
if (!first) return "";
|
|
671
|
+
if (ids.length === 1) return first.title;
|
|
672
|
+
return this.labels.collectionCountLabel
|
|
673
|
+
.replace("%name%", first.title)
|
|
674
|
+
.replace("%count%", String(ids.length - 1));
|
|
675
|
+
}
|
|
676
|
+
|
|
188
677
|
connectedCallback() {
|
|
189
678
|
super.connectedCallback();
|
|
190
679
|
this.addEventListener("keydown", this._handleKeydown);
|
|
@@ -194,11 +683,28 @@ export class JantComposeDialog extends LitElement {
|
|
|
194
683
|
"jant:attached-panel-open",
|
|
195
684
|
this._handleAttachedPanelOpen,
|
|
196
685
|
);
|
|
686
|
+
this.addEventListener(
|
|
687
|
+
"jant:compose-content-changed",
|
|
688
|
+
this._onContentChanged,
|
|
689
|
+
);
|
|
197
690
|
// Listen on document — fullscreen element lives on document.body, outside the dialog
|
|
198
691
|
document.addEventListener(
|
|
199
692
|
"jant:fullscreen-close",
|
|
200
693
|
this._handleFullscreenClose as EventListener,
|
|
201
694
|
);
|
|
695
|
+
|
|
696
|
+
// Flush pending draft save before page unload (covers refresh/close mid-debounce)
|
|
697
|
+
window.addEventListener("beforeunload", this._onBeforeUnload);
|
|
698
|
+
|
|
699
|
+
// Intercept native dialog cancel (ESC) to route through requestClose
|
|
700
|
+
const dialog = this.closest("dialog");
|
|
701
|
+
if (dialog) {
|
|
702
|
+
dialog.addEventListener("cancel", this._handleDialogCancel);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (this.pageMode) {
|
|
706
|
+
this.updateComplete.then(() => this._focusPageEditorOnMount());
|
|
707
|
+
}
|
|
202
708
|
}
|
|
203
709
|
|
|
204
710
|
disconnectedCallback() {
|
|
@@ -210,15 +716,56 @@ export class JantComposeDialog extends LitElement {
|
|
|
210
716
|
"jant:attached-panel-open",
|
|
211
717
|
this._handleAttachedPanelOpen,
|
|
212
718
|
);
|
|
719
|
+
this.removeEventListener(
|
|
720
|
+
"jant:compose-content-changed",
|
|
721
|
+
this._onContentChanged,
|
|
722
|
+
);
|
|
213
723
|
document.removeEventListener(
|
|
214
724
|
"jant:fullscreen-close",
|
|
215
725
|
this._handleFullscreenClose as EventListener,
|
|
216
726
|
);
|
|
727
|
+
window.removeEventListener("beforeunload", this._onBeforeUnload);
|
|
728
|
+
this._destroyAttachedEditor();
|
|
729
|
+
this._cancelDraftSaveTimer();
|
|
730
|
+
|
|
731
|
+
const dialog = this.closest("dialog");
|
|
732
|
+
if (dialog) {
|
|
733
|
+
dialog.removeEventListener("cancel", this._handleDialogCancel);
|
|
734
|
+
}
|
|
217
735
|
}
|
|
218
736
|
|
|
737
|
+
private _handleDialogCancel = (e: Event) => {
|
|
738
|
+
e.preventDefault();
|
|
739
|
+
this.requestClose();
|
|
740
|
+
};
|
|
741
|
+
|
|
219
742
|
private _handleKeydown = (e: Event) => {
|
|
220
743
|
const ke = e as globalThis.KeyboardEvent;
|
|
221
|
-
if (
|
|
744
|
+
if (ke.key === "Escape") {
|
|
745
|
+
ke.preventDefault();
|
|
746
|
+
ke.stopPropagation();
|
|
747
|
+
if (this._showCollection) {
|
|
748
|
+
this._showCollection = false;
|
|
749
|
+
this._collectionSearch = "";
|
|
750
|
+
} else if (this._showMoreMenu) {
|
|
751
|
+
this._showMoreMenu = false;
|
|
752
|
+
} else if (this._showVisibilityMenu) {
|
|
753
|
+
this._showVisibilityMenu = false;
|
|
754
|
+
} else if (this._addCollectionPanelOpen) {
|
|
755
|
+
this._addCollectionPanelOpen = false;
|
|
756
|
+
} else if (this._draftMenuOpenId) {
|
|
757
|
+
this._draftMenuOpenId = null;
|
|
758
|
+
} else if (this._draftsPanelOpen) {
|
|
759
|
+
this._closeDraftsPanel();
|
|
760
|
+
} else if (this._attachedPanelOpen) {
|
|
761
|
+
this._cancelAttachedPanel();
|
|
762
|
+
} else {
|
|
763
|
+
this.requestClose();
|
|
764
|
+
}
|
|
765
|
+
} else if (ke.key === "Enter" && this._confirmPanelOpen) {
|
|
766
|
+
ke.preventDefault();
|
|
767
|
+
this._handleConfirmSave();
|
|
768
|
+
} else if ((ke.metaKey || ke.ctrlKey) && ke.key === "Enter") {
|
|
222
769
|
e.preventDefault();
|
|
223
770
|
this._submit("published");
|
|
224
771
|
}
|
|
@@ -250,35 +797,610 @@ export class JantComposeDialog extends LitElement {
|
|
|
250
797
|
this._altPanelOpen = false;
|
|
251
798
|
}
|
|
252
799
|
|
|
253
|
-
private _handleFullscreenClose = (
|
|
254
|
-
e: CustomEvent<{ json: unknown; title: string }>,
|
|
255
|
-
) => {
|
|
256
|
-
const editor = this._editor;
|
|
257
|
-
if (editor) {
|
|
258
|
-
editor.setEditorState(
|
|
259
|
-
e.detail.json as import("@tiptap/core").JSONContent,
|
|
260
|
-
e.detail.title,
|
|
261
|
-
);
|
|
262
|
-
}
|
|
263
|
-
};
|
|
800
|
+
private _handleFullscreenClose = (
|
|
801
|
+
e: CustomEvent<{ json: unknown; title: string }>,
|
|
802
|
+
) => {
|
|
803
|
+
const editor = this._editor;
|
|
804
|
+
if (editor) {
|
|
805
|
+
editor.setEditorState(
|
|
806
|
+
e.detail.json as import("@tiptap/core").JSONContent,
|
|
807
|
+
e.detail.title,
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
private _handleAttachedPanelOpen = (e: Event) => {
|
|
813
|
+
const detail = (e as CustomEvent<{ index: number }>).detail;
|
|
814
|
+
this._attachedTextIndex = detail.index;
|
|
815
|
+
this._attachedPanelOpen = true;
|
|
816
|
+
this.updateComplete.then(() => {
|
|
817
|
+
const container = this.querySelector<HTMLElement>(
|
|
818
|
+
".compose-attached-tiptap",
|
|
819
|
+
);
|
|
820
|
+
if (!container) return;
|
|
821
|
+
const item = this._editor?._attachedTexts[this._attachedTextIndex];
|
|
822
|
+
const content = item?.bodyJson ?? null;
|
|
823
|
+
this._attachedTextSnapshot = content
|
|
824
|
+
? JSON.parse(JSON.stringify(content))
|
|
825
|
+
: null;
|
|
826
|
+
this._attachedEditor = createTiptapEditor({
|
|
827
|
+
element: container,
|
|
828
|
+
placeholder: this.labels.attachedTextPlaceholder,
|
|
829
|
+
content,
|
|
830
|
+
});
|
|
831
|
+
this._attachedEditor.commands.focus();
|
|
832
|
+
});
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
private _isAttachedTextDirty(): boolean {
|
|
836
|
+
if (!this._attachedEditor) return false;
|
|
837
|
+
return (
|
|
838
|
+
JSON.stringify(this._attachedEditor.getJSON()) !==
|
|
839
|
+
JSON.stringify(this._attachedTextSnapshot)
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
private _destroyAttachedEditor() {
|
|
844
|
+
if (this._attachedEditor) {
|
|
845
|
+
this._attachedEditor.destroy();
|
|
846
|
+
this._attachedEditor = null;
|
|
847
|
+
}
|
|
848
|
+
this._attachedTextSnapshot = null;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
private _doneAttachedPanel() {
|
|
852
|
+
if (this._attachedEditor) {
|
|
853
|
+
const json = this._attachedEditor.getJSON();
|
|
854
|
+
const html = this._attachedEditor.getHTML();
|
|
855
|
+
this._editor?.updateAttachedText(this._attachedTextIndex, json, html);
|
|
856
|
+
}
|
|
857
|
+
this._destroyAttachedEditor();
|
|
858
|
+
this._attachedPanelOpen = false;
|
|
859
|
+
this._editor?.closeAttachedPanel(this._attachedTextIndex);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private _cancelAttachedPanel() {
|
|
863
|
+
if (this._isAttachedTextDirty()) {
|
|
864
|
+
if (!globalThis.confirm("Discard changes?")) return;
|
|
865
|
+
}
|
|
866
|
+
// Revert to snapshot — don't save current editor content
|
|
867
|
+
this._destroyAttachedEditor();
|
|
868
|
+
this._attachedPanelOpen = false;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// ── Drafts panel ─────────────────────────────────────────────────
|
|
872
|
+
|
|
873
|
+
private _handleDraftButtonClick() {
|
|
874
|
+
if (this._loading) return;
|
|
875
|
+
if (this._hasContent()) {
|
|
876
|
+
this._confirmForDrafts = true;
|
|
877
|
+
this._confirmPanelOpen = true;
|
|
878
|
+
} else {
|
|
879
|
+
this._openDraftsPanel();
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
private async _openDraftsPanel() {
|
|
884
|
+
this._draftsPanelOpen = true;
|
|
885
|
+
this._draftsLoading = true;
|
|
886
|
+
this._draftsError = null;
|
|
887
|
+
this._draftMenuOpenId = null;
|
|
888
|
+
|
|
889
|
+
try {
|
|
890
|
+
const res = await fetch("/api/posts?status=draft&limit=50");
|
|
891
|
+
if (!res.ok) throw new Error("Failed to load drafts");
|
|
892
|
+
const json = await res.json();
|
|
893
|
+
const posts = json.posts ?? json;
|
|
894
|
+
this._drafts = (posts as Record<string, unknown>[]).map(
|
|
895
|
+
(p): DraftItem => ({
|
|
896
|
+
id: p.id as string,
|
|
897
|
+
format: p.format as ComposeFormat,
|
|
898
|
+
title: (p.title as string) ?? null,
|
|
899
|
+
bodyText: (p.bodyText as string) ?? null,
|
|
900
|
+
bodyHtml: (p.bodyHtml as string) ?? null,
|
|
901
|
+
url: (p.url as string) ?? null,
|
|
902
|
+
quoteText: (p.quoteText as string) ?? null,
|
|
903
|
+
replyToId: (p.replyToId as string) ?? null,
|
|
904
|
+
updatedAt: p.updatedAt as number,
|
|
905
|
+
mediaAttachments: (
|
|
906
|
+
(p.mediaAttachments as DraftItem["mediaAttachments"]) ?? []
|
|
907
|
+
).map((m) => ({
|
|
908
|
+
id: m.id,
|
|
909
|
+
previewUrl: m.previewUrl,
|
|
910
|
+
alt: m.alt,
|
|
911
|
+
mimeType: m.mimeType,
|
|
912
|
+
})),
|
|
913
|
+
}),
|
|
914
|
+
);
|
|
915
|
+
} catch {
|
|
916
|
+
this._draftsError = "Could not load drafts. Try again.";
|
|
917
|
+
this._drafts = [];
|
|
918
|
+
} finally {
|
|
919
|
+
this._draftsLoading = false;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
private _closeDraftsPanel() {
|
|
924
|
+
this._draftsPanelOpen = false;
|
|
925
|
+
this._draftMenuOpenId = null;
|
|
926
|
+
this.updateComplete.then(() => this._editor?.focusInput());
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
private async _loadDraft(id: string) {
|
|
930
|
+
this._draftsPanelOpen = false;
|
|
931
|
+
this._draftMenuOpenId = null;
|
|
932
|
+
this.reset();
|
|
933
|
+
|
|
934
|
+
const res = await fetch(`/api/posts/${id}`);
|
|
935
|
+
if (!res.ok) return;
|
|
936
|
+
const post = await res.json();
|
|
937
|
+
|
|
938
|
+
this._draftSourceId = id;
|
|
939
|
+
this._format = post.format;
|
|
940
|
+
|
|
941
|
+
if (post.collectionIds?.length) {
|
|
942
|
+
this._collectionIds = post.collectionIds;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Restore reply context if this draft was a reply
|
|
946
|
+
if (post.replyToId) {
|
|
947
|
+
this._replyToId = post.replyToId;
|
|
948
|
+
await this._fetchReplyContext(post.replyToId);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
await this.updateComplete;
|
|
952
|
+
|
|
953
|
+
// Separate text media items from other media attachments
|
|
954
|
+
const allMedia = post.mediaAttachments ?? [];
|
|
955
|
+
const nonTextMedia = allMedia.filter(
|
|
956
|
+
(m: { mimeType: string }) => !m.mimeType.startsWith("text/"),
|
|
957
|
+
);
|
|
958
|
+
const textMedia = allMedia.filter(
|
|
959
|
+
(m: { mimeType: string }) => m.mimeType === "text/x-tiptap+json",
|
|
960
|
+
);
|
|
961
|
+
|
|
962
|
+
// Fetch text content for TipTap text media items (stored as { json, html } envelope)
|
|
963
|
+
const textAttachments = await Promise.all(
|
|
964
|
+
textMedia.map(
|
|
965
|
+
async (m: { id: string; url: string; summary?: string }) => {
|
|
966
|
+
try {
|
|
967
|
+
const textRes = await fetch(`/api/media/${m.id}/content`);
|
|
968
|
+
if (textRes.ok) {
|
|
969
|
+
const raw = await textRes.text();
|
|
970
|
+
const envelope = JSON.parse(raw) as {
|
|
971
|
+
json?: unknown;
|
|
972
|
+
html?: string;
|
|
973
|
+
};
|
|
974
|
+
return {
|
|
975
|
+
bodyJson: JSON.stringify(envelope.json ?? {}),
|
|
976
|
+
bodyHtml: envelope.html ?? "",
|
|
977
|
+
summary: m.summary ?? "",
|
|
978
|
+
mediaId: m.id,
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
} catch {
|
|
982
|
+
// Fetch failed — skip
|
|
983
|
+
}
|
|
984
|
+
return {
|
|
985
|
+
bodyJson: "{}",
|
|
986
|
+
bodyHtml: "",
|
|
987
|
+
summary: m.summary ?? "",
|
|
988
|
+
mediaId: m.id,
|
|
989
|
+
};
|
|
990
|
+
},
|
|
991
|
+
),
|
|
992
|
+
);
|
|
993
|
+
|
|
994
|
+
this._editor?.populate({
|
|
995
|
+
format: post.format,
|
|
996
|
+
title: post.title ?? undefined,
|
|
997
|
+
bodyJson: post.body ?? undefined,
|
|
998
|
+
url: post.url ?? undefined,
|
|
999
|
+
quoteText: post.quoteText ?? undefined,
|
|
1000
|
+
quoteAuthor:
|
|
1001
|
+
post.format === "quote" ? (post.title ?? undefined) : undefined,
|
|
1002
|
+
rating: post.rating ?? undefined,
|
|
1003
|
+
media: nonTextMedia.map(
|
|
1004
|
+
(m: {
|
|
1005
|
+
id: string;
|
|
1006
|
+
previewUrl: string;
|
|
1007
|
+
alt?: string;
|
|
1008
|
+
mimeType: string;
|
|
1009
|
+
}) => ({
|
|
1010
|
+
id: m.id,
|
|
1011
|
+
previewUrl: m.previewUrl,
|
|
1012
|
+
alt: m.alt,
|
|
1013
|
+
mimeType: m.mimeType,
|
|
1014
|
+
}),
|
|
1015
|
+
),
|
|
1016
|
+
textAttachments,
|
|
1017
|
+
attachmentOrder: allMedia.map((m: { id: string }) => m.id),
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
globalThis.requestAnimationFrame(() => {
|
|
1021
|
+
this._editor?.focusInput();
|
|
1022
|
+
this._captureInitialSnapshot();
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
private async _deleteDraft(id: string) {
|
|
1027
|
+
this._draftMenuOpenId = null;
|
|
1028
|
+
this._drafts = this._drafts.filter((d) => d.id !== id);
|
|
1029
|
+
|
|
1030
|
+
try {
|
|
1031
|
+
const res = await fetch(`/api/posts/${id}`, { method: "DELETE" });
|
|
1032
|
+
if (!res.ok) throw new Error();
|
|
1033
|
+
showToast(this.labels.draftDeleted);
|
|
1034
|
+
} catch {
|
|
1035
|
+
showToast("Failed to delete draft. Try again.", "error");
|
|
1036
|
+
this._openDraftsPanel();
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
private _formatDraftDate(timestamp: number): string {
|
|
1041
|
+
const now = Date.now() / 1000;
|
|
1042
|
+
const diff = now - timestamp;
|
|
1043
|
+
if (diff < 60) return "now";
|
|
1044
|
+
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
|
1045
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
|
1046
|
+
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
|
|
1047
|
+
const d = new Date(timestamp * 1000);
|
|
1048
|
+
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
private _getDraftPreview(draft: DraftItem): string | null {
|
|
1052
|
+
if (draft.bodyText) return draft.bodyText;
|
|
1053
|
+
if (draft.title) return draft.title;
|
|
1054
|
+
if (draft.quoteText) return draft.quoteText;
|
|
1055
|
+
if (draft.url) return draft.url;
|
|
1056
|
+
return null;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// ── Local draft auto-save (globalThis.localStorage) ──────────────────────────
|
|
1060
|
+
|
|
1061
|
+
private static _DRAFT_KEY = "jant:compose-draft";
|
|
1062
|
+
private static _DRAFT_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
1063
|
+
|
|
1064
|
+
private _onContentChanged = () => {
|
|
1065
|
+
// Schedule localStorage auto-save for new-post mode only
|
|
1066
|
+
if (!this._editPostId && !this._draftSourceId) {
|
|
1067
|
+
this._scheduleDraftSave();
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
private _cancelDraftSaveTimer() {
|
|
1072
|
+
if (this._draftSaveTimer !== null) {
|
|
1073
|
+
clearTimeout(this._draftSaveTimer);
|
|
1074
|
+
this._draftSaveTimer = null;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
private _scheduleDraftSave() {
|
|
1079
|
+
this._cancelDraftSaveTimer();
|
|
1080
|
+
this._draftSaveTimer = setTimeout(() => this._saveDraftToStorage(), 1000);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/** Flush pending draft save and warn on unsaved changes before page unload */
|
|
1084
|
+
private _onBeforeUnload = (e: globalThis.BeforeUnloadEvent) => {
|
|
1085
|
+
if (this._suppressBeforeUnload) return;
|
|
1086
|
+
|
|
1087
|
+
// Flush any pending debounced draft save
|
|
1088
|
+
if (this._draftSaveTimer !== null) {
|
|
1089
|
+
this._cancelDraftSaveTimer();
|
|
1090
|
+
this._saveDraftToStorage();
|
|
1091
|
+
}
|
|
1092
|
+
// Warn if compose has unsaved modifications in either dialog or page mode.
|
|
1093
|
+
const dialog = this.closest("dialog");
|
|
1094
|
+
const shouldWarn =
|
|
1095
|
+
this._hasUnsavedChanges() && (this.pageMode || dialog?.open === true);
|
|
1096
|
+
if (shouldWarn) {
|
|
1097
|
+
e.preventDefault();
|
|
1098
|
+
e.returnValue = "";
|
|
1099
|
+
}
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
private _saveDraftToStorage() {
|
|
1103
|
+
const editor = this._editor;
|
|
1104
|
+
if (!editor) return;
|
|
1105
|
+
|
|
1106
|
+
const data = editor.getData();
|
|
1107
|
+
const hasContent =
|
|
1108
|
+
!!data.body ||
|
|
1109
|
+
!!data.title.trim() ||
|
|
1110
|
+
!!data.url.trim() ||
|
|
1111
|
+
!!data.quoteText.trim() ||
|
|
1112
|
+
!!data.quoteAuthor.trim() ||
|
|
1113
|
+
data.rating > 0 ||
|
|
1114
|
+
data.attachedTexts.some((t) => t.bodyJson !== null);
|
|
1115
|
+
|
|
1116
|
+
if (!hasContent) {
|
|
1117
|
+
globalThis.localStorage.removeItem(JantComposeDialog._DRAFT_KEY);
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const draft: LocalDraft = {
|
|
1122
|
+
format: this._format,
|
|
1123
|
+
title: data.title,
|
|
1124
|
+
bodyJson: editor._bodyJson,
|
|
1125
|
+
url: data.url,
|
|
1126
|
+
quoteText: data.quoteText,
|
|
1127
|
+
quoteAuthor: data.quoteAuthor,
|
|
1128
|
+
rating: data.rating,
|
|
1129
|
+
showTitle: editor._showTitle,
|
|
1130
|
+
showRating: editor._showRating,
|
|
1131
|
+
collectionIds: [...this._collectionIds],
|
|
1132
|
+
replyToId: this._replyToId,
|
|
1133
|
+
attachedTexts: data.attachedTexts.map((t) => ({
|
|
1134
|
+
clientId: t.clientId,
|
|
1135
|
+
bodyJson: t.bodyJson,
|
|
1136
|
+
bodyHtml: t.bodyHtml,
|
|
1137
|
+
summary: t.summary,
|
|
1138
|
+
})),
|
|
1139
|
+
attachmentOrder: [...(data.attachmentOrder ?? [])],
|
|
1140
|
+
savedAt: Date.now(),
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1143
|
+
try {
|
|
1144
|
+
globalThis.localStorage.setItem(
|
|
1145
|
+
JantComposeDialog._DRAFT_KEY,
|
|
1146
|
+
JSON.stringify(draft),
|
|
1147
|
+
);
|
|
1148
|
+
} catch {
|
|
1149
|
+
// Storage full or unavailable — silently ignore
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
private _clearDraftFromStorage() {
|
|
1154
|
+
this._cancelDraftSaveTimer();
|
|
1155
|
+
globalThis.localStorage.removeItem(JantComposeDialog._DRAFT_KEY);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
async restoreLocalDraft() {
|
|
1159
|
+
// Don't restore if already in edit or draft-load mode
|
|
1160
|
+
if (this._editPostId || this._draftSourceId) return;
|
|
1161
|
+
// Don't restore if the editor already has content (e.g. reopened dialog)
|
|
1162
|
+
if (this._hasContent()) return;
|
|
1163
|
+
|
|
1164
|
+
let raw: string | null;
|
|
1165
|
+
try {
|
|
1166
|
+
raw = globalThis.localStorage.getItem(JantComposeDialog._DRAFT_KEY);
|
|
1167
|
+
} catch {
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
if (!raw) return;
|
|
1171
|
+
|
|
1172
|
+
let draft: LocalDraft;
|
|
1173
|
+
try {
|
|
1174
|
+
draft = JSON.parse(raw) as LocalDraft;
|
|
1175
|
+
} catch {
|
|
1176
|
+
globalThis.localStorage.removeItem(JantComposeDialog._DRAFT_KEY);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Discard stale drafts
|
|
1181
|
+
if (Date.now() - draft.savedAt > JantComposeDialog._DRAFT_MAX_AGE) {
|
|
1182
|
+
globalThis.localStorage.removeItem(JantComposeDialog._DRAFT_KEY);
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
this._format = draft.format;
|
|
1187
|
+
this._collectionIds = [...(draft.collectionIds ?? [])];
|
|
1188
|
+
|
|
1189
|
+
// Restore reply context if this draft was a reply
|
|
1190
|
+
if (draft.replyToId) {
|
|
1191
|
+
this._replyToId = draft.replyToId;
|
|
1192
|
+
await this._fetchReplyContext(draft.replyToId);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
await this.updateComplete;
|
|
1196
|
+
|
|
1197
|
+
const textAttachments = draft.attachedTexts
|
|
1198
|
+
?.filter((t) => t.bodyJson !== null)
|
|
1199
|
+
.map((t) => ({
|
|
1200
|
+
clientId: t.clientId,
|
|
1201
|
+
bodyJson: JSON.stringify(t.bodyJson),
|
|
1202
|
+
bodyHtml: t.bodyHtml,
|
|
1203
|
+
summary: t.summary,
|
|
1204
|
+
}));
|
|
1205
|
+
|
|
1206
|
+
this._editor?.populate({
|
|
1207
|
+
format: draft.format,
|
|
1208
|
+
title: draft.title || undefined,
|
|
1209
|
+
bodyJson: draft.bodyJson ? JSON.stringify(draft.bodyJson) : undefined,
|
|
1210
|
+
url: draft.url || undefined,
|
|
1211
|
+
quoteText: draft.quoteText || undefined,
|
|
1212
|
+
quoteAuthor: draft.quoteAuthor || undefined,
|
|
1213
|
+
rating: draft.rating || undefined,
|
|
1214
|
+
showTitle: draft.showTitle,
|
|
1215
|
+
showRating: draft.showRating,
|
|
1216
|
+
textAttachments: textAttachments?.length ? textAttachments : undefined,
|
|
1217
|
+
attachmentOrder: draft.attachmentOrder,
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
this._draftRestored = true;
|
|
1221
|
+
showToast(this.labels.draftRestored);
|
|
1222
|
+
globalThis.requestAnimationFrame(() => {
|
|
1223
|
+
this._captureInitialSnapshot();
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
private async _focusPageEditorOnMount() {
|
|
1228
|
+
if (this._pageFocusApplied) return;
|
|
1229
|
+
|
|
1230
|
+
if (this.autoRestoreDraft) {
|
|
1231
|
+
await this.restoreLocalDraft();
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
await this.updateComplete;
|
|
1235
|
+
globalThis.requestAnimationFrame(() => {
|
|
1236
|
+
this._editor?.focusInput();
|
|
1237
|
+
this._pageFocusApplied = true;
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
private _renderDraftsPanel() {
|
|
1242
|
+
if (!this._draftsPanelOpen) return nothing;
|
|
1243
|
+
|
|
1244
|
+
return html`
|
|
1245
|
+
<div class="compose-drafts-panel">
|
|
1246
|
+
<div class="compose-alt-header">
|
|
1247
|
+
<button
|
|
1248
|
+
type="button"
|
|
1249
|
+
class="compose-attached-panel-back"
|
|
1250
|
+
@click=${() => this._closeDraftsPanel()}
|
|
1251
|
+
>
|
|
1252
|
+
<svg
|
|
1253
|
+
class="icon-fine"
|
|
1254
|
+
width="16"
|
|
1255
|
+
height="16"
|
|
1256
|
+
viewBox="0 0 16 16"
|
|
1257
|
+
fill="none"
|
|
1258
|
+
stroke="currentColor"
|
|
1259
|
+
stroke-width="1.5"
|
|
1260
|
+
stroke-linecap="round"
|
|
1261
|
+
stroke-linejoin="round"
|
|
1262
|
+
>
|
|
1263
|
+
<path d="M11 3L6 8l5 5" />
|
|
1264
|
+
</svg>
|
|
1265
|
+
</button>
|
|
1266
|
+
<span class="compose-alt-title">${this.labels.drafts}</span>
|
|
1267
|
+
</div>
|
|
1268
|
+
${this._draftsLoading
|
|
1269
|
+
? html`<div class="compose-drafts-loading">
|
|
1270
|
+
<svg
|
|
1271
|
+
class="animate-spin size-5"
|
|
1272
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
1273
|
+
viewBox="0 0 24 24"
|
|
1274
|
+
fill="none"
|
|
1275
|
+
stroke="currentColor"
|
|
1276
|
+
stroke-width="2"
|
|
1277
|
+
stroke-linecap="round"
|
|
1278
|
+
stroke-linejoin="round"
|
|
1279
|
+
>
|
|
1280
|
+
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
1281
|
+
</svg>
|
|
1282
|
+
</div>`
|
|
1283
|
+
: this._draftsError
|
|
1284
|
+
? html`<div class="compose-drafts-empty">${this._draftsError}</div>`
|
|
1285
|
+
: this._drafts.length === 0
|
|
1286
|
+
? html`<div class="compose-drafts-empty">
|
|
1287
|
+
${this.labels.draftsEmpty}
|
|
1288
|
+
</div>`
|
|
1289
|
+
: html`<div class="compose-drafts-list">
|
|
1290
|
+
${this._drafts.map(
|
|
1291
|
+
(draft, i) => html`
|
|
1292
|
+
${i > 0
|
|
1293
|
+
? html`<div class="compose-drafts-divider"></div>`
|
|
1294
|
+
: nothing}
|
|
1295
|
+
${this._renderDraftItem(draft)}
|
|
1296
|
+
`,
|
|
1297
|
+
)}
|
|
1298
|
+
</div>`}
|
|
1299
|
+
</div>
|
|
1300
|
+
`;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
private _renderDraftItem(draft: DraftItem) {
|
|
1304
|
+
const preview = this._getDraftPreview(draft);
|
|
1305
|
+
|
|
1306
|
+
return html`
|
|
1307
|
+
<div class="compose-draft-item" @click=${() => this._loadDraft(draft.id)}>
|
|
1308
|
+
<div class="compose-draft-content">
|
|
1309
|
+
${preview
|
|
1310
|
+
? html`<div class="compose-draft-preview">${preview}</div>`
|
|
1311
|
+
: html`<div
|
|
1312
|
+
class="compose-draft-preview compose-draft-preview-empty"
|
|
1313
|
+
>
|
|
1314
|
+
Empty draft
|
|
1315
|
+
</div>`}
|
|
1316
|
+
<div class="compose-draft-meta">
|
|
1317
|
+
${this._formatDraftDate(draft.updatedAt)}
|
|
1318
|
+
</div>
|
|
1319
|
+
</div>
|
|
1320
|
+
<div class="relative">
|
|
1321
|
+
${this._draftMenuOpenId === draft.id
|
|
1322
|
+
? html`<div
|
|
1323
|
+
class="compose-dropdown-backdrop"
|
|
1324
|
+
@click=${(e: Event) => {
|
|
1325
|
+
e.stopPropagation();
|
|
1326
|
+
this._draftMenuOpenId = null;
|
|
1327
|
+
}}
|
|
1328
|
+
></div>`
|
|
1329
|
+
: nothing}
|
|
1330
|
+
<button
|
|
1331
|
+
type="button"
|
|
1332
|
+
class="compose-draft-more"
|
|
1333
|
+
@click=${(e: Event) => {
|
|
1334
|
+
e.stopPropagation();
|
|
1335
|
+
this._draftMenuOpenId =
|
|
1336
|
+
this._draftMenuOpenId === draft.id ? null : draft.id;
|
|
1337
|
+
}}
|
|
1338
|
+
>
|
|
1339
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
1340
|
+
<circle cx="4" cy="8" r="1.2" />
|
|
1341
|
+
<circle cx="8" cy="8" r="1.2" />
|
|
1342
|
+
<circle cx="12" cy="8" r="1.2" />
|
|
1343
|
+
</svg>
|
|
1344
|
+
</button>
|
|
1345
|
+
${this._draftMenuOpenId === draft.id
|
|
1346
|
+
? html`
|
|
1347
|
+
<div class="compose-dropdown compose-dropdown-right">
|
|
1348
|
+
<button
|
|
1349
|
+
type="button"
|
|
1350
|
+
class="compose-dropdown-item compose-dropdown-item-danger"
|
|
1351
|
+
@click=${(e: Event) => {
|
|
1352
|
+
e.stopPropagation();
|
|
1353
|
+
this._deleteDraft(draft.id);
|
|
1354
|
+
}}
|
|
1355
|
+
>
|
|
1356
|
+
${this.labels.deleteDraft}
|
|
1357
|
+
</button>
|
|
1358
|
+
</div>
|
|
1359
|
+
`
|
|
1360
|
+
: nothing}
|
|
1361
|
+
</div>
|
|
1362
|
+
</div>
|
|
1363
|
+
`;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// ── Reply context rendering ──────────────────────────────────────
|
|
264
1367
|
|
|
265
|
-
private
|
|
266
|
-
this.
|
|
267
|
-
this.updateComplete.then(() => {
|
|
268
|
-
this.querySelector<HTMLTextAreaElement>(
|
|
269
|
-
".compose-attached-textarea",
|
|
270
|
-
)?.focus();
|
|
271
|
-
});
|
|
272
|
-
};
|
|
1368
|
+
private _renderReplyContext() {
|
|
1369
|
+
if (!this._replyToId || !this._replyToData) return nothing;
|
|
273
1370
|
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
this._editor?.updateAttachedText(value);
|
|
277
|
-
}
|
|
1371
|
+
const { contentHtml, dateText } = this._replyToData;
|
|
1372
|
+
const isExpanded = this._replyExpanded;
|
|
278
1373
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
1374
|
+
return html`
|
|
1375
|
+
<div class="compose-reply-row">
|
|
1376
|
+
<div class="compose-thread-dot"></div>
|
|
1377
|
+
<div
|
|
1378
|
+
class=${classMap({
|
|
1379
|
+
"compose-reply-context": true,
|
|
1380
|
+
expanded: isExpanded,
|
|
1381
|
+
})}
|
|
1382
|
+
>
|
|
1383
|
+
<div class="compose-reply-context-body">
|
|
1384
|
+
${unsafeHTML(contentHtml)}
|
|
1385
|
+
</div>
|
|
1386
|
+
${!isExpanded
|
|
1387
|
+
? html`<div class="compose-reply-fade"></div>`
|
|
1388
|
+
: nothing}
|
|
1389
|
+
</div>
|
|
1390
|
+
</div>
|
|
1391
|
+
<div class="compose-reply-meta">
|
|
1392
|
+
${dateText ? html`<span>${dateText}</span><span>·</span>` : nothing}
|
|
1393
|
+
<button
|
|
1394
|
+
type="button"
|
|
1395
|
+
class="compose-reply-toggle"
|
|
1396
|
+
@click=${() => {
|
|
1397
|
+
this._replyExpanded = !this._replyExpanded;
|
|
1398
|
+
}}
|
|
1399
|
+
>
|
|
1400
|
+
${isExpanded ? this.labels.showLess : this.labels.showMore}
|
|
1401
|
+
</button>
|
|
1402
|
+
</div>
|
|
1403
|
+
`;
|
|
282
1404
|
}
|
|
283
1405
|
|
|
284
1406
|
// ── Render helpers ────────────────────────────────────────────────
|
|
@@ -296,66 +1418,73 @@ export class JantComposeDialog extends LitElement {
|
|
|
296
1418
|
<button
|
|
297
1419
|
type="button"
|
|
298
1420
|
class="compose-dialog-cancel"
|
|
299
|
-
@click=${() => this.
|
|
1421
|
+
@click=${() => this.requestClose()}
|
|
300
1422
|
>
|
|
301
1423
|
${this.labels.cancel}
|
|
302
1424
|
</button>
|
|
303
1425
|
|
|
304
1426
|
<div class="compose-dialog-header-center">
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
"compose-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
1427
|
+
${this._editPostId
|
|
1428
|
+
? html`<span class="compose-dialog-title"
|
|
1429
|
+
>${this.labels.editPost}</span
|
|
1430
|
+
>`
|
|
1431
|
+
: html`
|
|
1432
|
+
<div class="compose-segmented">
|
|
1433
|
+
<div
|
|
1434
|
+
class=${classMap({
|
|
1435
|
+
"compose-format-pill": true,
|
|
1436
|
+
"compose-format-pill-link": this._format === "link",
|
|
1437
|
+
"compose-format-pill-quote": this._format === "quote",
|
|
1438
|
+
})}
|
|
1439
|
+
></div>
|
|
1440
|
+
${formats.map(
|
|
1441
|
+
(f) => html`
|
|
1442
|
+
<button
|
|
1443
|
+
type="button"
|
|
1444
|
+
class=${classMap({
|
|
1445
|
+
"compose-segmented-item": true,
|
|
1446
|
+
"compose-segmented-item-active": this._format === f,
|
|
1447
|
+
})}
|
|
1448
|
+
@click=${() => {
|
|
1449
|
+
this._format = f;
|
|
1450
|
+
globalThis.requestAnimationFrame(() =>
|
|
1451
|
+
this._editor?.focusInput(),
|
|
1452
|
+
);
|
|
1453
|
+
}}
|
|
1454
|
+
>
|
|
1455
|
+
${formatLabels[f]}
|
|
1456
|
+
</button>
|
|
1457
|
+
`,
|
|
1458
|
+
)}
|
|
1459
|
+
</div>
|
|
1460
|
+
`}
|
|
333
1461
|
</div>
|
|
334
1462
|
|
|
335
1463
|
<div class="flex items-center gap-0.5 shrink-0">
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
1464
|
+
${this._editPostId
|
|
1465
|
+
? nothing
|
|
1466
|
+
: html`<button
|
|
1467
|
+
type="button"
|
|
1468
|
+
class="compose-dialog-header-btn"
|
|
1469
|
+
title=${this.labels.saveDraft}
|
|
1470
|
+
?disabled=${this._loading}
|
|
1471
|
+
@click=${() => this._handleDraftButtonClick()}
|
|
1472
|
+
>
|
|
1473
|
+
<svg
|
|
1474
|
+
class="icon-fine"
|
|
1475
|
+
width="18"
|
|
1476
|
+
height="18"
|
|
1477
|
+
viewBox="0 0 18 18"
|
|
1478
|
+
fill="none"
|
|
1479
|
+
stroke="currentColor"
|
|
1480
|
+
stroke-width="1.3"
|
|
1481
|
+
stroke-linecap="round"
|
|
1482
|
+
stroke-linejoin="round"
|
|
1483
|
+
>
|
|
1484
|
+
<path d="M14 2.5L15.5 4 7 12.5l-3 .5.5-3L14 2.5z" />
|
|
1485
|
+
<path d="M4 15h10" />
|
|
1486
|
+
</svg>
|
|
1487
|
+
</button>`}
|
|
359
1488
|
${this._renderMoreMenu()}
|
|
360
1489
|
</div>
|
|
361
1490
|
</header>
|
|
@@ -404,8 +1533,8 @@ export class JantComposeDialog extends LitElement {
|
|
|
404
1533
|
type="button"
|
|
405
1534
|
class="compose-dropdown-item compose-dropdown-item-danger"
|
|
406
1535
|
@click=${() => {
|
|
407
|
-
this._closeDialog();
|
|
408
1536
|
this._showMoreMenu = false;
|
|
1537
|
+
this._discardAndClose();
|
|
409
1538
|
}}
|
|
410
1539
|
>
|
|
411
1540
|
${this.labels.discard}
|
|
@@ -418,14 +1547,11 @@ export class JantComposeDialog extends LitElement {
|
|
|
418
1547
|
}
|
|
419
1548
|
|
|
420
1549
|
private _renderCollectionSelector() {
|
|
421
|
-
|
|
422
|
-
return html`<div class="flex-1"></div>`;
|
|
423
|
-
}
|
|
424
|
-
|
|
1550
|
+
const collections = this.collections ?? [];
|
|
425
1551
|
const search = this._collectionSearch.toLowerCase();
|
|
426
1552
|
const filtered = search
|
|
427
|
-
?
|
|
428
|
-
:
|
|
1553
|
+
? collections.filter((c) => c.title.toLowerCase().includes(search))
|
|
1554
|
+
: collections;
|
|
429
1555
|
const selectedCount = this._collectionIds.length;
|
|
430
1556
|
|
|
431
1557
|
return html`
|
|
@@ -465,8 +1591,8 @@ export class JantComposeDialog extends LitElement {
|
|
|
465
1591
|
<path d="M6 5V4a1 1 0 011-1h4a1 1 0 011 1v1" />
|
|
466
1592
|
</svg>
|
|
467
1593
|
${selectedCount > 0
|
|
468
|
-
? html`<span class="
|
|
469
|
-
>${
|
|
1594
|
+
? html`<span class="compose-collection-label"
|
|
1595
|
+
>${this._selectedCollectionLabel(collections)}</span
|
|
470
1596
|
>`
|
|
471
1597
|
: html`<span>${this.labels.collection}</span>`}
|
|
472
1598
|
<svg
|
|
@@ -488,37 +1614,45 @@ export class JantComposeDialog extends LitElement {
|
|
|
488
1614
|
data-side="top"
|
|
489
1615
|
aria-hidden=${this._showCollection ? "false" : "true"}
|
|
490
1616
|
>
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
1617
|
+
${collections.length > 0
|
|
1618
|
+
? html`<header>
|
|
1619
|
+
<svg
|
|
1620
|
+
width="16"
|
|
1621
|
+
height="16"
|
|
1622
|
+
viewBox="0 0 24 24"
|
|
1623
|
+
fill="none"
|
|
1624
|
+
stroke="currentColor"
|
|
1625
|
+
stroke-width="2"
|
|
1626
|
+
stroke-linecap="round"
|
|
1627
|
+
stroke-linejoin="round"
|
|
1628
|
+
>
|
|
1629
|
+
<circle cx="11" cy="11" r="8" />
|
|
1630
|
+
<path d="m21 21-4.3-4.3" />
|
|
1631
|
+
</svg>
|
|
1632
|
+
<input
|
|
1633
|
+
type="text"
|
|
1634
|
+
role="combobox"
|
|
1635
|
+
placeholder=${this.labels.searchCollections}
|
|
1636
|
+
autocomplete="off"
|
|
1637
|
+
autocorrect="off"
|
|
1638
|
+
spellcheck="false"
|
|
1639
|
+
.value=${this._collectionSearch}
|
|
1640
|
+
@input=${(e: Event) => {
|
|
1641
|
+
this._collectionSearch = (
|
|
1642
|
+
e.target as HTMLInputElement
|
|
1643
|
+
).value;
|
|
1644
|
+
}}
|
|
1645
|
+
/>
|
|
1646
|
+
</header>`
|
|
1647
|
+
: nothing}
|
|
518
1648
|
<div
|
|
519
1649
|
role="listbox"
|
|
520
1650
|
aria-multiselectable="true"
|
|
521
|
-
data-empty=${
|
|
1651
|
+
data-empty=${filtered.length === 0
|
|
1652
|
+
? search
|
|
1653
|
+
? this.labels.noCollections
|
|
1654
|
+
: this.labels.emptyCollections
|
|
1655
|
+
: nothing}
|
|
522
1656
|
>
|
|
523
1657
|
${filtered.map(
|
|
524
1658
|
(col) => html`
|
|
@@ -541,67 +1675,154 @@ export class JantComposeDialog extends LitElement {
|
|
|
541
1675
|
`,
|
|
542
1676
|
)}
|
|
543
1677
|
</div>
|
|
1678
|
+
<div
|
|
1679
|
+
class="compose-collection-add-action"
|
|
1680
|
+
@click=${() => {
|
|
1681
|
+
this._showCollection = false;
|
|
1682
|
+
this._collectionSearch = "";
|
|
1683
|
+
this._addCollectionPanelOpen = true;
|
|
1684
|
+
}}
|
|
1685
|
+
>
|
|
1686
|
+
<svg
|
|
1687
|
+
width="14"
|
|
1688
|
+
height="14"
|
|
1689
|
+
viewBox="0 0 16 16"
|
|
1690
|
+
fill="none"
|
|
1691
|
+
stroke="currentColor"
|
|
1692
|
+
stroke-width="1.5"
|
|
1693
|
+
stroke-linecap="round"
|
|
1694
|
+
stroke-linejoin="round"
|
|
1695
|
+
>
|
|
1696
|
+
<path d="M8 3v10M3 8h10" />
|
|
1697
|
+
</svg>
|
|
1698
|
+
${this.labels.addCollection}
|
|
1699
|
+
</div>
|
|
544
1700
|
</div>
|
|
545
1701
|
</div>
|
|
546
1702
|
</div>
|
|
547
1703
|
`;
|
|
548
1704
|
}
|
|
549
1705
|
|
|
1706
|
+
// ── Add Collection panel ────────────────────────────────────────
|
|
1707
|
+
|
|
1708
|
+
private async _handleAddCollectionSubmit(e: Event) {
|
|
1709
|
+
const event = e as CustomEvent<CollectionSubmitDetail>;
|
|
1710
|
+
event.stopPropagation();
|
|
1711
|
+
|
|
1712
|
+
const detail = event.detail;
|
|
1713
|
+
if (!detail) return;
|
|
1714
|
+
|
|
1715
|
+
const formEl = this.querySelector("jant-collection-form") as
|
|
1716
|
+
| (HTMLElement & { loading: boolean })
|
|
1717
|
+
| null;
|
|
1718
|
+
if (formEl) formEl.loading = true;
|
|
1719
|
+
|
|
1720
|
+
try {
|
|
1721
|
+
const res = await fetch("/api/collections", {
|
|
1722
|
+
method: "POST",
|
|
1723
|
+
headers: { "Content-Type": "application/json" },
|
|
1724
|
+
body: JSON.stringify(detail.data),
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
1728
|
+
|
|
1729
|
+
const created = await res.json();
|
|
1730
|
+
const newCollection: ComposeCollection = {
|
|
1731
|
+
id: created.id,
|
|
1732
|
+
title: created.title,
|
|
1733
|
+
iconHtml: renderCollectionIcon(created.icon, { size: 16 }),
|
|
1734
|
+
};
|
|
1735
|
+
|
|
1736
|
+
this.collections = [...this.collections, newCollection];
|
|
1737
|
+
this._collectionIds = [...this._collectionIds, created.id];
|
|
1738
|
+
this._addCollectionPanelOpen = false;
|
|
1739
|
+
showToast(this.labels.collectionFormLabels.submitLabel);
|
|
1740
|
+
} catch {
|
|
1741
|
+
showToast("Failed to create collection. Try again.", "error");
|
|
1742
|
+
} finally {
|
|
1743
|
+
if (formEl) formEl.loading = false;
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
private _submitAddCollectionForm() {
|
|
1748
|
+
const form = this.querySelector<HTMLFormElement>(
|
|
1749
|
+
".compose-add-collection-panel form",
|
|
1750
|
+
);
|
|
1751
|
+
if (form) form.requestSubmit();
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
private _renderAddCollectionPanel() {
|
|
1755
|
+
if (!this._addCollectionPanelOpen) return nothing;
|
|
1756
|
+
|
|
1757
|
+
const initial = {
|
|
1758
|
+
title: "",
|
|
1759
|
+
slug: "",
|
|
1760
|
+
description: "",
|
|
1761
|
+
sortOrder: "newest",
|
|
1762
|
+
icon: "",
|
|
1763
|
+
};
|
|
1764
|
+
|
|
1765
|
+
return html`
|
|
1766
|
+
<div class="compose-add-collection-panel">
|
|
1767
|
+
<div class="compose-alt-header">
|
|
1768
|
+
<button
|
|
1769
|
+
type="button"
|
|
1770
|
+
class="compose-attached-cancel"
|
|
1771
|
+
@click=${() => {
|
|
1772
|
+
this._addCollectionPanelOpen = false;
|
|
1773
|
+
}}
|
|
1774
|
+
>
|
|
1775
|
+
${this.labels.cancel}
|
|
1776
|
+
</button>
|
|
1777
|
+
<span class="compose-alt-title">${this.labels.addCollection}</span>
|
|
1778
|
+
<button
|
|
1779
|
+
type="button"
|
|
1780
|
+
class="compose-post-btn ml-auto"
|
|
1781
|
+
@click=${() => this._submitAddCollectionForm()}
|
|
1782
|
+
>
|
|
1783
|
+
${this.labels.done}
|
|
1784
|
+
</button>
|
|
1785
|
+
</div>
|
|
1786
|
+
<div class="flex-1 overflow-y-auto">
|
|
1787
|
+
<jant-collection-form
|
|
1788
|
+
class="compose-add-collection-form"
|
|
1789
|
+
.labels=${this.labels.collectionFormLabels}
|
|
1790
|
+
.initial=${initial}
|
|
1791
|
+
action="/api/collections"
|
|
1792
|
+
cancel-href="javascript:void(0)"
|
|
1793
|
+
@jant:collection-submit=${(e: Event) =>
|
|
1794
|
+
this._handleAddCollectionSubmit(e)}
|
|
1795
|
+
></jant-collection-form>
|
|
1796
|
+
</div>
|
|
1797
|
+
</div>
|
|
1798
|
+
`;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
550
1801
|
private _renderAttachedPanel() {
|
|
551
1802
|
if (!this._attachedPanelOpen) return nothing;
|
|
552
|
-
const editor = this._editor;
|
|
553
|
-
const attachedText = editor?._attachedText ?? "";
|
|
554
1803
|
|
|
555
1804
|
return html`
|
|
556
1805
|
<div class="compose-attached-panel">
|
|
557
1806
|
<div class="compose-alt-header">
|
|
558
1807
|
<button
|
|
559
1808
|
type="button"
|
|
560
|
-
class="compose-attached-
|
|
561
|
-
@click=${() => this.
|
|
1809
|
+
class="compose-attached-cancel"
|
|
1810
|
+
@click=${() => this._cancelAttachedPanel()}
|
|
562
1811
|
>
|
|
563
|
-
|
|
564
|
-
class="icon-fine"
|
|
565
|
-
width="16"
|
|
566
|
-
height="16"
|
|
567
|
-
viewBox="0 0 16 16"
|
|
568
|
-
fill="none"
|
|
569
|
-
stroke="currentColor"
|
|
570
|
-
stroke-width="1.5"
|
|
571
|
-
stroke-linecap="round"
|
|
572
|
-
stroke-linejoin="round"
|
|
573
|
-
>
|
|
574
|
-
<path d="M11 3L6 8l5 5" />
|
|
575
|
-
</svg>
|
|
1812
|
+
${this.labels.cancel}
|
|
576
1813
|
</button>
|
|
577
1814
|
<span class="compose-alt-title">${this.labels.attachedText}</span>
|
|
578
|
-
${attachedText.length > 0
|
|
579
|
-
? html`<span
|
|
580
|
-
class="compose-attached-charcount text-xs text-muted-foreground tracking-wide"
|
|
581
|
-
>${attachedText.length.toLocaleString()} chars</span
|
|
582
|
-
>`
|
|
583
|
-
: nothing}
|
|
584
|
-
</div>
|
|
585
|
-
<div class="flex-1 p-4 overflow-hidden flex flex-col">
|
|
586
|
-
<textarea
|
|
587
|
-
.value=${attachedText}
|
|
588
|
-
@input=${(e: Event) => this._onAttachedTextInput(e)}
|
|
589
|
-
class="compose-input compose-attached-textarea"
|
|
590
|
-
placeholder=${this.labels.attachedTextPlaceholder}
|
|
591
|
-
></textarea>
|
|
592
|
-
</div>
|
|
593
|
-
<div class="compose-alt-footer">
|
|
594
|
-
<span class="text-xs text-muted-foreground"
|
|
595
|
-
>${this.labels.attachedTextHint}</span
|
|
596
|
-
>
|
|
597
1815
|
<button
|
|
598
1816
|
type="button"
|
|
599
|
-
class="compose-post-btn"
|
|
600
|
-
@click=${() => this.
|
|
1817
|
+
class="compose-post-btn ml-auto"
|
|
1818
|
+
@click=${() => this._doneAttachedPanel()}
|
|
601
1819
|
>
|
|
602
1820
|
${this.labels.done}
|
|
603
1821
|
</button>
|
|
604
1822
|
</div>
|
|
1823
|
+
<div class="flex-1 p-4 overflow-hidden flex flex-col">
|
|
1824
|
+
<div class="compose-attached-tiptap compose-tiptap-body"></div>
|
|
1825
|
+
</div>
|
|
605
1826
|
</div>
|
|
606
1827
|
`;
|
|
607
1828
|
}
|
|
@@ -680,44 +1901,259 @@ export class JantComposeDialog extends LitElement {
|
|
|
680
1901
|
`;
|
|
681
1902
|
}
|
|
682
1903
|
|
|
683
|
-
|
|
684
|
-
return
|
|
685
|
-
<div class="compose-dialog-inner">
|
|
686
|
-
${this._renderHeader()}
|
|
687
|
-
<jant-compose-editor
|
|
688
|
-
.format=${this._format}
|
|
689
|
-
.labels=${this.labels}
|
|
690
|
-
.uploadMaxFileSize=${this.uploadMaxFileSize}
|
|
691
|
-
></jant-compose-editor>
|
|
1904
|
+
private _renderConfirmPanel() {
|
|
1905
|
+
if (!this._confirmPanelOpen) return nothing;
|
|
692
1906
|
|
|
693
|
-
|
|
694
|
-
|
|
1907
|
+
const isEdit = !!this._editPostId;
|
|
1908
|
+
const title = isEdit
|
|
1909
|
+
? this.labels.confirmEditTitle
|
|
1910
|
+
: this.labels.confirmCloseTitle;
|
|
1911
|
+
const subtitle = isEdit
|
|
1912
|
+
? this.labels.confirmEditSubtitle
|
|
1913
|
+
: this.labels.confirmCloseSubtitle;
|
|
1914
|
+
const saveLabel = isEdit
|
|
1915
|
+
? this.labels.confirmEditPublish
|
|
1916
|
+
: this.labels.confirmCloseSave;
|
|
1917
|
+
const discardLabel = isEdit
|
|
1918
|
+
? this.labels.confirmEditDiscard
|
|
1919
|
+
: this.labels.confirmCloseDiscard;
|
|
1920
|
+
|
|
1921
|
+
return html`
|
|
1922
|
+
<div class="compose-confirm-panel">
|
|
1923
|
+
<div class="compose-confirm-sheet">
|
|
1924
|
+
<div class="compose-confirm-header">
|
|
1925
|
+
<p class="compose-confirm-title">${title}</p>
|
|
1926
|
+
<p class="compose-confirm-subtitle">${subtitle}</p>
|
|
1927
|
+
</div>
|
|
695
1928
|
<button
|
|
696
1929
|
type="button"
|
|
697
|
-
class="compose-
|
|
698
|
-
|
|
699
|
-
@click=${() => this._submit("published")}
|
|
1930
|
+
class="compose-confirm-action compose-confirm-save"
|
|
1931
|
+
@click=${() => this._handleConfirmSave()}
|
|
700
1932
|
>
|
|
701
|
-
${
|
|
702
|
-
? html`<svg
|
|
703
|
-
class="animate-spin size-4"
|
|
704
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
705
|
-
viewBox="0 0 24 24"
|
|
706
|
-
fill="none"
|
|
707
|
-
stroke="currentColor"
|
|
708
|
-
stroke-width="2"
|
|
709
|
-
stroke-linecap="round"
|
|
710
|
-
stroke-linejoin="round"
|
|
711
|
-
role="status"
|
|
712
|
-
>
|
|
713
|
-
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
714
|
-
</svg>`
|
|
715
|
-
: nothing}
|
|
716
|
-
${this.labels.post}
|
|
1933
|
+
${saveLabel}
|
|
717
1934
|
</button>
|
|
1935
|
+
<button
|
|
1936
|
+
type="button"
|
|
1937
|
+
class="compose-confirm-action compose-confirm-discard"
|
|
1938
|
+
@click=${() => this._handleConfirmDiscard()}
|
|
1939
|
+
>
|
|
1940
|
+
${discardLabel}
|
|
1941
|
+
</button>
|
|
1942
|
+
<button
|
|
1943
|
+
type="button"
|
|
1944
|
+
class="compose-confirm-action compose-confirm-cancel"
|
|
1945
|
+
@click=${() => this.requestClose()}
|
|
1946
|
+
>
|
|
1947
|
+
${this.labels.confirmCloseCancel}
|
|
1948
|
+
</button>
|
|
1949
|
+
</div>
|
|
1950
|
+
</div>
|
|
1951
|
+
`;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
private _getSubmitLabel(): string {
|
|
1955
|
+
if (this._editPostId) return this.labels.update;
|
|
1956
|
+
if (this._replyToId) return this.labels.reply;
|
|
1957
|
+
return this.labels.post;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
private _submitWithVisibility(visibility: ComposeVisibility) {
|
|
1961
|
+
this._visibility = visibility;
|
|
1962
|
+
this._showVisibilityMenu = false;
|
|
1963
|
+
// Wait for state to update before submitting
|
|
1964
|
+
this.updateComplete.then(() => this._submit("published"));
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
private _renderPublishButton() {
|
|
1968
|
+
const spinner = html`<svg
|
|
1969
|
+
class="animate-spin size-4"
|
|
1970
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
1971
|
+
viewBox="0 0 24 24"
|
|
1972
|
+
fill="none"
|
|
1973
|
+
stroke="currentColor"
|
|
1974
|
+
stroke-width="2"
|
|
1975
|
+
stroke-linecap="round"
|
|
1976
|
+
stroke-linejoin="round"
|
|
1977
|
+
role="status"
|
|
1978
|
+
>
|
|
1979
|
+
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
1980
|
+
</svg>`;
|
|
1981
|
+
|
|
1982
|
+
// In edit mode or reply mode, show a simple button (no visibility split)
|
|
1983
|
+
if (this._editPostId || this._replyToId) {
|
|
1984
|
+
return html`
|
|
1985
|
+
<button
|
|
1986
|
+
type="button"
|
|
1987
|
+
class="compose-post-btn"
|
|
1988
|
+
?disabled=${this._loading}
|
|
1989
|
+
@click=${() => this._submit("published")}
|
|
1990
|
+
>
|
|
1991
|
+
${this._loading ? spinner : nothing} ${this._getSubmitLabel()}
|
|
1992
|
+
</button>
|
|
1993
|
+
`;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
return html`
|
|
1997
|
+
<div class="compose-publish-group">
|
|
1998
|
+
${this._showVisibilityMenu
|
|
1999
|
+
? html`<div
|
|
2000
|
+
class="compose-dropdown-backdrop"
|
|
2001
|
+
@click=${() => {
|
|
2002
|
+
this._showVisibilityMenu = false;
|
|
2003
|
+
}}
|
|
2004
|
+
></div>`
|
|
2005
|
+
: nothing}
|
|
2006
|
+
<button
|
|
2007
|
+
type="button"
|
|
2008
|
+
class="compose-publish-main"
|
|
2009
|
+
?disabled=${this._loading}
|
|
2010
|
+
@click=${() => this._submit("published")}
|
|
2011
|
+
>
|
|
2012
|
+
${this._loading ? spinner : nothing} ${this._getSubmitLabel()}
|
|
2013
|
+
</button>
|
|
2014
|
+
<button
|
|
2015
|
+
type="button"
|
|
2016
|
+
class="compose-publish-toggle"
|
|
2017
|
+
?disabled=${this._loading}
|
|
2018
|
+
aria-haspopup="menu"
|
|
2019
|
+
aria-expanded=${this._showVisibilityMenu}
|
|
2020
|
+
@click=${() => {
|
|
2021
|
+
this._showVisibilityMenu = !this._showVisibilityMenu;
|
|
2022
|
+
}}
|
|
2023
|
+
>
|
|
2024
|
+
<svg
|
|
2025
|
+
width="14"
|
|
2026
|
+
height="14"
|
|
2027
|
+
viewBox="0 0 24 24"
|
|
2028
|
+
fill="none"
|
|
2029
|
+
stroke="currentColor"
|
|
2030
|
+
stroke-width="2.5"
|
|
2031
|
+
stroke-linecap="round"
|
|
2032
|
+
stroke-linejoin="round"
|
|
2033
|
+
>
|
|
2034
|
+
<path d="m6 9 6 6 6-6" />
|
|
2035
|
+
</svg>
|
|
2036
|
+
</button>
|
|
2037
|
+
${this._showVisibilityMenu
|
|
2038
|
+
? html`
|
|
2039
|
+
<div class="compose-dropdown" role="menu">
|
|
2040
|
+
<button
|
|
2041
|
+
type="button"
|
|
2042
|
+
class="compose-dropdown-item"
|
|
2043
|
+
role="menuitem"
|
|
2044
|
+
@click=${() => {
|
|
2045
|
+
this._featured = true;
|
|
2046
|
+
this._showVisibilityMenu = false;
|
|
2047
|
+
this.updateComplete.then(() => this._submit("published"));
|
|
2048
|
+
}}
|
|
2049
|
+
>
|
|
2050
|
+
<svg
|
|
2051
|
+
width="16"
|
|
2052
|
+
height="16"
|
|
2053
|
+
viewBox="0 0 24 24"
|
|
2054
|
+
fill="none"
|
|
2055
|
+
stroke="currentColor"
|
|
2056
|
+
stroke-width="2"
|
|
2057
|
+
stroke-linecap="round"
|
|
2058
|
+
stroke-linejoin="round"
|
|
2059
|
+
>
|
|
2060
|
+
<path
|
|
2061
|
+
d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"
|
|
2062
|
+
/>
|
|
2063
|
+
</svg>
|
|
2064
|
+
${this.labels.publishFeatured}
|
|
2065
|
+
</button>
|
|
2066
|
+
<button
|
|
2067
|
+
type="button"
|
|
2068
|
+
class="compose-dropdown-item"
|
|
2069
|
+
role="menuitem"
|
|
2070
|
+
@click=${() => this._submitWithVisibility("unlisted")}
|
|
2071
|
+
>
|
|
2072
|
+
<svg
|
|
2073
|
+
width="16"
|
|
2074
|
+
height="16"
|
|
2075
|
+
viewBox="0 0 24 24"
|
|
2076
|
+
fill="none"
|
|
2077
|
+
stroke="currentColor"
|
|
2078
|
+
stroke-width="2"
|
|
2079
|
+
stroke-linecap="round"
|
|
2080
|
+
stroke-linejoin="round"
|
|
2081
|
+
>
|
|
2082
|
+
<path d="M9 17H7A5 5 0 0 1 7 7h2" />
|
|
2083
|
+
<path d="M15 7h2a5 5 0 1 1 0 10h-2" />
|
|
2084
|
+
<line x1="8" x2="16" y1="12" y2="12" />
|
|
2085
|
+
</svg>
|
|
2086
|
+
${this.labels.publishUnlisted}
|
|
2087
|
+
</button>
|
|
2088
|
+
<button
|
|
2089
|
+
type="button"
|
|
2090
|
+
class="compose-dropdown-item"
|
|
2091
|
+
role="menuitem"
|
|
2092
|
+
@click=${() => this._submitWithVisibility("private")}
|
|
2093
|
+
>
|
|
2094
|
+
<svg
|
|
2095
|
+
width="16"
|
|
2096
|
+
height="16"
|
|
2097
|
+
viewBox="0 0 24 24"
|
|
2098
|
+
fill="none"
|
|
2099
|
+
stroke="currentColor"
|
|
2100
|
+
stroke-width="2"
|
|
2101
|
+
stroke-linecap="round"
|
|
2102
|
+
stroke-linejoin="round"
|
|
2103
|
+
>
|
|
2104
|
+
<path
|
|
2105
|
+
d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"
|
|
2106
|
+
/>
|
|
2107
|
+
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
|
|
2108
|
+
<path
|
|
2109
|
+
d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"
|
|
2110
|
+
/>
|
|
2111
|
+
<path d="m2 2 20 20" />
|
|
2112
|
+
</svg>
|
|
2113
|
+
${this.labels.publishPrivate}
|
|
2114
|
+
</button>
|
|
2115
|
+
</div>
|
|
2116
|
+
`
|
|
2117
|
+
: nothing}
|
|
2118
|
+
</div>
|
|
2119
|
+
`;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
render() {
|
|
2123
|
+
const isReply = !!(this._replyToId && this._replyToData);
|
|
2124
|
+
const editor = html`<jant-compose-editor
|
|
2125
|
+
.format=${this._format}
|
|
2126
|
+
.labels=${this.labels}
|
|
2127
|
+
.uploadMaxFileSize=${this.uploadMaxFileSize}
|
|
2128
|
+
></jant-compose-editor>`;
|
|
2129
|
+
|
|
2130
|
+
return html`
|
|
2131
|
+
<div
|
|
2132
|
+
class=${classMap({
|
|
2133
|
+
"compose-dialog-inner": true,
|
|
2134
|
+
"compose-dialog-inner-page": this.pageMode,
|
|
2135
|
+
})}
|
|
2136
|
+
>
|
|
2137
|
+
${this._renderHeader()}
|
|
2138
|
+
${isReply
|
|
2139
|
+
? html`
|
|
2140
|
+
<div class="compose-thread-layout">
|
|
2141
|
+
${this._renderReplyContext()}
|
|
2142
|
+
<div class="compose-editor-row">
|
|
2143
|
+
<div class="compose-thread-dot"></div>
|
|
2144
|
+
${editor}
|
|
2145
|
+
</div>
|
|
2146
|
+
</div>
|
|
2147
|
+
`
|
|
2148
|
+
: editor}
|
|
2149
|
+
|
|
2150
|
+
<div class="compose-action-row">
|
|
2151
|
+
${this._renderCollectionSelector()} ${this._renderPublishButton()}
|
|
718
2152
|
</div>
|
|
719
2153
|
${this._renderAttachedPanel()} ${this._renderAltPanel()}
|
|
2154
|
+
${this._renderDraftsPanel()} ${this._renderConfirmPanel()}
|
|
720
2155
|
</div>
|
|
2156
|
+
${this._renderAddCollectionPanel()}
|
|
721
2157
|
`;
|
|
722
2158
|
}
|
|
723
2159
|
}
|