@jant/core 0.3.25 → 0.3.27

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 (133) hide show
  1. package/dist/app.js +70 -563
  2. package/dist/auth.js +3 -0
  3. package/dist/client.js +1 -0
  4. package/dist/i18n/locales/en.js +1 -1
  5. package/dist/i18n/locales/zh-Hans.js +1 -1
  6. package/dist/i18n/locales/zh-Hant.js +1 -1
  7. package/dist/lib/avatar-upload.js +134 -0
  8. package/dist/lib/config.js +39 -0
  9. package/dist/lib/constants.js +10 -10
  10. package/dist/lib/favicon.js +102 -0
  11. package/dist/lib/image.js +13 -17
  12. package/dist/lib/media-helpers.js +2 -2
  13. package/dist/lib/navigation.js +23 -3
  14. package/dist/lib/render.js +10 -1
  15. package/dist/lib/schemas.js +31 -0
  16. package/dist/lib/timezones.js +388 -0
  17. package/dist/lib/view.js +1 -1
  18. package/dist/routes/api/posts.js +1 -1
  19. package/dist/routes/api/upload.js +3 -3
  20. package/dist/routes/auth/reset.js +221 -0
  21. package/dist/routes/auth/setup.js +194 -0
  22. package/dist/routes/auth/signin.js +176 -0
  23. package/dist/routes/dash/collections.js +23 -415
  24. package/dist/routes/dash/media.js +12 -392
  25. package/dist/routes/dash/pages.js +7 -330
  26. package/dist/routes/dash/redirects.js +18 -12
  27. package/dist/routes/dash/settings.js +198 -577
  28. package/dist/routes/feed/rss.js +2 -1
  29. package/dist/routes/feed/sitemap.js +4 -2
  30. package/dist/routes/pages/featured.js +5 -1
  31. package/dist/routes/pages/home.js +26 -1
  32. package/dist/routes/pages/latest.js +45 -0
  33. package/dist/services/post.js +30 -50
  34. package/dist/types/bindings.js +3 -0
  35. package/dist/types/config.js +147 -0
  36. package/dist/types/constants.js +27 -0
  37. package/dist/types/entities.js +3 -0
  38. package/dist/types/operations.js +3 -0
  39. package/dist/types/props.js +3 -0
  40. package/dist/types/views.js +5 -0
  41. package/dist/types.js +8 -111
  42. package/dist/ui/color-themes.js +33 -33
  43. package/dist/ui/compose/ComposeDialog.js +36 -21
  44. package/dist/ui/dash/PageForm.js +21 -15
  45. package/dist/ui/dash/PostForm.js +22 -16
  46. package/dist/ui/dash/collections/CollectionForm.js +152 -0
  47. package/dist/ui/dash/collections/CollectionsListContent.js +68 -0
  48. package/dist/ui/dash/collections/ViewCollectionContent.js +96 -0
  49. package/dist/ui/dash/media/MediaListContent.js +166 -0
  50. package/dist/ui/dash/media/ViewMediaContent.js +212 -0
  51. package/dist/ui/dash/pages/LinkFormContent.js +130 -0
  52. package/dist/ui/dash/pages/UnifiedPagesContent.js +193 -0
  53. package/dist/ui/dash/settings/AccountContent.js +209 -0
  54. package/dist/ui/dash/settings/AppearanceContent.js +259 -0
  55. package/dist/ui/dash/settings/GeneralContent.js +536 -0
  56. package/dist/ui/dash/settings/SettingsNav.js +41 -0
  57. package/dist/ui/font-themes.js +36 -0
  58. package/dist/ui/layouts/BaseLayout.js +24 -2
  59. package/dist/ui/layouts/SiteLayout.js +47 -19
  60. package/package.json +1 -1
  61. package/src/app.tsx +95 -553
  62. package/src/auth.ts +4 -1
  63. package/src/client.ts +1 -0
  64. package/src/i18n/locales/en.po +240 -175
  65. package/src/i18n/locales/en.ts +1 -1
  66. package/src/i18n/locales/zh-Hans.po +240 -175
  67. package/src/i18n/locales/zh-Hans.ts +1 -1
  68. package/src/i18n/locales/zh-Hant.po +240 -175
  69. package/src/i18n/locales/zh-Hant.ts +1 -1
  70. package/src/lib/__tests__/config.test.ts +192 -0
  71. package/src/lib/__tests__/favicon.test.ts +151 -0
  72. package/src/lib/__tests__/image.test.ts +2 -6
  73. package/src/lib/__tests__/timezones.test.ts +61 -0
  74. package/src/lib/__tests__/view.test.ts +2 -2
  75. package/src/lib/avatar-upload.ts +165 -0
  76. package/src/lib/config.ts +47 -0
  77. package/src/lib/constants.ts +19 -11
  78. package/src/lib/favicon.ts +115 -0
  79. package/src/lib/image.ts +13 -21
  80. package/src/lib/media-helpers.ts +2 -2
  81. package/src/lib/navigation.ts +33 -2
  82. package/src/lib/render.tsx +15 -1
  83. package/src/lib/schemas.ts +39 -0
  84. package/src/lib/timezones.ts +325 -0
  85. package/src/lib/view.ts +1 -1
  86. package/src/routes/api/posts.ts +1 -1
  87. package/src/routes/api/upload.ts +2 -3
  88. package/src/routes/auth/reset.tsx +239 -0
  89. package/src/routes/auth/setup.tsx +189 -0
  90. package/src/routes/auth/signin.tsx +163 -0
  91. package/src/routes/dash/__tests__/settings-avatar.test.ts +89 -0
  92. package/src/routes/dash/collections.tsx +17 -366
  93. package/src/routes/dash/media.tsx +12 -414
  94. package/src/routes/dash/pages.tsx +8 -348
  95. package/src/routes/dash/redirects.tsx +20 -14
  96. package/src/routes/dash/settings.tsx +243 -534
  97. package/src/routes/feed/__tests__/rss.test.ts +141 -0
  98. package/src/routes/feed/rss.ts +3 -1
  99. package/src/routes/feed/sitemap.ts +4 -2
  100. package/src/routes/pages/featured.tsx +7 -1
  101. package/src/routes/pages/home.tsx +25 -2
  102. package/src/routes/pages/latest.tsx +59 -0
  103. package/src/services/post.ts +34 -66
  104. package/src/styles/components.css +0 -65
  105. package/src/styles/tokens.css +1 -1
  106. package/src/styles/ui.css +24 -40
  107. package/src/types/bindings.ts +30 -0
  108. package/src/types/config.ts +183 -0
  109. package/src/types/constants.ts +26 -0
  110. package/src/types/entities.ts +109 -0
  111. package/src/types/operations.ts +88 -0
  112. package/src/types/props.ts +115 -0
  113. package/src/types/views.ts +172 -0
  114. package/src/types.ts +8 -644
  115. package/src/ui/__tests__/font-themes.test.ts +34 -0
  116. package/src/ui/color-themes.ts +34 -34
  117. package/src/ui/compose/ComposeDialog.tsx +40 -21
  118. package/src/ui/dash/PageForm.tsx +25 -19
  119. package/src/ui/dash/PostForm.tsx +26 -20
  120. package/src/ui/dash/collections/CollectionForm.tsx +153 -0
  121. package/src/ui/dash/collections/CollectionsListContent.tsx +85 -0
  122. package/src/ui/dash/collections/ViewCollectionContent.tsx +92 -0
  123. package/src/ui/dash/media/MediaListContent.tsx +201 -0
  124. package/src/ui/dash/media/ViewMediaContent.tsx +208 -0
  125. package/src/ui/dash/pages/LinkFormContent.tsx +119 -0
  126. package/src/ui/dash/pages/UnifiedPagesContent.tsx +203 -0
  127. package/src/ui/dash/settings/AccountContent.tsx +176 -0
  128. package/src/ui/dash/settings/AppearanceContent.tsx +254 -0
  129. package/src/ui/dash/settings/GeneralContent.tsx +533 -0
  130. package/src/ui/dash/settings/SettingsNav.tsx +56 -0
  131. package/src/ui/font-themes.ts +54 -0
  132. package/src/ui/layouts/BaseLayout.tsx +17 -0
  133. package/src/ui/layouts/SiteLayout.tsx +45 -31
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Unified pages list - navigation items + other pages
3
+ */
4
+
5
+ import { useLingui } from "@lingui/react/macro";
6
+ import type { Page, NavItem } from "../../../types.js";
7
+ import { ListItemRow, ActionButtons, CrudPageHeader } from "../index.js";
8
+
9
+ export function UnifiedPagesContent({
10
+ navItems,
11
+ otherPages,
12
+ }: {
13
+ navItems: NavItem[];
14
+ otherPages: Page[];
15
+ }) {
16
+ const { t } = useLingui();
17
+
18
+ return (
19
+ <>
20
+ <CrudPageHeader
21
+ title={t({
22
+ message: "Pages",
23
+ comment: "@context: Pages main heading",
24
+ })}
25
+ >
26
+ <div class="flex gap-2">
27
+ <a href="/dash/pages/links/new" class="btn-outline">
28
+ {t({
29
+ message: "Add Link",
30
+ comment: "@context: Button to add a navigation link",
31
+ })}
32
+ </a>
33
+ <a href="/dash/pages/new" class="btn">
34
+ {t({
35
+ message: "New Page",
36
+ comment: "@context: Button to create new page",
37
+ })}
38
+ </a>
39
+ </div>
40
+ </CrudPageHeader>
41
+
42
+ {/* Navigation section */}
43
+ <section class="mb-8">
44
+ <h2 class="text-lg font-medium mb-3">
45
+ {t({
46
+ message: "Your site navigation",
47
+ comment: "@context: Section heading for navigation items",
48
+ })}
49
+ </h2>
50
+ {navItems.length === 0 ? (
51
+ <p class="text-sm text-muted-foreground py-4">
52
+ {t({
53
+ message:
54
+ "No navigation links yet. Add pages to navigation or create links.",
55
+ comment: "@context: Empty state for navigation section",
56
+ })}
57
+ </p>
58
+ ) : (
59
+ <div id="nav-links-list" class="flex flex-col divide-y">
60
+ {navItems.map((item) => (
61
+ <ListItemRow
62
+ key={item.id}
63
+ actions={
64
+ item.type === "page" ? (
65
+ <>
66
+ <ActionButtons
67
+ editHref={
68
+ item.pageId
69
+ ? `/dash/pages/${item.pageId}/edit`
70
+ : undefined
71
+ }
72
+ editLabel={t({
73
+ message: "Edit",
74
+ comment: "@context: Button to edit page",
75
+ })}
76
+ />
77
+ <button
78
+ type="button"
79
+ class="btn-sm-ghost"
80
+ data-on:click__prevent={`@post('/dash/pages/${item.pageId}/remove-from-nav')`}
81
+ >
82
+ {t({
83
+ message: "Un-nav",
84
+ comment:
85
+ "@context: Button to remove page from navigation",
86
+ })}
87
+ </button>
88
+ </>
89
+ ) : (
90
+ <>
91
+ <ActionButtons
92
+ editHref={`/dash/pages/links/${item.id}/edit`}
93
+ editLabel={t({
94
+ message: "Edit",
95
+ comment: "@context: Button to edit link",
96
+ })}
97
+ deleteAction={`/dash/pages/links/${item.id}/delete`}
98
+ deleteLabel={t({
99
+ message: "Delete",
100
+ comment: "@context: Button to delete link",
101
+ })}
102
+ />
103
+ </>
104
+ )
105
+ }
106
+ >
107
+ <div
108
+ class="flex items-center gap-3 cursor-grab"
109
+ data-id={item.id}
110
+ >
111
+ <span class="text-muted-foreground select-none">⠿</span>
112
+ <div class="flex items-center gap-2">
113
+ <span class="font-medium">{item.label}</span>
114
+ <code class="text-sm text-muted-foreground bg-muted px-1 rounded">
115
+ {item.url}
116
+ </code>
117
+ <span class="badge-secondary">
118
+ {item.type === "page"
119
+ ? t({
120
+ message: "page",
121
+ comment: "@context: Nav item type badge",
122
+ })
123
+ : t({
124
+ message: "link",
125
+ comment: "@context: Nav item type badge",
126
+ })}
127
+ </span>
128
+ </div>
129
+ </div>
130
+ </ListItemRow>
131
+ ))}
132
+ </div>
133
+ )}
134
+ </section>
135
+
136
+ {/* Other pages section */}
137
+ <section>
138
+ <h2 class="text-lg font-medium mb-3">
139
+ {t({
140
+ message: "Other pages",
141
+ comment: "@context: Section heading for pages not in navigation",
142
+ })}
143
+ </h2>
144
+ {otherPages.length === 0 ? (
145
+ <p class="text-sm text-muted-foreground py-4">
146
+ {t({
147
+ message: "All pages are in your navigation.",
148
+ comment: "@context: Empty state when all pages are in nav",
149
+ })}
150
+ </p>
151
+ ) : (
152
+ <div class="flex flex-col divide-y">
153
+ {otherPages.map((page) => (
154
+ <ListItemRow
155
+ key={page.id}
156
+ actions={
157
+ <>
158
+ <button
159
+ type="button"
160
+ class="btn-sm-outline"
161
+ data-on:click__prevent={`@post('/dash/pages/${page.id}/add-to-nav')`}
162
+ >
163
+ {t({
164
+ message: "Add to nav",
165
+ comment: "@context: Button to add page to navigation",
166
+ })}
167
+ </button>
168
+ <ActionButtons
169
+ editHref={`/dash/pages/${page.id}/edit`}
170
+ editLabel={t({
171
+ message: "Edit",
172
+ comment: "@context: Button to edit page",
173
+ })}
174
+ viewHref={
175
+ page.status !== "draft" ? `/${page.slug}` : undefined
176
+ }
177
+ viewLabel={t({
178
+ message: "View",
179
+ comment: "@context: Button to view page on public site",
180
+ })}
181
+ />
182
+ </>
183
+ }
184
+ >
185
+ <a
186
+ href={`/dash/pages/${page.id}`}
187
+ class="font-medium hover:underline"
188
+ >
189
+ {page.title ||
190
+ t({
191
+ message: "Untitled",
192
+ comment: "@context: Default title for untitled page",
193
+ })}
194
+ </a>
195
+ <p class="text-sm text-muted-foreground mt-1">/{page.slug}</p>
196
+ </ListItemRow>
197
+ ))}
198
+ </div>
199
+ )}
200
+ </section>
201
+ </>
202
+ );
203
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Account settings: profile + password change forms
3
+ */
4
+
5
+ import { useLingui } from "@lingui/react/macro";
6
+ import { SettingsNav } from "./SettingsNav.js";
7
+
8
+ export function AccountContent({ userName }: { userName: string }) {
9
+ const { t } = useLingui();
10
+
11
+ const profileSignals = JSON.stringify({ userName }).replace(/</g, "\\u003c");
12
+
13
+ return (
14
+ <>
15
+ <h1 class="text-2xl font-semibold mb-2">
16
+ {t({ message: "Settings", comment: "@context: Dashboard heading" })}
17
+ </h1>
18
+ <SettingsNav currentTab="account" />
19
+
20
+ <div class="flex flex-col gap-6 max-w-lg">
21
+ <form
22
+ data-signals={profileSignals}
23
+ data-on:submit__prevent="@post('/dash/settings/account')"
24
+ data-indicator="_profileLoading"
25
+ >
26
+ <div class="card">
27
+ <header>
28
+ <h2>
29
+ {t({
30
+ message: "Profile",
31
+ comment: "@context: Account settings section heading",
32
+ })}
33
+ </h2>
34
+ </header>
35
+ <section class="flex flex-col gap-4">
36
+ <div class="field">
37
+ <label class="label">
38
+ {t({
39
+ message: "Name",
40
+ comment: "@context: Account settings form field",
41
+ })}
42
+ </label>
43
+ <input
44
+ type="text"
45
+ data-bind="userName"
46
+ class="input"
47
+ required
48
+ />
49
+ </div>
50
+ </section>
51
+ </div>
52
+
53
+ <button
54
+ type="submit"
55
+ class="btn mt-4"
56
+ data-attr:disabled="$_profileLoading"
57
+ >
58
+ <svg
59
+ data-show="$_profileLoading"
60
+ style="display:none"
61
+ class="animate-spin size-4"
62
+ xmlns="http://www.w3.org/2000/svg"
63
+ viewBox="0 0 24 24"
64
+ fill="none"
65
+ stroke="currentColor"
66
+ stroke-width="2"
67
+ stroke-linecap="round"
68
+ stroke-linejoin="round"
69
+ role="status"
70
+ >
71
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
72
+ </svg>
73
+ {t({
74
+ message: "Save Profile",
75
+ comment: "@context: Button to save profile",
76
+ })}
77
+ </button>
78
+ </form>
79
+
80
+ <form
81
+ data-signals="{currentPassword: '', newPassword: '', confirmPassword: ''}"
82
+ data-on:submit__prevent="@post('/dash/settings/password')"
83
+ data-indicator="_passwordLoading"
84
+ >
85
+ <div class="card">
86
+ <header>
87
+ <h2>
88
+ {t({
89
+ message: "Change Password",
90
+ comment: "@context: Settings section heading",
91
+ })}
92
+ </h2>
93
+ </header>
94
+ <section class="flex flex-col gap-4">
95
+ <div class="field">
96
+ <label class="label">
97
+ {t({
98
+ message: "Current Password",
99
+ comment: "@context: Password form field",
100
+ })}
101
+ </label>
102
+ <input
103
+ type="password"
104
+ data-bind="currentPassword"
105
+ class="input"
106
+ required
107
+ autocomplete="current-password"
108
+ />
109
+ </div>
110
+
111
+ <div class="field">
112
+ <label class="label">
113
+ {t({
114
+ message: "New Password",
115
+ comment: "@context: Password form field",
116
+ })}
117
+ </label>
118
+ <input
119
+ type="password"
120
+ data-bind="newPassword"
121
+ class="input"
122
+ required
123
+ minlength={8}
124
+ autocomplete="new-password"
125
+ />
126
+ </div>
127
+
128
+ <div class="field">
129
+ <label class="label">
130
+ {t({
131
+ message: "Confirm New Password",
132
+ comment: "@context: Password form field",
133
+ })}
134
+ </label>
135
+ <input
136
+ type="password"
137
+ data-bind="confirmPassword"
138
+ class="input"
139
+ required
140
+ minlength={8}
141
+ autocomplete="new-password"
142
+ />
143
+ </div>
144
+ </section>
145
+ </div>
146
+
147
+ <button
148
+ type="submit"
149
+ class="btn mt-4"
150
+ data-attr:disabled="$_passwordLoading"
151
+ >
152
+ <svg
153
+ data-show="$_passwordLoading"
154
+ style="display:none"
155
+ class="animate-spin size-4"
156
+ xmlns="http://www.w3.org/2000/svg"
157
+ viewBox="0 0 24 24"
158
+ fill="none"
159
+ stroke="currentColor"
160
+ stroke-width="2"
161
+ stroke-linecap="round"
162
+ stroke-linejoin="round"
163
+ role="status"
164
+ >
165
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
166
+ </svg>
167
+ {t({
168
+ message: "Change Password",
169
+ comment: "@context: Button to change password",
170
+ })}
171
+ </button>
172
+ </form>
173
+ </div>
174
+ </>
175
+ );
176
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Appearance settings: color theme picker + custom CSS form
3
+ */
4
+
5
+ import { useLingui } from "@lingui/react/macro";
6
+ import type { ColorTheme } from "../../color-themes.js";
7
+ import type { FontTheme } from "../../font-themes.js";
8
+ import { SettingsNav } from "./SettingsNav.js";
9
+
10
+ function ThemeCard({
11
+ theme,
12
+ selected,
13
+ }: {
14
+ theme: ColorTheme;
15
+ selected: boolean;
16
+ }) {
17
+ const expr = `$theme === '${theme.id}'`;
18
+ const { preview } = theme;
19
+
20
+ return (
21
+ <label
22
+ class={`block cursor-pointer rounded-lg border overflow-hidden transition-colors ${selected ? "border-primary" : "border-border"}`}
23
+ data-class:border-primary={expr}
24
+ data-class:border-border={`$theme !== '${theme.id}'`}
25
+ >
26
+ <div class="grid grid-cols-2">
27
+ <div
28
+ class="p-5"
29
+ style={`background-color:${preview.lightBg};color:${preview.lightText}`}
30
+ >
31
+ <input
32
+ type="radio"
33
+ name="theme"
34
+ value={theme.id}
35
+ data-bind="theme"
36
+ checked={selected || undefined}
37
+ class="mb-1"
38
+ />
39
+ <h3 class="font-bold text-lg">{theme.name}</h3>
40
+ <p class="text-sm mt-2 leading-relaxed">
41
+ This is the {theme.name} theme in light mode. Links{" "}
42
+ <a
43
+ tabIndex={-1}
44
+ class="underline"
45
+ style={`color:${preview.lightLink}`}
46
+ >
47
+ look like this
48
+ </a>
49
+ . We'll show the correct light or dark mode based on your visitor's
50
+ settings.
51
+ </p>
52
+ </div>
53
+ <div
54
+ class="p-5"
55
+ style={`background-color:${preview.darkBg};color:${preview.darkText}`}
56
+ >
57
+ <h3 class="font-bold text-lg">{theme.name}</h3>
58
+ <p class="text-sm mt-2 leading-relaxed">
59
+ This is the {theme.name} theme in dark mode. Links{" "}
60
+ <a
61
+ tabIndex={-1}
62
+ class="underline"
63
+ style={`color:${preview.darkLink}`}
64
+ >
65
+ look like this
66
+ </a>
67
+ . We'll show the correct light or dark mode based on your visitor's
68
+ settings.
69
+ </p>
70
+ </div>
71
+ </div>
72
+ </label>
73
+ );
74
+ }
75
+
76
+ export function AppearanceContent({
77
+ themes,
78
+ currentThemeId,
79
+ fontThemes,
80
+ currentFontThemeId,
81
+ customCSS,
82
+ }: {
83
+ themes: ColorTheme[];
84
+ currentThemeId: string;
85
+ fontThemes: FontTheme[];
86
+ currentFontThemeId: string;
87
+ customCSS: string;
88
+ }) {
89
+ const { t } = useLingui();
90
+
91
+ const themeSignals = JSON.stringify({ theme: currentThemeId }).replace(
92
+ /</g,
93
+ "\\u003c",
94
+ );
95
+
96
+ const cssSignals = JSON.stringify({ customCSS }).replace(/</g, "\\u003c");
97
+
98
+ return (
99
+ <>
100
+ <h1 class="text-2xl font-semibold mb-2">
101
+ {t({ message: "Settings", comment: "@context: Dashboard heading" })}
102
+ </h1>
103
+ <SettingsNav currentTab="appearance" />
104
+
105
+ <div
106
+ data-signals={themeSignals}
107
+ data-on:change="@post('/dash/settings/appearance')"
108
+ class="max-w-3xl mb-8"
109
+ >
110
+ <fieldset>
111
+ <legend class="text-lg font-semibold">
112
+ {t({
113
+ message: "Color theme",
114
+ comment: "@context: Appearance settings heading",
115
+ })}
116
+ </legend>
117
+ <p class="text-sm text-muted-foreground mb-4">
118
+ {t({
119
+ message:
120
+ "This will theme both your site and your dashboard. All color themes support dark mode.",
121
+ comment: "@context: Appearance settings description",
122
+ })}
123
+ </p>
124
+
125
+ <div class="flex flex-col gap-4">
126
+ {themes.map((theme) => (
127
+ <ThemeCard
128
+ key={theme.id}
129
+ theme={theme}
130
+ selected={theme.id === currentThemeId}
131
+ />
132
+ ))}
133
+ </div>
134
+ </fieldset>
135
+ </div>
136
+
137
+ <div
138
+ data-signals={JSON.stringify({ fontTheme: currentFontThemeId }).replace(
139
+ /</g,
140
+ "\\u003c",
141
+ )}
142
+ data-on:change="@post('/dash/settings/font-theme')"
143
+ class="max-w-3xl"
144
+ >
145
+ <fieldset>
146
+ <legend class="text-lg font-semibold">
147
+ {t({
148
+ message: "Font theme",
149
+ comment: "@context: Appearance settings heading for font theme",
150
+ })}
151
+ </legend>
152
+ <p class="text-sm text-muted-foreground mb-4">
153
+ {t({
154
+ message:
155
+ "Choose a font for your site. All options use system fonts for fast loading.",
156
+ comment: "@context: Font theme settings description",
157
+ })}
158
+ </p>
159
+ <div class="flex flex-col gap-2">
160
+ {fontThemes.map((ft) => (
161
+ <label
162
+ key={ft.id}
163
+ class={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${ft.id === currentFontThemeId ? "border-primary" : "border-border"}`}
164
+ data-class:border-primary={`$fontTheme === '${ft.id}'`}
165
+ data-class:border-border={`$fontTheme !== '${ft.id}'`}
166
+ >
167
+ <input
168
+ type="radio"
169
+ name="fontTheme"
170
+ value={ft.id}
171
+ data-bind="fontTheme"
172
+ checked={ft.id === currentFontThemeId || undefined}
173
+ class="mt-1"
174
+ />
175
+ <div>
176
+ <div class="font-medium">{ft.name}</div>
177
+ <div class="text-sm text-muted-foreground">
178
+ {ft.description}
179
+ </div>
180
+ <div
181
+ class="mt-1 text-sm"
182
+ style={`font-family:${ft.fontFamily}`}
183
+ >
184
+ The quick brown fox jumps over the lazy dog.{" "}
185
+ 敏捷的棕色狐狸跳过了懒狗。
186
+ </div>
187
+ </div>
188
+ </label>
189
+ ))}
190
+ </div>
191
+ </fieldset>
192
+ </div>
193
+
194
+ <form
195
+ data-signals={cssSignals}
196
+ data-on:submit__prevent="@post('/dash/settings/custom-css')"
197
+ data-indicator="_cssLoading"
198
+ class="max-w-3xl mt-8"
199
+ >
200
+ <fieldset>
201
+ <legend class="text-lg font-semibold">
202
+ {t({
203
+ message: "Custom CSS",
204
+ comment: "@context: Appearance settings heading for custom CSS",
205
+ })}
206
+ </legend>
207
+ <p class="text-sm text-muted-foreground mb-4">
208
+ {t({
209
+ message:
210
+ "Add custom CSS to override any styles. Use data attributes like [data-page], [data-post], [data-format] to target specific elements.",
211
+ comment: "@context: Custom CSS settings description",
212
+ })}
213
+ </p>
214
+ <textarea
215
+ data-bind="customCSS"
216
+ class="textarea font-mono text-sm min-h-32"
217
+ rows={8}
218
+ placeholder={t({
219
+ message: "/* Your custom CSS here */",
220
+ comment: "@context: Custom CSS textarea placeholder",
221
+ })}
222
+ >
223
+ {customCSS}
224
+ </textarea>
225
+ </fieldset>
226
+ <button
227
+ type="submit"
228
+ class="btn mt-4"
229
+ data-attr:disabled="$_cssLoading"
230
+ >
231
+ <svg
232
+ data-show="$_cssLoading"
233
+ style="display:none"
234
+ class="animate-spin size-4"
235
+ xmlns="http://www.w3.org/2000/svg"
236
+ viewBox="0 0 24 24"
237
+ fill="none"
238
+ stroke="currentColor"
239
+ stroke-width="2"
240
+ stroke-linecap="round"
241
+ stroke-linejoin="round"
242
+ role="status"
243
+ >
244
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
245
+ </svg>
246
+ {t({
247
+ message: "Save CSS",
248
+ comment: "@context: Button to save custom CSS",
249
+ })}
250
+ </button>
251
+ </form>
252
+ </>
253
+ );
254
+ }