@jant/core 0.2.17 → 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 +1 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +307 -137
- package/dist/client.js +1 -0
- package/dist/i18n/context.d.ts +2 -2
- package/dist/i18n/context.js +1 -1
- package/dist/i18n/i18n.d.ts +1 -1
- package/dist/i18n/i18n.js +1 -1
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/locales/en.d.ts.map +1 -1
- package/dist/i18n/locales/en.js +1 -1
- package/dist/i18n/locales/zh-Hans.d.ts.map +1 -1
- package/dist/i18n/locales/zh-Hans.js +1 -1
- package/dist/i18n/locales/zh-Hant.d.ts.map +1 -1
- package/dist/i18n/locales/zh-Hant.js +1 -1
- package/dist/lib/config.d.ts +44 -10
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +69 -44
- package/dist/lib/constants.d.ts +2 -1
- package/dist/lib/constants.d.ts.map +1 -1
- package/dist/lib/constants.js +5 -2
- package/dist/lib/image-processor.js +0 -4
- package/dist/lib/media-upload.js +104 -0
- package/dist/lib/sse.d.ts +82 -13
- package/dist/lib/sse.d.ts.map +1 -1
- package/dist/lib/sse.js +115 -17
- package/dist/lib/theme.d.ts +44 -0
- package/dist/lib/theme.d.ts.map +1 -0
- package/dist/lib/theme.js +65 -0
- package/dist/routes/api/upload.js +16 -18
- package/dist/routes/dash/appearance.d.ts +13 -0
- package/dist/routes/dash/appearance.d.ts.map +1 -0
- package/dist/routes/dash/appearance.js +160 -0
- 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 +52 -42
- package/dist/services/settings.d.ts +1 -0
- package/dist/services/settings.d.ts.map +1 -1
- package/dist/services/settings.js +3 -0
- package/dist/theme/color-themes.d.ts +30 -0
- package/dist/theme/color-themes.d.ts.map +1 -0
- package/dist/theme/color-themes.js +268 -0
- package/dist/theme/layouts/BaseLayout.d.ts +5 -0
- package/dist/theme/layouts/BaseLayout.d.ts.map +1 -1
- package/dist/theme/layouts/BaseLayout.js +70 -3
- package/dist/theme/layouts/DashLayout.d.ts +2 -0
- package/dist/theme/layouts/DashLayout.d.ts.map +1 -1
- package/dist/theme/layouts/DashLayout.js +11 -1
- package/dist/theme/layouts/index.d.ts +1 -1
- package/dist/theme/layouts/index.d.ts.map +1 -1
- package/dist/types.d.ts +53 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +52 -0
- package/package.json +1 -1
- package/src/app.tsx +260 -81
- package/src/client.ts +1 -0
- package/src/db/migrations/{0000_solid_moon_knight.sql → 0000_square_wallflower.sql} +3 -3
- package/src/db/migrations/meta/0000_snapshot.json +9 -9
- package/src/db/migrations/meta/_journal.json +2 -30
- package/src/i18n/context.tsx +2 -2
- package/src/i18n/i18n.ts +1 -1
- package/src/i18n/index.ts +1 -1
- package/src/i18n/locales/en.po +328 -252
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +315 -278
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +315 -278
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/lib/config.ts +73 -47
- package/src/lib/constants.ts +3 -0
- package/src/lib/image-processor.ts +0 -7
- package/src/lib/media-upload.ts +148 -0
- package/src/lib/sse.ts +156 -16
- package/src/lib/theme.ts +86 -0
- package/src/preset.css +9 -0
- package/src/routes/api/upload.ts +12 -18
- package/src/routes/dash/appearance.tsx +176 -0
- 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 +71 -55
- package/src/services/settings.ts +5 -0
- package/src/styles/components.css +93 -0
- package/src/theme/color-themes.ts +321 -0
- package/src/theme/layouts/BaseLayout.tsx +61 -1
- package/src/theme/layouts/DashLayout.tsx +14 -3
- package/src/theme/layouts/index.ts +5 -1
- package/src/types.ts +62 -1
- package/src/db/migrations/0001_add_search_fts.sql +0 -40
- package/src/db/migrations/0002_collection_path.sql +0 -2
- package/src/db/migrations/0003_collection_path_nullable.sql +0 -21
- package/src/db/migrations/0004_media_uuid.sql +0 -35
package/src/lib/theme.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme Resolution Helpers
|
|
3
|
+
*
|
|
4
|
+
* Resolves the active color theme and builds CSS for injection into `<head>`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ColorTheme } from "../theme/color-themes.js";
|
|
8
|
+
import { BUILTIN_COLOR_THEMES } from "../theme/color-themes.js";
|
|
9
|
+
import type { JantConfig } from "../types.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Get the list of available color themes.
|
|
13
|
+
*
|
|
14
|
+
* Returns `config.theme.colorThemes` if provided, otherwise the built-in list.
|
|
15
|
+
*
|
|
16
|
+
* @param config - The Jant configuration
|
|
17
|
+
* @returns Array of available color themes
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* const themes = getAvailableThemes(c.var.config);
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function getAvailableThemes(config: JantConfig): ColorTheme[] {
|
|
25
|
+
return config.theme?.colorThemes ?? BUILTIN_COLOR_THEMES;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build a `<style>` CSS string from a color theme and optional cssVariables overlay.
|
|
30
|
+
*
|
|
31
|
+
* Priority (lowest → highest):
|
|
32
|
+
* BaseCoat defaults → selected theme → cssVariables
|
|
33
|
+
*
|
|
34
|
+
* @param theme - The active color theme (undefined = no theme overrides)
|
|
35
|
+
* @param cssVariables - Extra CSS variable overrides from `createApp({ theme: { cssVariables } })`
|
|
36
|
+
* @returns CSS string to inject in `<head>`, or empty string if nothing to inject
|
|
37
|
+
*
|
|
38
|
+
* Uses `:root:root` and `:root.dark` selectors for higher specificity than
|
|
39
|
+
* BaseCoat defaults (`:root` and `.dark`). This ensures theme overrides win
|
|
40
|
+
* regardless of source order — important because Vite dev mode injects CSS
|
|
41
|
+
* as `<style>` tags after the theme `<style>`.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```typescript
|
|
45
|
+
* const css = buildThemeStyle(blueTheme, { "--radius": "0.5rem" });
|
|
46
|
+
* // => ":root:root { --primary: oklch(...); ... }\n:root.dark { ... }"
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function buildThemeStyle(
|
|
50
|
+
theme: ColorTheme | undefined,
|
|
51
|
+
cssVariables?: Record<string, string>,
|
|
52
|
+
): string {
|
|
53
|
+
const lightVars: Record<string, string> = {
|
|
54
|
+
...(theme?.light ?? {}),
|
|
55
|
+
...(cssVariables ?? {}),
|
|
56
|
+
};
|
|
57
|
+
const darkVars: Record<string, string> = {
|
|
58
|
+
...(theme?.dark ?? {}),
|
|
59
|
+
...(cssVariables ?? {}),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const hasLight = Object.keys(lightVars).length > 0;
|
|
63
|
+
const hasDark = Object.keys(darkVars).length > 0;
|
|
64
|
+
|
|
65
|
+
if (!hasLight && !hasDark) return "";
|
|
66
|
+
|
|
67
|
+
const parts: string[] = [];
|
|
68
|
+
|
|
69
|
+
if (hasLight) {
|
|
70
|
+
const declarations = Object.entries(lightVars)
|
|
71
|
+
.map(([k, v]) => ` ${k}: ${v};`)
|
|
72
|
+
.join("\n");
|
|
73
|
+
// :root:root has specificity (0,0,2) > BaseCoat's :root (0,0,1)
|
|
74
|
+
parts.push(`:root:root {\n${declarations}\n}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (hasDark) {
|
|
78
|
+
const declarations = Object.entries(darkVars)
|
|
79
|
+
.map(([k, v]) => ` ${k}: ${v};`)
|
|
80
|
+
.join("\n");
|
|
81
|
+
// :root.dark has specificity (0,1,1) > BaseCoat's .dark (0,1,0)
|
|
82
|
+
parts.push(`:root.dark {\n${declarations}\n}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return parts.join("\n");
|
|
86
|
+
}
|
package/src/preset.css
CHANGED
package/src/routes/api/upload.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { Bindings } from "../../types.js";
|
|
|
11
11
|
import type { AppVariables } from "../../app.js";
|
|
12
12
|
import { requireAuthApi } from "../../middleware/auth.js";
|
|
13
13
|
import { getMediaUrl, getImageUrl } from "../../lib/image.js";
|
|
14
|
-
import { sse } from "../../lib/sse.js";
|
|
14
|
+
import { sse, dsSignals } from "../../lib/sse.js";
|
|
15
15
|
|
|
16
16
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
17
17
|
|
|
@@ -117,11 +117,7 @@ function wantsSSE(c: {
|
|
|
117
117
|
uploadApiRoutes.post("/", async (c) => {
|
|
118
118
|
if (!c.env.R2) {
|
|
119
119
|
if (wantsSSE(c)) {
|
|
120
|
-
return
|
|
121
|
-
await stream.patchSignals({
|
|
122
|
-
_uploadError: "R2 storage not configured",
|
|
123
|
-
});
|
|
124
|
-
});
|
|
120
|
+
return dsSignals({ _uploadError: "R2 storage not configured" });
|
|
125
121
|
}
|
|
126
122
|
return c.json({ error: "R2 storage not configured" }, 500);
|
|
127
123
|
}
|
|
@@ -131,9 +127,7 @@ uploadApiRoutes.post("/", async (c) => {
|
|
|
131
127
|
|
|
132
128
|
if (!file) {
|
|
133
129
|
if (wantsSSE(c)) {
|
|
134
|
-
return
|
|
135
|
-
await stream.patchSignals({ _uploadError: "No file provided" });
|
|
136
|
-
});
|
|
130
|
+
return dsSignals({ _uploadError: "No file provided" });
|
|
137
131
|
}
|
|
138
132
|
return c.json({ error: "No file provided" }, 400);
|
|
139
133
|
}
|
|
@@ -148,9 +142,7 @@ uploadApiRoutes.post("/", async (c) => {
|
|
|
148
142
|
];
|
|
149
143
|
if (!allowedTypes.includes(file.type)) {
|
|
150
144
|
if (wantsSSE(c)) {
|
|
151
|
-
return
|
|
152
|
-
await stream.patchSignals({ _uploadError: "File type not allowed" });
|
|
153
|
-
});
|
|
145
|
+
return dsSignals({ _uploadError: "File type not allowed" });
|
|
154
146
|
}
|
|
155
147
|
return c.json({ error: "File type not allowed" }, 400);
|
|
156
148
|
}
|
|
@@ -159,11 +151,7 @@ uploadApiRoutes.post("/", async (c) => {
|
|
|
159
151
|
const maxSize = 10 * 1024 * 1024;
|
|
160
152
|
if (file.size > maxSize) {
|
|
161
153
|
if (wantsSSE(c)) {
|
|
162
|
-
return
|
|
163
|
-
await stream.patchSignals({
|
|
164
|
-
_uploadError: "File too large (max 10MB)",
|
|
165
|
-
});
|
|
166
|
-
});
|
|
154
|
+
return dsSignals({ _uploadError: "File too large (max 10MB)" });
|
|
167
155
|
}
|
|
168
156
|
return c.json({ error: "File too large (max 10MB)" }, 400);
|
|
169
157
|
}
|
|
@@ -206,6 +194,7 @@ uploadApiRoutes.post("/", async (c) => {
|
|
|
206
194
|
mode: "outer",
|
|
207
195
|
selector: "#upload-placeholder",
|
|
208
196
|
});
|
|
197
|
+
await stream.toast("Upload successful!");
|
|
209
198
|
});
|
|
210
199
|
}
|
|
211
200
|
|
|
@@ -222,7 +211,12 @@ uploadApiRoutes.post("/", async (c) => {
|
|
|
222
211
|
// eslint-disable-next-line no-console -- Error logging is intentional
|
|
223
212
|
console.error("Upload error:", err);
|
|
224
213
|
|
|
225
|
-
|
|
214
|
+
if (wantsSSE(c)) {
|
|
215
|
+
return sse(c, async (stream) => {
|
|
216
|
+
await stream.remove("#upload-placeholder");
|
|
217
|
+
await stream.toast("Upload failed. Please try again.", "error");
|
|
218
|
+
});
|
|
219
|
+
}
|
|
226
220
|
return c.json({ error: "Upload failed" }, 500);
|
|
227
221
|
}
|
|
228
222
|
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard Appearance Routes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import { useLingui } from "@lingui/react/macro";
|
|
7
|
+
import type { Bindings } from "../../types.js";
|
|
8
|
+
import type { AppVariables } from "../../app.js";
|
|
9
|
+
import { DashLayout } from "../../theme/layouts/index.js";
|
|
10
|
+
import { dsRedirect, dsToast } from "../../lib/sse.js";
|
|
11
|
+
import { getSiteName } from "../../lib/config.js";
|
|
12
|
+
import { SETTINGS_KEYS } from "../../lib/constants.js";
|
|
13
|
+
import { getAvailableThemes } from "../../lib/theme.js";
|
|
14
|
+
import type { ColorTheme } from "../../theme/color-themes.js";
|
|
15
|
+
|
|
16
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
17
|
+
|
|
18
|
+
export const appearanceRoutes = new Hono<Env>();
|
|
19
|
+
|
|
20
|
+
function ThemeCard({
|
|
21
|
+
theme,
|
|
22
|
+
selected,
|
|
23
|
+
}: {
|
|
24
|
+
theme: ColorTheme;
|
|
25
|
+
selected: boolean;
|
|
26
|
+
}) {
|
|
27
|
+
const expr = `$theme === '${theme.id}'`;
|
|
28
|
+
const { preview } = theme;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<label
|
|
32
|
+
class={`block cursor-pointer rounded-lg border overflow-hidden transition-colors ${selected ? "border-primary" : "border-border"}`}
|
|
33
|
+
data-class:border-primary={expr}
|
|
34
|
+
data-class:border-border={`$theme !== '${theme.id}'`}
|
|
35
|
+
>
|
|
36
|
+
<div class="grid grid-cols-2">
|
|
37
|
+
<div
|
|
38
|
+
class="p-5"
|
|
39
|
+
style={`background-color:${preview.lightBg};color:${preview.lightText}`}
|
|
40
|
+
>
|
|
41
|
+
<input
|
|
42
|
+
type="radio"
|
|
43
|
+
name="theme"
|
|
44
|
+
value={theme.id}
|
|
45
|
+
data-bind="theme"
|
|
46
|
+
checked={selected || undefined}
|
|
47
|
+
class="mb-1"
|
|
48
|
+
/>
|
|
49
|
+
<h3 class="font-bold text-lg">{theme.name}</h3>
|
|
50
|
+
<p class="text-sm mt-2 leading-relaxed">
|
|
51
|
+
This is the {theme.name} theme in light mode. Links{" "}
|
|
52
|
+
<a
|
|
53
|
+
tabIndex={-1}
|
|
54
|
+
class="underline"
|
|
55
|
+
style={`color:${preview.lightLink}`}
|
|
56
|
+
>
|
|
57
|
+
look like this
|
|
58
|
+
</a>
|
|
59
|
+
. We'll show the correct light or dark mode based on your visitor's
|
|
60
|
+
settings.
|
|
61
|
+
</p>
|
|
62
|
+
</div>
|
|
63
|
+
<div
|
|
64
|
+
class="p-5"
|
|
65
|
+
style={`background-color:${preview.darkBg};color:${preview.darkText}`}
|
|
66
|
+
>
|
|
67
|
+
<h3 class="font-bold text-lg">{theme.name}</h3>
|
|
68
|
+
<p class="text-sm mt-2 leading-relaxed">
|
|
69
|
+
This is the {theme.name} theme in dark mode. Links{" "}
|
|
70
|
+
<a
|
|
71
|
+
tabIndex={-1}
|
|
72
|
+
class="underline"
|
|
73
|
+
style={`color:${preview.darkLink}`}
|
|
74
|
+
>
|
|
75
|
+
look like this
|
|
76
|
+
</a>
|
|
77
|
+
. We'll show the correct light or dark mode based on your visitor's
|
|
78
|
+
settings.
|
|
79
|
+
</p>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</label>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function AppearanceContent({
|
|
87
|
+
themes,
|
|
88
|
+
currentThemeId,
|
|
89
|
+
}: {
|
|
90
|
+
themes: ColorTheme[];
|
|
91
|
+
currentThemeId: string;
|
|
92
|
+
}) {
|
|
93
|
+
const { t } = useLingui();
|
|
94
|
+
|
|
95
|
+
const signals = JSON.stringify({ theme: currentThemeId }).replace(
|
|
96
|
+
/</g,
|
|
97
|
+
"\\u003c",
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div
|
|
102
|
+
data-signals={signals}
|
|
103
|
+
data-on:change="@post('/dash/appearance')"
|
|
104
|
+
class="max-w-3xl"
|
|
105
|
+
>
|
|
106
|
+
<fieldset>
|
|
107
|
+
<legend class="text-lg font-semibold">
|
|
108
|
+
{t({
|
|
109
|
+
message: "Color theme",
|
|
110
|
+
comment: "@context: Appearance settings heading",
|
|
111
|
+
})}
|
|
112
|
+
</legend>
|
|
113
|
+
<p class="text-sm text-muted-foreground mb-4">
|
|
114
|
+
{t({
|
|
115
|
+
message:
|
|
116
|
+
"This will theme both your site and your dashboard. All color themes support dark mode.",
|
|
117
|
+
comment: "@context: Appearance settings description",
|
|
118
|
+
})}
|
|
119
|
+
</p>
|
|
120
|
+
|
|
121
|
+
<div class="flex flex-col gap-4">
|
|
122
|
+
{themes.map((theme) => (
|
|
123
|
+
<ThemeCard
|
|
124
|
+
key={theme.id}
|
|
125
|
+
theme={theme}
|
|
126
|
+
selected={theme.id === currentThemeId}
|
|
127
|
+
/>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
</fieldset>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Appearance page
|
|
136
|
+
appearanceRoutes.get("/", async (c) => {
|
|
137
|
+
const { settings } = c.var.services;
|
|
138
|
+
const siteName = await getSiteName(c);
|
|
139
|
+
const currentThemeId = (await settings.get(SETTINGS_KEYS.THEME)) ?? "default";
|
|
140
|
+
const themes = getAvailableThemes(c.var.config);
|
|
141
|
+
const saved = c.req.query("saved") !== undefined;
|
|
142
|
+
|
|
143
|
+
return c.html(
|
|
144
|
+
<DashLayout
|
|
145
|
+
c={c}
|
|
146
|
+
title="Appearance"
|
|
147
|
+
siteName={siteName}
|
|
148
|
+
currentPath="/dash/appearance"
|
|
149
|
+
toast={saved ? { message: "Theme saved successfully." } : undefined}
|
|
150
|
+
>
|
|
151
|
+
<AppearanceContent themes={themes} currentThemeId={currentThemeId} />
|
|
152
|
+
</DashLayout>,
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Save theme
|
|
157
|
+
appearanceRoutes.post("/", async (c) => {
|
|
158
|
+
const body = await c.req.json<{ theme: string }>();
|
|
159
|
+
const { settings } = c.var.services;
|
|
160
|
+
const themes = getAvailableThemes(c.var.config);
|
|
161
|
+
|
|
162
|
+
// Validate theme ID
|
|
163
|
+
const validTheme = themes.find((t) => t.id === body.theme);
|
|
164
|
+
if (!validTheme) {
|
|
165
|
+
return dsToast("Invalid theme selected.", "error");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (validTheme.id === "default") {
|
|
169
|
+
await settings.remove(SETTINGS_KEYS.THEME);
|
|
170
|
+
} else {
|
|
171
|
+
await settings.set(SETTINGS_KEYS.THEME, validTheme.id);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Full page reload to apply the new theme CSS
|
|
175
|
+
return dsRedirect("/dash/appearance?saved");
|
|
176
|
+
});
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
DangerZone,
|
|
17
17
|
} from "../../theme/components/index.js";
|
|
18
18
|
import * as sqid from "../../lib/sqid.js";
|
|
19
|
-
import {
|
|
19
|
+
import { dsRedirect } from "../../lib/sse.js";
|
|
20
20
|
|
|
21
21
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
22
22
|
|
|
@@ -409,9 +409,7 @@ collectionsRoutes.post("/", async (c) => {
|
|
|
409
409
|
description: body.description || undefined,
|
|
410
410
|
});
|
|
411
411
|
|
|
412
|
-
return
|
|
413
|
-
await stream.redirect(`/dash/collections/${collection.id}`);
|
|
414
|
-
});
|
|
412
|
+
return dsRedirect(`/dash/collections/${collection.id}`);
|
|
415
413
|
});
|
|
416
414
|
|
|
417
415
|
// View single collection
|
|
@@ -476,9 +474,7 @@ collectionsRoutes.post("/:id", async (c) => {
|
|
|
476
474
|
description: body.description || undefined,
|
|
477
475
|
});
|
|
478
476
|
|
|
479
|
-
return
|
|
480
|
-
await stream.redirect(`/dash/collections/${id}`);
|
|
481
|
-
});
|
|
477
|
+
return dsRedirect(`/dash/collections/${id}`);
|
|
482
478
|
});
|
|
483
479
|
|
|
484
480
|
// Delete collection
|
|
@@ -488,9 +484,7 @@ collectionsRoutes.post("/:id/delete", async (c) => {
|
|
|
488
484
|
|
|
489
485
|
await c.var.services.collections.delete(id);
|
|
490
486
|
|
|
491
|
-
return
|
|
492
|
-
await stream.redirect("/dash/collections");
|
|
493
|
-
});
|
|
487
|
+
return dsRedirect("/dash/collections");
|
|
494
488
|
});
|
|
495
489
|
|
|
496
490
|
// Remove post from collection
|
|
@@ -504,7 +498,5 @@ collectionsRoutes.post("/:id/remove-post", async (c) => {
|
|
|
504
498
|
await c.var.services.collections.removePost(id, body.postId);
|
|
505
499
|
}
|
|
506
500
|
|
|
507
|
-
return
|
|
508
|
-
await stream.redirect(`/dash/collections/${id}`);
|
|
509
|
-
});
|
|
501
|
+
return dsRedirect(`/dash/collections/${id}`);
|
|
510
502
|
});
|
|
@@ -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
|
});
|