@jant/core 0.3.27 → 0.3.29

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 (314) hide show
  1. package/bin/reset-password.js +22 -0
  2. package/dist/client/client.css +1 -0
  3. package/dist/client/client.js +31561 -0
  4. package/dist/index.js +15209 -15
  5. package/package.json +25 -15
  6. package/src/__tests__/helpers/app.ts +19 -3
  7. package/src/__tests__/helpers/db.ts +44 -0
  8. package/src/__tests__/helpers/lingui-core-macro-mock.ts +33 -0
  9. package/src/app.tsx +111 -174
  10. package/src/client.ts +13 -0
  11. package/src/db/migrations/0007_post_collections_m2m.sql +94 -0
  12. package/src/db/migrations/0008_add_collection_dividers.sql +8 -0
  13. package/src/db/migrations/0009_drop_collection_show_divider.sql +2 -0
  14. package/src/db/migrations/0010_add_performance_indexes.sql +16 -0
  15. package/src/db/schema.ts +24 -4
  16. package/src/i18n/locales/en.po +810 -385
  17. package/src/i18n/locales/en.ts +1 -1
  18. package/src/i18n/locales/zh-Hans.po +733 -522
  19. package/src/i18n/locales/zh-Hans.ts +1 -1
  20. package/src/i18n/locales/zh-Hant.po +733 -522
  21. package/src/i18n/locales/zh-Hant.ts +1 -1
  22. package/src/i18n/middleware.ts +7 -11
  23. package/src/index.ts +1 -1
  24. package/src/lib/__tests__/icons.test.ts +178 -0
  25. package/src/lib/__tests__/resolve-config.test.ts +184 -0
  26. package/src/lib/__tests__/schemas.test.ts +12 -6
  27. package/src/lib/__tests__/theme.test.ts +62 -0
  28. package/src/lib/__tests__/timezones.test.ts +1 -1
  29. package/src/lib/__tests__/url.test.ts +12 -0
  30. package/src/lib/__tests__/view.test.ts +1 -5
  31. package/src/lib/avatar-upload.ts +18 -10
  32. package/src/lib/collection-form-bridge.ts +52 -0
  33. package/src/lib/collections-reorder.ts +28 -0
  34. package/src/lib/compose-bridge.ts +251 -0
  35. package/src/lib/errors.ts +116 -0
  36. package/src/lib/excerpt.ts +1 -1
  37. package/src/lib/favicon.ts +3 -5
  38. package/src/lib/html.ts +22 -0
  39. package/src/lib/icon-catalog.ts +181 -0
  40. package/src/lib/icons.ts +202 -0
  41. package/src/lib/navigation.ts +18 -33
  42. package/src/lib/pagination.ts +3 -2
  43. package/src/lib/post-form-bridge.ts +136 -0
  44. package/src/lib/render.tsx +11 -4
  45. package/src/lib/resolve-config.ts +157 -0
  46. package/src/lib/schemas.ts +76 -12
  47. package/src/lib/settings-bridge.ts +139 -0
  48. package/src/lib/storage.ts +37 -16
  49. package/src/lib/theme.ts +5 -7
  50. package/src/lib/timeline.ts +4 -8
  51. package/src/lib/toast.ts +134 -0
  52. package/src/lib/upload.ts +71 -0
  53. package/src/lib/url.ts +9 -1
  54. package/src/lib/version.ts +16 -0
  55. package/src/lib/view.ts +9 -10
  56. package/src/middleware/__tests__/auth.test.ts +6 -28
  57. package/src/middleware/__tests__/onboarding.test.ts +1 -1
  58. package/src/middleware/auth.ts +6 -12
  59. package/src/middleware/config.ts +51 -0
  60. package/src/middleware/error-handler.ts +56 -0
  61. package/src/middleware/onboarding.ts +1 -1
  62. package/src/preset.css +6 -0
  63. package/src/routes/__tests__/compose.test.ts +104 -17
  64. package/src/routes/api/__tests__/collections.test.ts +93 -2
  65. package/src/routes/api/__tests__/posts.test.ts +2 -1
  66. package/src/routes/api/__tests__/settings.test.ts +1 -1
  67. package/src/routes/api/collections.ts +64 -68
  68. package/src/routes/api/nav-items.ts +21 -59
  69. package/src/routes/api/pages.ts +18 -46
  70. package/src/routes/api/posts.ts +64 -86
  71. package/src/routes/api/search.ts +6 -4
  72. package/src/routes/api/settings.ts +8 -24
  73. package/src/routes/api/upload.ts +55 -53
  74. package/src/routes/auth/__tests__/setup.test.ts +118 -0
  75. package/src/routes/auth/reset.tsx +17 -66
  76. package/src/routes/auth/setup.tsx +67 -11
  77. package/src/routes/auth/signin.tsx +44 -8
  78. package/src/routes/compose.tsx +194 -0
  79. package/src/routes/dash/__tests__/font-theme.test.ts +110 -0
  80. package/src/routes/dash/__tests__/pages.test.ts +2 -2
  81. package/src/routes/dash/__tests__/settings-avatar.test.ts +23 -12
  82. package/src/routes/dash/appearance.tsx +173 -0
  83. package/src/routes/dash/collections.tsx +80 -14
  84. package/src/routes/dash/index.tsx +12 -14
  85. package/src/routes/dash/media.tsx +46 -49
  86. package/src/routes/dash/pages.tsx +85 -37
  87. package/src/routes/dash/posts.tsx +60 -23
  88. package/src/routes/dash/redirects.tsx +43 -33
  89. package/src/routes/dash/settings.tsx +234 -214
  90. package/src/routes/feed/__tests__/rss.test.ts +7 -3
  91. package/src/routes/feed/rss.ts +11 -16
  92. package/src/routes/feed/sitemap.ts +15 -9
  93. package/src/routes/pages/__tests__/collections.test.ts +9 -8
  94. package/src/routes/pages/archive.tsx +2 -2
  95. package/src/routes/pages/collection.tsx +76 -9
  96. package/src/routes/pages/collections.tsx +3 -1
  97. package/src/routes/pages/featured.tsx +2 -2
  98. package/src/routes/pages/home.tsx +3 -3
  99. package/src/routes/pages/latest.tsx +2 -2
  100. package/src/routes/pages/page.tsx +2 -2
  101. package/src/routes/pages/post.tsx +2 -2
  102. package/src/routes/pages/search.tsx +2 -2
  103. package/src/services/__tests__/collection.test.ts +324 -34
  104. package/src/services/__tests__/media.test.ts +1 -1
  105. package/src/services/__tests__/page.test.ts +116 -1
  106. package/src/services/auth.ts +88 -0
  107. package/src/services/collection.ts +169 -30
  108. package/src/services/index.ts +8 -3
  109. package/src/services/media.ts +39 -12
  110. package/src/services/navigation.ts +17 -5
  111. package/src/services/page.ts +24 -4
  112. package/src/services/post.ts +87 -19
  113. package/src/services/search.ts +0 -1
  114. package/src/services/settings.ts +21 -13
  115. package/src/style.css +3 -0
  116. package/src/styles/components.css +42 -1
  117. package/src/styles/tokens.css +4 -0
  118. package/src/styles/ui.css +902 -73
  119. package/src/types/app-context.ts +25 -0
  120. package/src/types/bindings.ts +1 -0
  121. package/src/types/config.ts +60 -23
  122. package/src/types/entities.ts +12 -2
  123. package/src/types/lingui-react-macro.d.ts +3 -3
  124. package/src/types/operations.ts +2 -4
  125. package/src/types/views.ts +1 -3
  126. package/src/ui/__tests__/font-themes.test.ts +27 -8
  127. package/src/ui/color-themes.ts +1 -1
  128. package/src/ui/components/__tests__/jant-collection-form.test.ts +153 -0
  129. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +512 -0
  130. package/src/ui/components/__tests__/jant-compose-editor.test.ts +272 -0
  131. package/src/ui/components/__tests__/jant-post-form.test.ts +172 -0
  132. package/src/ui/components/__tests__/jant-settings-avatar.test.ts +235 -0
  133. package/src/ui/components/__tests__/jant-settings-general.test.ts +319 -0
  134. package/src/ui/components/collection-types.ts +45 -0
  135. package/src/ui/components/compose-types.ts +75 -0
  136. package/src/ui/components/jant-collection-form.ts +512 -0
  137. package/src/ui/components/jant-compose-dialog.ts +494 -0
  138. package/src/ui/components/jant-compose-editor.ts +799 -0
  139. package/src/ui/components/jant-post-form.ts +290 -0
  140. package/src/ui/components/jant-settings-avatar.ts +231 -0
  141. package/src/ui/components/jant-settings-general.ts +436 -0
  142. package/src/ui/components/post-form-template.ts +260 -0
  143. package/src/ui/components/post-form-types.ts +87 -0
  144. package/src/ui/components/settings-types.ts +62 -0
  145. package/src/ui/compose/ComposeDialog.tsx +141 -385
  146. package/src/ui/compose/ComposePrompt.tsx +3 -3
  147. package/src/ui/dash/PostList.tsx +55 -61
  148. package/src/ui/dash/appearance/AdvancedContent.tsx +80 -0
  149. package/src/ui/dash/appearance/AppearanceNav.tsx +56 -0
  150. package/src/ui/dash/appearance/ColorThemeContent.tsx +129 -0
  151. package/src/ui/dash/appearance/FontThemeContent.tsx +98 -0
  152. package/src/ui/dash/collections/CollectionForm.tsx +130 -117
  153. package/src/ui/dash/collections/CollectionsListContent.tsx +102 -41
  154. package/src/ui/dash/collections/IconPickerGrid.tsx +50 -0
  155. package/src/ui/dash/collections/ViewCollectionContent.tsx +14 -3
  156. package/src/ui/dash/index.ts +1 -1
  157. package/src/ui/dash/posts/PostForm.tsx +248 -0
  158. package/src/ui/dash/settings/AccountContent.tsx +69 -80
  159. package/src/ui/dash/settings/GeneralContent.tsx +159 -478
  160. package/src/ui/dash/settings/SettingsNav.tsx +4 -4
  161. package/src/ui/font-themes.ts +115 -32
  162. package/src/ui/layouts/BaseLayout.tsx +49 -19
  163. package/src/ui/layouts/DashLayout.tsx +14 -9
  164. package/src/ui/layouts/SiteLayout.tsx +38 -23
  165. package/src/ui/pages/CollectionPage.tsx +12 -2
  166. package/src/ui/pages/CollectionsPage.tsx +27 -27
  167. package/src/ui/pages/HomePage.tsx +15 -6
  168. package/src/ui/pages/SearchPage.tsx +1 -2
  169. package/src/ui/shared/CollectionsSidebar.tsx +59 -0
  170. package/src/ui/shared/Pagination.tsx +2 -2
  171. package/dist/app.js +0 -267
  172. package/dist/auth.js +0 -39
  173. package/dist/client.js +0 -13
  174. package/dist/db/index.js +0 -10
  175. package/dist/db/schema.js +0 -224
  176. package/dist/i18n/Trans.js +0 -24
  177. package/dist/i18n/context.js +0 -58
  178. package/dist/i18n/detect.js +0 -26
  179. package/dist/i18n/i18n.js +0 -49
  180. package/dist/i18n/index.js +0 -44
  181. package/dist/i18n/locales/en.js +0 -1
  182. package/dist/i18n/locales/zh-Hans.js +0 -1
  183. package/dist/i18n/locales/zh-Hant.js +0 -1
  184. package/dist/i18n/locales.js +0 -13
  185. package/dist/i18n/middleware.js +0 -30
  186. package/dist/lib/avatar-upload.js +0 -134
  187. package/dist/lib/config.js +0 -143
  188. package/dist/lib/constants.js +0 -50
  189. package/dist/lib/excerpt.js +0 -76
  190. package/dist/lib/favicon.js +0 -102
  191. package/dist/lib/feed.js +0 -123
  192. package/dist/lib/image-processor.js +0 -187
  193. package/dist/lib/image.js +0 -97
  194. package/dist/lib/index.js +0 -7
  195. package/dist/lib/markdown.js +0 -83
  196. package/dist/lib/media-helpers.js +0 -49
  197. package/dist/lib/media-upload.js +0 -104
  198. package/dist/lib/nav-reorder.js +0 -27
  199. package/dist/lib/navigation.js +0 -79
  200. package/dist/lib/pagination.js +0 -44
  201. package/dist/lib/render.js +0 -53
  202. package/dist/lib/schemas.js +0 -174
  203. package/dist/lib/sqid.js +0 -72
  204. package/dist/lib/sse.js +0 -218
  205. package/dist/lib/storage.js +0 -164
  206. package/dist/lib/theme.js +0 -65
  207. package/dist/lib/time.js +0 -159
  208. package/dist/lib/timeline.js +0 -95
  209. package/dist/lib/timezones.js +0 -388
  210. package/dist/lib/url.js +0 -89
  211. package/dist/lib/view.js +0 -217
  212. package/dist/middleware/auth.js +0 -52
  213. package/dist/middleware/onboarding.js +0 -41
  214. package/dist/routes/api/collections.js +0 -124
  215. package/dist/routes/api/nav-items.js +0 -104
  216. package/dist/routes/api/pages.js +0 -91
  217. package/dist/routes/api/posts.js +0 -218
  218. package/dist/routes/api/search.js +0 -48
  219. package/dist/routes/api/settings.js +0 -68
  220. package/dist/routes/api/upload.js +0 -246
  221. package/dist/routes/auth/reset.js +0 -221
  222. package/dist/routes/auth/setup.js +0 -194
  223. package/dist/routes/auth/signin.js +0 -176
  224. package/dist/routes/compose.js +0 -48
  225. package/dist/routes/dash/collections.js +0 -115
  226. package/dist/routes/dash/index.js +0 -118
  227. package/dist/routes/dash/media.js +0 -106
  228. package/dist/routes/dash/pages.js +0 -294
  229. package/dist/routes/dash/posts.js +0 -244
  230. package/dist/routes/dash/redirects.js +0 -257
  231. package/dist/routes/dash/settings.js +0 -379
  232. package/dist/routes/feed/rss.js +0 -62
  233. package/dist/routes/feed/sitemap.js +0 -49
  234. package/dist/routes/pages/archive.js +0 -62
  235. package/dist/routes/pages/collection.js +0 -34
  236. package/dist/routes/pages/collections.js +0 -28
  237. package/dist/routes/pages/featured.js +0 -36
  238. package/dist/routes/pages/home.js +0 -64
  239. package/dist/routes/pages/latest.js +0 -45
  240. package/dist/routes/pages/page.js +0 -68
  241. package/dist/routes/pages/post.js +0 -44
  242. package/dist/routes/pages/search.js +0 -54
  243. package/dist/services/collection.js +0 -109
  244. package/dist/services/index.js +0 -24
  245. package/dist/services/media.js +0 -117
  246. package/dist/services/navigation.js +0 -91
  247. package/dist/services/page.js +0 -84
  248. package/dist/services/post.js +0 -229
  249. package/dist/services/redirect.js +0 -48
  250. package/dist/services/search.js +0 -67
  251. package/dist/services/settings.js +0 -68
  252. package/dist/types/bindings.js +0 -3
  253. package/dist/types/config.js +0 -147
  254. package/dist/types/constants.js +0 -27
  255. package/dist/types/entities.js +0 -3
  256. package/dist/types/lingui-react-macro.d.js +0 -9
  257. package/dist/types/operations.js +0 -3
  258. package/dist/types/props.js +0 -3
  259. package/dist/types/sortablejs.d.js +0 -5
  260. package/dist/types/views.js +0 -5
  261. package/dist/types.js +0 -11
  262. package/dist/ui/color-themes.js +0 -268
  263. package/dist/ui/compose/ComposeDialog.js +0 -467
  264. package/dist/ui/compose/ComposePrompt.js +0 -55
  265. package/dist/ui/dash/ActionButtons.js +0 -46
  266. package/dist/ui/dash/CrudPageHeader.js +0 -22
  267. package/dist/ui/dash/DangerZone.js +0 -36
  268. package/dist/ui/dash/FormatBadge.js +0 -27
  269. package/dist/ui/dash/ListItemRow.js +0 -21
  270. package/dist/ui/dash/PageForm.js +0 -195
  271. package/dist/ui/dash/PostForm.js +0 -395
  272. package/dist/ui/dash/PostList.js +0 -83
  273. package/dist/ui/dash/StatusBadge.js +0 -46
  274. package/dist/ui/dash/collections/CollectionForm.js +0 -152
  275. package/dist/ui/dash/collections/CollectionsListContent.js +0 -68
  276. package/dist/ui/dash/collections/ViewCollectionContent.js +0 -96
  277. package/dist/ui/dash/index.js +0 -10
  278. package/dist/ui/dash/media/MediaListContent.js +0 -166
  279. package/dist/ui/dash/media/ViewMediaContent.js +0 -212
  280. package/dist/ui/dash/pages/LinkFormContent.js +0 -130
  281. package/dist/ui/dash/pages/UnifiedPagesContent.js +0 -193
  282. package/dist/ui/dash/settings/AccountContent.js +0 -209
  283. package/dist/ui/dash/settings/AppearanceContent.js +0 -259
  284. package/dist/ui/dash/settings/GeneralContent.js +0 -536
  285. package/dist/ui/dash/settings/SettingsNav.js +0 -41
  286. package/dist/ui/feed/LinkCard.js +0 -72
  287. package/dist/ui/feed/NoteCard.js +0 -58
  288. package/dist/ui/feed/QuoteCard.js +0 -63
  289. package/dist/ui/feed/ThreadPreview.js +0 -48
  290. package/dist/ui/feed/TimelineFeed.js +0 -41
  291. package/dist/ui/feed/TimelineItem.js +0 -27
  292. package/dist/ui/font-themes.js +0 -36
  293. package/dist/ui/layouts/BaseLayout.js +0 -153
  294. package/dist/ui/layouts/DashLayout.js +0 -141
  295. package/dist/ui/layouts/SiteLayout.js +0 -169
  296. package/dist/ui/pages/ArchivePage.js +0 -143
  297. package/dist/ui/pages/CollectionPage.js +0 -70
  298. package/dist/ui/pages/CollectionsPage.js +0 -76
  299. package/dist/ui/pages/FeaturedPage.js +0 -24
  300. package/dist/ui/pages/HomePage.js +0 -24
  301. package/dist/ui/pages/PostPage.js +0 -55
  302. package/dist/ui/pages/SearchPage.js +0 -122
  303. package/dist/ui/pages/SinglePage.js +0 -23
  304. package/dist/ui/shared/EmptyState.js +0 -27
  305. package/dist/ui/shared/MediaGallery.js +0 -35
  306. package/dist/ui/shared/Pagination.js +0 -195
  307. package/dist/ui/shared/ThreadView.js +0 -108
  308. package/dist/ui/shared/index.js +0 -5
  309. package/dist/vendor/datastar.js +0 -1606
  310. package/src/lib/__tests__/config.test.ts +0 -192
  311. package/src/lib/config.ts +0 -167
  312. package/src/routes/compose.ts +0 -63
  313. package/src/ui/dash/PostForm.tsx +0 -360
  314. package/src/ui/dash/settings/AppearanceContent.tsx +0 -254
@@ -0,0 +1,436 @@
1
+ /**
2
+ * General Settings Component
3
+ *
4
+ * Main container for the General settings page. Contains:
5
+ * - Avatar section (delegated to <jant-settings-avatar>)
6
+ * - General settings form (site name, description, footer, language, homepage view, timezone)
7
+ * - SEO form
8
+ *
9
+ * Each form section tracks dirty state independently and dispatches
10
+ * `jant:settings-save` events for the bridge to handle.
11
+ *
12
+ * Light DOM only — BaseCoat and Tailwind classes apply directly.
13
+ */
14
+
15
+ import { LitElement, html, nothing } from "lit";
16
+ import type {
17
+ SettingsLabels,
18
+ SettingsTimezone,
19
+ SettingsLanguage,
20
+ } from "./settings-types.js";
21
+
22
+ export class JantSettingsGeneral extends LitElement {
23
+ static properties = {
24
+ labels: { type: Object },
25
+ timezones: { type: Array },
26
+ languages: { type: Array },
27
+ siteNameFallback: { type: String, attribute: "sitename-fallback" },
28
+ siteDescriptionFallback: {
29
+ type: String,
30
+ attribute: "sitedescription-fallback",
31
+ },
32
+
33
+ // General form
34
+ _siteName: { state: true },
35
+ _siteDescription: { state: true },
36
+ _siteFooter: { state: true },
37
+ _siteLanguage: { state: true },
38
+ _homeDefaultView: { state: true },
39
+ _timeZone: { state: true },
40
+ _origGeneral: { state: true },
41
+ _generalDirty: { state: true },
42
+ _generalLoading: { state: true },
43
+
44
+ // SEO form
45
+ _noindex: { state: true },
46
+ _origNoindex: { state: true },
47
+ _seoDirty: { state: true },
48
+ _seoLoading: { state: true },
49
+ };
50
+
51
+ declare labels: SettingsLabels;
52
+ declare timezones: SettingsTimezone[];
53
+ declare languages: SettingsLanguage[];
54
+ declare siteNameFallback: string;
55
+ declare siteDescriptionFallback: string;
56
+
57
+ // General
58
+ declare _siteName: string;
59
+ declare _siteDescription: string;
60
+ declare _siteFooter: string;
61
+ declare _siteLanguage: string;
62
+ declare _homeDefaultView: string;
63
+ declare _timeZone: string;
64
+ declare _origGeneral: Record<string, string>;
65
+ declare _generalDirty: boolean;
66
+ declare _generalLoading: boolean;
67
+
68
+ // SEO
69
+ declare _noindex: boolean;
70
+ declare _origNoindex: boolean;
71
+ declare _seoDirty: boolean;
72
+ declare _seoLoading: boolean;
73
+
74
+ createRenderRoot() {
75
+ this.innerHTML = "";
76
+ return this;
77
+ }
78
+
79
+ constructor() {
80
+ super();
81
+ this.labels = {} as SettingsLabels;
82
+ this.timezones = [];
83
+ this.languages = [];
84
+ this.siteNameFallback = "";
85
+ this.siteDescriptionFallback = "";
86
+
87
+ this._siteName = "";
88
+ this._siteDescription = "";
89
+ this._siteFooter = "";
90
+ this._siteLanguage = "en";
91
+ this._homeDefaultView = "latest";
92
+ this._timeZone = "UTC";
93
+ this._origGeneral = {};
94
+ this._generalDirty = false;
95
+ this._generalLoading = false;
96
+
97
+ this._noindex = false;
98
+ this._origNoindex = false;
99
+ this._seoDirty = false;
100
+ this._seoLoading = false;
101
+ }
102
+
103
+ /** Initialize form state from data attributes set by the bridge */
104
+ initData(data: {
105
+ siteName: string;
106
+ siteDescription: string;
107
+ siteLanguage: string;
108
+ homeDefaultView: string;
109
+ timeZone: string;
110
+ siteFooter: string;
111
+ noindex: boolean;
112
+ }) {
113
+ this._siteName = data.siteName;
114
+ this._siteDescription = data.siteDescription;
115
+ this._siteFooter = data.siteFooter;
116
+ this._siteLanguage = data.siteLanguage;
117
+ this._homeDefaultView = data.homeDefaultView;
118
+ this._timeZone = data.timeZone;
119
+ this._origGeneral = {
120
+ siteName: data.siteName,
121
+ siteDescription: data.siteDescription,
122
+ siteFooter: data.siteFooter,
123
+ siteLanguage: data.siteLanguage,
124
+ homeDefaultView: data.homeDefaultView,
125
+ timeZone: data.timeZone,
126
+ };
127
+
128
+ this._noindex = data.noindex;
129
+ this._origNoindex = data.noindex;
130
+ }
131
+
132
+ /** Called by bridge after a section save succeeds */
133
+ sectionSaved(section: string) {
134
+ if (section === "general") {
135
+ this._origGeneral = {
136
+ siteName: this._siteName,
137
+ siteDescription: this._siteDescription,
138
+ siteFooter: this._siteFooter,
139
+ siteLanguage: this._siteLanguage,
140
+ homeDefaultView: this._homeDefaultView,
141
+ timeZone: this._timeZone,
142
+ };
143
+ this._generalDirty = false;
144
+ this._generalLoading = false;
145
+ } else if (section === "seo") {
146
+ this._origNoindex = this._noindex;
147
+ this._seoDirty = false;
148
+ this._seoLoading = false;
149
+ }
150
+ }
151
+
152
+ /** Called by bridge on save error */
153
+ sectionError(section: string) {
154
+ if (section === "general") this._generalLoading = false;
155
+ else if (section === "seo") this._seoLoading = false;
156
+ }
157
+
158
+ // ── General form helpers ──────────────────────────────────────────
159
+
160
+ private _markGeneralDirty() {
161
+ this._generalDirty = true;
162
+ }
163
+
164
+ private _cancelGeneral() {
165
+ this._siteName = this._origGeneral.siteName ?? "";
166
+ this._siteDescription = this._origGeneral.siteDescription ?? "";
167
+ this._siteFooter = this._origGeneral.siteFooter ?? "";
168
+ this._siteLanguage = this._origGeneral.siteLanguage ?? "en";
169
+ this._homeDefaultView = this._origGeneral.homeDefaultView ?? "latest";
170
+ this._timeZone = this._origGeneral.timeZone ?? "UTC";
171
+ this._generalDirty = false;
172
+ }
173
+
174
+ private _saveGeneral() {
175
+ if (this._generalLoading || !this._generalDirty) return;
176
+ this._generalLoading = true;
177
+ this.dispatchEvent(
178
+ new CustomEvent("jant:settings-save", {
179
+ bubbles: true,
180
+ detail: {
181
+ endpoint: "/dash/settings",
182
+ data: {
183
+ siteName: this._siteName,
184
+ siteDescription: this._siteDescription,
185
+ siteFooter: this._siteFooter,
186
+ siteLanguage: this._siteLanguage,
187
+ homeDefaultView: this._homeDefaultView,
188
+ timeZone: this._timeZone,
189
+ },
190
+ section: "general",
191
+ },
192
+ }),
193
+ );
194
+ }
195
+
196
+ // ── SEO form helpers ──────────────────────────────────────────────
197
+
198
+ private _toggleNoindex() {
199
+ this._noindex = !this._noindex;
200
+ this._seoDirty = this._noindex !== this._origNoindex;
201
+ }
202
+
203
+ private _cancelSeo() {
204
+ this._noindex = this._origNoindex;
205
+ this._seoDirty = false;
206
+ }
207
+
208
+ private _saveSeo() {
209
+ if (this._seoLoading || !this._seoDirty) return;
210
+ this._seoLoading = true;
211
+ this.dispatchEvent(
212
+ new CustomEvent("jant:settings-save", {
213
+ bubbles: true,
214
+ detail: {
215
+ endpoint: "/dash/settings/seo",
216
+ data: { noindex: this._noindex ? "" : "true" },
217
+ section: "seo",
218
+ },
219
+ }),
220
+ );
221
+ }
222
+
223
+ // ── Render helpers ────────────────────────────────────────────────
224
+
225
+ private _renderActions(
226
+ loading: boolean,
227
+ dirty: boolean,
228
+ onSave: () => void,
229
+ onCancel: () => void,
230
+ ) {
231
+ return html`
232
+ <div class="flex gap-2 mt-4">
233
+ <button
234
+ type="button"
235
+ class="btn"
236
+ ?disabled=${loading || !dirty}
237
+ @click=${onSave}
238
+ >
239
+ ${loading
240
+ ? html`<svg
241
+ class="animate-spin size-4"
242
+ xmlns="http://www.w3.org/2000/svg"
243
+ viewBox="0 0 24 24"
244
+ fill="none"
245
+ stroke="currentColor"
246
+ stroke-width="2"
247
+ stroke-linecap="round"
248
+ stroke-linejoin="round"
249
+ role="status"
250
+ >
251
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
252
+ </svg>`
253
+ : nothing}
254
+ ${this.labels.save}
255
+ </button>
256
+ <button
257
+ type="button"
258
+ class="btn-outline"
259
+ ?disabled=${loading || !dirty}
260
+ @click=${onCancel}
261
+ >
262
+ ${this.labels.cancel}
263
+ </button>
264
+ </div>
265
+ `;
266
+ }
267
+
268
+ private _renderGeneralForm() {
269
+ return html`
270
+ <div>
271
+ <h2 class="text-lg font-semibold mb-4">${this.labels.general}</h2>
272
+ <div class="flex flex-col gap-4">
273
+ <div class="field">
274
+ <label class="label">${this.labels.siteName}</label>
275
+ <input
276
+ type="text"
277
+ class="input"
278
+ .value=${this._siteName}
279
+ placeholder=${this.siteNameFallback}
280
+ @input=${(e: Event) => {
281
+ this._siteName = (e.target as HTMLInputElement).value;
282
+ this._markGeneralDirty();
283
+ }}
284
+ />
285
+ </div>
286
+
287
+ <div class="field">
288
+ <label class="label">${this.labels.aboutBlog}</label>
289
+ <textarea
290
+ class="textarea"
291
+ rows="3"
292
+ .value=${this._siteDescription}
293
+ placeholder=${this.labels.markdownSupported}
294
+ @input=${(e: Event) => {
295
+ this._siteDescription = (e.target as HTMLTextAreaElement).value;
296
+ this._markGeneralDirty();
297
+ }}
298
+ ></textarea>
299
+ <p class="text-sm text-muted-foreground mt-1">
300
+ ${this.labels.aboutBlogHelp}
301
+ </p>
302
+ </div>
303
+
304
+ <div class="field">
305
+ <label class="label">${this.labels.siteFooter}</label>
306
+ <textarea
307
+ class="textarea font-mono text-sm"
308
+ rows="4"
309
+ .value=${this._siteFooter}
310
+ placeholder=${this.labels.markdownSupported}
311
+ @input=${(e: Event) => {
312
+ this._siteFooter = (e.target as HTMLTextAreaElement).value;
313
+ this._markGeneralDirty();
314
+ }}
315
+ ></textarea>
316
+ <p class="text-sm text-muted-foreground mt-1">
317
+ ${this.labels.footerHelp}
318
+ </p>
319
+ </div>
320
+
321
+ <div class="field">
322
+ <label class="label">${this.labels.language}</label>
323
+ <select
324
+ class="select"
325
+ @change=${(e: Event) => {
326
+ this._siteLanguage = (e.target as HTMLSelectElement).value;
327
+ this._markGeneralDirty();
328
+ }}
329
+ >
330
+ ${this.languages.map(
331
+ (lang) => html`
332
+ <option
333
+ value=${lang.value}
334
+ ?selected=${this._siteLanguage === lang.value}
335
+ >
336
+ ${lang.label}
337
+ </option>
338
+ `,
339
+ )}
340
+ </select>
341
+ </div>
342
+
343
+ <div class="field">
344
+ <label class="label">${this.labels.defaultHomepageView}</label>
345
+ <select
346
+ class="select"
347
+ @change=${(e: Event) => {
348
+ this._homeDefaultView = (e.target as HTMLSelectElement).value;
349
+ this._markGeneralDirty();
350
+ }}
351
+ >
352
+ <option
353
+ value="latest"
354
+ ?selected=${this._homeDefaultView === "latest"}
355
+ >
356
+ ${this.labels.latest}
357
+ </option>
358
+ <option
359
+ value="featured"
360
+ ?selected=${this._homeDefaultView === "featured"}
361
+ >
362
+ ${this.labels.featured}
363
+ </option>
364
+ </select>
365
+ </div>
366
+
367
+ <div class="field">
368
+ <label class="label">${this.labels.timeZone}</label>
369
+ <select
370
+ class="select"
371
+ @change=${(e: Event) => {
372
+ this._timeZone = (e.target as HTMLSelectElement).value;
373
+ this._markGeneralDirty();
374
+ }}
375
+ >
376
+ ${this.timezones.map(
377
+ (tz) => html`
378
+ <option
379
+ value=${tz.value}
380
+ ?selected=${this._timeZone === tz.value}
381
+ >
382
+ ${tz.label}
383
+ </option>
384
+ `,
385
+ )}
386
+ </select>
387
+ </div>
388
+
389
+ ${this._renderActions(
390
+ this._generalLoading,
391
+ this._generalDirty,
392
+ () => this._saveGeneral(),
393
+ () => this._cancelGeneral(),
394
+ )}
395
+ </div>
396
+ </div>
397
+ `;
398
+ }
399
+
400
+ private _renderSeoForm() {
401
+ return html`
402
+ <div>
403
+ <h2 class="text-lg font-semibold mb-4">${this.labels.seo}</h2>
404
+ <div>
405
+ <label class="flex items-center gap-2 cursor-pointer">
406
+ <input
407
+ type="checkbox"
408
+ class="checkbox"
409
+ .checked=${!this._noindex}
410
+ @change=${this._toggleNoindex}
411
+ />
412
+ <span>${this.labels.allowIndexing}</span>
413
+ </label>
414
+ ${this._renderActions(
415
+ this._seoLoading,
416
+ this._seoDirty,
417
+ () => this._saveSeo(),
418
+ () => this._cancelSeo(),
419
+ )}
420
+ </div>
421
+ </div>
422
+ `;
423
+ }
424
+
425
+ render() {
426
+ return html`
427
+ <div class="flex flex-col">
428
+ ${this._renderGeneralForm()}
429
+ <hr class="my-8" />
430
+ ${this._renderSeoForm()}
431
+ </div>
432
+ `;
433
+ }
434
+ }
435
+
436
+ customElements.define("jant-settings-general", JantSettingsGeneral);
@@ -0,0 +1,260 @@
1
+ import { html, nothing } from "lit";
2
+ import { unsafeHTML } from "lit/directives/unsafe-html.js";
3
+ import type { JantPostForm } from "./jant-post-form.js";
4
+
5
+ function renderMediaList(component: JantPostForm) {
6
+ const { media, labels, _mediaIds } = component;
7
+ if (_mediaIds.length === 0) {
8
+ return html`<p class="text-sm text-muted-foreground">
9
+ ${labels.mediaEmptyLabel}
10
+ </p>`;
11
+ }
12
+
13
+ const mediaMap = new Map(media.map((item) => [item.id, item]));
14
+
15
+ return html`<div class="grid grid-cols-4 sm:grid-cols-6 gap-2 mb-2">
16
+ ${_mediaIds.map((id) => {
17
+ const item = mediaMap.get(id);
18
+ if (!item) {
19
+ return html`<div
20
+ class="relative group aspect-square rounded-lg border bg-muted flex items-center justify-center text-xs text-muted-foreground"
21
+ >
22
+ ${id}
23
+ <button
24
+ type="button"
25
+ class="absolute top-1 right-1 w-5 h-5 flex items-center justify-center bg-black/60 text-white rounded-full text-xs opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
26
+ @click=${() => component.removeMedia(id)}
27
+ aria-label=${labels.mediaRemoveButton}
28
+ >
29
+ &times;
30
+ </button>
31
+ </div>`;
32
+ }
33
+
34
+ return html`<div class="relative group aspect-square" data-media-id=${id}>
35
+ <img
36
+ src=${item.thumbUrl}
37
+ alt=${item.alt}
38
+ class="w-full h-full object-cover rounded-lg border"
39
+ loading="lazy"
40
+ />
41
+ <button
42
+ type="button"
43
+ class="absolute top-1 right-1 w-5 h-5 flex items-center justify-center bg-black/60 text-white rounded-full text-xs opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
44
+ @click=${() => component.removeMedia(id)}
45
+ aria-label=${labels.mediaRemoveButton}
46
+ >
47
+ &times;
48
+ </button>
49
+ </div>`;
50
+ })}
51
+ </div>`;
52
+ }
53
+
54
+ function renderCollections(component: JantPostForm) {
55
+ if (!component.collections.length) return nothing;
56
+
57
+ return html`<div class="field">
58
+ <label class="label">${component.labels.collectionsLabel}</label>
59
+ <div class="flex flex-col gap-1">
60
+ ${component.collections.map((col) => {
61
+ const iconNode = col.iconHtml
62
+ ? html`<span class="inline-flex items-center justify-center w-5 h-5">
63
+ ${unsafeHTML(col.iconHtml)}
64
+ </span>`
65
+ : nothing;
66
+ return html`<label class="flex items-center gap-2 text-sm">
67
+ <input
68
+ type="checkbox"
69
+ class="checkbox"
70
+ .checked=${component._collectionIds.includes(col.id)}
71
+ @change=${() => component.toggleCollection(col.id)}
72
+ />
73
+ ${iconNode}
74
+ <span>${col.title}</span>
75
+ </label>`;
76
+ })}
77
+ </div>
78
+ </div>`;
79
+ }
80
+
81
+ export function renderPostForm(component: JantPostForm) {
82
+ return html`<form
83
+ class="flex flex-col gap-4 max-w-2xl"
84
+ @submit=${(e: Event) => component.handleSubmit(e)}
85
+ >
86
+ <div class="field">
87
+ <label class="label">${component.labels.formatLabel}</label>
88
+ <select
89
+ class="select"
90
+ .value=${component._format}
91
+ @change=${(e: Event) => {
92
+ const target = e.target as HTMLSelectElement;
93
+ component._format =
94
+ (target.value as typeof component._format) ?? "note";
95
+ }}
96
+ >
97
+ <option value="note">${component.labels.noteOption}</option>
98
+ <option value="link">${component.labels.linkOption}</option>
99
+ <option value="quote">${component.labels.quoteOption}</option>
100
+ </select>
101
+ </div>
102
+
103
+ <div class="field">
104
+ <label class="label">${component.labels.titleLabel}</label>
105
+ <input
106
+ type="text"
107
+ class="input"
108
+ placeholder=${component.labels.titlePlaceholder}
109
+ .value=${component._title}
110
+ @input=${(e: Event) => component.handleInput("_title", e)}
111
+ />
112
+ </div>
113
+
114
+ <div class="field">
115
+ <label class="label">${component.labels.bodyLabel}</label>
116
+ <textarea
117
+ class="textarea min-h-32"
118
+ placeholder=${component.labels.bodyPlaceholder}
119
+ .value=${component._body}
120
+ @input=${(e: Event) => component.handleInput("_body", e)}
121
+ ></textarea>
122
+ </div>
123
+
124
+ <div class="field">
125
+ <label class="label">${component.labels.urlLabel}</label>
126
+ <input
127
+ type="url"
128
+ class="input"
129
+ placeholder=${component.labels.urlPlaceholder}
130
+ .value=${component._url}
131
+ @input=${(e: Event) => component.handleInput("_url", e)}
132
+ />
133
+ </div>
134
+
135
+ ${component._format === "quote"
136
+ ? html`<div class="field">
137
+ <label class="label">${component.labels.quoteTextLabel}</label>
138
+ <textarea
139
+ class="textarea"
140
+ rows="3"
141
+ placeholder=${component.labels.quoteTextPlaceholder}
142
+ .value=${component._quoteText}
143
+ @input=${(e: Event) => component.handleInput("_quoteText", e)}
144
+ ></textarea>
145
+ </div>`
146
+ : nothing}
147
+
148
+ <div class="field">
149
+ <label class="label">${component.labels.mediaLabel}</label>
150
+ ${renderMediaList(component)}
151
+ <button
152
+ type="button"
153
+ class="btn-outline text-sm"
154
+ @click=${() => component.openMediaPicker()}
155
+ >
156
+ ${component.labels.mediaAddButton}
157
+ </button>
158
+ </div>
159
+
160
+ <div class="field">
161
+ <label class="label">${component.labels.statusLabel}</label>
162
+ <select
163
+ class="select"
164
+ .value=${component._status}
165
+ @change=${(e: Event) => {
166
+ const target = e.target as HTMLSelectElement;
167
+ component._status =
168
+ (target.value as typeof component._status) ?? "published";
169
+ }}
170
+ >
171
+ <option value="published">${component.labels.statusPublished}</option>
172
+ <option value="draft">${component.labels.statusDraft}</option>
173
+ </select>
174
+ </div>
175
+
176
+ <div class="flex gap-4">
177
+ <label class="flex items-center gap-2 text-sm">
178
+ <input
179
+ type="checkbox"
180
+ class="checkbox"
181
+ .checked=${component._featured}
182
+ @change=${(e: Event) => {
183
+ const target = e.target as HTMLInputElement;
184
+ component._featured = target.checked;
185
+ }}
186
+ />
187
+ ${component.labels.featuredLabel}
188
+ </label>
189
+ <label class="flex items-center gap-2 text-sm">
190
+ <input
191
+ type="checkbox"
192
+ class="checkbox"
193
+ .checked=${component._pinned}
194
+ @change=${(e: Event) => {
195
+ const target = e.target as HTMLInputElement;
196
+ component._pinned = target.checked;
197
+ }}
198
+ />
199
+ ${component.labels.pinnedLabel}
200
+ </label>
201
+ </div>
202
+
203
+ ${renderCollections(component)}
204
+
205
+ <div class="flex gap-2">
206
+ <button type="submit" class="btn" ?disabled=${component._loading}>
207
+ ${component._loading
208
+ ? html`<svg
209
+ class="animate-spin size-4"
210
+ xmlns="http://www.w3.org/2000/svg"
211
+ viewBox="0 0 24 24"
212
+ fill="none"
213
+ stroke="currentColor"
214
+ stroke-width="2"
215
+ stroke-linecap="round"
216
+ stroke-linejoin="round"
217
+ role="status"
218
+ >
219
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
220
+ </svg>`
221
+ : nothing}
222
+ ${component.labels.submitLabel}
223
+ </button>
224
+ <a href=${component.cancelHref} class="btn-outline"
225
+ >${component.labels.cancelLabel}</a
226
+ >
227
+ </div>
228
+ </form>
229
+
230
+ <dialog
231
+ id="post-media-picker"
232
+ class="p-6 rounded-lg max-w-2xl w-full backdrop:bg-black/50"
233
+ @click=${(event: Event) => {
234
+ if (event.target === event.currentTarget) {
235
+ component.closeMediaPicker();
236
+ }
237
+ }}
238
+ >
239
+ <div class="flex items-center justify-between mb-4">
240
+ <h2 class="text-lg font-semibold">
241
+ ${component.labels.mediaDialogTitle}
242
+ </h2>
243
+ <button
244
+ type="button"
245
+ class="btn-outline text-sm"
246
+ @click=${() => component.closeMediaPicker()}
247
+ >
248
+ ${component.labels.mediaDialogDone}
249
+ </button>
250
+ </div>
251
+ <div
252
+ id="post-media-grid"
253
+ class="grid grid-cols-4 gap-2 max-h-96 overflow-y-auto"
254
+ >
255
+ <p class="text-muted-foreground text-sm col-span-4">
256
+ ${component.labels.mediaDialogLoading}
257
+ </p>
258
+ </div>
259
+ </dialog>`;
260
+ }