hazo_blog 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGE_LOG.md +87 -0
  2. package/README.md +11 -0
  3. package/dist/components/admin/featured-image-field.d.ts.map +1 -1
  4. package/dist/components/admin/featured-image-field.js +9 -4
  5. package/dist/components/admin/post-form.d.ts +4 -2
  6. package/dist/components/admin/post-form.d.ts.map +1 -1
  7. package/dist/components/admin/post-form.js +27 -18
  8. package/dist/components/author-bio.d.ts +3 -1
  9. package/dist/components/author-bio.d.ts.map +1 -1
  10. package/dist/components/author-bio.js +3 -4
  11. package/dist/components/blog-search.d.ts +3 -1
  12. package/dist/components/blog-search.d.ts.map +1 -1
  13. package/dist/components/blog-search.js +4 -5
  14. package/dist/components/editorial-post-card.d.ts +3 -1
  15. package/dist/components/editorial-post-card.d.ts.map +1 -1
  16. package/dist/components/editorial-post-card.js +2 -2
  17. package/dist/components/faq-section.d.ts +3 -1
  18. package/dist/components/faq-section.d.ts.map +1 -1
  19. package/dist/components/faq-section.js +4 -5
  20. package/dist/components/featured-post-card.d.ts +3 -1
  21. package/dist/components/featured-post-card.d.ts.map +1 -1
  22. package/dist/components/featured-post-card.js +4 -5
  23. package/dist/components/layouts/blog-index-editorial.d.ts +1 -1
  24. package/dist/components/layouts/blog-index-editorial.d.ts.map +1 -1
  25. package/dist/components/layouts/blog-index-editorial.js +6 -7
  26. package/dist/components/layouts/blog-index-grid.d.ts +1 -1
  27. package/dist/components/layouts/blog-index-grid.d.ts.map +1 -1
  28. package/dist/components/layouts/blog-index-grid.js +6 -7
  29. package/dist/components/layouts/index.d.ts +2 -0
  30. package/dist/components/layouts/index.d.ts.map +1 -1
  31. package/dist/components/mdx/youtube.js +1 -1
  32. package/dist/components/post-card.d.ts +3 -1
  33. package/dist/components/post-card.d.ts.map +1 -1
  34. package/dist/components/post-card.js +5 -5
  35. package/dist/components/post-hero.d.ts +3 -1
  36. package/dist/components/post-hero.d.ts.map +1 -1
  37. package/dist/components/post-hero.js +5 -13
  38. package/dist/components/related-posts.d.ts +3 -1
  39. package/dist/components/related-posts.d.ts.map +1 -1
  40. package/dist/components/related-posts.js +4 -5
  41. package/dist/components/table-of-contents.d.ts +3 -1
  42. package/dist/components/table-of-contents.d.ts.map +1 -1
  43. package/dist/components/table-of-contents.js +5 -5
  44. package/dist/lib/dates.d.ts +6 -1
  45. package/dist/lib/dates.d.ts.map +1 -1
  46. package/dist/lib/dates.js +14 -2
  47. package/dist/lib/i18n.d.ts +28 -0
  48. package/dist/lib/i18n.d.ts.map +1 -0
  49. package/dist/lib/i18n.js +74 -0
  50. package/dist/lib/index.d.ts +1 -0
  51. package/dist/lib/index.d.ts.map +1 -1
  52. package/dist/lib/index.js +1 -0
  53. package/dist/locales/de.json +107 -0
  54. package/dist/locales/en.json +84 -1
  55. package/dist/locales/sv.json +107 -0
  56. package/dist/next/blog-content.js +1 -1
  57. package/dist/next/pages.d.ts.map +1 -1
  58. package/dist/next/pages.js +17 -13
  59. package/dist/types/index.d.ts +8 -0
  60. package/dist/types/index.d.ts.map +1 -1
  61. package/dist/ui/blog-admin-panel.d.ts.map +1 -1
  62. package/dist/ui/blog-admin-panel.js +10 -4
  63. package/dist/ui/layout-selector.d.ts.map +1 -1
  64. package/dist/ui/layout-selector.js +6 -3
  65. package/dist/ui/post-list-table.d.ts.map +1 -1
  66. package/dist/ui/post-list-table.js +9 -6
  67. package/dist/ui/post-preview.d.ts.map +1 -1
  68. package/dist/ui/post-preview.js +4 -1
  69. package/package.json +15 -15
package/CHANGE_LOG.md CHANGED
@@ -3,6 +3,93 @@
3
3
  All notable changes are documented here. This project follows
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## 1.4.0 — 2026-10-06
7
+
8
+ **Locale-aware UI text and dates (`en` / `de` / `sv`)**
9
+
10
+ The package's own user-visible text was English-only, and `createPackageT` cannot serve a
11
+ different locale (it bundles one English catalog and has no synchronous locale getter). This
12
+ release adds a per-render translator and a `locale` prop without changing any default output.
13
+
14
+ **New: `src/lib/i18n.ts`** — `blogTranslator(locale)` returns a `t()` bound to the matching
15
+ catalog, with English fallback for missing keys or unshipped locales. Also `resolveBlogLocale`
16
+ (`"de-AT"` → `de`, unknown → `en`), `pluralSuffix(locale, count)` (for the one plural-dependent
17
+ key), `BLOG_LOCALES`. Exported from `hazo_blog/lib`.
18
+
19
+ **New catalogs: `src/locales/de.json`, `src/locales/sv.json`** — same 105 keys as `en.json`
20
+ (enforced by a test). `en.json` grew from 90 to 105 keys: toolbar labels (`post_form.embed_*`),
21
+ status badges (`post_status.*`), and admin page chrome (`admin_pages.*`).
22
+
23
+ - **de / sv values are first-pass drafts and need translation review.** Every value is a real
24
+ translation (no English placeholders), but none has been reviewed by a native speaker. Priority
25
+ review: admin-facing strings (`post_form.*`, `blog_admin_panel.*`, `featured_image_field.*`).
26
+ - Pluralisation is handled by the caller via `pluralSuffix`: German "Ergebnis" + "se", Swedish
27
+ "resultat" is invariant.
28
+
29
+ **`locale` prop (optional, default `"en"`)** on: `PostCard`, `FeaturedPostCard`,
30
+ `EditorialPostCard`, `PostHero`, `BlogSearch`, `RelatedPosts`, `FaqSection`, `TableOfContents`,
31
+ `AuthorBio`, `PostForm`, and `BlogIndexLayoutProps` (both `/blog` layouts). Components no longer
32
+ import `createPackageT` for these strings. Explicit props (`title`, `placeholder`) still win over
33
+ the locale default.
34
+
35
+ **Dates: `formatPostDate(iso, style, locale)`** — the `en-US` hardcode in `post-card.tsx` and
36
+ `post-hero.tsx` is gone; both now go through `formatPostDate`. Locale maps to `en-US`, `de-DE`,
37
+ `sv-SE`. Omitted or unknown locales produce byte-identical English output (`"Jan 5, 2026"`).
38
+
39
+ **`PostForm` toolbar labels** (`YT`, `Note`, `Img`) are now catalog keys. A custom `embeds` array
40
+ still overrides the defaults entirely.
41
+
42
+ **Sealed pages: `BlogConfig.uiLocale`** (optional, default `"en"`, resolved to the same key on
43
+ `ResolvedBlogConfig`) drives chrome text and dates in `createBlogIndexPage`,
44
+ `createBlogPostPage`, `createBlogTagPage`, and the admin list/new/edit pages. The admin pages'
45
+ previously hardcoded English ("Blog posts", "+ New post", table headers, "Edit: …") are catalog
46
+ keys. This is distinct from `defaultLocale`, which stays the content/JSON-LD locale.
47
+
48
+ **Back-compat: zero break.** Every new prop and config field is optional, and the default output
49
+ for every component is unchanged. `formatPostDate`'s new `locale` argument is last and optional.
50
+
51
+ **Not yet locale-aware (still English-only, follow-up):** the `./ui` admin tree
52
+ (`BlogAdminPanel`, `PostListTable`, `LayoutSelector`, `PostPreview`, `PanelDialog`),
53
+ `FeaturedImageField`, the `YouTube` MDX embed, and `BlogContent`'s render-error text. These still
54
+ use `createPackageT` and need the same treatment before a German admin is usable end to end.
55
+
56
+ **Tests:** `src/__tests__/i18n.test.ts` (translator, catalog parity and placeholder integrity,
57
+ plurals, date formatting per locale) and `src/__tests__/locale-components.test.ts`
58
+ (react-dom/server render of the locale prop on FAQ, TOC, hero and card). New autotest cases in
59
+ `test-app/app/autotest/hazo_blog_i18n_scenarios.ts` cover catalog parity and the locale-aware
60
+ translator and dates.
61
+
62
+ ## 1.3.0 — 2026-09-20
63
+
64
+ **Migrated onto the canonical `hazo_core/i18n` bridge**
65
+
66
+ `src/lib/create_package_t.ts` (this package's local ~120-line copy of the `createPackageT`
67
+ pattern) is deleted. All 13 call sites now import `createPackageT` from `hazo_core/i18n` — the
68
+ canonical, workspace-shared version of the same bridge (flat-key catalog, `{{var}}` mustache
69
+ interpolation, optional fire-and-forget registration into `hazo_i18n`'s cross-package registry
70
+ when that package is installed). No `resolvePackageLocale` callers existed in this package, so
71
+ none needed updating. `hazo_core` peer bumped to `^2.4.0` (first version shipping
72
+ `hazo_core/i18n`); `hazo_i18n` peer bumped to `^0.5.0`.
73
+
74
+ **Extracted ~68 residual hardcoded strings into `src/locales/en.json`**
75
+
76
+ The admin/editorial UI surface — `PostForm`, `FeaturedImageField`, `BlogAdminPanel`,
77
+ `PostListTable`, `LayoutSelector`, and `PostPreview` — had never been wired through `t()`. Every
78
+ label, placeholder, button, status message, and error string across those six components is now a
79
+ flat, literal-dotted key in `en.json` (`post_form.*`, `featured_image_field.*`,
80
+ `blog_admin_panel.*`, `post_list_table.*`, `layout_selector.*`, `post_preview.*`), routed through a
81
+ module-scope `t = createPackageT("hazo_blog", en)`. `en.json` now has 90 keys total, all
82
+ referenced, zero orphans (verified by a full-repo cross-reference of every `t("...")` call against
83
+ the catalog).
84
+
85
+ **New `/autotest` scenario group: `hazo_blog_i18n`**
86
+
87
+ `test-app/app/autotest/hazo_blog_i18n_scenarios.ts` — catalog loads (`t`/`t.has`/`t.raw` shape),
88
+ sampled keys resolve across both pre-existing and newly-extracted areas, `{{var}}` interpolation
89
+ substitutes correctly, and a coverage-threshold guard confirms every sampled key is a real
90
+ `en.json` entry. Wired into `test-app/app/autotest/page.tsx` alongside the existing
91
+ `hazo_blog_scenarios` group.
92
+
6
93
  ## 1.2.0 — 2026-09-12
7
94
 
8
95
  **Side preview in the post editor (`PanelDialog` + `PostPreview`)**
package/README.md CHANGED
@@ -259,6 +259,17 @@ await repo.getPostBySlug(slug, { includeUnpublished: true });
259
259
 
260
260
  — and `getAllSlugs()` now returns `Array<{ slug, locale }>` instead of `string[]`. Existing single-locale data needs no migration: every current row's `locale` is `NULL` already, which *is* the default-locale bucket every existing query path reads from.
261
261
 
262
+ ## UI locale (chrome text and dates)
263
+
264
+ Separate from the content locale above, the package's own UI text (headings, labels, empty states, admin pages, search placeholder) and date formatting follow a **UI locale**. Shipped catalogs: `en` (default), `de`, `sv` (`src/locales/*.json`, identical key sets). Any other value falls back to English text.
265
+
266
+ - **Config:** `BlogConfig.uiLocale` (default `"en"`) drives the sealed page factories (`createBlogIndexPage`, `createBlogPostPage`, `createBlogTagPage`, admin pages). Build one factory per UI locale if your site serves several.
267
+ - **Components:** the public presentational components accept an optional `locale` prop (default `"en"`): `PostCard`, `FeaturedPostCard`, `EditorialPostCard`, `PostHero`, `BlogSearch`, `RelatedPosts`, `FaqSection`, `TableOfContents`, `AuthorBio`, `PostForm` (toolbar labels included), and `BlogIndexLayoutProps.locale` for the `/blog` layouts.
268
+ - **Dates:** `formatPostDate(iso, style, locale)` uses the locale's Intl conventions (`en-US`, `de-DE`, `sv-SE`).
269
+ - **Translator:** `blogTranslator(locale)` (exported from `hazo_blog/lib`) returns a per-call `t()` bound to that catalog, with English fallback. `pluralSuffix(locale, count)` covers the one plural-dependent key.
270
+
271
+ Not yet locale-aware (still English-only): the `./ui` admin panel tree (`BlogAdminPanel`, `PostListTable`, `LayoutSelector`, `PostPreview`, `PanelDialog`), `FeaturedImageField`, the MDX `YouTube` embed, and `BlogContent`'s error text.
272
+
262
273
  ## Layouts
263
274
 
264
275
  `config.defaultIndexLayout` (and the runtime override in [Settings](#settings))
@@ -1 +1 @@
1
- {"version":3,"file":"featured-image-field.d.ts","sourceRoot":"","sources":["../../../src/components/admin/featured-image-field.tsx"],"names":[],"mappings":"AAMA,MAAM,WAAW,uBAAuB;IACtC,+CAA+C;IAC/C,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAClD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAKD,wBAAgB,kBAAkB,CAAC,EACjC,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,SAAS,GACV,EAAE,uBAAuB,2CA6GzB"}
1
+ {"version":3,"file":"featured-image-field.d.ts","sourceRoot":"","sources":["../../../src/components/admin/featured-image-field.tsx"],"names":[],"mappings":"AAUA,MAAM,WAAW,uBAAuB;IACtC,+CAA+C;IAC/C,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAClD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAKD,wBAAgB,kBAAkB,CAAC,EACjC,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,SAAS,GACV,EAAE,uBAAuB,2CA+GzB"}
@@ -3,6 +3,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useRef, useState } from "react";
4
4
  import { cn } from "hazo_ui/utils";
5
5
  import { ImageIcon, TrashIcon, UploadIcon } from "../icons.js";
6
+ import en from "../../locales/en.json" with { type: "json" };
7
+ import { createPackageT } from "hazo_core/i18n";
8
+ const t = createPackageT("hazo_blog", en);
6
9
  const inputCls = "w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary";
7
10
  export function FeaturedImageField({ value, onChange, uploader, className, }) {
8
11
  const [uploading, setUploading] = useState(false);
@@ -20,7 +23,7 @@ export function FeaturedImageField({ value, onChange, uploader, className, }) {
20
23
  onChange(url);
21
24
  }
22
25
  else {
23
- setError("Upload failed — try again or paste a URL below.");
26
+ setError(t("featured_image_field.upload_failed"));
24
27
  }
25
28
  }
26
29
  finally {
@@ -34,13 +37,15 @@ export function FeaturedImageField({ value, onChange, uploader, className, }) {
34
37
  return;
35
38
  void handleFile(e.dataTransfer.files?.[0]);
36
39
  }
37
- return (_jsxs("div", { className: cn("space-y-2", className), children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Featured image" }), value ? (_jsxs("div", { className: "flex items-start gap-3", children: [_jsx("img", { src: value, alt: "Featured", className: "h-24 w-24 rounded-lg border border-border object-cover" }), _jsxs("button", { type: "button", onClick: () => onChange(""), className: "inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent", children: [_jsx(TrashIcon, { width: 16, height: 16 }), "Clear"] })] })) : (_jsxs("div", { onDragOver: (e) => {
40
+ return (_jsxs("div", { className: cn("space-y-2", className), children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: t("featured_image_field.label") }), value ? (_jsxs("div", { className: "flex items-start gap-3", children: [_jsx("img", { src: value, alt: t("featured_image_field.alt_text"), className: "h-24 w-24 rounded-lg border border-border object-cover" }), _jsxs("button", { type: "button", onClick: () => onChange(""), className: "inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent", children: [_jsx(TrashIcon, { width: 16, height: 16 }), t("featured_image_field.clear")] })] })) : (_jsxs("div", { onDragOver: (e) => {
38
41
  if (!uploader)
39
42
  return;
40
43
  e.preventDefault();
41
44
  setDragOver(true);
42
- }, onDragLeave: () => setDragOver(false), onDrop: onDrop, className: cn("flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-muted px-4 py-8 text-center", dragOver && "border-primary bg-accent"), children: [_jsx(ImageIcon, { width: 28, height: 28, className: "text-muted-foreground" }), _jsx("p", { className: "text-sm text-muted-foreground", children: uploader ? "Drag & drop an image, or" : "No image set — paste a URL below" }), uploader && (_jsxs("button", { type: "button", disabled: uploading, onClick: () => fileInputRef.current?.click(), className: "inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent disabled:opacity-50", children: [_jsx(UploadIcon, { width: 16, height: 16 }), uploading ? "Uploading…" : "Upload"] }))] })), uploader && (_jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", className: "hidden", onChange: (e) => {
45
+ }, onDragLeave: () => setDragOver(false), onDrop: onDrop, className: cn("flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-muted px-4 py-8 text-center", dragOver && "border-primary bg-accent"), children: [_jsx(ImageIcon, { width: 28, height: 28, className: "text-muted-foreground" }), _jsx("p", { className: "text-sm text-muted-foreground", children: uploader
46
+ ? t("featured_image_field.drag_drop_or")
47
+ : t("featured_image_field.no_image_set") }), uploader && (_jsxs("button", { type: "button", disabled: uploading, onClick: () => fileInputRef.current?.click(), className: "inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent disabled:opacity-50", children: [_jsx(UploadIcon, { width: 16, height: 16 }), uploading ? t("featured_image_field.uploading") : t("featured_image_field.upload")] }))] })), uploader && (_jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", className: "hidden", onChange: (e) => {
43
48
  void handleFile(e.target.files?.[0]);
44
49
  e.target.value = "";
45
- } })), error && _jsx("p", { className: "text-sm text-destructive", children: error }), _jsx("input", { className: inputCls, value: value, onChange: (e) => onChange(e.target.value), placeholder: "https://\u2026" })] }));
50
+ } })), error && _jsx("p", { className: "text-sm text-destructive", children: error }), _jsx("input", { className: inputCls, value: value, onChange: (e) => onChange(e.target.value), placeholder: t("featured_image_field.url_placeholder") })] }));
46
51
  }
@@ -8,8 +8,10 @@ export interface PostFormProps {
8
8
  endpoint?: string;
9
9
  /** Called after a successful save with the server response. */
10
10
  onSaved?: (result: unknown) => void;
11
- /** MDX embeds offered in the editor toolbar (host can extend). */
11
+ /** MDX embeds offered in the editor toolbar (host can extend). Default: YouTube / Note / Image, labelled for `locale`. */
12
12
  embeds?: MarkdownEmbed[];
13
+ /** UI locale for user-visible text, toolbar labels and dates. Default "en". */
14
+ locale?: string;
13
15
  /**
14
16
  * Paste-to-upload handler wired to hazo_files by the host. Takes
15
17
  * precedence over `imageUploadEndpoint` when both are provided (back-compat
@@ -28,5 +30,5 @@ export interface PostFormProps {
28
30
  onCancel?: () => void;
29
31
  className?: string;
30
32
  }
31
- export declare function PostForm({ post, categories, endpoint, onSaved, embeds, onImageUpload, imageUploadEndpoint, fetchFn, onCancel, className, }: PostFormProps): import("react/jsx-runtime").JSX.Element;
33
+ export declare function PostForm({ post, categories, endpoint, onSaved, embeds, onImageUpload, imageUploadEndpoint, fetchFn, onCancel, className, locale: uiLocale, }: PostFormProps): import("react/jsx-runtime").JSX.Element;
32
34
  //# sourceMappingURL=post-form.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"post-form.d.ts","sourceRoot":"","sources":["../../../src/components/admin/post-form.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAG7D,OAAO,KAAK,EACV,YAAY,EACZ,qBAAqB,EAGtB,MAAM,sBAAsB,CAAC;AAI9B,MAAM,WAAW,aAAa;IAC5B,kDAAkD;IAClD,IAAI,CAAC,EAAE,qBAAqB,GAAG,IAAI,CAAC;IACpC,UAAU,CAAC,EAAE,YAAY,EAAE,CAAC;IAC5B,kFAAkF;IAClF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+DAA+D;IAC/D,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IACpC,kEAAkE;IAClE,MAAM,CAAC,EAAE,aAAa,EAAE,CAAC;IACzB;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvD;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,8EAA8E;IAC9E,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAClE,4FAA4F;IAC5F,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAgBD,wBAAgB,QAAQ,CAAC,EACvB,IAAI,EACJ,UAAe,EACf,QAA4B,EAC5B,OAAO,EACP,MAAuB,EACvB,aAAa,EACb,mBAAmB,EACnB,OAAO,EACP,QAAQ,EACR,SAAS,GACV,EAAE,aAAa,2CAyQf"}
1
+ {"version":3,"file":"post-form.d.ts","sourceRoot":"","sources":["../../../src/components/admin/post-form.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAG7D,OAAO,KAAK,EACV,YAAY,EACZ,qBAAqB,EAGtB,MAAM,sBAAsB,CAAC;AAK9B,MAAM,WAAW,aAAa;IAC5B,kDAAkD;IAClD,IAAI,CAAC,EAAE,qBAAqB,GAAG,IAAI,CAAC;IACpC,UAAU,CAAC,EAAE,YAAY,EAAE,CAAC;IAC5B,kFAAkF;IAClF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+DAA+D;IAC/D,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IACpC,0HAA0H;IAC1H,MAAM,CAAC,EAAE,aAAa,EAAE,CAAC;IACzB,+EAA+E;IAC/E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvD;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,8EAA8E;IAC9E,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAClE,4FAA4F;IAC5F,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAsBD,wBAAgB,QAAQ,CAAC,EACvB,IAAI,EACJ,UAAe,EACf,QAA4B,EAC5B,OAAO,EACP,MAAM,EACN,aAAa,EACb,mBAAmB,EACnB,OAAO,EACP,QAAQ,EACR,SAAS,EACT,MAAM,EAAE,QAAe,GACxB,EAAE,aAAa,2CA2Qf"}
@@ -6,20 +6,29 @@ import { cn } from "hazo_ui/utils";
6
6
  import { slugify } from "../../lib/text.js";
7
7
  import { createBlogImageUploader } from "./upload-image.js";
8
8
  import { FeaturedImageField } from "./featured-image-field.js";
9
- const DEFAULT_EMBEDS = [
10
- { name: "youtube", label: "YT", snippet: '\n<YouTube id="VIDEO_ID" title="" />\n' },
11
- {
12
- name: "callout",
13
- label: "Note",
14
- snippet: (sel) => `\n<Callout type="info">\n${sel || "..."}\n</Callout>\n`,
15
- },
16
- {
17
- name: "image",
18
- label: "Img",
19
- snippet: '\n<BlogImage src="/path.jpg" alt="" caption="" />\n',
20
- },
21
- ];
22
- export function PostForm({ post, categories = [], endpoint = "/api/admin/blog", onSaved, embeds = DEFAULT_EMBEDS, onImageUpload, imageUploadEndpoint, fetchFn, onCancel, className, }) {
9
+ import { blogTranslator } from "../../lib/i18n.js";
10
+ function buildDefaultEmbeds(t) {
11
+ return [
12
+ {
13
+ name: "youtube",
14
+ label: t("post_form.embed_youtube_label"),
15
+ snippet: '\n<YouTube id="VIDEO_ID" title="" />\n',
16
+ },
17
+ {
18
+ name: "callout",
19
+ label: t("post_form.embed_note_label"),
20
+ snippet: (sel) => `\n<Callout type="info">\n${sel || "..."}\n</Callout>\n`,
21
+ },
22
+ {
23
+ name: "image",
24
+ label: t("post_form.embed_image_label"),
25
+ snippet: '\n<BlogImage src="/path.jpg" alt="" caption="" />\n',
26
+ },
27
+ ];
28
+ }
29
+ export function PostForm({ post, categories = [], endpoint = "/api/admin/blog", onSaved, embeds, onImageUpload, imageUploadEndpoint, fetchFn, onCancel, className, locale: uiLocale = "en", }) {
30
+ const t = blogTranslator(uiLocale);
31
+ const toolbarEmbeds = useMemo(() => embeds ?? buildDefaultEmbeds(t), [embeds, uiLocale]);
23
32
  // Reuse an existing `onImageUpload` if the caller already wired one
24
33
  // (back-compat); otherwise build one from `imageUploadEndpoint` if given;
25
34
  // otherwise `undefined` (URL-only mode, no upload UI).
@@ -73,21 +82,21 @@ export function PostForm({ post, categories = [], endpoint = "/api/admin/blog",
73
82
  body: JSON.stringify(payload),
74
83
  });
75
84
  if (!res.ok) {
76
- setError(`Save failed (${res.status})`);
85
+ setError(t("post_form.save_failed_with_status", { status: res.status }));
77
86
  return;
78
87
  }
79
88
  onSaved?.(await res.json());
80
89
  }
81
90
  catch {
82
- setError("Save failed");
91
+ setError(t("post_form.save_failed"));
83
92
  }
84
93
  finally {
85
94
  setSaving(false);
86
95
  }
87
96
  }
88
97
  const inputCls = "w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary";
89
- return (_jsxs("form", { className: cn("space-y-5", className), onSubmit: (e) => e.preventDefault(), children: [_jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Title" }), _jsx("input", { className: inputCls, value: title, onChange: (e) => setTitle(e.target.value), placeholder: "Post title" })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Slug" }), _jsx("input", { className: inputCls, value: slugTouched ? slug : effectiveSlug(), onChange: (e) => {
98
+ return (_jsxs("form", { className: cn("space-y-5", className), onSubmit: (e) => e.preventDefault(), children: [_jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: t("post_form.title_label") }), _jsx("input", { className: inputCls, value: title, onChange: (e) => setTitle(e.target.value), placeholder: t("post_form.title_placeholder") })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: t("post_form.slug_label") }), _jsx("input", { className: inputCls, value: slugTouched ? slug : effectiveSlug(), onChange: (e) => {
90
99
  setSlugTouched(true);
91
100
  setSlug(e.target.value);
92
- }, placeholder: "auto-generated-from-title" })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Content (MDX)" }), _jsx(MarkdownEditor, { value: content, onChange: setContent, height: 480, embeds: embeds, onImageUpload: uploader, placeholder: "Write your post in Markdown / MDX\u2026" })] }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Category" }), _jsxs("select", { className: inputCls, value: categoryId, onChange: (e) => setCategoryId(e.target.value), children: [_jsx("option", { value: "", children: "\u2014 none \u2014" }), categories.map((c) => (_jsx("option", { value: c.id, children: c.name }, c.id)))] })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Tags (comma-separated)" }), _jsx("input", { className: inputCls, value: tags, onChange: (e) => setTags(e.target.value), placeholder: "guides, timers" })] })] }), _jsx(FeaturedImageField, { value: featuredImage, onChange: setFeaturedImage, uploader: uploader }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Locale" }), _jsx("input", { className: inputCls, value: locale, onChange: (e) => setLocale(e.target.value), placeholder: "e.g. fr, pt-BR (blank = default locale)" })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Translation of (post ID)" }), _jsx("input", { className: inputCls, value: translationOf, onChange: (e) => setTranslationOf(e.target.value), placeholder: "Leave blank for the root post" })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { id: "post-form-indexable", type: "checkbox", className: "h-4 w-4 rounded border-border accent-primary", checked: indexable, onChange: (e) => setIndexable(e.target.checked) }), _jsx("label", { htmlFor: "post-form-indexable", className: "text-sm font-medium", children: "Indexable (include in sitemap/robots)" })] }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Meta title (SEO)" }), _jsx("input", { className: inputCls, value: metaTitle, onChange: (e) => setMetaTitle(e.target.value) })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Meta description (SEO)" }), _jsx("input", { className: inputCls, value: metaDescription, onChange: (e) => setMetaDescription(e.target.value) })] })] }), _jsxs("fieldset", { className: "rounded-lg border border-border p-4", children: [_jsx("legend", { className: "px-1 text-sm font-medium", children: "FAQ" }), faq.map((item, i) => (_jsxs("div", { className: "mb-2 grid gap-2 sm:grid-cols-2", children: [_jsx("input", { className: inputCls, value: item.question, placeholder: "Question", onChange: (e) => setFaq((prev) => prev.map((f, j) => (j === i ? { ...f, question: e.target.value } : f))) }), _jsx("input", { className: inputCls, value: item.answer, placeholder: "Answer", onChange: (e) => setFaq((prev) => prev.map((f, j) => (j === i ? { ...f, answer: e.target.value } : f))) })] }, i))), _jsx("button", { type: "button", className: "mt-1 text-sm text-primary hover:underline", onClick: () => setFaq((p) => [...p, { question: "", answer: "" }]), children: "+ Add FAQ item" })] }), error && _jsx("p", { className: "text-sm text-destructive", children: error }), _jsxs("div", { className: "flex gap-3", children: [onCancel && (_jsx("button", { type: "button", disabled: saving, onClick: onCancel, className: "rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50", children: "Cancel" })), _jsx("button", { type: "button", disabled: saving, onClick: () => save("draft"), className: "rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50", children: "Save draft" }), _jsx("button", { type: "button", disabled: saving, onClick: () => save("published"), className: "rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50", children: saving ? "Saving…" : "Publish" })] })] }));
101
+ }, placeholder: t("post_form.slug_placeholder") })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: t("post_form.content_label") }), _jsx(MarkdownEditor, { value: content, onChange: setContent, height: 480, embeds: toolbarEmbeds, onImageUpload: uploader, placeholder: t("post_form.content_placeholder") })] }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: t("post_form.category_label") }), _jsxs("select", { className: inputCls, value: categoryId, onChange: (e) => setCategoryId(e.target.value), children: [_jsx("option", { value: "", children: t("post_form.category_none_option") }), categories.map((c) => (_jsx("option", { value: c.id, children: c.name }, c.id)))] })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: t("post_form.tags_label") }), _jsx("input", { className: inputCls, value: tags, onChange: (e) => setTags(e.target.value), placeholder: t("post_form.tags_placeholder") })] })] }), _jsx(FeaturedImageField, { value: featuredImage, onChange: setFeaturedImage, uploader: uploader }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: t("post_form.locale_label") }), _jsx("input", { className: inputCls, value: locale, onChange: (e) => setLocale(e.target.value), placeholder: t("post_form.locale_placeholder") })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: t("post_form.translation_of_label") }), _jsx("input", { className: inputCls, value: translationOf, onChange: (e) => setTranslationOf(e.target.value), placeholder: t("post_form.translation_of_placeholder") })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { id: "post-form-indexable", type: "checkbox", className: "h-4 w-4 rounded border-border accent-primary", checked: indexable, onChange: (e) => setIndexable(e.target.checked) }), _jsx("label", { htmlFor: "post-form-indexable", className: "text-sm font-medium", children: t("post_form.indexable_label") })] }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: t("post_form.meta_title_label") }), _jsx("input", { className: inputCls, value: metaTitle, onChange: (e) => setMetaTitle(e.target.value) })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: t("post_form.meta_description_label") }), _jsx("input", { className: inputCls, value: metaDescription, onChange: (e) => setMetaDescription(e.target.value) })] })] }), _jsxs("fieldset", { className: "rounded-lg border border-border p-4", children: [_jsx("legend", { className: "px-1 text-sm font-medium", children: t("post_form.faq_legend") }), faq.map((item, i) => (_jsxs("div", { className: "mb-2 grid gap-2 sm:grid-cols-2", children: [_jsx("input", { className: inputCls, value: item.question, placeholder: t("post_form.faq_question_placeholder"), onChange: (e) => setFaq((prev) => prev.map((f, j) => (j === i ? { ...f, question: e.target.value } : f))) }), _jsx("input", { className: inputCls, value: item.answer, placeholder: t("post_form.faq_answer_placeholder"), onChange: (e) => setFaq((prev) => prev.map((f, j) => (j === i ? { ...f, answer: e.target.value } : f))) })] }, i))), _jsx("button", { type: "button", className: "mt-1 text-sm text-primary hover:underline", onClick: () => setFaq((p) => [...p, { question: "", answer: "" }]), children: t("post_form.faq_add_item") })] }), error && _jsx("p", { className: "text-sm text-destructive", children: error }), _jsxs("div", { className: "flex gap-3", children: [onCancel && (_jsx("button", { type: "button", disabled: saving, onClick: onCancel, className: "rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50", children: t("post_form.cancel") })), _jsx("button", { type: "button", disabled: saving, onClick: () => save("draft"), className: "rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50", children: t("post_form.save_draft") }), _jsx("button", { type: "button", disabled: saving, onClick: () => save("published"), className: "rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50", children: saving ? t("post_form.saving") : t("post_form.publish") })] })] }));
93
102
  }
@@ -2,6 +2,8 @@ import type { AuthorInfo } from "../types/index.js";
2
2
  export interface AuthorBioProps {
3
3
  author: AuthorInfo;
4
4
  className?: string;
5
+ /** UI locale for user-visible text. Default "en". */
6
+ locale?: string;
5
7
  }
6
- export declare function AuthorBio({ author, className }: AuthorBioProps): import("react/jsx-runtime").JSX.Element;
8
+ export declare function AuthorBio({ author, className, locale }: AuthorBioProps): import("react/jsx-runtime").JSX.Element;
7
9
  //# sourceMappingURL=author-bio.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"author-bio.d.ts","sourceRoot":"","sources":["../../src/components/author-bio.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAMpD,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,UAAU,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,SAAS,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,EAAE,cAAc,2CA0B9D"}
1
+ {"version":3,"file":"author-bio.d.ts","sourceRoot":"","sources":["../../src/components/author-bio.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGpD,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,UAAU,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qDAAqD;IACrD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,SAAS,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,MAAa,EAAE,EAAE,cAAc,2CA2B7E"}
@@ -1,9 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import Image from "next/image";
3
3
  import { cn } from "hazo_ui/utils";
4
- import en from "../locales/en.json" with { type: "json" };
5
- import { createPackageT } from "../lib/create_package_t.js";
6
- const t = createPackageT("hazo_blog", en);
7
- export function AuthorBio({ author, className }) {
4
+ import { blogTranslator } from "../lib/i18n.js";
5
+ export function AuthorBio({ author, className, locale = "en" }) {
6
+ const t = blogTranslator(locale);
8
7
  return (_jsxs("aside", { className: cn("my-8 flex items-center gap-4 rounded-xl border border-border bg-muted/30 p-5", className), children: [author.avatar && (_jsx(Image, { src: author.avatar, alt: author.name, width: 56, height: 56, className: "h-14 w-14 rounded-full object-cover" })), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("author_bio.written_by") }), _jsx("a", { href: author.url, className: "font-semibold text-foreground hover:text-primary", children: author.name }), author.bio && _jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: author.bio })] })] }));
9
8
  }
@@ -6,6 +6,8 @@ export interface BlogSearchProps {
6
6
  className?: string;
7
7
  /** Visual style. "default" preserves the original boxy input; "pill" renders a rounded, icon-prefixed input. */
8
8
  variant?: "default" | "pill";
9
+ /** UI locale for the placeholder. Default "en". An explicit `placeholder` always wins. */
10
+ locale?: string;
9
11
  }
10
- export declare function BlogSearch({ endpoint, basePath, placeholder, className, variant, }: BlogSearchProps): import("react/jsx-runtime").JSX.Element;
12
+ export declare function BlogSearch({ endpoint, basePath, placeholder, className, variant, locale, }: BlogSearchProps): import("react/jsx-runtime").JSX.Element;
11
13
  //# sourceMappingURL=blog-search.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"blog-search.d.ts","sourceRoot":"","sources":["../../src/components/blog-search.tsx"],"names":[],"mappings":"AAmBA,MAAM,WAAW,eAAe;IAC9B,iGAAiG;IACjG,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gHAAgH;IAChH,OAAO,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC;CAC9B;AAED,wBAAgB,UAAU,CAAC,EACzB,QAAQ,EACR,QAAkB,EAClB,WAA0C,EAC1C,SAAS,EACT,OAAmB,GACpB,EAAE,eAAe,2CAyFjB"}
1
+ {"version":3,"file":"blog-search.d.ts","sourceRoot":"","sources":["../../src/components/blog-search.tsx"],"names":[],"mappings":"AAgBA,MAAM,WAAW,eAAe;IAC9B,iGAAiG;IACjG,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gHAAgH;IAChH,OAAO,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC;IAC7B,0FAA0F;IAC1F,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,UAAU,CAAC,EACzB,QAAQ,EACR,QAAkB,EAClB,WAAW,EACX,SAAS,EACT,OAAmB,EACnB,MAAa,GACd,EAAE,eAAe,2CA0FjB"}
@@ -6,10 +6,9 @@ import Link from "next/link";
6
6
  import { cn } from "hazo_ui/utils";
7
7
  import { trackBlogEvent } from "../lib/analytics.js";
8
8
  import { SearchIcon } from "./icons.js";
9
- import en from "../locales/en.json" with { type: "json" };
10
- import { createPackageT } from "../lib/create_package_t.js";
11
- const t = createPackageT("hazo_blog", en);
12
- export function BlogSearch({ endpoint, basePath = "/blog", placeholder = t("blog_search.placeholder"), className, variant = "default", }) {
9
+ import { blogTranslator } from "../lib/i18n.js";
10
+ export function BlogSearch({ endpoint, basePath = "/blog", placeholder, className, variant = "default", locale = "en", }) {
11
+ const t = blogTranslator(locale);
13
12
  const url = endpoint ?? `${basePath}/api/search`;
14
13
  const [query, setQuery] = useState("");
15
14
  const [results, setResults] = useState([]);
@@ -63,7 +62,7 @@ export function BlogSearch({ endpoint, basePath = "/blog", placeholder = t("blog
63
62
  setResults([]);
64
63
  }
65
64
  }
66
- return (_jsxs("div", { className: cn("relative", className), children: [variant === "pill" && (_jsx(SearchIcon, { className: "pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" })), _jsx("input", { type: "search", value: query, onChange: (e) => setQuery(e.target.value), onKeyDown: handleKeyDown, placeholder: placeholder, className: variant === "pill"
65
+ return (_jsxs("div", { className: cn("relative", className), children: [variant === "pill" && (_jsx(SearchIcon, { className: "pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" })), _jsx("input", { type: "search", value: query, onChange: (e) => setQuery(e.target.value), onKeyDown: handleKeyDown, placeholder: placeholder ?? t("blog_search.placeholder"), className: variant === "pill"
67
66
  ? "w-full rounded-full border border-border bg-background py-2.5 pl-11 pr-4 text-sm outline-none focus:ring-2 focus:ring-primary"
68
67
  : "w-full rounded-lg border border-border bg-background px-4 py-2 text-sm outline-none focus:ring-2 focus:ring-primary" }), open && results.length > 0 && (_jsx("ul", { className: "absolute z-50 mt-1 max-h-80 w-full overflow-auto rounded-lg border border-border bg-background shadow-xl", children: results.map((r) => (_jsx("li", { className: "border-b border-border last:border-0", children: _jsxs(Link, { href: `${basePath}/${r.slug}`, className: "block px-4 py-3 text-sm hover:bg-accent", onClick: () => setOpen(false), children: [_jsx("span", { className: "font-medium text-foreground", children: r.title }), r.excerpt && (_jsx("span", { className: "block truncate text-xs text-muted-foreground", children: r.excerpt }))] }) }, r.slug))) }))] }));
69
68
  }
@@ -5,6 +5,8 @@ export interface EditorialPostCardProps {
5
5
  index: number;
6
6
  basePath?: string;
7
7
  className?: string;
8
+ /** UI locale for dates. Default "en". */
9
+ locale?: string;
8
10
  }
9
- export declare function EditorialPostCard({ post, index, basePath, className, }: EditorialPostCardProps): import("react/jsx-runtime").JSX.Element;
11
+ export declare function EditorialPostCard({ post, index, basePath, className, locale, }: EditorialPostCardProps): import("react/jsx-runtime").JSX.Element;
10
12
  //# sourceMappingURL=editorial-post-card.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"editorial-post-card.d.ts","sourceRoot":"","sources":["../../src/components/editorial-post-card.tsx"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAK/D,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,qBAAqB,CAAC;IAC5B,qFAAqF;IACrF,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,iBAAiB,CAAC,EAChC,IAAI,EACJ,KAAK,EACL,QAAkB,EAClB,SAAS,GACV,EAAE,sBAAsB,2CA+CxB"}
1
+ {"version":3,"file":"editorial-post-card.d.ts","sourceRoot":"","sources":["../../src/components/editorial-post-card.tsx"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAK/D,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,qBAAqB,CAAC;IAC5B,qFAAqF;IACrF,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yCAAyC;IACzC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,iBAAiB,CAAC,EAChC,IAAI,EACJ,KAAK,EACL,QAAkB,EAClB,SAAS,EACT,MAAa,GACd,EAAE,sBAAsB,2CA+CxB"}
@@ -11,10 +11,10 @@ import { ImageIcon } from "./icons.js";
11
11
  import { formatPostDate } from "../lib/dates.js";
12
12
  const ASPECT_BY_POSITION = ["aspect-[4/5]", "aspect-[16/10]", "aspect-[4/3]"];
13
13
  const OFFSET_BY_POSITION = ["", "", "lg:mt-6"];
14
- export function EditorialPostCard({ post, index, basePath = "/blog", className, }) {
14
+ export function EditorialPostCard({ post, index, basePath = "/blog", className, locale = "en", }) {
15
15
  const position = index % 3;
16
16
  return (_jsx("article", { className: cn(OFFSET_BY_POSITION[position], className), children: _jsxs(Link, { href: `${basePath}/${post.slug}`, className: "group block", children: [_jsx("div", { className: cn("relative w-full overflow-hidden rounded-2xl", ASPECT_BY_POSITION[position]), children: post.featured_image ? (_jsx(Image, { src: post.featured_image, alt: post.title, fill: true, className: "object-cover transition-transform duration-300 group-hover:scale-105", sizes: "(max-width: 768px) 100vw, 33vw" })) : (_jsx("div", { className: "flex h-full w-full items-center justify-center bg-gradient-to-br from-muted to-muted/50", children: _jsx(ImageIcon, { className: "h-10 w-10 text-muted-foreground/40" }) })) }), _jsxs("div", { className: "mt-4", children: [post.category && (_jsx("span", { className: "mb-2 inline-block rounded-full px-2.5 py-0.5 text-xs font-medium", style: {
17
17
  backgroundColor: `${post.category.colour}20`,
18
18
  color: post.category.colour,
19
- }, children: post.category.name })), _jsx("h3", { className: "line-clamp-2 text-lg font-semibold text-foreground group-hover:text-primary", children: post.title }), _jsx("time", { className: "mt-2 block text-xs text-muted-foreground", children: formatPostDate(post.publish_date, "short") })] })] }) }));
19
+ }, children: post.category.name })), _jsx("h3", { className: "line-clamp-2 text-lg font-semibold text-foreground group-hover:text-primary", children: post.title }), _jsx("time", { className: "mt-2 block text-xs text-muted-foreground", children: formatPostDate(post.publish_date, "short", locale) })] })] }) }));
20
20
  }
@@ -3,7 +3,9 @@ export interface FaqSectionProps {
3
3
  faq: FaqItem[];
4
4
  title?: string;
5
5
  className?: string;
6
+ /** UI locale for the default heading. Default "en". */
7
+ locale?: string;
6
8
  }
7
9
  /** Renders FAQ as a no-JS <details> accordion (the FAQPage JSON-LD is emitted separately). */
8
- export declare function FaqSection({ faq, title, className }: FaqSectionProps): import("react/jsx-runtime").JSX.Element | null;
10
+ export declare function FaqSection({ faq, title, className, locale }: FaqSectionProps): import("react/jsx-runtime").JSX.Element | null;
9
11
  //# sourceMappingURL=faq-section.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"faq-section.d.ts","sourceRoot":"","sources":["../../src/components/faq-section.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAMjD,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,OAAO,EAAE,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,8FAA8F;AAC9F,wBAAgB,UAAU,CAAC,EAAE,GAAG,EAAE,KAA8B,EAAE,SAAS,EAAE,EAAE,eAAe,kDAiB7F"}
1
+ {"version":3,"file":"faq-section.d.ts","sourceRoot":"","sources":["../../src/components/faq-section.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAGjD,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,OAAO,EAAE,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uDAAuD;IACvD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,8FAA8F;AAC9F,wBAAgB,UAAU,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,SAAS,EAAE,MAAa,EAAE,EAAE,eAAe,kDAkBnF"}
@@ -1,11 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cn } from "hazo_ui/utils";
3
- import en from "../locales/en.json" with { type: "json" };
4
- import { createPackageT } from "../lib/create_package_t.js";
5
- const t = createPackageT("hazo_blog", en);
3
+ import { blogTranslator } from "../lib/i18n.js";
6
4
  /** Renders FAQ as a no-JS <details> accordion (the FAQPage JSON-LD is emitted separately). */
7
- export function FaqSection({ faq, title = t("faq_section.title"), className }) {
5
+ export function FaqSection({ faq, title, className, locale = "en" }) {
8
6
  if (!faq || faq.length === 0)
9
7
  return null;
10
- return (_jsxs("section", { className: cn("my-8", className), children: [_jsx("h2", { className: "mb-4 text-2xl font-bold text-foreground", children: title }), _jsx("dl", { className: "divide-y divide-border rounded-xl border border-border", children: faq.map((item, i) => (_jsxs("details", { className: "group p-4", children: [_jsx("summary", { className: "cursor-pointer list-none font-medium text-foreground", children: _jsx("dt", { className: "inline", children: item.question }) }), _jsx("dd", { className: "mt-2 text-sm text-muted-foreground", children: item.answer })] }, i))) })] }));
8
+ const t = blogTranslator(locale);
9
+ return (_jsxs("section", { className: cn("my-8", className), children: [_jsx("h2", { className: "mb-4 text-2xl font-bold text-foreground", children: title ?? t("faq_section.title") }), _jsx("dl", { className: "divide-y divide-border rounded-xl border border-border", children: faq.map((item, i) => (_jsxs("details", { className: "group p-4", children: [_jsx("summary", { className: "cursor-pointer list-none font-medium text-foreground", children: _jsx("dt", { className: "inline", children: item.question }) }), _jsx("dd", { className: "mt-2 text-sm text-muted-foreground", children: item.answer })] }, i))) })] }));
11
10
  }
@@ -3,6 +3,8 @@ export interface FeaturedPostCardProps {
3
3
  post: BlogPostWithRelations;
4
4
  basePath?: string;
5
5
  className?: string;
6
+ /** UI locale for user-visible text and dates. Default "en". */
7
+ locale?: string;
6
8
  }
7
- export declare function FeaturedPostCard({ post, basePath, className }: FeaturedPostCardProps): import("react/jsx-runtime").JSX.Element;
9
+ export declare function FeaturedPostCard({ post, basePath, className, locale, }: FeaturedPostCardProps): import("react/jsx-runtime").JSX.Element;
8
10
  //# sourceMappingURL=featured-post-card.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"featured-post-card.d.ts","sourceRoot":"","sources":["../../src/components/featured-post-card.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAM/D,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,qBAAqB,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,gBAAgB,CAAC,EAAE,IAAI,EAAE,QAAkB,EAAE,SAAS,EAAE,EAAE,qBAAqB,2CA+D9F"}
1
+ {"version":3,"file":"featured-post-card.d.ts","sourceRoot":"","sources":["../../src/components/featured-post-card.tsx"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAE/D,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,qBAAqB,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,+DAA+D;IAC/D,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,gBAAgB,CAAC,EAC/B,IAAI,EACJ,QAAkB,EAClB,SAAS,EACT,MAAa,GACd,EAAE,qBAAqB,2CAgEvB"}
@@ -7,12 +7,11 @@ import Image from "next/image";
7
7
  import { cn } from "hazo_ui/utils";
8
8
  import { ClockIcon, ArrowRightIcon, ImageIcon } from "./icons.js";
9
9
  import { formatPostDate } from "../lib/dates.js";
10
- import en from "../locales/en.json" with { type: "json" };
11
- import { createPackageT } from "../lib/create_package_t.js";
12
- const t = createPackageT("hazo_blog", en);
13
- export function FeaturedPostCard({ post, basePath = "/blog", className }) {
10
+ import { blogTranslator } from "../lib/i18n.js";
11
+ export function FeaturedPostCard({ post, basePath = "/blog", className, locale = "en", }) {
12
+ const t = blogTranslator(locale);
14
13
  return (_jsxs("article", { className: cn("grid overflow-hidden rounded-3xl border border-border bg-card md:grid-cols-[3fr_2fr]", className), children: [_jsx("div", { className: "relative aspect-video w-full overflow-hidden md:aspect-auto md:h-full", children: post.featured_image ? (_jsx(Image, { src: post.featured_image, alt: post.title, fill: true, className: "object-cover", sizes: "(max-width: 768px) 100vw, 60vw", priority: true })) : (_jsx("div", { className: "flex h-full min-h-48 w-full items-center justify-center bg-gradient-to-br from-muted to-muted/50", children: _jsx(ImageIcon, { className: "h-12 w-12 text-muted-foreground/40" }) })) }), _jsxs("div", { className: "flex flex-col justify-center gap-4 p-6 sm:p-8 lg:p-10", children: [_jsxs("div", { className: "flex items-center gap-3 text-xs font-medium text-muted-foreground", children: [post.category && (_jsx("span", { className: "inline-block rounded-full px-2.5 py-0.5 text-xs font-medium", style: {
15
14
  backgroundColor: `${post.category.colour}20`,
16
15
  color: post.category.colour,
17
- }, children: post.category.name })), _jsxs("span", { className: "inline-flex items-center gap-1", children: [_jsx(ClockIcon, { className: "h-3.5 w-3.5" }), t("featured_post_card.reading_time", { minutes: String(post.reading_time) })] })] }), _jsx("h2", { className: "text-2xl font-bold leading-tight text-foreground sm:text-3xl lg:text-4xl", children: _jsx(Link, { href: `${basePath}/${post.slug}`, className: "hover:text-primary", children: post.title }) }), post.excerpt && (_jsx("p", { className: "line-clamp-3 text-base text-muted-foreground", children: post.excerpt })), _jsxs("div", { className: "mt-2 flex items-center justify-between", children: [_jsx("time", { className: "text-sm text-muted-foreground", children: formatPostDate(post.publish_date) }), _jsx(Link, { href: `${basePath}/${post.slug}`, "aria-label": t("featured_post_card.read_aria", { title: post.title }), className: "flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-border text-foreground transition-colors hover:bg-accent", children: _jsx(ArrowRightIcon, { className: "h-5 w-5" }) })] })] })] }));
16
+ }, children: post.category.name })), _jsxs("span", { className: "inline-flex items-center gap-1", children: [_jsx(ClockIcon, { className: "h-3.5 w-3.5" }), t("featured_post_card.reading_time", { minutes: String(post.reading_time) })] })] }), _jsx("h2", { className: "text-2xl font-bold leading-tight text-foreground sm:text-3xl lg:text-4xl", children: _jsx(Link, { href: `${basePath}/${post.slug}`, className: "hover:text-primary", children: post.title }) }), post.excerpt && (_jsx("p", { className: "line-clamp-3 text-base text-muted-foreground", children: post.excerpt })), _jsxs("div", { className: "mt-2 flex items-center justify-between", children: [_jsx("time", { className: "text-sm text-muted-foreground", children: formatPostDate(post.publish_date, "short", locale) }), _jsx(Link, { href: `${basePath}/${post.slug}`, "aria-label": t("featured_post_card.read_aria", { title: post.title }), className: "flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-border text-foreground transition-colors hover:bg-accent", children: _jsx(ArrowRightIcon, { className: "h-5 w-5" }) })] })] })] }));
18
17
  }
@@ -1,3 +1,3 @@
1
1
  import type { BlogIndexLayoutProps } from "./index.js";
2
- export declare function BlogIndexEditorial({ posts, basePath, searchEndpoint, query, page, totalPages, title, description, }: BlogIndexLayoutProps): import("react/jsx-runtime").JSX.Element;
2
+ export declare function BlogIndexEditorial({ posts, basePath, searchEndpoint, query, page, totalPages, title, description, locale, }: BlogIndexLayoutProps): import("react/jsx-runtime").JSX.Element;
3
3
  //# sourceMappingURL=blog-index-editorial.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"blog-index-editorial.d.ts","sourceRoot":"","sources":["../../../src/components/layouts/blog-index-editorial.tsx"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAMvD,wBAAgB,kBAAkB,CAAC,EACjC,KAAK,EACL,QAAQ,EACR,cAAc,EACd,KAAK,EACL,IAAI,EACJ,UAAU,EACV,KAAsC,EACtC,WAAW,GACZ,EAAE,oBAAoB,2CA+EtB"}
1
+ {"version":3,"file":"blog-index-editorial.d.ts","sourceRoot":"","sources":["../../../src/components/layouts/blog-index-editorial.tsx"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAGvD,wBAAgB,kBAAkB,CAAC,EACjC,KAAK,EACL,QAAQ,EACR,cAAc,EACd,KAAK,EACL,IAAI,EACJ,UAAU,EACV,KAAK,EACL,WAAW,EACX,MAAa,GACd,EAAE,oBAAoB,2CAiFtB"}
@@ -13,18 +13,17 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
13
13
  import { FeaturedPostCard } from "../featured-post-card.js";
14
14
  import { EditorialPostCard } from "../editorial-post-card.js";
15
15
  import { BlogSearch } from "../blog-search.js";
16
- import en from "../../locales/en.json" with { type: "json" };
17
- import { createPackageT } from "../../lib/create_package_t.js";
18
- const t = createPackageT("hazo_blog", en);
19
- export function BlogIndexEditorial({ posts, basePath, searchEndpoint, query, page, totalPages, title = t("common.default_blog_title"), description, }) {
16
+ import { blogTranslator, pluralSuffix } from "../../lib/i18n.js";
17
+ export function BlogIndexEditorial({ posts, basePath, searchEndpoint, query, page, totalPages, title, description, locale = "en", }) {
18
+ const t = blogTranslator(locale);
20
19
  const showFeatured = !query && posts.length > 0;
21
20
  const featured = showFeatured ? posts[0] : null;
22
21
  const gridPosts = showFeatured ? posts.slice(1) : posts;
23
- return (_jsxs("div", { className: "mx-auto max-w-6xl px-4 py-10", children: [_jsxs("div", { className: "mb-8 flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-4xl font-bold text-foreground sm:text-5xl", children: title }), description && (_jsx("p", { className: "mt-3 max-w-xl text-base text-muted-foreground", children: description })), query && (_jsxs("p", { className: "mt-3 text-sm text-muted-foreground", children: [t("common.results_count", {
22
+ return (_jsxs("div", { className: "mx-auto max-w-6xl px-4 py-10", children: [_jsxs("div", { className: "mb-8 flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-4xl font-bold text-foreground sm:text-5xl", children: title ?? t("common.default_blog_title") }), description && (_jsx("p", { className: "mt-3 max-w-xl text-base text-muted-foreground", children: description })), query && (_jsxs("p", { className: "mt-3 text-sm text-muted-foreground", children: [t("common.results_count", {
24
23
  count: String(posts.length),
25
- plural: posts.length !== 1 ? "s" : "",
24
+ plural: pluralSuffix(locale, posts.length),
26
25
  query,
27
- }), "\u00A0\u00B7\u00A0", _jsx("a", { href: basePath, className: "text-primary hover:underline", children: t("common.clear") })] }))] }), _jsx(BlogSearch, { variant: "pill", basePath: basePath, endpoint: searchEndpoint, className: "sm:w-80" })] }), _jsx("div", { className: "border-t border-border" }), posts.length === 0 ? (_jsx("p", { className: "py-16 text-center text-muted-foreground", children: query ? t("common.no_results_for_query", { query }) : t("common.no_posts_yet") })) : (_jsxs(_Fragment, { children: [featured && (_jsx("div", { className: "mt-8", children: _jsx(FeaturedPostCard, { post: featured, basePath: basePath }) })), gridPosts.length > 0 && (_jsx("div", { className: "mt-8 grid items-start sm:grid-cols-2 lg:grid-cols-3 gap-6", children: gridPosts.map((p, i) => (_jsx(EditorialPostCard, { post: p, index: i, basePath: basePath }, p.id))) }))] })), !query && totalPages > 1 && (_jsx("nav", { className: "mt-10 flex justify-center gap-2 text-sm", children: Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (_jsx("a", { href: `${basePath}?page=${n}`, className: n === page
26
+ }), "\u00A0\u00B7\u00A0", _jsx("a", { href: basePath, className: "text-primary hover:underline", children: t("common.clear") })] }))] }), _jsx(BlogSearch, { variant: "pill", basePath: basePath, endpoint: searchEndpoint, className: "sm:w-80", locale: locale })] }), _jsx("div", { className: "border-t border-border" }), posts.length === 0 ? (_jsx("p", { className: "py-16 text-center text-muted-foreground", children: query ? t("common.no_results_for_query", { query }) : t("common.no_posts_yet") })) : (_jsxs(_Fragment, { children: [featured && (_jsx("div", { className: "mt-8", children: _jsx(FeaturedPostCard, { post: featured, basePath: basePath, locale: locale }) })), gridPosts.length > 0 && (_jsx("div", { className: "mt-8 grid items-start sm:grid-cols-2 lg:grid-cols-3 gap-6", children: gridPosts.map((p, i) => (_jsx(EditorialPostCard, { post: p, index: i, basePath: basePath, locale: locale }, p.id))) }))] })), !query && totalPages > 1 && (_jsx("nav", { className: "mt-10 flex justify-center gap-2 text-sm", children: Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (_jsx("a", { href: `${basePath}?page=${n}`, className: n === page
28
27
  ? "rounded-full bg-primary px-4 py-1.5 text-primary-foreground"
29
28
  : "rounded-full border border-border px-4 py-1.5 hover:bg-accent", children: n }, n))) }))] }));
30
29
  }
@@ -1,3 +1,3 @@
1
1
  import type { BlogIndexLayoutProps } from "./index.js";
2
- export declare function BlogIndexGrid({ posts, basePath, searchEndpoint, query, page, totalPages, title, }: BlogIndexLayoutProps): import("react/jsx-runtime").JSX.Element;
2
+ export declare function BlogIndexGrid({ posts, basePath, searchEndpoint, query, page, totalPages, title, locale, }: BlogIndexLayoutProps): import("react/jsx-runtime").JSX.Element;
3
3
  //# sourceMappingURL=blog-index-grid.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"blog-index-grid.d.ts","sourceRoot":"","sources":["../../../src/components/layouts/blog-index-grid.tsx"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAMvD,wBAAgB,aAAa,CAAC,EAC5B,KAAK,EACL,QAAQ,EACR,cAAc,EACd,KAAK,EACL,IAAI,EACJ,UAAU,EACV,KAAsC,GACvC,EAAE,oBAAoB,2CAoDtB"}
1
+ {"version":3,"file":"blog-index-grid.d.ts","sourceRoot":"","sources":["../../../src/components/layouts/blog-index-grid.tsx"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAGvD,wBAAgB,aAAa,CAAC,EAC5B,KAAK,EACL,QAAQ,EACR,cAAc,EACd,KAAK,EACL,IAAI,EACJ,UAAU,EACV,KAAK,EACL,MAAa,GACd,EAAE,oBAAoB,2CAqDtB"}
@@ -9,15 +9,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  // Do NOT modernize this file — new layouts belong alongside it, not inside it.
10
10
  import { PostCard } from "../post-card.js";
11
11
  import { BlogSearch } from "../blog-search.js";
12
- import en from "../../locales/en.json" with { type: "json" };
13
- import { createPackageT } from "../../lib/create_package_t.js";
14
- const t = createPackageT("hazo_blog", en);
15
- export function BlogIndexGrid({ posts, basePath, searchEndpoint, query, page, totalPages, title = t("common.default_blog_title"), }) {
16
- return (_jsxs("div", { className: "mx-auto max-w-6xl px-4 py-10", children: [_jsxs("div", { className: "mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-3xl font-bold text-foreground", children: title }), query && (_jsxs("p", { className: "mt-1 text-sm text-muted-foreground", children: [t("common.results_count", {
12
+ import { blogTranslator, pluralSuffix } from "../../lib/i18n.js";
13
+ export function BlogIndexGrid({ posts, basePath, searchEndpoint, query, page, totalPages, title, locale = "en", }) {
14
+ const t = blogTranslator(locale);
15
+ return (_jsxs("div", { className: "mx-auto max-w-6xl px-4 py-10", children: [_jsxs("div", { className: "mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-3xl font-bold text-foreground", children: title ?? t("common.default_blog_title") }), query && (_jsxs("p", { className: "mt-1 text-sm text-muted-foreground", children: [t("common.results_count", {
17
16
  count: String(posts.length),
18
- plural: posts.length !== 1 ? "s" : "",
17
+ plural: pluralSuffix(locale, posts.length),
19
18
  query,
20
- }), "\u00A0\u00B7\u00A0", _jsx("a", { href: basePath, className: "text-primary hover:underline", children: t("common.clear") })] }))] }), _jsx(BlogSearch, { basePath: basePath, endpoint: searchEndpoint, className: "sm:w-72" })] }), posts.length === 0 ? (_jsx("p", { className: "text-muted-foreground", children: query ? t("common.no_results_for_query", { query }) : t("common.no_posts_yet") })) : (_jsx("div", { className: "grid gap-6 sm:grid-cols-2 lg:grid-cols-3", children: posts.map((p) => (_jsx(PostCard, { post: p, basePath: basePath }, p.id))) })), !query && totalPages > 1 && (_jsx("nav", { className: "mt-10 flex justify-center gap-2 text-sm", children: Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (_jsx("a", { href: `${basePath}?page=${n}`, className: n === page
19
+ }), "\u00A0\u00B7\u00A0", _jsx("a", { href: basePath, className: "text-primary hover:underline", children: t("common.clear") })] }))] }), _jsx(BlogSearch, { basePath: basePath, endpoint: searchEndpoint, className: "sm:w-72", locale: locale })] }), posts.length === 0 ? (_jsx("p", { className: "text-muted-foreground", children: query ? t("common.no_results_for_query", { query }) : t("common.no_posts_yet") })) : (_jsx("div", { className: "grid gap-6 sm:grid-cols-2 lg:grid-cols-3", children: posts.map((p) => (_jsx(PostCard, { post: p, basePath: basePath, locale: locale }, p.id))) })), !query && totalPages > 1 && (_jsx("nav", { className: "mt-10 flex justify-center gap-2 text-sm", children: Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (_jsx("a", { href: `${basePath}?page=${n}`, className: n === page
21
20
  ? "rounded bg-primary px-3 py-1 text-primary-foreground"
22
21
  : "rounded border border-border px-3 py-1 hover:bg-accent", children: n }, n))) }))] }));
23
22
  }
@@ -21,6 +21,8 @@ export interface BlogIndexLayoutProps {
21
21
  /** Subtitle/description copy shown under the heading. */
22
22
  description?: string;
23
23
  className?: string;
24
+ /** UI locale for user-visible text and dates. Default "en". */
25
+ locale?: string;
24
26
  }
25
27
  /** Layout id → component lookup, consumed by `createBlogIndexPage`. */
26
28
  export declare const BLOG_INDEX_LAYOUTS: Record<BlogIndexLayout, ComponentType<BlogIndexLayoutProps>>;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/layouts/index.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AAC3C,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAClE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAE/D,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,KAAK,EAAE,qBAAqB,EAAE,CAAC;IAC/B,oCAAoC;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,wFAAwF;IACxF,cAAc,EAAE,MAAM,CAAC;IACvB,mGAAmG;IACnG,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mEAAmE;IACnE,IAAI,EAAE,MAAM,CAAC;IACb,sDAAsD;IACtD,UAAU,EAAE,MAAM,CAAC;IACnB,+CAA+C;IAC/C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,uEAAuE;AACvE,eAAO,MAAM,kBAAkB,EAAE,MAAM,CAAC,eAAe,EAAE,aAAa,CAAC,oBAAoB,CAAC,CAG3F,CAAC;AAEF,OAAO,EAAE,aAAa,EAAE,kBAAkB,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/layouts/index.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AAC3C,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAClE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAE/D,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,KAAK,EAAE,qBAAqB,EAAE,CAAC;IAC/B,oCAAoC;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,wFAAwF;IACxF,cAAc,EAAE,MAAM,CAAC;IACvB,mGAAmG;IACnG,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mEAAmE;IACnE,IAAI,EAAE,MAAM,CAAC;IACb,sDAAsD;IACtD,UAAU,EAAE,MAAM,CAAC;IACnB,+CAA+C;IAC/C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,+DAA+D;IAC/D,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,uEAAuE;AACvE,eAAO,MAAM,kBAAkB,EAAE,MAAM,CAAC,eAAe,EAAE,aAAa,CAAC,oBAAoB,CAAC,CAG3F,CAAC;AAEF,OAAO,EAAE,aAAa,EAAE,kBAAkB,EAAE,CAAC"}