@jant/core 0.2.18 → 0.2.19
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/app.d.ts.map +1 -1
- package/dist/app.js +18 -52
- package/dist/client.js +1 -0
- package/dist/lib/image-processor.js +0 -4
- package/dist/lib/media-upload.js +104 -0
- package/dist/lib/sse.d.ts +67 -13
- package/dist/lib/sse.d.ts.map +1 -1
- package/dist/lib/sse.js +108 -23
- package/dist/routes/api/upload.js +16 -18
- package/dist/routes/dash/appearance.js +3 -7
- package/dist/routes/dash/collections.js +5 -13
- package/dist/routes/dash/media.js +17 -167
- package/dist/routes/dash/pages.js +4 -10
- package/dist/routes/dash/posts.js +4 -10
- package/dist/routes/dash/redirects.js +3 -7
- package/dist/routes/dash/settings.d.ts.map +1 -1
- package/dist/routes/dash/settings.js +16 -7
- package/dist/theme/layouts/DashLayout.js +1 -0
- package/package.json +1 -1
- package/src/app.tsx +18 -56
- package/src/client.ts +1 -0
- package/src/lib/image-processor.ts +0 -7
- package/src/lib/media-upload.ts +148 -0
- package/src/lib/sse.ts +130 -28
- package/src/routes/api/upload.ts +12 -18
- package/src/routes/dash/appearance.tsx +3 -7
- package/src/routes/dash/collections.tsx +5 -13
- package/src/routes/dash/media.tsx +16 -165
- package/src/routes/dash/pages.tsx +4 -10
- package/src/routes/dash/posts.tsx +4 -10
- package/src/routes/dash/redirects.tsx +3 -7
- package/src/routes/dash/settings.tsx +25 -7
- package/src/theme/layouts/DashLayout.tsx +1 -1
|
@@ -14,7 +14,7 @@ import { DashLayout } from "../../theme/layouts/index.js";
|
|
|
14
14
|
import { EmptyState, DangerZone } from "../../theme/components/index.js";
|
|
15
15
|
import * as time from "../../lib/time.js";
|
|
16
16
|
import { getMediaUrl, getImageUrl } from "../../lib/image.js";
|
|
17
|
-
import {
|
|
17
|
+
import { dsRedirect } from "../../lib/sse.js";
|
|
18
18
|
|
|
19
19
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
20
20
|
|
|
@@ -90,8 +90,7 @@ function MediaCard({
|
|
|
90
90
|
/**
|
|
91
91
|
* Media list page content
|
|
92
92
|
*
|
|
93
|
-
*
|
|
94
|
-
* for complex async flows like file uploads with SSE responses).
|
|
93
|
+
* Upload is handled by media-upload.ts (client module) + Datastar @post for SSE.
|
|
95
94
|
*/
|
|
96
95
|
function MediaListContent({
|
|
97
96
|
mediaList,
|
|
@@ -121,163 +120,17 @@ function MediaListContent({
|
|
|
121
120
|
comment: "@context: Upload error message",
|
|
122
121
|
});
|
|
123
122
|
|
|
124
|
-
// Plain JavaScript upload handler - shows progress in the list
|
|
125
|
-
const uploadScript = `
|
|
126
|
-
async function handleMediaUpload(input) {
|
|
127
|
-
if (!input.files || !input.files[0]) return;
|
|
128
|
-
|
|
129
|
-
const file = input.files[0];
|
|
130
|
-
const errorBox = document.getElementById('upload-error');
|
|
131
|
-
errorBox.classList.add('hidden');
|
|
132
|
-
|
|
133
|
-
// Ensure grid exists (remove empty state if needed)
|
|
134
|
-
let grid = document.getElementById('media-grid');
|
|
135
|
-
if (!grid) {
|
|
136
|
-
document.getElementById('empty-state')?.remove();
|
|
137
|
-
grid = document.createElement('div');
|
|
138
|
-
grid.id = 'media-grid';
|
|
139
|
-
grid.className = 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4';
|
|
140
|
-
document.getElementById('media-content').appendChild(grid);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Create placeholder card showing progress
|
|
144
|
-
const placeholder = document.createElement('div');
|
|
145
|
-
placeholder.id = 'upload-placeholder';
|
|
146
|
-
placeholder.className = 'group relative';
|
|
147
|
-
placeholder.innerHTML = \`
|
|
148
|
-
<div class="aspect-square bg-muted rounded-lg overflow-hidden border flex items-center justify-center">
|
|
149
|
-
<div class="text-center px-2">
|
|
150
|
-
<svg class="animate-spin h-6 w-6 text-muted-foreground mx-auto mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
151
|
-
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
152
|
-
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
153
|
-
</svg>
|
|
154
|
-
<span id="upload-status" class="text-xs text-muted-foreground">${processingText}</span>
|
|
155
|
-
</div>
|
|
156
|
-
</div>
|
|
157
|
-
<div class="mt-2 text-xs truncate" title="\${file.name}">\${file.name}</div>
|
|
158
|
-
<div class="text-xs text-muted-foreground">\${formatFileSize(file.size)}</div>
|
|
159
|
-
\`;
|
|
160
|
-
grid.prepend(placeholder);
|
|
161
|
-
|
|
162
|
-
try {
|
|
163
|
-
if (typeof ImageProcessor === 'undefined') {
|
|
164
|
-
throw new Error('ImageProcessor not loaded');
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Process image client-side
|
|
168
|
-
const processed = await ImageProcessor.processToFile(file);
|
|
169
|
-
document.getElementById('upload-status').textContent = '${uploadingText}';
|
|
170
|
-
|
|
171
|
-
// Upload with SSE response
|
|
172
|
-
const fd = new FormData();
|
|
173
|
-
fd.append('file', processed);
|
|
174
|
-
|
|
175
|
-
const response = await fetch('/api/upload', {
|
|
176
|
-
method: 'POST',
|
|
177
|
-
body: fd,
|
|
178
|
-
headers: { 'Accept': 'text/event-stream' }
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
if (!response.ok) throw new Error('Upload failed: ' + response.status);
|
|
182
|
-
|
|
183
|
-
// Parse SSE stream - will replace placeholder with real card
|
|
184
|
-
const reader = response.body.getReader();
|
|
185
|
-
const decoder = new TextDecoder();
|
|
186
|
-
let buffer = '';
|
|
187
|
-
|
|
188
|
-
while (true) {
|
|
189
|
-
const { done, value } = await reader.read();
|
|
190
|
-
if (done) break;
|
|
191
|
-
|
|
192
|
-
buffer += decoder.decode(value, { stream: true });
|
|
193
|
-
const events = buffer.split('\\n\\n');
|
|
194
|
-
buffer = events.pop() || '';
|
|
195
|
-
|
|
196
|
-
for (const event of events) {
|
|
197
|
-
if (!event.trim()) continue;
|
|
198
|
-
processSSEEvent(event);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
} catch (err) {
|
|
203
|
-
console.error('Upload error:', err);
|
|
204
|
-
// Show error in placeholder
|
|
205
|
-
placeholder.innerHTML = \`
|
|
206
|
-
<div class="aspect-square bg-destructive/10 rounded-lg overflow-hidden border border-destructive flex items-center justify-center">
|
|
207
|
-
<div class="text-center px-2">
|
|
208
|
-
<span class="text-xs text-destructive">\${err.message || '${errorText}'}</span>
|
|
209
|
-
</div>
|
|
210
|
-
</div>
|
|
211
|
-
<div class="mt-2 text-xs truncate text-destructive">\${file.name}</div>
|
|
212
|
-
<button type="button" class="text-xs text-muted-foreground hover:underline" onclick="this.closest('.group').remove()">Remove</button>
|
|
213
|
-
\`;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
input.value = '';
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function formatFileSize(bytes) {
|
|
220
|
-
if (bytes < 1024) return bytes + ' B';
|
|
221
|
-
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
222
|
-
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function processSSEEvent(event) {
|
|
226
|
-
const lines = event.split('\\n');
|
|
227
|
-
let eventType = '';
|
|
228
|
-
const data = {};
|
|
229
|
-
let elementsLines = [];
|
|
230
|
-
let inElements = false;
|
|
231
|
-
|
|
232
|
-
for (const line of lines) {
|
|
233
|
-
if (line.startsWith('event: ')) {
|
|
234
|
-
eventType = line.slice(7);
|
|
235
|
-
} else if (line.startsWith('data: ')) {
|
|
236
|
-
const content = line.slice(6);
|
|
237
|
-
if (content.startsWith('mode ')) {
|
|
238
|
-
data.mode = content.slice(5);
|
|
239
|
-
inElements = false;
|
|
240
|
-
} else if (content.startsWith('selector ')) {
|
|
241
|
-
data.selector = content.slice(9);
|
|
242
|
-
inElements = false;
|
|
243
|
-
} else if (content.startsWith('elements ')) {
|
|
244
|
-
elementsLines = [content.slice(9)];
|
|
245
|
-
inElements = true;
|
|
246
|
-
} else if (inElements) {
|
|
247
|
-
// Continuation of elements content
|
|
248
|
-
elementsLines.push(content);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (elementsLines.length > 0) {
|
|
254
|
-
data.elements = elementsLines.join('\\n');
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
if (eventType === 'datastar-patch-elements') {
|
|
258
|
-
if (data.mode === 'remove' && data.selector) {
|
|
259
|
-
document.querySelector(data.selector)?.remove();
|
|
260
|
-
} else if (data.mode === 'outer' && data.selector && data.elements) {
|
|
261
|
-
// Replace element entirely (used for placeholder -> real card)
|
|
262
|
-
const target = document.querySelector(data.selector);
|
|
263
|
-
if (target) {
|
|
264
|
-
const temp = document.createElement('div');
|
|
265
|
-
temp.innerHTML = data.elements;
|
|
266
|
-
const newElement = temp.firstElementChild;
|
|
267
|
-
if (newElement) {
|
|
268
|
-
target.replaceWith(newElement);
|
|
269
|
-
if (window.Datastar) Datastar.apply(newElement);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
`.trim();
|
|
276
|
-
|
|
277
123
|
return (
|
|
278
124
|
<>
|
|
279
|
-
{/*
|
|
280
|
-
<
|
|
125
|
+
{/* Hidden form for Datastar-driven upload */}
|
|
126
|
+
<form
|
|
127
|
+
id="upload-form"
|
|
128
|
+
class="hidden"
|
|
129
|
+
enctype="multipart/form-data"
|
|
130
|
+
data-on:submit__prevent="@post('/api/upload', {contentType: 'form'})"
|
|
131
|
+
>
|
|
132
|
+
<input id="upload-file-input" type="file" name="file" />
|
|
133
|
+
</form>
|
|
281
134
|
|
|
282
135
|
{/* Header */}
|
|
283
136
|
<div class="flex items-center justify-between mb-6">
|
|
@@ -290,14 +143,14 @@ function processSSEEvent(event) {
|
|
|
290
143
|
type="file"
|
|
291
144
|
class="hidden"
|
|
292
145
|
accept="image/*"
|
|
293
|
-
|
|
146
|
+
data-media-upload
|
|
147
|
+
data-text-processing={processingText}
|
|
148
|
+
data-text-uploading={uploadingText}
|
|
149
|
+
data-text-error={errorText}
|
|
294
150
|
/>
|
|
295
151
|
</label>
|
|
296
152
|
</div>
|
|
297
153
|
|
|
298
|
-
{/* Hidden error container for global errors */}
|
|
299
|
-
<div id="upload-error" class="hidden"></div>
|
|
300
|
-
|
|
301
154
|
{/* Upload instructions */}
|
|
302
155
|
<div class="card mb-6">
|
|
303
156
|
<section class="text-sm text-muted-foreground">
|
|
@@ -610,7 +463,5 @@ mediaRoutes.post("/:id/delete", async (c) => {
|
|
|
610
463
|
// Delete from database
|
|
611
464
|
await c.var.services.media.delete(id);
|
|
612
465
|
|
|
613
|
-
return
|
|
614
|
-
await stream.redirect("/dash/media");
|
|
615
|
-
});
|
|
466
|
+
return dsRedirect("/dash/media");
|
|
616
467
|
});
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from "../../theme/components/index.js";
|
|
22
22
|
import * as sqid from "../../lib/sqid.js";
|
|
23
23
|
import * as time from "../../lib/time.js";
|
|
24
|
-
import {
|
|
24
|
+
import { dsRedirect } from "../../lib/sse.js";
|
|
25
25
|
|
|
26
26
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
27
27
|
|
|
@@ -237,9 +237,7 @@ pagesRoutes.post("/", async (c) => {
|
|
|
237
237
|
path: body.path.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
|
|
238
238
|
});
|
|
239
239
|
|
|
240
|
-
return
|
|
241
|
-
await stream.redirect(`/dash/pages/${sqid.encode(page.id)}`);
|
|
242
|
-
});
|
|
240
|
+
return dsRedirect(`/dash/pages/${sqid.encode(page.id)}`);
|
|
243
241
|
});
|
|
244
242
|
|
|
245
243
|
// View single page
|
|
@@ -306,9 +304,7 @@ pagesRoutes.post("/:id", async (c) => {
|
|
|
306
304
|
path: body.path.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
|
|
307
305
|
});
|
|
308
306
|
|
|
309
|
-
return
|
|
310
|
-
await stream.redirect(`/dash/pages/${sqid.encode(id)}`);
|
|
311
|
-
});
|
|
307
|
+
return dsRedirect(`/dash/pages/${sqid.encode(id)}`);
|
|
312
308
|
});
|
|
313
309
|
|
|
314
310
|
// Delete page
|
|
@@ -318,7 +314,5 @@ pagesRoutes.post("/:id/delete", async (c) => {
|
|
|
318
314
|
|
|
319
315
|
await c.var.services.posts.delete(id);
|
|
320
316
|
|
|
321
|
-
return
|
|
322
|
-
await stream.redirect("/dash/pages");
|
|
323
|
-
});
|
|
317
|
+
return dsRedirect("/dash/pages");
|
|
324
318
|
});
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
ActionButtons,
|
|
16
16
|
} from "../../theme/components/index.js";
|
|
17
17
|
import * as sqid from "../../lib/sqid.js";
|
|
18
|
-
import {
|
|
18
|
+
import { dsRedirect } from "../../lib/sse.js";
|
|
19
19
|
|
|
20
20
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
21
21
|
|
|
@@ -105,9 +105,7 @@ postsRoutes.post("/", async (c) => {
|
|
|
105
105
|
path: body.path || undefined,
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
return
|
|
109
|
-
await stream.redirect(`/dash/posts/${sqid.encode(post.id)}`);
|
|
110
|
-
});
|
|
108
|
+
return dsRedirect(`/dash/posts/${sqid.encode(post.id)}`);
|
|
111
109
|
});
|
|
112
110
|
|
|
113
111
|
function ViewPostContent({ post }: { post: Post }) {
|
|
@@ -227,9 +225,7 @@ postsRoutes.post("/:id", async (c) => {
|
|
|
227
225
|
path: body.path || null,
|
|
228
226
|
});
|
|
229
227
|
|
|
230
|
-
return
|
|
231
|
-
await stream.redirect(`/dash/posts/${sqid.encode(id)}`);
|
|
232
|
-
});
|
|
228
|
+
return dsRedirect(`/dash/posts/${sqid.encode(id)}`);
|
|
233
229
|
});
|
|
234
230
|
|
|
235
231
|
// Delete post
|
|
@@ -239,7 +235,5 @@ postsRoutes.post("/:id/delete", async (c) => {
|
|
|
239
235
|
|
|
240
236
|
await c.var.services.posts.delete(id);
|
|
241
237
|
|
|
242
|
-
return
|
|
243
|
-
await stream.redirect("/dash/posts");
|
|
244
|
-
});
|
|
238
|
+
return dsRedirect("/dash/posts");
|
|
245
239
|
});
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
ActionButtons,
|
|
15
15
|
CrudPageHeader,
|
|
16
16
|
} from "../../theme/components/index.js";
|
|
17
|
-
import {
|
|
17
|
+
import { dsRedirect } from "../../lib/sse.js";
|
|
18
18
|
|
|
19
19
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
20
20
|
|
|
@@ -219,9 +219,7 @@ redirectsRoutes.post("/", async (c) => {
|
|
|
219
219
|
const type = parseInt(body.type, 10) as 301 | 302;
|
|
220
220
|
await c.var.services.redirects.create(body.fromPath, body.toPath, type);
|
|
221
221
|
|
|
222
|
-
return
|
|
223
|
-
await stream.redirect("/dash/redirects");
|
|
224
|
-
});
|
|
222
|
+
return dsRedirect("/dash/redirects");
|
|
225
223
|
});
|
|
226
224
|
|
|
227
225
|
// Delete redirect
|
|
@@ -231,7 +229,5 @@ redirectsRoutes.post("/:id/delete", async (c) => {
|
|
|
231
229
|
await c.var.services.redirects.delete(id);
|
|
232
230
|
}
|
|
233
231
|
|
|
234
|
-
return
|
|
235
|
-
await stream.redirect("/dash/redirects");
|
|
236
|
-
});
|
|
232
|
+
return dsRedirect("/dash/redirects");
|
|
237
233
|
});
|
|
@@ -7,9 +7,18 @@ import { useLingui } from "@lingui/react/macro";
|
|
|
7
7
|
import type { Bindings } from "../../types.js";
|
|
8
8
|
import type { AppVariables } from "../../app.js";
|
|
9
9
|
import { DashLayout } from "../../theme/layouts/index.js";
|
|
10
|
-
import { sse } from "../../lib/sse.js";
|
|
10
|
+
import { sse, dsToast } from "../../lib/sse.js";
|
|
11
11
|
import { getSiteLanguage, getConfigFallback } from "../../lib/config.js";
|
|
12
12
|
|
|
13
|
+
/** Escape HTML special characters for safe insertion into HTML strings */
|
|
14
|
+
function escapeHtml(str: string): string {
|
|
15
|
+
return str
|
|
16
|
+
.replace(/&/g, "&")
|
|
17
|
+
.replace(/</g, "<")
|
|
18
|
+
.replace(/>/g, ">")
|
|
19
|
+
.replace(/"/g, """);
|
|
20
|
+
}
|
|
21
|
+
|
|
13
22
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
14
23
|
|
|
15
24
|
export const settingsRoutes = new Hono<Env>();
|
|
@@ -260,11 +269,24 @@ settingsRoutes.post("/", async (c) => {
|
|
|
260
269
|
|
|
261
270
|
const languageChanged = oldLanguage !== body.siteLanguage;
|
|
262
271
|
|
|
272
|
+
// Determine the effective display name after save
|
|
273
|
+
const displayName = body.siteName.trim() || getConfigFallback(c, "SITE_NAME");
|
|
274
|
+
|
|
263
275
|
return sse(c, async (stream) => {
|
|
264
276
|
if (languageChanged) {
|
|
265
277
|
// Language changed - full reload needed to update all UI text
|
|
266
278
|
await stream.redirect("/dash/settings?saved");
|
|
267
279
|
} else {
|
|
280
|
+
const escaped = escapeHtml(displayName);
|
|
281
|
+
// Update header site name
|
|
282
|
+
await stream.patchElements(
|
|
283
|
+
`<a id="site-name" href="/dash" class="font-semibold">${escaped}</a>`,
|
|
284
|
+
);
|
|
285
|
+
// Update page title
|
|
286
|
+
await stream.patchElements(`Settings - ${escaped}`, {
|
|
287
|
+
mode: "inner",
|
|
288
|
+
selector: "title",
|
|
289
|
+
});
|
|
268
290
|
await stream.toast("Settings saved successfully.");
|
|
269
291
|
}
|
|
270
292
|
});
|
|
@@ -279,9 +301,7 @@ settingsRoutes.post("/password", async (c) => {
|
|
|
279
301
|
}>();
|
|
280
302
|
|
|
281
303
|
if (body.newPassword !== body.confirmPassword) {
|
|
282
|
-
return
|
|
283
|
-
await stream.toast("Passwords do not match.", "error");
|
|
284
|
-
});
|
|
304
|
+
return dsToast("Passwords do not match.", "error");
|
|
285
305
|
}
|
|
286
306
|
|
|
287
307
|
try {
|
|
@@ -294,9 +314,7 @@ settingsRoutes.post("/password", async (c) => {
|
|
|
294
314
|
headers: c.req.raw.headers,
|
|
295
315
|
});
|
|
296
316
|
} catch {
|
|
297
|
-
return
|
|
298
|
-
await stream.toast("Current password is incorrect.", "error");
|
|
299
|
-
});
|
|
317
|
+
return dsToast("Current password is incorrect.", "error");
|
|
300
318
|
}
|
|
301
319
|
|
|
302
320
|
return sse(c, async (stream) => {
|
|
@@ -42,7 +42,7 @@ function DashLayoutContent({
|
|
|
42
42
|
{/* Header */}
|
|
43
43
|
<header class="border-b bg-card">
|
|
44
44
|
<div class="container flex h-14 items-center justify-between">
|
|
45
|
-
<a href="/dash" class="font-semibold">
|
|
45
|
+
<a id="site-name" href="/dash" class="font-semibold">
|
|
46
46
|
{siteName}
|
|
47
47
|
</a>
|
|
48
48
|
<nav class="flex items-center gap-4">
|