@jant/core 0.3.34 → 0.3.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/module-RjUF93sV.js +716 -0
- package/dist/client/assets/native-48B9X9Wg.js +1 -0
- package/dist/client/assets/url-8Dj-5CLW.js +1 -0
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +3109 -2294
- package/dist/index.js +3327 -3031
- package/package.json +13 -4
- package/src/__tests__/helpers/app.ts +1 -1
- package/src/__tests__/helpers/db.ts +6 -0
- package/src/app.tsx +1 -5
- package/src/{lib → client}/avatar-upload.ts +1 -1
- package/src/{lib → client}/collection-form-bridge.ts +2 -2
- package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
- package/src/{ui → client}/components/__tests__/jant-compose-dialog.test.ts +46 -14
- package/src/{ui → client}/components/__tests__/jant-compose-editor.test.ts +64 -24
- package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +24 -14
- package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-sidebar-types.ts +45 -0
- package/src/{ui → client}/components/collection-types.ts +3 -4
- package/src/{ui → client}/components/compose-types.ts +3 -1
- package/src/{ui → client}/components/jant-collection-form.ts +301 -182
- package/src/client/components/jant-collection-sidebar.ts +801 -0
- package/src/{ui → client}/components/jant-compose-dialog.ts +231 -1
- package/src/client/components/jant-compose-editor.ts +1249 -0
- package/src/client/components/jant-compose-fullscreen.ts +338 -0
- package/src/client/components/jant-media-lightbox.ts +257 -0
- package/src/{ui → client}/components/jant-nav-manager.ts +143 -84
- package/src/{ui → client}/components/jant-post-form.ts +57 -8
- package/src/{ui → client}/components/jant-settings-general.ts +2 -2
- package/src/{ui → client}/components/nav-manager-types.ts +3 -0
- package/src/{ui → client}/components/post-form-template.ts +35 -31
- package/src/{ui → client}/components/post-form-types.ts +7 -3
- package/src/{lib → client}/compose-bridge.ts +9 -7
- package/src/client/lazy-slugify.ts +51 -0
- package/src/{lib → client}/media-upload.ts +16 -3
- package/src/{lib → client}/nav-manager-bridge.ts +1 -1
- package/src/client/page-slug-bridge.ts +42 -0
- package/src/{lib → client}/post-form-bridge.ts +2 -2
- package/src/{lib → client}/settings-bridge.ts +3 -3
- package/src/client/tiptap/bubble-menu.ts +205 -0
- package/src/client/tiptap/create-editor.ts +40 -0
- package/src/client/tiptap/exitable-marks.ts +73 -0
- package/src/client/tiptap/extensions.ts +60 -0
- package/src/client/tiptap/image-node.ts +488 -0
- package/src/client/tiptap/link-toolbar.ts +371 -0
- package/src/client/tiptap/more-break.ts +50 -0
- package/src/client/tiptap/paste-image.ts +140 -0
- package/src/client/tiptap/slash-commands.ts +328 -0
- package/src/{types → client/types}/sortablejs.d.ts +1 -1
- package/src/client.ts +24 -17
- package/src/db/migrations/0012_add_tiptap_columns.sql +2 -0
- package/src/db/migrations/0013_replace_featured_with_visibility.sql +8 -0
- package/src/db/schema.ts +6 -1
- package/src/i18n/locales/en.po +641 -215
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +642 -204
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +642 -204
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/lib/__tests__/resolve-config.test.ts +2 -2
- package/src/lib/__tests__/schemas.test.ts +9 -6
- package/src/lib/__tests__/url.test.ts +2 -2
- package/src/lib/__tests__/view.test.ts +9 -9
- package/src/lib/emoji-catalog.ts +146 -0
- package/src/lib/feed.ts +1 -1
- package/src/lib/media-helpers.ts +10 -9
- package/src/lib/render.tsx +4 -3
- package/src/lib/resolve-config.ts +8 -1
- package/src/lib/schemas.ts +2 -3
- package/src/lib/summary.ts +92 -0
- package/src/lib/timeline.ts +2 -0
- package/src/lib/tiptap-render.ts +196 -0
- package/src/lib/upload.ts +97 -9
- package/src/lib/url.ts +7 -23
- package/src/lib/view.ts +33 -19
- package/src/middleware/error-handler.ts +3 -3
- package/src/preset.css +38 -0
- package/src/routes/api/collections.ts +20 -3
- package/src/routes/api/posts.ts +48 -33
- package/src/routes/api/upload.ts +7 -5
- package/src/routes/auth/reset.tsx +5 -4
- package/src/routes/auth/setup.tsx +26 -11
- package/src/routes/auth/signin.tsx +10 -7
- package/src/routes/compose.tsx +20 -11
- package/src/routes/dash/__tests__/settings-avatar.test.ts +43 -8
- package/src/routes/dash/index.tsx +7 -1
- package/src/routes/dash/media.tsx +3 -0
- package/src/routes/dash/pages.tsx +8 -2
- package/src/routes/dash/posts.tsx +6 -2
- package/src/routes/dash/redirects.tsx +15 -9
- package/src/routes/dash/settings.tsx +336 -32
- package/src/routes/feed/__tests__/rss.test.ts +245 -6
- package/src/routes/feed/rss.ts +70 -6
- package/src/routes/pages/__tests__/featured.test.ts +6 -7
- package/src/routes/pages/archive.tsx +11 -7
- package/src/routes/pages/collection.tsx +32 -15
- package/src/routes/pages/collections.tsx +11 -2
- package/src/routes/pages/featured.tsx +1 -1
- package/src/routes/pages/home.tsx +1 -1
- package/src/services/__tests__/post.test.ts +124 -33
- package/src/services/__tests__/settings.test.ts +3 -3
- package/src/services/page.ts +16 -3
- package/src/services/post.ts +96 -37
- package/src/services/search.ts +4 -2
- package/src/services/settings.ts +6 -2
- package/src/styles/components.css +240 -60
- package/src/styles/tokens.css +10 -0
- package/src/styles/ui.css +1157 -81
- package/src/types/bindings.ts +5 -0
- package/src/types/config.ts +23 -1
- package/src/types/constants.ts +3 -0
- package/src/types/entities.ts +9 -2
- package/src/types/operations.ts +9 -3
- package/src/types/props.ts +3 -3
- package/src/types/views.ts +3 -2
- package/src/ui/compose/ComposeDialog.tsx +24 -7
- package/src/ui/dash/PageForm.tsx +2 -0
- package/src/ui/dash/PostList.tsx +5 -5
- package/src/ui/dash/StatusBadge.tsx +13 -5
- package/src/ui/dash/appearance/AdvancedContent.tsx +52 -61
- package/src/ui/dash/appearance/ColorThemeContent.tsx +30 -35
- package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
- package/src/ui/dash/appearance/NavigationContent.tsx +107 -96
- package/src/ui/dash/media/MediaListContent.tsx +9 -4
- package/src/ui/dash/media/ViewMediaContent.tsx +2 -2
- package/src/ui/dash/pages/PagesContent.tsx +2 -1
- package/src/ui/dash/posts/PostForm.tsx +19 -7
- package/src/ui/dash/settings/AccountContent.tsx +133 -138
- package/src/ui/dash/settings/AvatarContent.tsx +70 -0
- package/src/ui/dash/settings/GeneralContent.tsx +3 -62
- package/src/ui/dash/settings/SettingsRootContent.tsx +236 -0
- package/src/ui/layouts/DashLayout.tsx +157 -75
- package/src/ui/layouts/SiteLayout.tsx +13 -13
- package/src/ui/pages/ArchivePage.tsx +10 -7
- package/src/ui/pages/CollectionPage.tsx +6 -35
- package/src/ui/pages/CollectionsPage.tsx +2 -1
- package/src/ui/pages/FeaturedPage.tsx +2 -1
- package/src/ui/pages/HomePage.tsx +1 -1
- package/src/ui/pages/SearchPage.tsx +1 -1
- package/src/ui/shared/CollectionsSidebar.tsx +228 -3
- package/src/ui/shared/MediaGallery.tsx +179 -41
- package/src/lib/collections-reorder.ts +0 -28
- package/src/routes/dash/appearance.tsx +0 -240
- package/src/routes/dash/collections.tsx +0 -211
- package/src/ui/components/jant-compose-editor.ts +0 -814
- package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
- package/src/ui/dash/collections/CollectionForm.tsx +0 -166
- package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
- package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
- package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
- package/src/ui/dash/settings/SettingsNav.tsx +0 -52
- /package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +0 -0
- /package/src/{ui → client}/components/jant-settings-avatar.ts +0 -0
- /package/src/{ui → client}/components/settings-types.ts +0 -0
- /package/src/{lib → client}/image-processor.ts +0 -0
- /package/src/{lib → client}/toast.ts +0 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiptap JSON → HTML Renderer
|
|
3
|
+
*
|
|
4
|
+
* Lightweight server-side renderer that converts Tiptap JSON documents
|
|
5
|
+
* to HTML strings. Pure string concatenation — no DOM required.
|
|
6
|
+
* Works on Cloudflare Workers and any JS runtime.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
interface TiptapMark {
|
|
10
|
+
type: string;
|
|
11
|
+
attrs?: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface TiptapNode {
|
|
15
|
+
type: string;
|
|
16
|
+
content?: TiptapNode[];
|
|
17
|
+
text?: string;
|
|
18
|
+
marks?: TiptapMark[];
|
|
19
|
+
attrs?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function escapeHtml(str: string): string {
|
|
23
|
+
return str
|
|
24
|
+
.replace(/&/g, "&")
|
|
25
|
+
.replace(/</g, "<")
|
|
26
|
+
.replace(/>/g, ">")
|
|
27
|
+
.replace(/"/g, """);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function renderMarks(text: string, marks: TiptapMark[]): string {
|
|
31
|
+
let result = escapeHtml(text);
|
|
32
|
+
|
|
33
|
+
for (const mark of marks) {
|
|
34
|
+
switch (mark.type) {
|
|
35
|
+
case "bold":
|
|
36
|
+
result = `<strong>${result}</strong>`;
|
|
37
|
+
break;
|
|
38
|
+
case "italic":
|
|
39
|
+
result = `<em>${result}</em>`;
|
|
40
|
+
break;
|
|
41
|
+
case "strike":
|
|
42
|
+
result = `<s>${result}</s>`;
|
|
43
|
+
break;
|
|
44
|
+
case "code":
|
|
45
|
+
result = `<code>${result}</code>`;
|
|
46
|
+
break;
|
|
47
|
+
case "link": {
|
|
48
|
+
const href = escapeHtml(String(mark.attrs?.href ?? ""));
|
|
49
|
+
const target = mark.attrs?.target
|
|
50
|
+
? ` target="${escapeHtml(String(mark.attrs.target))}"`
|
|
51
|
+
: "";
|
|
52
|
+
const rel = mark.attrs?.target
|
|
53
|
+
? ' rel="noopener noreferrer nofollow"'
|
|
54
|
+
: "";
|
|
55
|
+
result = `<a href="${href}"${target}${rel}>${result}</a>`;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderNode(node: TiptapNode): string {
|
|
65
|
+
switch (node.type) {
|
|
66
|
+
case "doc":
|
|
67
|
+
return (node.content ?? []).map(renderNode).join("");
|
|
68
|
+
|
|
69
|
+
case "paragraph":
|
|
70
|
+
return `<p>${renderChildren(node)}</p>`;
|
|
71
|
+
|
|
72
|
+
case "heading": {
|
|
73
|
+
const level = Math.min(Math.max(Number(node.attrs?.level ?? 1), 1), 6);
|
|
74
|
+
return `<h${level}>${renderChildren(node)}</h${level}>`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
case "text":
|
|
78
|
+
if (node.marks && node.marks.length > 0) {
|
|
79
|
+
return renderMarks(node.text ?? "", node.marks);
|
|
80
|
+
}
|
|
81
|
+
return escapeHtml(node.text ?? "");
|
|
82
|
+
|
|
83
|
+
case "bulletList":
|
|
84
|
+
return `<ul>${renderChildren(node)}</ul>`;
|
|
85
|
+
|
|
86
|
+
case "orderedList": {
|
|
87
|
+
const start = node.attrs?.start;
|
|
88
|
+
const startAttr = start && start !== 1 ? ` start="${start}"` : "";
|
|
89
|
+
return `<ol${startAttr}>${renderChildren(node)}</ol>`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
case "listItem":
|
|
93
|
+
return `<li>${renderChildren(node)}</li>`;
|
|
94
|
+
|
|
95
|
+
case "blockquote":
|
|
96
|
+
return `<blockquote>${renderChildren(node)}</blockquote>`;
|
|
97
|
+
|
|
98
|
+
case "codeBlock": {
|
|
99
|
+
const lang = node.attrs?.language;
|
|
100
|
+
const langClass = lang
|
|
101
|
+
? ` class="language-${escapeHtml(String(lang))}"`
|
|
102
|
+
: "";
|
|
103
|
+
return `<pre><code${langClass}>${renderChildren(node)}</code></pre>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
case "table":
|
|
107
|
+
return `<table>${renderChildren(node)}</table>`;
|
|
108
|
+
|
|
109
|
+
case "tableRow":
|
|
110
|
+
return `<tr>${renderChildren(node)}</tr>`;
|
|
111
|
+
|
|
112
|
+
case "tableCell": {
|
|
113
|
+
const colspan = node.attrs?.colspan;
|
|
114
|
+
const rowspan = node.attrs?.rowspan;
|
|
115
|
+
const colspanAttr =
|
|
116
|
+
colspan && colspan !== 1 ? ` colspan="${colspan}"` : "";
|
|
117
|
+
const rowspanAttr =
|
|
118
|
+
rowspan && rowspan !== 1 ? ` rowspan="${rowspan}"` : "";
|
|
119
|
+
return `<td${colspanAttr}${rowspanAttr}>${renderChildren(node)}</td>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
case "tableHeader": {
|
|
123
|
+
const thColspan = node.attrs?.colspan;
|
|
124
|
+
const thRowspan = node.attrs?.rowspan;
|
|
125
|
+
const thColspanAttr =
|
|
126
|
+
thColspan && thColspan !== 1 ? ` colspan="${thColspan}"` : "";
|
|
127
|
+
const thRowspanAttr =
|
|
128
|
+
thRowspan && thRowspan !== 1 ? ` rowspan="${thRowspan}"` : "";
|
|
129
|
+
return `<th${thColspanAttr}${thRowspanAttr}>${renderChildren(node)}</th>`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
case "horizontalRule":
|
|
133
|
+
return "<hr>";
|
|
134
|
+
|
|
135
|
+
case "hardBreak":
|
|
136
|
+
return "<br>";
|
|
137
|
+
|
|
138
|
+
case "image": {
|
|
139
|
+
const src = escapeHtml(String(node.attrs?.src ?? ""));
|
|
140
|
+
const alt = node.attrs?.alt
|
|
141
|
+
? ` alt="${escapeHtml(String(node.attrs.alt))}"`
|
|
142
|
+
: "";
|
|
143
|
+
const title = node.attrs?.title
|
|
144
|
+
? ` title="${escapeHtml(String(node.attrs.title))}"`
|
|
145
|
+
: "";
|
|
146
|
+
const caption = node.attrs?.caption ? String(node.attrs.caption) : "";
|
|
147
|
+
const layout = node.attrs?.layout ?? "regular";
|
|
148
|
+
const href = node.attrs?.href ? String(node.attrs.href) : "";
|
|
149
|
+
const layoutAttr =
|
|
150
|
+
layout !== "regular"
|
|
151
|
+
? ` data-layout="${escapeHtml(String(layout))}"`
|
|
152
|
+
: "";
|
|
153
|
+
const imgTag = `<img src="${src}"${alt}${title}>`;
|
|
154
|
+
const linkedImg = href
|
|
155
|
+
? `<a href="${escapeHtml(href)}">${imgTag}</a>`
|
|
156
|
+
: imgTag;
|
|
157
|
+
const figcaption = caption
|
|
158
|
+
? `<figcaption>${escapeHtml(caption)}</figcaption>`
|
|
159
|
+
: "";
|
|
160
|
+
return `<figure${layoutAttr}>${linkedImg}${figcaption}</figure>`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
case "moreBreak":
|
|
164
|
+
return "<!--more-->";
|
|
165
|
+
|
|
166
|
+
default:
|
|
167
|
+
// Unknown node: render children if any, skip otherwise
|
|
168
|
+
return node.content ? renderChildren(node) : "";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderChildren(node: TiptapNode): string {
|
|
173
|
+
return (node.content ?? []).map(renderNode).join("");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Renders a Tiptap JSON document to an HTML string.
|
|
178
|
+
*
|
|
179
|
+
* @param json - Tiptap JSON string or parsed document object
|
|
180
|
+
* @returns HTML string
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* ```ts
|
|
184
|
+
* const html = renderTiptapJson('{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}]}');
|
|
185
|
+
* // "<p>Hello</p>"
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
188
|
+
export function renderTiptapJson(json: string): string {
|
|
189
|
+
try {
|
|
190
|
+
const doc = JSON.parse(json) as TiptapNode;
|
|
191
|
+
if (doc.type !== "doc") return "";
|
|
192
|
+
return renderNode(doc);
|
|
193
|
+
} catch {
|
|
194
|
+
return "";
|
|
195
|
+
}
|
|
196
|
+
}
|
package/src/lib/upload.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
import { uuidv7 } from "uuidv7";
|
|
8
8
|
|
|
9
|
-
/** MIME types allowed for upload */
|
|
10
|
-
const
|
|
9
|
+
/** MIME types allowed for upload — images */
|
|
10
|
+
const IMAGE_MIME_TYPES = [
|
|
11
11
|
"image/jpeg",
|
|
12
12
|
"image/png",
|
|
13
13
|
"image/gif",
|
|
@@ -15,30 +15,118 @@ const ALLOWED_UPLOAD_TYPES = [
|
|
|
15
15
|
"image/svg+xml",
|
|
16
16
|
] as const;
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
const
|
|
18
|
+
/** MIME types allowed for upload — video */
|
|
19
|
+
const VIDEO_MIME_TYPES = [
|
|
20
|
+
"video/mp4",
|
|
21
|
+
"video/webm",
|
|
22
|
+
"video/quicktime",
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
/** MIME types allowed for upload — audio */
|
|
26
|
+
const AUDIO_MIME_TYPES = [
|
|
27
|
+
"audio/mpeg",
|
|
28
|
+
"audio/ogg",
|
|
29
|
+
"audio/wav",
|
|
30
|
+
"audio/mp4",
|
|
31
|
+
"audio/x-m4a",
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
/** MIME types allowed for upload — documents */
|
|
35
|
+
const DOCUMENT_MIME_TYPES = ["application/pdf"] as const;
|
|
36
|
+
|
|
37
|
+
/** All allowed MIME types */
|
|
38
|
+
const ALLOWED_UPLOAD_TYPES = [
|
|
39
|
+
...IMAGE_MIME_TYPES,
|
|
40
|
+
...VIDEO_MIME_TYPES,
|
|
41
|
+
...AUDIO_MIME_TYPES,
|
|
42
|
+
...DOCUMENT_MIME_TYPES,
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Accept string for file inputs, covering all allowed upload types.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* <input type="file" accept={UPLOAD_ACCEPT} />
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export const UPLOAD_ACCEPT = (ALLOWED_UPLOAD_TYPES as readonly string[]).join(
|
|
54
|
+
",",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
export type MediaCategory = "image" | "video" | "audio" | "document";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns the media category for a given MIME type.
|
|
61
|
+
*
|
|
62
|
+
* @param mimeType - The MIME type to classify
|
|
63
|
+
* @returns The media category, or null if the MIME type is not supported
|
|
64
|
+
* @example
|
|
65
|
+
* ```ts
|
|
66
|
+
* getMediaCategory("video/mp4"); // "video"
|
|
67
|
+
* getMediaCategory("text/plain"); // null
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export function getMediaCategory(mimeType: string): MediaCategory | null {
|
|
71
|
+
if (mimeType.startsWith("image/")) return "image";
|
|
72
|
+
if (mimeType.startsWith("video/")) return "video";
|
|
73
|
+
if (mimeType.startsWith("audio/")) return "audio";
|
|
74
|
+
if (mimeType === "application/pdf") return "document";
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Returns true if the given MIME type is an image type.
|
|
80
|
+
*
|
|
81
|
+
* @param mimeType - The MIME type to check
|
|
82
|
+
* @returns Whether the MIME type is an image
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* isImageMimeType("image/jpeg"); // true
|
|
86
|
+
* isImageMimeType("video/mp4"); // false
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export function isImageMimeType(mimeType: string): boolean {
|
|
90
|
+
return mimeType.startsWith("image/");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ValidateUploadOptions {
|
|
94
|
+
/** When true, only image MIME types are accepted (e.g. for avatar uploads). */
|
|
95
|
+
imagesOnly?: boolean;
|
|
96
|
+
/** Max file size in MB. */
|
|
97
|
+
maxFileSizeMB: number;
|
|
98
|
+
}
|
|
20
99
|
|
|
21
100
|
/**
|
|
22
101
|
* Validates an uploaded file's type and size.
|
|
23
102
|
*
|
|
24
103
|
* @param file - The uploaded File object
|
|
104
|
+
* @param options - Validation constraints
|
|
25
105
|
* @returns null if valid, error message string if invalid
|
|
26
106
|
* @example
|
|
27
107
|
* ```ts
|
|
28
|
-
* const error = validateUploadFile(file);
|
|
108
|
+
* const error = validateUploadFile(file, { maxFileSizeMB: 500 });
|
|
29
109
|
* if (error) return dsToast(error, "error");
|
|
30
110
|
* ```
|
|
31
111
|
*/
|
|
32
|
-
export function validateUploadFile(
|
|
33
|
-
|
|
112
|
+
export function validateUploadFile(
|
|
113
|
+
file: File,
|
|
114
|
+
options: ValidateUploadOptions,
|
|
115
|
+
): string | null {
|
|
116
|
+
if (options?.imagesOnly) {
|
|
117
|
+
if (!isImageMimeType(file.type)) {
|
|
118
|
+
return "File type not allowed.";
|
|
119
|
+
}
|
|
120
|
+
} else if (
|
|
34
121
|
!ALLOWED_UPLOAD_TYPES.includes(
|
|
35
122
|
file.type as (typeof ALLOWED_UPLOAD_TYPES)[number],
|
|
36
123
|
)
|
|
37
124
|
) {
|
|
38
125
|
return "File type not allowed.";
|
|
39
126
|
}
|
|
40
|
-
|
|
41
|
-
|
|
127
|
+
const maxMB = options.maxFileSizeMB;
|
|
128
|
+
if (file.size > maxMB * 1024 * 1024) {
|
|
129
|
+
return `File too large (max ${maxMB}MB).`;
|
|
42
130
|
}
|
|
43
131
|
return null;
|
|
44
132
|
}
|
package/src/lib/url.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* URL Utilities
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
import limax from "limax";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Extracts the hostname (domain) from a URL string.
|
|
@@ -79,37 +79,21 @@ export function isFullUrl(str: string): boolean {
|
|
|
79
79
|
/**
|
|
80
80
|
* Converts text to a URL-friendly slug.
|
|
81
81
|
*
|
|
82
|
-
* Transforms text into a lowercase, hyphen-separated slug
|
|
83
|
-
* -
|
|
84
|
-
* - Removing special characters (keeping only word characters, spaces, and hyphens)
|
|
85
|
-
* - Replacing whitespace and underscores with hyphens
|
|
86
|
-
* - Removing leading and trailing hyphens
|
|
87
|
-
*
|
|
88
|
-
* Used for generating clean URLs from titles and names.
|
|
82
|
+
* Transforms text into a lowercase, hyphen-separated slug using limax for
|
|
83
|
+
* i18n-aware transliteration (CJK → Pinyin, Japanese → Romaji, accented → ASCII).
|
|
89
84
|
*
|
|
90
85
|
* @param text - The text to convert to a slug
|
|
91
86
|
* @returns The slugified string
|
|
92
87
|
*
|
|
93
88
|
* @example
|
|
94
89
|
* ```ts
|
|
95
|
-
*
|
|
90
|
+
* slugify("Hello World! This is a Test.");
|
|
96
91
|
* // Returns: "hello-world-this-is-a-test"
|
|
97
92
|
*
|
|
98
|
-
*
|
|
99
|
-
* // Returns: "
|
|
93
|
+
* slugify("书评");
|
|
94
|
+
* // Returns: "shu-ping"
|
|
100
95
|
* ```
|
|
101
96
|
*/
|
|
102
97
|
export function slugify(text: string): string {
|
|
103
|
-
|
|
104
|
-
const converted = text.replace(
|
|
105
|
-
/[\u4e00-\u9fff\u3400-\u4dbf]+/g,
|
|
106
|
-
(match) => ` ${pinyin(match, { toneType: "none", separator: " " })} `,
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
return converted
|
|
110
|
-
.toLowerCase()
|
|
111
|
-
.trim()
|
|
112
|
-
.replace(/[^\w\s-]/g, "")
|
|
113
|
-
.replace(/[\s_-]+/g, "-")
|
|
114
|
-
.replace(/^-+|-+$/g, "");
|
|
98
|
+
return limax(text, { tone: false }).replace(/_/g, "-");
|
|
115
99
|
}
|
package/src/lib/view.ts
CHANGED
|
@@ -78,13 +78,17 @@ export function toMediaView(media: Media, ctx: MediaContext): MediaView {
|
|
|
78
78
|
ctx.s3PublicUrl,
|
|
79
79
|
);
|
|
80
80
|
const url = getMediaUrl(media.storageKey, publicUrl);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
81
|
+
|
|
82
|
+
// Only apply image transforms for image MIME types
|
|
83
|
+
const thumbnailUrl = media.mimeType.startsWith("image/")
|
|
84
|
+
? getImageUrl(url, ctx.imageTransformUrl, {
|
|
85
|
+
width: 1200,
|
|
86
|
+
height: 768,
|
|
87
|
+
quality: 80,
|
|
88
|
+
format: "auto",
|
|
89
|
+
fit: "scale-down",
|
|
90
|
+
})
|
|
91
|
+
: url;
|
|
88
92
|
|
|
89
93
|
return {
|
|
90
94
|
id: media.id,
|
|
@@ -124,17 +128,27 @@ export function toPostView(post: PostWithMedia, _ctx: MediaContext): PostView {
|
|
|
124
128
|
let summaryHasMore: boolean | undefined;
|
|
125
129
|
let bodyHtmlWithAnchor = post.bodyHtml;
|
|
126
130
|
if (post.title && post.bodyHtml) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
131
|
+
if (post.summary) {
|
|
132
|
+
// Use stored summary (generated from Tiptap JSON)
|
|
133
|
+
summaryHtml = post.summary
|
|
134
|
+
.split("\n\n")
|
|
135
|
+
.map((p) => `<p>${p}</p>`)
|
|
136
|
+
.join("");
|
|
137
|
+
summaryHasMore = true;
|
|
138
|
+
} else {
|
|
139
|
+
// Fallback: extract from rendered HTML
|
|
140
|
+
const result = getHtmlExcerpt(post.bodyHtml);
|
|
141
|
+
summaryHtml = result.excerpt;
|
|
142
|
+
summaryHasMore = result.hasMore;
|
|
143
|
+
|
|
144
|
+
// Inject #continue anchor at the excerpt boundary for scroll targeting
|
|
145
|
+
if (result.hasMore) {
|
|
146
|
+
const pos = result.excerptEnd;
|
|
147
|
+
bodyHtmlWithAnchor =
|
|
148
|
+
post.bodyHtml.slice(0, pos) +
|
|
149
|
+
'<span id="continue"></span>' +
|
|
150
|
+
post.bodyHtml.slice(pos);
|
|
151
|
+
}
|
|
138
152
|
}
|
|
139
153
|
}
|
|
140
154
|
|
|
@@ -162,7 +176,7 @@ export function toPostView(post: PostWithMedia, _ctx: MediaContext): PostView {
|
|
|
162
176
|
quoteText: post.quoteText ?? undefined,
|
|
163
177
|
format: post.format as Format,
|
|
164
178
|
status: post.status as Status,
|
|
165
|
-
|
|
179
|
+
visibility: post.visibility,
|
|
166
180
|
pinned: post.pinned === 1,
|
|
167
181
|
rating: post.rating ?? undefined,
|
|
168
182
|
publishedAt: toISOString(post.publishedAt),
|
|
@@ -33,7 +33,7 @@ export const errorHandler: ErrorHandler<Env> = (err, c) => {
|
|
|
33
33
|
// Unknown API error
|
|
34
34
|
// eslint-disable-next-line no-console -- Server error logging is intentional
|
|
35
35
|
console.error("[Jant] Unhandled error:", err);
|
|
36
|
-
return c.json({ error: "
|
|
36
|
+
return c.json({ error: "Something went wrong on our end" }, 500);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
// Datastar requests: return toast
|
|
@@ -43,7 +43,7 @@ export const errorHandler: ErrorHandler<Env> = (err, c) => {
|
|
|
43
43
|
}
|
|
44
44
|
// eslint-disable-next-line no-console -- Server error logging is intentional
|
|
45
45
|
console.error("[Jant] Unhandled error:", err);
|
|
46
|
-
return dsToast("
|
|
46
|
+
return dsToast("Something went wrong. Try refreshing the page.", "error");
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
// JSON-accepting requests (Lit bridges)
|
|
@@ -59,7 +59,7 @@ export const errorHandler: ErrorHandler<Env> = (err, c) => {
|
|
|
59
59
|
}
|
|
60
60
|
// eslint-disable-next-line no-console -- Server error logging is intentional
|
|
61
61
|
console.error("[Jant] Unhandled error:", err);
|
|
62
|
-
return c.json({ error: "
|
|
62
|
+
return c.json({ error: "Something went wrong on our end" }, 500);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
// Non-API routes: map NotFoundError to Hono's built-in 404
|
package/src/preset.css
CHANGED
|
@@ -73,4 +73,42 @@
|
|
|
73
73
|
:where(h1, h2, h3, h4, h5, h6) {
|
|
74
74
|
font-family: var(--font-heading);
|
|
75
75
|
}
|
|
76
|
+
|
|
77
|
+
/* Image figures */
|
|
78
|
+
figure {
|
|
79
|
+
margin: 1.5em 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
figure img {
|
|
83
|
+
width: 100%;
|
|
84
|
+
max-height: 500px;
|
|
85
|
+
object-fit: contain;
|
|
86
|
+
border-radius: 6px;
|
|
87
|
+
cursor: pointer;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
figcaption {
|
|
91
|
+
text-align: center;
|
|
92
|
+
font-size: 0.875rem;
|
|
93
|
+
color: var(--tw-prose-captions);
|
|
94
|
+
margin-top: 0.5em;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* Layout variants — center-breakout via margin+transform (no scrollbars) */
|
|
98
|
+
figure[data-layout="wide"] {
|
|
99
|
+
width: 1200px;
|
|
100
|
+
max-width: 100vw;
|
|
101
|
+
margin-left: 50%;
|
|
102
|
+
transform: translateX(-50%);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
figure[data-layout="full"] {
|
|
106
|
+
width: 100vw;
|
|
107
|
+
margin-left: 50%;
|
|
108
|
+
transform: translateX(-50%);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
figure[data-layout="full"] img {
|
|
112
|
+
border-radius: 0;
|
|
113
|
+
}
|
|
76
114
|
}
|
|
@@ -36,19 +36,36 @@ const PostAssignSchema = z.object({
|
|
|
36
36
|
postId: z.number().int().positive(),
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
-
// List collections (includes post counts)
|
|
39
|
+
// List collections (includes post counts and dividers)
|
|
40
40
|
collectionsApiRoutes.get("/", async (c) => {
|
|
41
|
-
const collections = await
|
|
42
|
-
|
|
41
|
+
const [collections, dividers, postCounts] = await Promise.all([
|
|
42
|
+
c.var.services.collections.list(),
|
|
43
|
+
c.var.services.collections.listDividers(),
|
|
44
|
+
c.var.services.collections.getPostCounts(),
|
|
45
|
+
]);
|
|
43
46
|
|
|
44
47
|
return c.json({
|
|
45
48
|
collections: collections.map((col) => ({
|
|
46
49
|
...col,
|
|
47
50
|
postCount: postCounts.get(col.id) ?? 0,
|
|
48
51
|
})),
|
|
52
|
+
dividers,
|
|
49
53
|
});
|
|
50
54
|
});
|
|
51
55
|
|
|
56
|
+
// Create divider (requires auth) — must be before /:id
|
|
57
|
+
collectionsApiRoutes.post("/dividers", requireAuthApi(), async (c) => {
|
|
58
|
+
const divider = await c.var.services.collections.createDivider();
|
|
59
|
+
return c.json(divider, 201);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Delete divider (requires auth) — must be before /:id
|
|
63
|
+
collectionsApiRoutes.delete("/dividers/:id", requireAuthApi(), async (c) => {
|
|
64
|
+
const id = parseIntParam(c.req.param("id"));
|
|
65
|
+
await c.var.services.collections.deleteDivider(id);
|
|
66
|
+
return c.json({ success: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
52
69
|
// Get single collection
|
|
53
70
|
collectionsApiRoutes.get("/:id", async (c) => {
|
|
54
71
|
const id = parseIntParam(c.req.param("id"));
|
package/src/routes/api/posts.ts
CHANGED
|
@@ -126,23 +126,31 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
|
|
|
126
126
|
await c.var.services.media.validateIds(body.mediaIds);
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
const post = await c.var.services.posts.create(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
129
|
+
const post = await c.var.services.posts.create(
|
|
130
|
+
{
|
|
131
|
+
format: body.format,
|
|
132
|
+
title: body.title,
|
|
133
|
+
body: body.body,
|
|
134
|
+
path: body.path || undefined,
|
|
135
|
+
status: body.status,
|
|
136
|
+
visibility: body.visibility,
|
|
137
|
+
pinned: body.pinned,
|
|
138
|
+
url: body.url || undefined,
|
|
139
|
+
quoteText: body.quoteText,
|
|
140
|
+
rating: body.rating || undefined,
|
|
141
|
+
collectionIds: body.collectionIds?.length
|
|
142
|
+
? body.collectionIds
|
|
143
|
+
: undefined,
|
|
144
|
+
replyToId: body.replyToId
|
|
145
|
+
? (sqid.decode(body.replyToId) ?? undefined)
|
|
146
|
+
: undefined,
|
|
147
|
+
publishedAt: body.publishedAt,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
maxParagraphs: c.var.appConfig.summaryMaxParagraphs,
|
|
151
|
+
maxChars: c.var.appConfig.summaryMaxChars,
|
|
152
|
+
},
|
|
153
|
+
);
|
|
146
154
|
|
|
147
155
|
// Attach media
|
|
148
156
|
if (body.mediaIds && body.mediaIds.length > 0) {
|
|
@@ -177,22 +185,29 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
|
|
|
177
185
|
}
|
|
178
186
|
|
|
179
187
|
const post = assertFound(
|
|
180
|
-
await c.var.services.posts.update(
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
:
|
|
194
|
-
|
|
195
|
-
|
|
188
|
+
await c.var.services.posts.update(
|
|
189
|
+
id,
|
|
190
|
+
{
|
|
191
|
+
format: body.format,
|
|
192
|
+
title: body.title,
|
|
193
|
+
body: body.body,
|
|
194
|
+
path: body.path,
|
|
195
|
+
status: body.status,
|
|
196
|
+
visibility: body.visibility,
|
|
197
|
+
pinned: body.pinned,
|
|
198
|
+
url: body.url,
|
|
199
|
+
quoteText: body.quoteText,
|
|
200
|
+
rating: body.rating || undefined,
|
|
201
|
+
collectionIds: body.collectionIds?.length
|
|
202
|
+
? body.collectionIds
|
|
203
|
+
: undefined,
|
|
204
|
+
publishedAt: body.publishedAt,
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
maxParagraphs: c.var.appConfig.summaryMaxParagraphs,
|
|
208
|
+
maxChars: c.var.appConfig.summaryMaxChars,
|
|
209
|
+
},
|
|
210
|
+
),
|
|
196
211
|
"Post",
|
|
197
212
|
);
|
|
198
213
|
|