@jant/core 0.2.11 → 0.2.12
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/bin/jant.js +1 -3
- package/dist/app.d.ts.map +1 -1
- package/dist/lib/image.d.ts.map +1 -1
- package/dist/lib/schemas.d.ts.map +1 -1
- package/dist/lib/sse.d.ts.map +1 -1
- package/dist/routes/api/upload.js +10 -2
- package/dist/routes/dash/collections.d.ts.map +1 -1
- package/dist/routes/dash/index.js +2 -1
- package/dist/routes/dash/pages.d.ts.map +1 -1
- package/dist/routes/dash/redirects.d.ts.map +1 -1
- package/dist/services/collection.d.ts.map +1 -1
- package/dist/services/post.d.ts.map +1 -1
- package/dist/theme/components/ActionButtons.d.ts.map +1 -1
- package/dist/theme/components/CrudPageHeader.d.ts.map +1 -1
- package/dist/theme/components/EmptyState.d.ts.map +1 -1
- package/dist/theme/components/PageForm.d.ts.map +1 -1
- package/dist/theme/components/Pagination.d.ts.map +1 -1
- package/dist/theme/components/PostForm.d.ts.map +1 -1
- package/dist/theme/components/PostList.d.ts.map +1 -1
- package/dist/theme/components/ThreadView.d.ts.map +1 -1
- package/dist/theme/components/index.d.ts +1 -1
- package/dist/theme/components/index.d.ts.map +1 -1
- package/dist/theme/layouts/BaseLayout.d.ts.map +1 -1
- package/dist/theme/layouts/DashLayout.d.ts.map +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -14
- package/src/app.tsx +56 -12
- package/src/db/migrations/meta/0000_snapshot.json +16 -47
- package/src/db/migrations/meta/_journal.json +1 -1
- package/src/i18n/EXAMPLES.md +15 -13
- package/src/i18n/README.md +22 -18
- package/src/i18n/context.tsx +1 -1
- package/src/lib/image-processor.ts +2 -10
- package/src/lib/image.ts +1 -5
- package/src/lib/schemas.ts +6 -6
- package/src/lib/sse.ts +2 -8
- package/src/routes/api/posts.ts +4 -13
- package/src/routes/api/upload.ts +19 -8
- package/src/routes/dash/collections.tsx +102 -26
- package/src/routes/dash/index.tsx +5 -5
- package/src/routes/dash/media.tsx +51 -24
- package/src/routes/dash/pages.tsx +41 -21
- package/src/routes/dash/posts.tsx +12 -3
- package/src/routes/dash/redirects.tsx +53 -20
- package/src/routes/dash/settings.tsx +26 -6
- package/src/routes/pages/archive.tsx +19 -15
- package/src/routes/pages/collection.tsx +11 -2
- package/src/routes/pages/home.tsx +10 -3
- package/src/routes/pages/page.tsx +6 -5
- package/src/routes/pages/post.tsx +1 -4
- package/src/routes/pages/search.tsx +14 -8
- package/src/services/collection.ts +1 -5
- package/src/services/post.ts +1 -3
- package/src/theme/components/ActionButtons.tsx +6 -2
- package/src/theme/components/CrudPageHeader.tsx +4 -10
- package/src/theme/components/EmptyState.tsx +2 -11
- package/src/theme/components/PageForm.tsx +17 -9
- package/src/theme/components/Pagination.tsx +25 -40
- package/src/theme/components/PostForm.tsx +25 -8
- package/src/theme/components/PostList.tsx +17 -11
- package/src/theme/components/ThreadView.tsx +16 -19
- package/src/theme/components/index.ts +8 -1
- package/src/theme/layouts/BaseLayout.tsx +1 -3
- package/src/theme/layouts/DashLayout.tsx +32 -8
- package/src/types.ts +0 -2
- package/dist/plugin.d.ts +0 -3
- package/dist/plugin.d.ts.map +0 -1
- package/dist/plugin.js +0 -20
- package/dist/tailwind.d.ts +0 -12
- package/dist/tailwind.d.ts.map +0 -1
- package/dist/tailwind.js +0 -15
package/src/i18n/README.md
CHANGED
|
@@ -85,7 +85,10 @@ function DashboardContent({ postCount }: { postCount: number }) {
|
|
|
85
85
|
{/* 3. With embedded components - use Trans */}
|
|
86
86
|
<p>
|
|
87
87
|
<Trans comment="@context: Help text">
|
|
88
|
-
Read the
|
|
88
|
+
Read the{" "}
|
|
89
|
+
<a href="/docs" class="underline">
|
|
90
|
+
documentation
|
|
91
|
+
</a>
|
|
89
92
|
</Trans>
|
|
90
93
|
</p>
|
|
91
94
|
</div>
|
|
@@ -118,7 +121,7 @@ dashRoute.get("/", async (c) => {
|
|
|
118
121
|
|
|
119
122
|
return c.html(
|
|
120
123
|
<Layout title={i18n._({ message: "Dashboard", comment: "@context: ..." })}>
|
|
121
|
-
<MyComponent c={c} />
|
|
124
|
+
<MyComponent c={c} /> {/* Need to pass c prop */}
|
|
122
125
|
</Layout>
|
|
123
126
|
);
|
|
124
127
|
});
|
|
@@ -138,14 +141,14 @@ dashRoute.get("/", async (c) => {
|
|
|
138
141
|
return c.html(
|
|
139
142
|
<I18nProvider c={c}>
|
|
140
143
|
<Layout>
|
|
141
|
-
<MyComponent />
|
|
144
|
+
<MyComponent /> {/* No need to pass c prop */}
|
|
142
145
|
</Layout>
|
|
143
146
|
</I18nProvider>
|
|
144
147
|
);
|
|
145
148
|
});
|
|
146
149
|
|
|
147
150
|
function MyComponent() {
|
|
148
|
-
const { t } = useLingui();
|
|
151
|
+
const { t } = useLingui(); // Like React hook
|
|
149
152
|
return <h1>{t({ message: "Hello", comment: "@context: ..." })}</h1>;
|
|
150
153
|
}
|
|
151
154
|
```
|
|
@@ -159,10 +162,10 @@ function MyComponent() {
|
|
|
159
162
|
```tsx
|
|
160
163
|
// ✅ Correct - comment is crucial for AI translation
|
|
161
164
|
const { t } = useLingui();
|
|
162
|
-
t({ message: "Dashboard", comment: "@context: Page title" })
|
|
165
|
+
t({ message: "Dashboard", comment: "@context: Page title" });
|
|
163
166
|
|
|
164
167
|
// ❌ Wrong - missing comment reduces translation quality
|
|
165
|
-
t({ message: "Dashboard" })
|
|
168
|
+
t({ message: "Dashboard" });
|
|
166
169
|
```
|
|
167
170
|
|
|
168
171
|
### 2. **I18nProvider must wrap your app**
|
|
@@ -173,10 +176,10 @@ c.html(
|
|
|
173
176
|
<I18nProvider c={c}>
|
|
174
177
|
<App />
|
|
175
178
|
</I18nProvider>
|
|
176
|
-
)
|
|
179
|
+
);
|
|
177
180
|
|
|
178
181
|
// ❌ Wrong - useLingui() will throw error
|
|
179
|
-
c.html(<App />)
|
|
182
|
+
c.html(<App />); // useLingui() inside App won't find i18n context
|
|
180
183
|
```
|
|
181
184
|
|
|
182
185
|
### 3. **useLingui() only works inside components**
|
|
@@ -201,10 +204,10 @@ dashRoute.get("/", async (c) => {
|
|
|
201
204
|
const { t } = useLingui();
|
|
202
205
|
|
|
203
206
|
// ✅ Correct - values as second parameter
|
|
204
|
-
t({ message: "Hello {name}", comment: "@context: Greeting" }, { name: "Alice" })
|
|
207
|
+
t({ message: "Hello {name}", comment: "@context: Greeting" }, { name: "Alice" });
|
|
205
208
|
|
|
206
209
|
// ❌ Wrong - values inside first parameter (not supported)
|
|
207
|
-
t({ message: "Hello {name}", comment: "@context: Greeting", values: { name: "Alice" } })
|
|
210
|
+
t({ message: "Hello {name}", comment: "@context: Greeting", values: { name: "Alice" } });
|
|
208
211
|
```
|
|
209
212
|
|
|
210
213
|
---
|
|
@@ -227,14 +230,14 @@ Provides i18n context to all child components. Must wrap your app in route handl
|
|
|
227
230
|
|
|
228
231
|
```tsx
|
|
229
232
|
interface I18nProviderProps {
|
|
230
|
-
c: Context;
|
|
233
|
+
c: Context; // Hono context
|
|
231
234
|
children: JSX.Element;
|
|
232
235
|
}
|
|
233
236
|
|
|
234
237
|
// Usage
|
|
235
238
|
<I18nProvider c={c}>
|
|
236
239
|
<YourApp />
|
|
237
|
-
</I18nProvider
|
|
240
|
+
</I18nProvider>;
|
|
238
241
|
```
|
|
239
242
|
|
|
240
243
|
### `useLingui()`
|
|
@@ -243,10 +246,10 @@ Hook to access i18n functionality inside components. Must be used within `I18nPr
|
|
|
243
246
|
|
|
244
247
|
```tsx
|
|
245
248
|
function useLingui(): {
|
|
246
|
-
i18n: I18n;
|
|
249
|
+
i18n: I18n; // Lingui i18n instance
|
|
247
250
|
t: (descriptor: MessageDescriptor, values?: Record<string, any>) => string;
|
|
248
251
|
_: (descriptor: MessageDescriptor, values?: Record<string, any>) => string;
|
|
249
|
-
}
|
|
252
|
+
};
|
|
250
253
|
|
|
251
254
|
// Usage
|
|
252
255
|
function MyComponent() {
|
|
@@ -263,15 +266,15 @@ Component for translations with embedded JSX elements. Simplified implementation
|
|
|
263
266
|
|
|
264
267
|
```tsx
|
|
265
268
|
interface TransProps {
|
|
266
|
-
comment?: string;
|
|
267
|
-
id?: string;
|
|
268
|
-
children: JSX.Element;
|
|
269
|
+
comment?: string; // @context comment for translators
|
|
270
|
+
id?: string; // Optional message ID
|
|
271
|
+
children: JSX.Element; // JSX content with embedded elements
|
|
269
272
|
}
|
|
270
273
|
|
|
271
274
|
// Usage
|
|
272
275
|
<Trans comment="@context: Help text">
|
|
273
276
|
Read the <a href="/docs">documentation</a>
|
|
274
|
-
</Trans
|
|
277
|
+
</Trans>;
|
|
275
278
|
```
|
|
276
279
|
|
|
277
280
|
**Note**: This is a simplified implementation. For complex translations with dynamic content, use `t()` with placeholders instead.
|
|
@@ -290,6 +293,7 @@ This implementation mimics React's Context API but is optimized for Hono JSX SSR
|
|
|
290
293
|
### Why Global State is Safe
|
|
291
294
|
|
|
292
295
|
Unlike React (client-side with multiple re-renders), Hono JSX renders once per request on the server:
|
|
296
|
+
|
|
293
297
|
- Request arrives → I18nProvider sets global i18n → Components render → Response sent
|
|
294
298
|
- Next request → New i18n instance → Components render → Response sent
|
|
295
299
|
|
package/src/i18n/context.tsx
CHANGED
|
@@ -79,7 +79,7 @@ export function useLingui() {
|
|
|
79
79
|
if (!currentI18n) {
|
|
80
80
|
throw new Error(
|
|
81
81
|
"useLingui() called outside of I18nProvider. " +
|
|
82
|
-
|
|
82
|
+
"Make sure your component is wrapped in <I18nProvider c={c}>...</I18nProvider>"
|
|
83
83
|
);
|
|
84
84
|
}
|
|
85
85
|
|
|
@@ -53,10 +53,7 @@ function readExifOrientation(buffer: ArrayBuffer): number {
|
|
|
53
53
|
const exifOffset = offset + 4;
|
|
54
54
|
|
|
55
55
|
// Check for "Exif\0\0"
|
|
56
|
-
if (
|
|
57
|
-
view.getUint32(exifOffset) !== 0x45786966 ||
|
|
58
|
-
view.getUint16(exifOffset + 4) !== 0x0000
|
|
59
|
-
) {
|
|
56
|
+
if (view.getUint32(exifOffset) !== 0x45786966 || view.getUint16(exifOffset + 4) !== 0x0000) {
|
|
60
57
|
return 1;
|
|
61
58
|
}
|
|
62
59
|
|
|
@@ -148,12 +145,7 @@ async function process(file: File, options: ProcessOptions = {}): Promise<Blob>
|
|
|
148
145
|
const srcHeight = isRotated ? img.width : img.height;
|
|
149
146
|
|
|
150
147
|
// Calculate output size
|
|
151
|
-
const { width, height } = calculateDimensions(
|
|
152
|
-
srcWidth,
|
|
153
|
-
srcHeight,
|
|
154
|
-
opts.maxWidth,
|
|
155
|
-
opts.maxHeight
|
|
156
|
-
);
|
|
148
|
+
const { width, height } = calculateDimensions(srcWidth, srcHeight, opts.maxWidth, opts.maxHeight);
|
|
157
149
|
|
|
158
150
|
// Create canvas
|
|
159
151
|
const canvas = document.createElement("canvas");
|
package/src/lib/image.ts
CHANGED
|
@@ -93,11 +93,7 @@ export function getImageUrl(
|
|
|
93
93
|
* // Returns: "https://cdn.example.com/uploads/file.webp"
|
|
94
94
|
* ```
|
|
95
95
|
*/
|
|
96
|
-
export function getMediaUrl(
|
|
97
|
-
mediaId: string,
|
|
98
|
-
r2Key: string,
|
|
99
|
-
r2PublicUrl?: string
|
|
100
|
-
): string {
|
|
96
|
+
export function getMediaUrl(mediaId: string, r2Key: string, r2PublicUrl?: string): string {
|
|
101
97
|
if (r2PublicUrl) {
|
|
102
98
|
return `${r2PublicUrl}/${r2Key}`;
|
|
103
99
|
}
|
package/src/lib/schemas.ts
CHANGED
|
@@ -39,7 +39,11 @@ export const CreatePostSchema = z.object({
|
|
|
39
39
|
visibility: VisibilitySchema,
|
|
40
40
|
sourceUrl: z.string().url().optional().or(z.literal("")),
|
|
41
41
|
sourceName: z.string().optional(),
|
|
42
|
-
path: z
|
|
42
|
+
path: z
|
|
43
|
+
.string()
|
|
44
|
+
.regex(/^[a-z0-9-]*$/)
|
|
45
|
+
.optional()
|
|
46
|
+
.or(z.literal("")),
|
|
43
47
|
replyToId: z.string().optional(), // Sqid format
|
|
44
48
|
publishedAt: z.number().int().positive().optional(),
|
|
45
49
|
});
|
|
@@ -58,11 +62,7 @@ export const UpdatePostSchema = CreatePostSchema.partial();
|
|
|
58
62
|
* // type is PostType, throws if invalid
|
|
59
63
|
* ```
|
|
60
64
|
*/
|
|
61
|
-
export function parseFormData<T>(
|
|
62
|
-
formData: FormData,
|
|
63
|
-
key: string,
|
|
64
|
-
schema: z.ZodSchema<T>
|
|
65
|
-
): T {
|
|
65
|
+
export function parseFormData<T>(formData: FormData, key: string, schema: z.ZodSchema<T>): T {
|
|
66
66
|
const value = formData.get(key);
|
|
67
67
|
if (value === null) {
|
|
68
68
|
throw new Error(`Missing required field: ${key}`);
|
package/src/lib/sse.ts
CHANGED
|
@@ -58,10 +58,7 @@ export interface SSEStream {
|
|
|
58
58
|
* });
|
|
59
59
|
* ```
|
|
60
60
|
*/
|
|
61
|
-
patchElements(
|
|
62
|
-
html: string,
|
|
63
|
-
options?: { mode?: PatchMode; selector?: string }
|
|
64
|
-
): Promise<void>;
|
|
61
|
+
patchElements(html: string, options?: { mode?: PatchMode; selector?: string }): Promise<void>;
|
|
65
62
|
|
|
66
63
|
/**
|
|
67
64
|
* Execute JavaScript on the client
|
|
@@ -97,10 +94,7 @@ export interface SSEStream {
|
|
|
97
94
|
* });
|
|
98
95
|
* ```
|
|
99
96
|
*/
|
|
100
|
-
export function sse(
|
|
101
|
-
c: Context,
|
|
102
|
-
handler: (stream: SSEStream) => Promise<void>
|
|
103
|
-
): Response {
|
|
97
|
+
export function sse(c: Context, handler: (stream: SSEStream) => Promise<void>): Response {
|
|
104
98
|
const encoder = new TextEncoder();
|
|
105
99
|
|
|
106
100
|
const stream = new ReadableStream({
|
package/src/routes/api/posts.ts
CHANGED
|
@@ -23,7 +23,7 @@ postsApiRoutes.get("/", async (c) => {
|
|
|
23
23
|
const posts = await c.var.services.posts.list({
|
|
24
24
|
type,
|
|
25
25
|
visibility: visibility ? [visibility] : ["featured", "quiet"],
|
|
26
|
-
cursor: cursor ? sqid.decode(cursor) ?? undefined : undefined,
|
|
26
|
+
cursor: cursor ? (sqid.decode(cursor) ?? undefined) : undefined,
|
|
27
27
|
limit,
|
|
28
28
|
});
|
|
29
29
|
|
|
@@ -50,16 +50,12 @@ postsApiRoutes.get("/:id", async (c) => {
|
|
|
50
50
|
|
|
51
51
|
// Create post (requires auth)
|
|
52
52
|
postsApiRoutes.post("/", requireAuthApi(), async (c) => {
|
|
53
|
-
|
|
54
53
|
const rawBody = await c.req.json();
|
|
55
54
|
|
|
56
55
|
// Validate request body
|
|
57
56
|
const parseResult = CreatePostSchema.safeParse(rawBody);
|
|
58
57
|
if (!parseResult.success) {
|
|
59
|
-
return c.json(
|
|
60
|
-
{ error: "Validation failed", details: parseResult.error.flatten() },
|
|
61
|
-
400
|
|
62
|
-
);
|
|
58
|
+
return c.json({ error: "Validation failed", details: parseResult.error.flatten() }, 400);
|
|
63
59
|
}
|
|
64
60
|
|
|
65
61
|
const body = parseResult.data;
|
|
@@ -72,7 +68,7 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
|
|
|
72
68
|
sourceUrl: body.sourceUrl || undefined,
|
|
73
69
|
sourceName: body.sourceName,
|
|
74
70
|
path: body.path || undefined,
|
|
75
|
-
replyToId: body.replyToId ? sqid.decode(body.replyToId) ?? undefined : undefined,
|
|
71
|
+
replyToId: body.replyToId ? (sqid.decode(body.replyToId) ?? undefined) : undefined,
|
|
76
72
|
publishedAt: body.publishedAt,
|
|
77
73
|
});
|
|
78
74
|
|
|
@@ -81,7 +77,6 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
|
|
|
81
77
|
|
|
82
78
|
// Update post (requires auth)
|
|
83
79
|
postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
|
|
84
|
-
|
|
85
80
|
const id = sqid.decode(c.req.param("id"));
|
|
86
81
|
if (!id) return c.json({ error: "Invalid ID" }, 400);
|
|
87
82
|
|
|
@@ -90,10 +85,7 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
|
|
|
90
85
|
// Validate request body
|
|
91
86
|
const parseResult = UpdatePostSchema.safeParse(rawBody);
|
|
92
87
|
if (!parseResult.success) {
|
|
93
|
-
return c.json(
|
|
94
|
-
{ error: "Validation failed", details: parseResult.error.flatten() },
|
|
95
|
-
400
|
|
96
|
-
);
|
|
88
|
+
return c.json({ error: "Validation failed", details: parseResult.error.flatten() }, 400);
|
|
97
89
|
}
|
|
98
90
|
|
|
99
91
|
const body = parseResult.data;
|
|
@@ -116,7 +108,6 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
|
|
|
116
108
|
|
|
117
109
|
// Delete post (requires auth)
|
|
118
110
|
postsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
|
|
119
|
-
|
|
120
111
|
const id = sqid.decode(c.req.param("id"));
|
|
121
112
|
if (!id) return c.json({ error: "Invalid ID" }, 400);
|
|
122
113
|
|
package/src/routes/api/upload.ts
CHANGED
|
@@ -24,7 +24,14 @@ uploadApiRoutes.use("*", requireAuthApi());
|
|
|
24
24
|
* Render a media card HTML string for SSE response
|
|
25
25
|
*/
|
|
26
26
|
function renderMediaCard(
|
|
27
|
-
media: {
|
|
27
|
+
media: {
|
|
28
|
+
id: string;
|
|
29
|
+
r2Key: string;
|
|
30
|
+
mimeType: string;
|
|
31
|
+
originalName: string;
|
|
32
|
+
alt: string | null;
|
|
33
|
+
size: number;
|
|
34
|
+
},
|
|
28
35
|
r2PublicUrl?: string,
|
|
29
36
|
imageTransformUrl?: string
|
|
30
37
|
): string {
|
|
@@ -54,7 +61,11 @@ function renderMediaCard(
|
|
|
54
61
|
loading="lazy"
|
|
55
62
|
/>
|
|
56
63
|
</button>
|
|
57
|
-
<a
|
|
64
|
+
<a
|
|
65
|
+
href="/dash/media/${media.id}"
|
|
66
|
+
class="block mt-2 text-xs truncate hover:underline"
|
|
67
|
+
title="${media.originalName}"
|
|
68
|
+
>
|
|
58
69
|
${media.originalName}
|
|
59
70
|
</a>
|
|
60
71
|
<div class="text-xs text-muted-foreground">${sizeStr}</div>
|
|
@@ -72,7 +83,11 @@ function renderMediaCard(
|
|
|
72
83
|
<span class="text-xs">${media.mimeType}</span>
|
|
73
84
|
</div>
|
|
74
85
|
</a>
|
|
75
|
-
<a
|
|
86
|
+
<a
|
|
87
|
+
href="/dash/media/${media.id}"
|
|
88
|
+
class="block mt-2 text-xs truncate hover:underline"
|
|
89
|
+
title="${media.originalName}"
|
|
90
|
+
>
|
|
76
91
|
${media.originalName}
|
|
77
92
|
</a>
|
|
78
93
|
<div class="text-xs text-muted-foreground">${sizeStr}</div>
|
|
@@ -165,11 +180,7 @@ uploadApiRoutes.post("/", async (c) => {
|
|
|
165
180
|
|
|
166
181
|
// SSE response for Datastar
|
|
167
182
|
if (wantsSSE(c)) {
|
|
168
|
-
const cardHtml = renderMediaCard(
|
|
169
|
-
media,
|
|
170
|
-
c.env.R2_PUBLIC_URL,
|
|
171
|
-
c.env.IMAGE_TRANSFORM_URL
|
|
172
|
-
);
|
|
183
|
+
const cardHtml = renderMediaCard(media, c.env.R2_PUBLIC_URL, c.env.IMAGE_TRANSFORM_URL);
|
|
173
184
|
|
|
174
185
|
return sse(c, async (stream) => {
|
|
175
186
|
// Replace placeholder with real media card
|
|
@@ -7,7 +7,13 @@ import { useLingui } from "../../i18n/index.js";
|
|
|
7
7
|
import type { Bindings, Collection, Post } from "../../types.js";
|
|
8
8
|
import type { AppVariables } from "../../app.js";
|
|
9
9
|
import { DashLayout } from "../../theme/layouts/index.js";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
EmptyState,
|
|
12
|
+
ListItemRow,
|
|
13
|
+
ActionButtons,
|
|
14
|
+
CrudPageHeader,
|
|
15
|
+
DangerZone,
|
|
16
|
+
} from "../../theme/components/index.js";
|
|
11
17
|
import * as sqid from "../../lib/sqid.js";
|
|
12
18
|
|
|
13
19
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
@@ -21,14 +27,20 @@ function CollectionsListContent({ collections }: { collections: Collection[] })
|
|
|
21
27
|
<>
|
|
22
28
|
<CrudPageHeader
|
|
23
29
|
title={t({ message: "Collections", comment: "@context: Dashboard heading" })}
|
|
24
|
-
ctaLabel={t({
|
|
30
|
+
ctaLabel={t({
|
|
31
|
+
message: "New Collection",
|
|
32
|
+
comment: "@context: Button to create new collection",
|
|
33
|
+
})}
|
|
25
34
|
ctaHref="/dash/collections/new"
|
|
26
35
|
/>
|
|
27
36
|
|
|
28
37
|
{collections.length === 0 ? (
|
|
29
38
|
<EmptyState
|
|
30
39
|
message={t({ message: "No collections yet.", comment: "@context: Empty state message" })}
|
|
31
|
-
ctaText={t({
|
|
40
|
+
ctaText={t({
|
|
41
|
+
message: "New Collection",
|
|
42
|
+
comment: "@context: Button to create new collection",
|
|
43
|
+
})}
|
|
32
44
|
ctaHref="/dash/collections/new"
|
|
33
45
|
/>
|
|
34
46
|
) : (
|
|
@@ -64,16 +76,31 @@ function NewCollectionContent() {
|
|
|
64
76
|
const { t } = useLingui();
|
|
65
77
|
return (
|
|
66
78
|
<>
|
|
67
|
-
<h1 class="text-2xl font-semibold mb-6">
|
|
79
|
+
<h1 class="text-2xl font-semibold mb-6">
|
|
80
|
+
{t({ message: "New Collection", comment: "@context: Page heading" })}
|
|
81
|
+
</h1>
|
|
68
82
|
|
|
69
83
|
<form method="post" action="/dash/collections" class="flex flex-col gap-4 max-w-lg">
|
|
70
84
|
<div class="field">
|
|
71
|
-
<label class="label">
|
|
72
|
-
|
|
85
|
+
<label class="label">
|
|
86
|
+
{t({ message: "Title", comment: "@context: Collection form field" })}
|
|
87
|
+
</label>
|
|
88
|
+
<input
|
|
89
|
+
type="text"
|
|
90
|
+
name="title"
|
|
91
|
+
class="input"
|
|
92
|
+
required
|
|
93
|
+
placeholder={t({
|
|
94
|
+
message: "My Collection",
|
|
95
|
+
comment: "@context: Collection title placeholder",
|
|
96
|
+
})}
|
|
97
|
+
/>
|
|
73
98
|
</div>
|
|
74
99
|
|
|
75
100
|
<div class="field">
|
|
76
|
-
<label class="label">
|
|
101
|
+
<label class="label">
|
|
102
|
+
{t({ message: "Slug", comment: "@context: Collection form field" })}
|
|
103
|
+
</label>
|
|
77
104
|
<input
|
|
78
105
|
type="text"
|
|
79
106
|
name="path"
|
|
@@ -83,18 +110,34 @@ function NewCollectionContent() {
|
|
|
83
110
|
pattern="[a-z0-9-]+"
|
|
84
111
|
/>
|
|
85
112
|
<p class="text-xs text-muted-foreground mt-1">
|
|
86
|
-
{t({
|
|
113
|
+
{t({
|
|
114
|
+
message: "URL-safe identifier (lowercase, numbers, hyphens)",
|
|
115
|
+
comment: "@context: Collection path help text",
|
|
116
|
+
})}
|
|
87
117
|
</p>
|
|
88
118
|
</div>
|
|
89
119
|
|
|
90
120
|
<div class="field">
|
|
91
|
-
<label class="label">
|
|
92
|
-
|
|
121
|
+
<label class="label">
|
|
122
|
+
{t({ message: "Description (optional)", comment: "@context: Collection form field" })}
|
|
123
|
+
</label>
|
|
124
|
+
<textarea
|
|
125
|
+
name="description"
|
|
126
|
+
class="textarea"
|
|
127
|
+
rows={3}
|
|
128
|
+
placeholder={t({
|
|
129
|
+
message: "What's this collection about?",
|
|
130
|
+
comment: "@context: Collection description placeholder",
|
|
131
|
+
})}
|
|
132
|
+
/>
|
|
93
133
|
</div>
|
|
94
134
|
|
|
95
135
|
<div class="flex gap-2">
|
|
96
136
|
<button type="submit" class="btn">
|
|
97
|
-
{t({
|
|
137
|
+
{t({
|
|
138
|
+
message: "Create Collection",
|
|
139
|
+
comment: "@context: Button to save new collection",
|
|
140
|
+
})}
|
|
98
141
|
</button>
|
|
99
142
|
<a href="/dash/collections" class="btn-outline">
|
|
100
143
|
{t({ message: "Cancel", comment: "@context: Button to cancel form" })}
|
|
@@ -107,7 +150,11 @@ function NewCollectionContent() {
|
|
|
107
150
|
|
|
108
151
|
function ViewCollectionContent({ collection, posts }: { collection: Collection; posts: Post[] }) {
|
|
109
152
|
const { t } = useLingui();
|
|
110
|
-
const postsHeader = t({
|
|
153
|
+
const postsHeader = t({
|
|
154
|
+
message: "Posts in Collection ({count})",
|
|
155
|
+
comment: "@context: Collection posts section heading",
|
|
156
|
+
values: { count: String(posts.length) },
|
|
157
|
+
});
|
|
111
158
|
|
|
112
159
|
return (
|
|
113
160
|
<>
|
|
@@ -124,9 +171,7 @@ function ViewCollectionContent({ collection, posts }: { collection: Collection;
|
|
|
124
171
|
/>
|
|
125
172
|
</div>
|
|
126
173
|
|
|
127
|
-
{collection.description &&
|
|
128
|
-
<p class="text-muted-foreground mb-6">{collection.description}</p>
|
|
129
|
-
)}
|
|
174
|
+
{collection.description && <p class="text-muted-foreground mb-6">{collection.description}</p>}
|
|
130
175
|
|
|
131
176
|
<div class="card">
|
|
132
177
|
<header>
|
|
@@ -134,7 +179,12 @@ function ViewCollectionContent({ collection, posts }: { collection: Collection;
|
|
|
134
179
|
</header>
|
|
135
180
|
<section>
|
|
136
181
|
{posts.length === 0 ? (
|
|
137
|
-
<p class="text-muted-foreground">
|
|
182
|
+
<p class="text-muted-foreground">
|
|
183
|
+
{t({
|
|
184
|
+
message: "No posts in this collection.",
|
|
185
|
+
comment: "@context: Empty state message",
|
|
186
|
+
})}
|
|
187
|
+
</p>
|
|
138
188
|
) : (
|
|
139
189
|
<div class="flex flex-col divide-y">
|
|
140
190
|
{posts.map((post) => (
|
|
@@ -150,7 +200,10 @@ function ViewCollectionContent({ collection, posts }: { collection: Collection;
|
|
|
150
200
|
<form method="post" action={`/dash/collections/${collection.id}/remove-post`}>
|
|
151
201
|
<input type="hidden" name="postId" value={post.id} />
|
|
152
202
|
<button type="submit" class="btn-sm-ghost text-destructive">
|
|
153
|
-
{t({
|
|
203
|
+
{t({
|
|
204
|
+
message: "Remove",
|
|
205
|
+
comment: "@context: Button to remove post from collection",
|
|
206
|
+
})}
|
|
154
207
|
</button>
|
|
155
208
|
</form>
|
|
156
209
|
</div>
|
|
@@ -174,16 +227,26 @@ function EditCollectionContent({ collection }: { collection: Collection }) {
|
|
|
174
227
|
|
|
175
228
|
return (
|
|
176
229
|
<>
|
|
177
|
-
<h1 class="text-2xl font-semibold mb-6">
|
|
178
|
-
|
|
179
|
-
|
|
230
|
+
<h1 class="text-2xl font-semibold mb-6">
|
|
231
|
+
{t({ message: "Edit Collection", comment: "@context: Page heading" })}
|
|
232
|
+
</h1>
|
|
233
|
+
|
|
234
|
+
<form
|
|
235
|
+
method="post"
|
|
236
|
+
action={`/dash/collections/${collection.id}`}
|
|
237
|
+
class="flex flex-col gap-4 max-w-lg"
|
|
238
|
+
>
|
|
180
239
|
<div class="field">
|
|
181
|
-
<label class="label">
|
|
240
|
+
<label class="label">
|
|
241
|
+
{t({ message: "Title", comment: "@context: Collection form field" })}
|
|
242
|
+
</label>
|
|
182
243
|
<input type="text" name="title" class="input" required value={collection.title} />
|
|
183
244
|
</div>
|
|
184
245
|
|
|
185
246
|
<div class="field">
|
|
186
|
-
<label class="label">
|
|
247
|
+
<label class="label">
|
|
248
|
+
{t({ message: "Slug", comment: "@context: Collection form field" })}
|
|
249
|
+
</label>
|
|
187
250
|
<input
|
|
188
251
|
type="text"
|
|
189
252
|
name="path"
|
|
@@ -195,7 +258,9 @@ function EditCollectionContent({ collection }: { collection: Collection }) {
|
|
|
195
258
|
</div>
|
|
196
259
|
|
|
197
260
|
<div class="field">
|
|
198
|
-
<label class="label">
|
|
261
|
+
<label class="label">
|
|
262
|
+
{t({ message: "Description (optional)", comment: "@context: Collection form field" })}
|
|
263
|
+
</label>
|
|
199
264
|
<textarea name="description" class="textarea" rows={3}>
|
|
200
265
|
{collection.description ?? ""}
|
|
201
266
|
</textarea>
|
|
@@ -203,7 +268,10 @@ function EditCollectionContent({ collection }: { collection: Collection }) {
|
|
|
203
268
|
|
|
204
269
|
<div class="flex gap-2">
|
|
205
270
|
<button type="submit" class="btn">
|
|
206
|
-
{t({
|
|
271
|
+
{t({
|
|
272
|
+
message: "Update Collection",
|
|
273
|
+
comment: "@context: Button to save collection changes",
|
|
274
|
+
})}
|
|
207
275
|
</button>
|
|
208
276
|
<a href={`/dash/collections/${collection.id}`} class="btn-outline">
|
|
209
277
|
{t({ message: "Cancel", comment: "@context: Button to cancel form" })}
|
|
@@ -212,7 +280,10 @@ function EditCollectionContent({ collection }: { collection: Collection }) {
|
|
|
212
280
|
</form>
|
|
213
281
|
|
|
214
282
|
<DangerZone
|
|
215
|
-
actionLabel={t({
|
|
283
|
+
actionLabel={t({
|
|
284
|
+
message: "Delete Collection",
|
|
285
|
+
comment: "@context: Button to delete collection",
|
|
286
|
+
})}
|
|
216
287
|
formAction={`/dash/collections/${collection.id}/delete`}
|
|
217
288
|
confirmMessage="Are you sure you want to delete this collection?"
|
|
218
289
|
/>
|
|
@@ -289,7 +360,12 @@ collectionsRoutes.get("/:id/edit", async (c) => {
|
|
|
289
360
|
const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
|
|
290
361
|
|
|
291
362
|
return c.html(
|
|
292
|
-
<DashLayout
|
|
363
|
+
<DashLayout
|
|
364
|
+
c={c}
|
|
365
|
+
title={`Edit: ${collection.title}`}
|
|
366
|
+
siteName={siteName}
|
|
367
|
+
currentPath="/dash/collections"
|
|
368
|
+
>
|
|
293
369
|
<EditCollectionContent collection={collection} />
|
|
294
370
|
</DashLayout>
|
|
295
371
|
);
|
|
@@ -63,7 +63,10 @@ function DashboardContent({
|
|
|
63
63
|
{/* ✅ Trans component with embedded JSX! */}
|
|
64
64
|
<p>
|
|
65
65
|
<Trans comment="@context: Help text with link">
|
|
66
|
-
Need help? Visit the
|
|
66
|
+
Need help? Visit the{" "}
|
|
67
|
+
<a href="/docs" class="underline">
|
|
68
|
+
documentation
|
|
69
|
+
</a>
|
|
67
70
|
</Trans>
|
|
68
71
|
</p>
|
|
69
72
|
</div>
|
|
@@ -80,10 +83,7 @@ dashIndexRoutes.get("/", async (c) => {
|
|
|
80
83
|
|
|
81
84
|
return c.html(
|
|
82
85
|
<DashLayout c={c} title="Dashboard" siteName={siteName} currentPath="/dash">
|
|
83
|
-
<DashboardContent
|
|
84
|
-
publishedCount={publishedPosts.length}
|
|
85
|
-
draftCount={draftPosts.length}
|
|
86
|
-
/>
|
|
86
|
+
<DashboardContent publishedCount={publishedPosts.length} draftCount={draftPosts.length} />
|
|
87
87
|
</DashLayout>
|
|
88
88
|
);
|
|
89
89
|
});
|