hazo_blog 0.4.0 → 0.5.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 (85) hide show
  1. package/CHANGE_LOG.md +98 -0
  2. package/README.md +251 -2
  3. package/SETUP_CHECKLIST.md +33 -0
  4. package/db_setup_postgres.sql +13 -0
  5. package/db_setup_sqlite.sql +14 -0
  6. package/dist/components/admin/featured-image-field.d.ts +13 -0
  7. package/dist/components/admin/featured-image-field.d.ts.map +1 -0
  8. package/dist/components/admin/featured-image-field.js +46 -0
  9. package/dist/components/admin/post-form.d.ts +16 -2
  10. package/dist/components/admin/post-form.d.ts.map +1 -1
  11. package/dist/components/admin/post-form.js +12 -3
  12. package/dist/components/admin/upload-image.d.ts +17 -0
  13. package/dist/components/admin/upload-image.d.ts.map +1 -0
  14. package/dist/components/admin/upload-image.js +52 -0
  15. package/dist/components/blog-search.d.ts +3 -1
  16. package/dist/components/blog-search.d.ts.map +1 -1
  17. package/dist/components/blog-search.js +5 -2
  18. package/dist/components/editorial-post-card.d.ts +10 -0
  19. package/dist/components/editorial-post-card.d.ts.map +1 -0
  20. package/dist/components/editorial-post-card.js +20 -0
  21. package/dist/components/featured-post-card.d.ts +8 -0
  22. package/dist/components/featured-post-card.d.ts.map +1 -0
  23. package/dist/components/featured-post-card.js +15 -0
  24. package/dist/components/icons.d.ts +14 -0
  25. package/dist/components/icons.d.ts.map +1 -0
  26. package/dist/components/icons.js +38 -0
  27. package/dist/components/index.d.ts +1 -0
  28. package/dist/components/index.d.ts.map +1 -1
  29. package/dist/components/index.js +1 -0
  30. package/dist/components/layouts/blog-index-editorial.d.ts +3 -0
  31. package/dist/components/layouts/blog-index-editorial.d.ts.map +1 -0
  32. package/dist/components/layouts/blog-index-editorial.js +23 -0
  33. package/dist/components/layouts/blog-index-grid.d.ts +3 -0
  34. package/dist/components/layouts/blog-index-grid.d.ts.map +1 -0
  35. package/dist/components/layouts/blog-index-grid.js +16 -0
  36. package/dist/components/layouts/index.d.ts +28 -0
  37. package/dist/components/layouts/index.d.ts.map +1 -0
  38. package/dist/components/layouts/index.js +15 -0
  39. package/dist/index.d.ts +3 -2
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +3 -2
  42. package/dist/lib/dates.d.ts +9 -0
  43. package/dist/lib/dates.d.ts.map +1 -0
  44. package/dist/lib/dates.js +22 -0
  45. package/dist/lib/index.d.ts +2 -0
  46. package/dist/lib/index.d.ts.map +1 -1
  47. package/dist/lib/index.js +2 -0
  48. package/dist/lib/layouts.d.ts +22 -0
  49. package/dist/lib/layouts.d.ts.map +1 -0
  50. package/dist/lib/layouts.js +32 -0
  51. package/dist/next/image-upload.d.ts +17 -0
  52. package/dist/next/image-upload.d.ts.map +1 -0
  53. package/dist/next/image-upload.js +102 -0
  54. package/dist/next/pages.d.ts.map +1 -1
  55. package/dist/next/pages.js +6 -10
  56. package/dist/next/routes.d.ts +16 -0
  57. package/dist/next/routes.d.ts.map +1 -1
  58. package/dist/next/routes.js +69 -8
  59. package/dist/repository/index.d.ts +6 -0
  60. package/dist/repository/index.d.ts.map +1 -1
  61. package/dist/repository/index.js +52 -0
  62. package/dist/service/index.d.ts +13 -0
  63. package/dist/service/index.d.ts.map +1 -1
  64. package/dist/service/index.js +47 -1
  65. package/dist/types/index.d.ts +67 -0
  66. package/dist/types/index.d.ts.map +1 -1
  67. package/dist/ui/blog-admin-panel.d.ts +15 -0
  68. package/dist/ui/blog-admin-panel.d.ts.map +1 -0
  69. package/dist/ui/blog-admin-panel.js +48 -0
  70. package/dist/ui/index.d.ts +6 -0
  71. package/dist/ui/index.d.ts.map +1 -0
  72. package/dist/ui/index.js +9 -0
  73. package/dist/ui/layout-selector.d.ts +10 -0
  74. package/dist/ui/layout-selector.d.ts.map +1 -0
  75. package/dist/ui/layout-selector.js +44 -0
  76. package/dist/ui/panel-dialog.d.ts +10 -0
  77. package/dist/ui/panel-dialog.d.ts.map +1 -0
  78. package/dist/ui/panel-dialog.js +22 -0
  79. package/dist/ui/post-list-table.d.ts +11 -0
  80. package/dist/ui/post-list-table.d.ts.map +1 -0
  81. package/dist/ui/post-list-table.js +43 -0
  82. package/dist/ui/use-blog-admin.d.ts +41 -0
  83. package/dist/ui/use-blog-admin.d.ts.map +1 -0
  84. package/dist/ui/use-blog-admin.js +150 -0
  85. package/package.json +19 -15
package/CHANGE_LOG.md CHANGED
@@ -3,6 +3,104 @@
3
3
  All notable changes are documented here. This project follows
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## 0.5.0 — 2026-08-17
7
+
8
+ **Two named `/blog` index layouts — `grid` (unchanged) + new `editorial`**
9
+
10
+ `BlogIndexLayout` (`src/lib/layouts.ts`) is now `"grid" | "editorial"`, with `BLOG_INDEX_LAYOUT_META`
11
+ carrying a label + description for each. `BlogIndexGrid` is a verbatim extraction of the previous
12
+ inline index markup — the back-compat anchor. `BlogIndexEditorial` is new: a big title/subtitle, a
13
+ pill search box, a full-width featured split card for the latest post, then a staggered 3-column
14
+ grid. `createBlogIndexPage` selects between them via `BLOG_INDEX_LAYOUTS`
15
+ (`src/components/layouts/index.ts`), keyed by the host's persisted `BlogSettings.indexLayout` (or
16
+ `BlogConfig.defaultIndexLayout` when nothing is persisted). Both layouts accept the identical
17
+ `BlogIndexLayoutProps` — that's the point, they're swappable without touching the page factory.
18
+
19
+ **Package-owned settings store (`hazo_blog_settings`, new table)**
20
+
21
+ A single host-wide settings row (`BLOG_SETTINGS_KEY = "display"`, tenant-scoped like every other
22
+ table when `scope_id` is set), read/written through three new `BlogService` methods:
23
+ `getSettings()` (never throws — falls back to `defaultIndexLayout`), `updateSettings(patch)`
24
+ (merge + persist, throws a typed `BlogSettingsUnavailableError` if the table can't be written),
25
+ and `settingsAvailable()` (a cheap existence probe). Read degrades gracefully, write surfaces
26
+ failure clearly — see "Key invariants" in `CLAUDE.md` for the reasoning. Exposed over HTTP via
27
+ the new `createBlogSettingsRoutes` (`GET`/`PUT`, cookie/session-guarded via the newly-extracted
28
+ `createAuthGuard` helper, now shared with `createBlogAdminRoutes`).
29
+
30
+ **Real admin panel — new `./ui` export, `BlogAdminPanel`**
31
+
32
+ Replaces the "coming soon" stub story for host admin shells (e.g. `hazo_admin`) with a working
33
+ panel: `BlogAdminPanel` composes `PostListTable` + the existing `PostForm` (in a new `PanelDialog`)
34
+ + a new `LayoutSelector` for the index-layout setting, all driven by the `useBlogAdmin` hook. The
35
+ whole tree is pure React, `next/*`-free, and deliberately token-free Tailwind (not this package's
36
+ usual `bg-background`-style tokens) so it renders correctly inside a host shell that may not define
37
+ `hazo_theme`'s CSS variables — the strictest export-boundary tier in the package. See `AGENTS.md`
38
+ for the full isolation rule.
39
+
40
+ **Featured-image + MDX-image uploads — one endpoint, one policy**
41
+
42
+ `createBlogImageUploadRoute` (`hazo_blog/next`) delegates to `hazo_images`' image-processing
43
+ handler (an optional peer, dynamically imported so hosts without it installed don't crash — 501s
44
+ with a named-package message instead) and rewrites the saved URL through the new
45
+ `BlogConfig.media` config (`getFileManager`/`pathPrefix`/`publicUrlBase`/`toPublicUrl`). Deliberately
46
+ one endpoint with one upload policy: a host wires the same route to both the featured-image picker
47
+ (new `featured-image-field.tsx`, used by `PostForm`) and an MDX editor's "insert image" action,
48
+ rather than standing up two handlers. 501s clearly when `media.getFileManager` isn't configured,
49
+ independent of the `hazo_images`-not-installed 501.
50
+
51
+ **Dark-mode search dropdown fix**
52
+
53
+ `BlogSearch`'s results dropdown previously hardcoded `style={{ backgroundColor: "white" }}` plus
54
+ raw `zinc-*` classes, which was genuinely illegible against a dark host theme. Fixed to use theme
55
+ tokens. New optional `variant?: "default" | "pill"` prop (default preserves the exact prior
56
+ markup/behaviour) — this is a fix, not a new feature; call it out explicitly as such.
57
+
58
+ **Back-compat: zero break**
59
+
60
+ Every consumer on `^0.4.x` upgrades with no code changes required. The default index layout
61
+ (`grid`) renders byte-identical markup to the pre-0.5.0 inline implementation. Every new
62
+ `BlogConfig` field (`defaultIndexLayout`, `media`, `settingsApiPath`, `imageUploadApiPath`,
63
+ `onSettingsChanged`) is optional. `createBlogAdminRoutes`' `collection.GET` only gains a response
64
+ key. A host that never migrates the new `hazo_blog_settings` table simply gets `getSettings()`
65
+ falling back to `defaultIndexLayout` (or `"grid"`) forever, with one process-lifetime console
66
+ warning — never a crash.
67
+
68
+ **Internal**
69
+
70
+ - New `src/ui/*` test coverage alongside the existing `src/__tests__/` suites.
71
+ - `__mocks__/hazo_core.cjs` was missing `globalSingleton`/`resetGlobalSingleton` — a real
72
+ pre-existing gap, not something this release introduced. `hazo_connect`'s dependency chain now
73
+ calls both at module load, and the stale mock was blocking 2 existing test suites from loading
74
+ at all ("hazo_core does not provide an export named 'globalSingleton'"). Fixed with a
75
+ self-contained promise-cache implementation matching real `hazo_core`'s evict-on-reject
76
+ semantics, deliberately kept separate from the older `registerSingleton`/`getSingleton` mock
77
+ pair rather than unifying the two (that pair already diverges from the real factory-less API,
78
+ and changing it risked currently-passing tests).
79
+ - `src/__tests__/seo.test.ts`'s `ResolvedBlogConfig` test fixture was missing `adminBasePath`/
80
+ `adminApiBasePath` — a pre-existing, unrelated `tsc` error, fixed incidentally while touching
81
+ this release's config surface.
82
+
83
+ ## 0.4.2 — 2026-08-14
84
+
85
+ **`BlogConfig.apiKeyService` (new, optional) — fixes a `createBlogManageRoutes` contract bug**
86
+
87
+ `createBlogManageRoutes` (the programmatic, hazo_api-key-guarded `/api/blog/manage` routes)
88
+ always built its API-key validation adapter as
89
+ `createApiKeyService({ getHazoConnect: config.getHazoConnect })`. But `getHazoConnect` is
90
+ documented — and used elsewhere, by `createBlogRepository` — as a hazo_connect CRUD adapter,
91
+ a *different* shape than hazo_api's raw-SQL `{dialect, query(sql, params)}` contract that
92
+ `createApiKeyService` actually needs. Any host whose `getHazoConnect` returns the (expected)
93
+ CRUD adapter got a route that threw on every request to `/api/blog/manage` — found while wiring
94
+ this into stocktools, whose `getHazoConnect` returns its hazo_connect singleton.
95
+
96
+ `apiKeyService?: ApiKeyService` on `BlogConfig` lets a host inject an already-built
97
+ `ApiKeyService` — from `hazo_api`'s `createApiKeyService` (raw-SQL) or
98
+ `createPostgrestApiKeyService` (no raw-SQL conduit needed, the better fit for a PostgREST
99
+ backend) — that `createBlogManageRoutes` uses in preference to the old
100
+ `getHazoConnect`-derived fallback. Fully backward-compatible: omitting the field preserves the
101
+ exact pre-existing behaviour (and its pre-existing footgun, for hosts that happen to already
102
+ satisfy the raw-SQL shape). New test: `src/__tests__/routes.manage-api-key.test.ts`.
103
+
6
104
  ## 0.4.0 — 2026-07-14
7
105
 
8
106
  **`validatePost()` — pre-publish content-quality gate (new, `hazo_blog/lib`)**
package/README.md CHANGED
@@ -4,7 +4,11 @@ SEO-first blogging for Next.js App Router apps: posts, categories, tags, MDX
4
4
  content, and GA4 / Google Search Console / Bing-ready output (BlogPosting +
5
5
  BreadcrumbList + FAQPage JSON-LD, canonical, OG, sitemap, RSS). The package owns
6
6
  the page layout **and** all SEO; the host customizes via Tailwind theme tokens
7
- and a single `BlogConfig`.
7
+ and a single `BlogConfig`. The `/blog` index ships two selectable layouts (grid
8
+ and editorial — see [Layouts](#layouts)), and the admin surface can be mounted
9
+ either as sealed pages or embedded as one framework-agnostic panel component
10
+ (`hazo_blog/ui`'s `BlogAdminPanel` — see
11
+ [Option C](#option-c--embed-the-panel-in-your-admin-shell)).
8
12
 
9
13
  ## Install
10
14
 
@@ -42,6 +46,13 @@ export const blogConfig: BlogConfig = {
42
46
  defaultOgImage: "https://acme.com/og.png",
43
47
  author: { name: "Acme Team", url: "https://acme.com/about" },
44
48
  getHazoConnect: () => myHazoConnectAdapter, // SQLite or Postgres
49
+ // Only needed if you mount createBlogManageRoutes (step 4's `manage`
50
+ // export) — `getHazoConnect` above is a hazo_connect CRUD adapter, a
51
+ // DIFFERENT shape than the raw-SQL adapter hazo_api's API-key validation
52
+ // needs, so most hosts must supply this explicitly rather than relying on
53
+ // the (rarely-satisfiable) getHazoConnect fallback. Build one with
54
+ // hazo_api's `createApiKeyService`/`createPostgrestApiKeyService`:
55
+ // apiKeyService: createPostgrestApiKeyService({ postgrest_url, postgrest_api_key }),
45
56
  authorize: (req) => checkAdminPermission(req), // wire to hazo_auth
46
57
  // resolveScope: (req) => orgIdFrom(req), // multi-tenant only
47
58
  // getAuthor: (id) => lookupAuthor(id), // per-post authors
@@ -137,6 +148,192 @@ export default async function sitemap() {
137
148
  }
138
149
  ```
139
150
 
151
+ ## Settings
152
+
153
+ hazo_blog owns a small settings table (`hazo_blog_settings`) for host-configurable
154
+ display/behavior settings. As of 0.5.0 it holds one setting — `indexLayout` (see
155
+ [Layouts](#layouts)) — with room for more to be added later without a config or
156
+ DDL churn for hosts already wired up.
157
+
158
+ **Read** — `service.getSettings(req?)` never throws. It reads the persisted row
159
+ and falls back to `{ indexLayout: config.defaultIndexLayout ?? "grid" }` if the
160
+ table is missing or the read errors — logging one warning per process, not per
161
+ request, so a fleet of requests hitting an un-migrated table doesn't spam logs.
162
+
163
+ **Write** — `service.updateSettings(patch, req?)` merges `patch` into the current
164
+ settings and persists it. Throws `BlogSettingsUnavailableError` if the table
165
+ can't be written to (e.g. not migrated yet) — the route factory below turns
166
+ this into a `503`.
167
+
168
+ **HTTP surface** — mount `createBlogSettingsRoutes(config)` at
169
+ `config.settingsApiPath` (default `${adminApiBasePath}/settings`):
170
+
171
+ ```ts
172
+ // app/api/admin/blog/settings/route.ts
173
+ import { createBlogSettingsRoutes } from "hazo_blog";
174
+ import { blogConfig } from "@/lib/blog-config";
175
+
176
+ const routes = createBlogSettingsRoutes(blogConfig);
177
+ export const GET = routes.GET;
178
+ export const PUT = routes.PUT;
179
+ ```
180
+
181
+ Both `GET` and `PUT` are guarded by `config.authorize`, same as the other admin
182
+ routes. `GET` returns:
183
+
184
+ ```ts
185
+ {
186
+ settings: BlogSettings; // { indexLayout: "grid" | "editorial" }
187
+ defaults: { indexLayout?: BlogIndexLayout }; // echoes config.defaultIndexLayout
188
+ layouts: typeof BLOG_INDEX_LAYOUT_META; // id/label/description per layout
189
+ persisted: boolean; // false = table not migrated, read-only
190
+ }
191
+ ```
192
+
193
+ `PUT` accepts a `Partial<BlogSettings>` body and returns `{ settings }` on
194
+ success, or `503 SETTINGS_UNAVAILABLE` when the table can't be written to (the
195
+ same `BlogSettingsUnavailableError` from `getSettings`/`updateSettings` above).
196
+
197
+ **Schema** — `hazo_blog_settings` (dual-dialect, defined in `db_setup_sqlite.sql`
198
+ / `db_setup_postgres.sql` alongside the other four tables): one row per
199
+ `(scope_id, setting_key)` with a JSON `payload` column. Apply it the same way as
200
+ the rest of this package's DDL — through your migration runner on SQLite, or a
201
+ manual `psql -f` step on Postgres, per your app's own DDL convention.
202
+
203
+ **`onSettingsChanged`** — fires after a successful `updateSettings` write,
204
+ inside its own `try/catch` so a host revalidate failure can't fail the settings
205
+ save itself:
206
+
207
+ ```ts
208
+ onSettingsChanged: () => {
209
+ try {
210
+ revalidatePath("/blog");
211
+ } catch {
212
+ // No-op outside a request/render context (e.g. called from a script).
213
+ }
214
+ },
215
+ ```
216
+
217
+ This matters because `/blog` is typically ISR-cached (`revalidate = 3600` from
218
+ step 3) — without a revalidate hook, switching layouts via the admin panel
219
+ won't be visible on the public index until the next natural ISR window.
220
+
221
+ ## Layouts
222
+
223
+ `config.defaultIndexLayout` (and the runtime override in [Settings](#settings))
224
+ picks which component renders the `/blog` index — `BLOG_INDEX_LAYOUT_META`
225
+ (exported from `hazo_blog` and `hazo_blog/lib`) describes both:
226
+
227
+ | Layout | Looks like | Use it when |
228
+ |---|---|---|
229
+ | `grid` (default) | Classic responsive card grid — one flat `sm:grid-cols-2 lg:grid-cols-3` layout for every post. Byte-identical to the pre-0.5.0 index markup. | You want the plainest, safest option, or your posts don't reliably carry a `featured_image`. |
230
+ | `editorial` | Big title + subtitle, pill search, a hairline rule, a full-width featured split card for the newest post, then a staggered 3-column grid below. | You want a more magazine-like landing page and most posts have a `featured_image`. |
231
+
232
+ **Degradation behavior** (same for both layouts unless noted):
233
+
234
+ - **0 posts** — empty state.
235
+ - **1 post** — `editorial` renders the featured-card only (no grid below); `grid` renders one card.
236
+ - **2 posts** — `editorial` renders the featured card + 1 grid card below.
237
+ - **Active search (`?q=`)** — `editorial` drops the featured card entirely; every matching post
238
+ (including the newest) renders as a plain grid card, same as `grid`.
239
+ - **`featured_image: null`** — both the featured card and grid cards render a
240
+ gradient + icon placeholder instead of an image block. No broken-image state either layout.
241
+ - **Mobile** — single column; `editorial`'s featured card stacks image-over-text instead of
242
+ side-by-side.
243
+
244
+ **Set the default** at boot via `defaultIndexLayout` in `BlogConfig` (falls back
245
+ to `"grid"` when omitted). **Switch it live** at runtime via the admin panel's
246
+ built-in `LayoutSelector` (see [Option C](#option-c--embed-the-panel-in-your-admin-shell)),
247
+ which writes through `service.updateSettings({ indexLayout })` to the
248
+ `hazo_blog_settings` table above — no redeploy required, only an ISR revalidate
249
+ (wire `onSettingsChanged` as shown in [Settings](#settings), or wait out the
250
+ natural `revalidate` window).
251
+
252
+ > **Remote images:** the `editorial` layout's featured card leans hard on
253
+ > `featured_image` being visible (`grid`'s thumbnails always did too, just
254
+ > smaller). If any post's `featured_image` is a remote, non-same-origin URL,
255
+ > configure `images.remotePatterns` in your own `next.config.js` — otherwise
256
+ > `next/image` refuses to render it. See
257
+ > [Required Next.js config](#required-nextjs-config).
258
+
259
+ ## Images
260
+
261
+ Featured-image and MDX-body-image uploads share **one endpoint and one upload
262
+ policy** — there's no separate pipeline for the two.
263
+
264
+ **Server** — `createBlogImageUploadRoute(config, deps?)`, mounted at
265
+ `config.imageUploadApiPath` (default `${adminApiBasePath}/images`):
266
+
267
+ ```ts
268
+ // app/api/admin/blog/images/route.ts
269
+ import { createBlogImageUploadRoute } from "hazo_blog";
270
+ import { blogConfig } from "@/lib/blog-config";
271
+
272
+ export const POST = createBlogImageUploadRoute(blogConfig);
273
+ ```
274
+
275
+ It requires **both**:
276
+ - `hazo_images` installed (optional peer) — `501 IMAGE_PIPELINE_UNAVAILABLE` naming the missing
277
+ package if it isn't.
278
+ - `config.media` configured (see `BlogMediaConfig` below) — `501 MEDIA_NOT_CONFIGURED` if absent.
279
+
280
+ On success it rewrites the saved file's URL via `config.media.toPublicUrl` (if
281
+ given), or by joining `config.media.publicUrlBase` with the stored path
282
+ otherwise:
283
+
284
+ ```ts
285
+ interface BlogMediaConfig {
286
+ getFileManager: () => BlogFileManager | Promise<BlogFileManager>;
287
+ pathPrefix?: string; // default "/hazo-blog"
288
+ publicUrlBase?: string;
289
+ toPublicUrl?: (saved: { path: string; url: string }) => string;
290
+ maxUploadBytes?: number;
291
+ allowedTypes?: string[];
292
+ processOptions?: Record<string, unknown>;
293
+ }
294
+ ```
295
+
296
+ `BlogFileManager` is a **structural** interface (`uploadFile(source, remotePath, options?)`) — you
297
+ don't need an adapter to satisfy it with `hazo_files`' `FileManager`/`TrackedFileManager`, e.g.:
298
+
299
+ ```ts
300
+ // lib/blog-config.ts
301
+ import { createInitializedFileManager, type FileManager } from "hazo_files";
302
+
303
+ let fileManagerPromise: Promise<FileManager> | null = null;
304
+ function getFileManager(): Promise<FileManager> {
305
+ if (!fileManagerPromise) {
306
+ fileManagerPromise = createInitializedFileManager({
307
+ config: { provider: "local", local: { basePath: "/path/to/public/uploads" } },
308
+ });
309
+ }
310
+ return fileManagerPromise;
311
+ }
312
+
313
+ export const blogConfig: BlogConfig = {
314
+ // ...
315
+ media: {
316
+ getFileManager: () => getFileManager(),
317
+ pathPrefix: "/hazo-blog",
318
+ publicUrlBase: "/uploads",
319
+ },
320
+ };
321
+ ```
322
+
323
+ **Client** — `createBlogImageUploader({ endpoint, fetchFn?, processOptions? })`
324
+ (exported from `hazo_blog/client`) returns a `(file: File) => Promise<string | null>`
325
+ function — never throws, resolves `null` on any failure. This is exactly what
326
+ `PostForm`'s featured-image field and its `MarkdownEditor`'s inline-image button
327
+ call under the hood when `PostForm` is given `imageUploadEndpoint`:
328
+
329
+ ```tsx
330
+ "use client";
331
+ import { createBlogImageUploader } from "hazo_blog/client";
332
+
333
+ const uploadImage = createBlogImageUploader({ endpoint: "/api/admin/blog/images" });
334
+ const url = await uploadImage(file); // string | null
335
+ ```
336
+
140
337
  ## 6. Admin pages
141
338
 
142
339
  ### Option A — Sealed admin pages (recommended)
@@ -185,6 +382,51 @@ import { PostForm } from "hazo_blog/client";
185
382
  // For edit: <PostForm post={existingPost} categories={categories} ... />
186
383
  ```
187
384
 
385
+ ### Option C — embed the panel in your admin shell
386
+
387
+ `hazo_blog/ui` exports `BlogAdminPanel` — a **framework-agnostic** (no `next/*`
388
+ imports), **token-free-styled** (raw Tailwind palette classes, not this
389
+ package's theme CSS variables) React component. Token-free means it drops into
390
+ any host admin shell without that host needing to adopt `hazo_theme`.
391
+
392
+ ```ts
393
+ interface BlogAdminPanelProps {
394
+ fetchFn: (path: string, init?: RequestInit) => Promise<Response>;
395
+ basePath: string; // e.g. "/api/admin/blog"
396
+ blogBasePath?: string; // public link base, default "/blog"
397
+ settingsPath?: string; // default `${basePath}/settings`
398
+ imageUploadPath?: string; // default `${basePath}/images`
399
+ className?: string;
400
+ }
401
+ ```
402
+
403
+ ```tsx
404
+ // A "use client" host component (fetchFn is a closure — it can't cross the
405
+ // server→client prop boundary unless it's a Server Action, so this has to be
406
+ // a client module, not a server component rendering a client child).
407
+ "use client";
408
+ import { BlogAdminPanel } from "hazo_blog/ui";
409
+
410
+ export function BlogAdmin() {
411
+ return (
412
+ <BlogAdminPanel
413
+ fetchFn={(path, init) => fetch(path, { credentials: "include", ...init })}
414
+ basePath="/api/admin/blog"
415
+ />
416
+ );
417
+ }
418
+ ```
419
+
420
+ It provides: a searchable/filterable post list, create/edit via a built-in
421
+ dialog (wraps `PostForm`), delete, and the layout selector from
422
+ [Layouts](#layouts) (with a "not migrated, read-only" banner when settings
423
+ persistence isn't available — see [Settings](#settings)).
424
+
425
+ This is the pattern `hazo_admin` 0.13.2 uses to lazy-mount the panel (a dynamic
426
+ `import("hazo_blog/ui")` behind a `.catch()` fallback, same shape as its
427
+ `hazo_jobs/ui` integration) — see `hazo_admin`'s own docs/CHANGE_LOG for that
428
+ side of the wiring, not this package's concern.
429
+
188
430
  ## Required Next.js config
189
431
 
190
432
  ```js
@@ -202,7 +444,13 @@ const nextConfig = {
202
444
  `node_modules/sql.js/dist/sql-wasm.wasm` to your app's `public/sql-wasm.wasm`.
203
445
  - **Tailwind v4:** add `@source "../node_modules/hazo_blog/dist";` to your CSS so
204
446
  the package's classes are compiled. The admin editor's styles load automatically
205
- via `hazo_ui`'s `MarkdownEditor`.
447
+ via `hazo_ui`'s `MarkdownEditor`. This single, whole-`dist`-directory entry already
448
+ covers the 0.5.0 `dist/ui` (`BlogAdminPanel`) and `dist/components/layouts`
449
+ (grid/editorial index layouts) output — no extra `@source` line needed.
450
+ - **Remote images:** if any post's `featured_image` is a non-same-origin URL, add it
451
+ to `images.remotePatterns` — required for `next/image` to render it, and far more
452
+ visible now that the `editorial` layout's featured card leans on it (see
453
+ [Layouts](#layouts)).
206
454
 
207
455
  ### Build resilience
208
456
 
@@ -227,6 +475,7 @@ pipelines.
227
475
  | `hazo_blog/lib` | pure text utilities: `sanitizeMdx`, `mdxToPlainText`, `buildExcerpt`, `slugify`, `calculateReadingTime`, `extractToc` + `validatePost` (pre-publish content-quality gate) |
228
476
  | `hazo_blog/seo` | `buildBlogPostingJsonLd`, `buildBreadcrumbJsonLd`, `buildFaqJsonLd`, `getBlogSitemapEntries`, `getBlogRobotsRules`, `getBlogRssXml` |
229
477
  | `hazo_blog/config` | `BlogConfig` types + `resolveConfig` |
478
+ | `hazo_blog/ui` | `BlogAdminPanel` — framework-agnostic, token-free admin panel component for embedding in a host admin shell (e.g. `hazo_admin`). No `next/*`, no server-only imports — pure React + Tailwind. |
230
479
 
231
480
  ### Search
232
481
 
@@ -15,6 +15,10 @@ for full code samples.
15
15
  - [ ] Apply `db_setup_sqlite.sql` or `db_setup_postgres.sql` (or add to your migration runner)
16
16
  - [ ] Confirm tables exist: `hazo_blog_posts`, `hazo_blog_categories`, `hazo_blog_tags`, `hazo_blog_post_tags`
17
17
  - [ ] If using `hazo_api`'s management API: ensure `hazo_api_keys` table exists (ships with hazo_api)
18
+ - [ ] (0.5.0+) Confirm `hazo_blog_settings` exists — backs the `indexLayout` setting (dual-dialect,
19
+ same two SQL files as above). On Postgres this is a manual `psql -f` step, same convention as
20
+ the rest of this package's DDL. Not strictly required to boot: a missing table degrades to
21
+ `config.defaultIndexLayout` (read-only) rather than a 500 — see `README.md`'s Settings section
18
22
 
19
23
  ## 3. Config
20
24
 
@@ -26,6 +30,10 @@ for full code samples.
26
30
  - [ ] (per-post authors) set `getAuthor(id)`
27
31
  - [ ] (analytics) set `onAnalyticsEvent` or rely on the `window.gtag` fallback
28
32
  - [ ] (non-default paths) set `adminBasePath`, `adminApiBasePath`, `searchApiPath` if your routes differ from defaults (`/admin/blog`, `/api/admin/blog`, `/api/blog/search`)
33
+ - [ ] (0.5.0+, index layout) set `defaultIndexLayout` (`"grid"` default | `"editorial"`), and optionally `indexTitle`/`indexDescription`
34
+ - [ ] (0.5.0+, image uploads) set `media: { getFileManager, pathPrefix?, publicUrlBase?, toPublicUrl?, maxUploadBytes?, allowedTypes? }` — `getFileManager` must resolve a `BlogFileManager`-satisfying manager (e.g. `hazo_files`' `FileManager`/`TrackedFileManager` via `createInitializedFileManager`)
35
+ - [ ] (0.5.0+, non-default paths) set `settingsApiPath`, `imageUploadApiPath` if your routes differ from defaults (`${adminApiBasePath}/settings`, `${adminApiBasePath}/images`)
36
+ - [ ] (0.5.0+, optional) set `onSettingsChanged` (e.g. `() => revalidatePath("/blog")`) so an admin-panel layout switch shows up on `/blog` without waiting for the next ISR window
29
37
 
30
38
  ## 4. Routes
31
39
 
@@ -38,6 +46,8 @@ for full code samples.
38
46
  - [ ] (optional) `app/api/blog/manage/route.ts` → `createBlogManageRoutes`
39
47
  - [ ] (optional) call `validatePost(input, opts)` in your create/update/publish path and reject on `!result.ok` (pre-publish content-quality gate)
40
48
  - [ ] Merge `getBlogSitemapEntries()` into root `app/sitemap.ts` and `getBlogRobotsRules()` into `app/robots.ts`
49
+ - [ ] (0.5.0+) `app/api/admin/blog/settings/route.ts` → `createBlogSettingsRoutes` (`GET`/`PUT`, cookie/session, guarded by `authorize`)
50
+ - [ ] (0.5.0+, only if uploading images) `app/api/admin/blog/images/route.ts` → `createBlogImageUploadRoute` (`POST`) — needs `hazo_images` installed AND `config.media` configured, else it 501s
41
51
 
42
52
  ### Admin UI pages (sealed, recommended)
43
53
 
@@ -46,12 +56,27 @@ for full code samples.
46
56
  - [ ] `app/admin/blog/[slug]/edit/page.tsx` → `createBlogAdminEditPage` + `export const dynamic = "force-dynamic"`
47
57
  - [ ] **Note**: `dynamic` must be a static string literal — cannot re-export from the factory object
48
58
 
59
+ ### Admin panel (0.5.0+, alternative to the sealed pages — Option C)
60
+
61
+ - [ ] Instead of (or alongside) the three sealed pages above, mount `<BlogAdminPanel>` from
62
+ `hazo_blog/ui` in your own `"use client"` admin route/shell, e.g.
63
+ `import { BlogAdminPanel } from "hazo_blog/ui"`
64
+ - [ ] Pass `fetchFn` (wraps your host's auth/credentials), `basePath` (e.g. `/api/admin/blog`), and
65
+ optionally `blogBasePath`/`settingsPath`/`imageUploadPath` if they differ from the defaults
66
+ - [ ] This is the shape `hazo_admin` 0.13.2 uses to lazy-`import("hazo_blog/ui")` behind a `.catch()`
67
+ fallback — same pattern as its `hazo_jobs/ui` integration
68
+
49
69
  ## 5. Next.js config (required)
50
70
 
51
71
  - [ ] `transpilePackages: ["hazo_blog", "hazo_ui", "next-mdx-remote"]` ← transpiling `next-mdx-remote` prevents the "React Element from an older version" MDX prerender error
52
72
  - [ ] `serverExternalPackages: ["better-sqlite3"]` (if using the SQLite adapter)
53
73
  - [ ] Copy `node_modules/sql.js/dist/sql-wasm.wasm` → `public/sql-wasm.wasm` (hazo_connect SQLite uses sql.js/WASM)
54
74
  - [ ] Tailwind v4: add `@source "../node_modules/hazo_blog/dist";` to your CSS
75
+ - [ ] (0.5.0+) Confirm that single `@source` entry already covers the new `dist/ui`
76
+ (`BlogAdminPanel`) and `dist/components/layouts` (grid/editorial) output — it's the whole
77
+ `dist` directory, so no additional `@source` line is needed
78
+ - [ ] (0.5.0+, if any post's `featured_image` is a remote URL) add it to `images.remotePatterns` —
79
+ required for `next/image` to render it, most visible on the `editorial` layout's featured card
55
80
 
56
81
  ## 6. Verify
57
82
 
@@ -62,4 +87,12 @@ for full code samples.
62
87
  - [ ] Admin list page shows status badges (green=published, grey=draft) and publish dates
63
88
  - [ ] Admin edit page pre-populates all fields; Save draft / Publish both work
64
89
  - [ ] Search: typing 2+ chars shows dropdown; Enter navigates to `?q=` filtered index
90
+ - [ ] Search dropdown is legible in dark mode (0.5.0 fix — was hardcoded white/`zinc-*`, now theme tokens)
65
91
  - [ ] Paste a post's HTML into Google's Rich Results Test — no errors
92
+ - [ ] (0.5.0+) Switch the index layout via the admin panel's layout selector, then confirm `/blog`
93
+ reflects it after your `onSettingsChanged` revalidate fires (or after the next ISR window)
94
+ - [ ] (0.5.0+) Upload a featured image via the admin form/panel — the thumbnail renders immediately
95
+ without a manual refresh
96
+ - [ ] (0.5.0+) Point `getHazoConnect` at a DB that hasn't run the `hazo_blog_settings` DDL — `/blog`
97
+ still renders (no 500), the settings panel shows a "not migrated, read-only" banner, and the
98
+ server logs exactly one warning (not one per request)
@@ -69,3 +69,16 @@ CREATE TABLE IF NOT EXISTS hazo_blog_post_tags (
69
69
  CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_post_tags_unique
70
70
  ON hazo_blog_post_tags (post_id, tag_id);
71
71
  CREATE INDEX IF NOT EXISTS idx_hazo_blog_post_tags_tag ON hazo_blog_post_tags (tag_id);
72
+
73
+ -- package-owned display/behavior settings, e.g. which blog index layout is
74
+ -- active — one row per (scope_id, setting_key)
75
+ CREATE TABLE IF NOT EXISTS hazo_blog_settings (
76
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
77
+ scope_id TEXT,
78
+ setting_key TEXT NOT NULL,
79
+ payload JSONB NOT NULL DEFAULT '{}'::jsonb,
80
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
81
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
82
+ );
83
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_settings_scope_key
84
+ ON hazo_blog_settings (scope_id, setting_key);
@@ -8,6 +8,7 @@
8
8
  -- hazo_blog_categories — single category per post
9
9
  -- hazo_blog_tags — free-form tags (many-to-many with posts)
10
10
  -- hazo_blog_post_tags — post↔tag join
11
+ -- hazo_blog_settings — package-owned display/behavior settings
11
12
  --
12
13
  -- Multi-tenancy: every table carries a nullable `scope_id`. Single-tenant
13
14
  -- consumers (e.g. gotimer) leave it NULL; multi-tenant consumers populate it
@@ -75,3 +76,16 @@ CREATE TABLE IF NOT EXISTS hazo_blog_post_tags (
75
76
  CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_post_tags_unique
76
77
  ON hazo_blog_post_tags (post_id, tag_id);
77
78
  CREATE INDEX IF NOT EXISTS idx_hazo_blog_post_tags_tag ON hazo_blog_post_tags (tag_id);
79
+
80
+ -- package-owned display/behavior settings, e.g. which blog index layout is
81
+ -- active — one row per (scope_id, setting_key)
82
+ CREATE TABLE IF NOT EXISTS hazo_blog_settings (
83
+ id TEXT PRIMARY KEY,
84
+ scope_id TEXT,
85
+ setting_key TEXT NOT NULL,
86
+ payload TEXT NOT NULL DEFAULT '{}',
87
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
88
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
89
+ );
90
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_settings_scope_key
91
+ ON hazo_blog_settings (scope_id, setting_key);
@@ -0,0 +1,13 @@
1
+ export interface FeaturedImageFieldProps {
2
+ /** Current featured_image URL, "" if unset. */
3
+ value: string;
4
+ onChange: (url: string) => void;
5
+ /**
6
+ * Upload handler — if absent, the field falls back to URL-only mode (no
7
+ * drag/drop, no Upload button, just the manual text input).
8
+ */
9
+ uploader?: (file: File) => Promise<string | null>;
10
+ className?: string;
11
+ }
12
+ export declare function FeaturedImageField({ value, onChange, uploader, className, }: FeaturedImageFieldProps): import("react/jsx-runtime").JSX.Element;
13
+ //# sourceMappingURL=featured-image-field.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,46 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useRef, useState } from "react";
4
+ import { cn } from "hazo_ui/utils";
5
+ import { ImageIcon, TrashIcon, UploadIcon } from "../icons.js";
6
+ 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
+ export function FeaturedImageField({ value, onChange, uploader, className, }) {
8
+ const [uploading, setUploading] = useState(false);
9
+ const [error, setError] = useState("");
10
+ const [dragOver, setDragOver] = useState(false);
11
+ const fileInputRef = useRef(null);
12
+ async function handleFile(file) {
13
+ if (!file || !uploader)
14
+ return;
15
+ setUploading(true);
16
+ setError("");
17
+ try {
18
+ const url = await uploader(file);
19
+ if (url) {
20
+ onChange(url);
21
+ }
22
+ else {
23
+ setError("Upload failed — try again or paste a URL below.");
24
+ }
25
+ }
26
+ finally {
27
+ setUploading(false);
28
+ }
29
+ }
30
+ function onDrop(e) {
31
+ e.preventDefault();
32
+ setDragOver(false);
33
+ if (!uploader)
34
+ return;
35
+ void handleFile(e.dataTransfer.files?.[0]);
36
+ }
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) => {
38
+ if (!uploader)
39
+ return;
40
+ e.preventDefault();
41
+ 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) => {
43
+ void handleFile(e.target.files?.[0]);
44
+ 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" })] }));
46
+ }
@@ -10,9 +10,23 @@ export interface PostFormProps {
10
10
  onSaved?: (result: unknown) => void;
11
11
  /** MDX embeds offered in the editor toolbar (host can extend). */
12
12
  embeds?: MarkdownEmbed[];
13
- /** Paste-to-upload handler wired to hazo_files by the host. */
13
+ /**
14
+ * Paste-to-upload handler wired to hazo_files by the host. Takes
15
+ * precedence over `imageUploadEndpoint` when both are provided (back-compat
16
+ * for existing callers).
17
+ */
14
18
  onImageUpload?: (file: File) => Promise<string | null>;
19
+ /**
20
+ * Route to POST image uploads to (see `createBlogImageUploadRoute` in
21
+ * ../../next/image-upload.ts). Ignored if `onImageUpload` is provided.
22
+ * When neither is set, the form falls back to URL-only mode.
23
+ */
24
+ imageUploadEndpoint?: string;
25
+ /** Injectable fetch for the built-in uploader, defaults to global `fetch`. */
26
+ fetchFn?: (path: string, init?: RequestInit) => Promise<Response>;
27
+ /** Called when the host-provided "Cancel" affordance is clicked (e.g. to close a modal). */
28
+ onCancel?: () => void;
15
29
  className?: string;
16
30
  }
17
- export declare function PostForm({ post, categories, endpoint, onSaved, embeds, onImageUpload, className, }: PostFormProps): import("react/jsx-runtime").JSX.Element;
31
+ export declare function PostForm({ post, categories, endpoint, onSaved, embeds, onImageUpload, imageUploadEndpoint, fetchFn, onCancel, className, }: PostFormProps): import("react/jsx-runtime").JSX.Element;
18
32
  //# 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;AAE9B,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,+DAA+D;IAC/D,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAgBD,wBAAgB,QAAQ,CAAC,EACvB,IAAI,EACJ,UAAe,EACf,QAA4B,EAC5B,OAAO,EACP,MAAuB,EACvB,aAAa,EACb,SAAS,GACV,EAAE,aAAa,2CA+Mf"}
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,2CAiOf"}