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.
- package/CHANGE_LOG.md +98 -0
- package/README.md +251 -2
- package/SETUP_CHECKLIST.md +33 -0
- package/db_setup_postgres.sql +13 -0
- package/db_setup_sqlite.sql +14 -0
- package/dist/components/admin/featured-image-field.d.ts +13 -0
- package/dist/components/admin/featured-image-field.d.ts.map +1 -0
- package/dist/components/admin/featured-image-field.js +46 -0
- package/dist/components/admin/post-form.d.ts +16 -2
- package/dist/components/admin/post-form.d.ts.map +1 -1
- package/dist/components/admin/post-form.js +12 -3
- package/dist/components/admin/upload-image.d.ts +17 -0
- package/dist/components/admin/upload-image.d.ts.map +1 -0
- package/dist/components/admin/upload-image.js +52 -0
- package/dist/components/blog-search.d.ts +3 -1
- package/dist/components/blog-search.d.ts.map +1 -1
- package/dist/components/blog-search.js +5 -2
- package/dist/components/editorial-post-card.d.ts +10 -0
- package/dist/components/editorial-post-card.d.ts.map +1 -0
- package/dist/components/editorial-post-card.js +20 -0
- package/dist/components/featured-post-card.d.ts +8 -0
- package/dist/components/featured-post-card.d.ts.map +1 -0
- package/dist/components/featured-post-card.js +15 -0
- package/dist/components/icons.d.ts +14 -0
- package/dist/components/icons.d.ts.map +1 -0
- package/dist/components/icons.js +38 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/components/layouts/blog-index-editorial.d.ts +3 -0
- package/dist/components/layouts/blog-index-editorial.d.ts.map +1 -0
- package/dist/components/layouts/blog-index-editorial.js +23 -0
- package/dist/components/layouts/blog-index-grid.d.ts +3 -0
- package/dist/components/layouts/blog-index-grid.d.ts.map +1 -0
- package/dist/components/layouts/blog-index-grid.js +16 -0
- package/dist/components/layouts/index.d.ts +28 -0
- package/dist/components/layouts/index.d.ts.map +1 -0
- package/dist/components/layouts/index.js +15 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/lib/dates.d.ts +9 -0
- package/dist/lib/dates.d.ts.map +1 -0
- package/dist/lib/dates.js +22 -0
- package/dist/lib/index.d.ts +2 -0
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +2 -0
- package/dist/lib/layouts.d.ts +22 -0
- package/dist/lib/layouts.d.ts.map +1 -0
- package/dist/lib/layouts.js +32 -0
- package/dist/next/image-upload.d.ts +17 -0
- package/dist/next/image-upload.d.ts.map +1 -0
- package/dist/next/image-upload.js +102 -0
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +6 -10
- package/dist/next/routes.d.ts +16 -0
- package/dist/next/routes.d.ts.map +1 -1
- package/dist/next/routes.js +69 -8
- package/dist/repository/index.d.ts +6 -0
- package/dist/repository/index.d.ts.map +1 -1
- package/dist/repository/index.js +52 -0
- package/dist/service/index.d.ts +13 -0
- package/dist/service/index.d.ts.map +1 -1
- package/dist/service/index.js +47 -1
- package/dist/types/index.d.ts +67 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/ui/blog-admin-panel.d.ts +15 -0
- package/dist/ui/blog-admin-panel.d.ts.map +1 -0
- package/dist/ui/blog-admin-panel.js +48 -0
- package/dist/ui/index.d.ts +6 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +9 -0
- package/dist/ui/layout-selector.d.ts +10 -0
- package/dist/ui/layout-selector.d.ts.map +1 -0
- package/dist/ui/layout-selector.js +44 -0
- package/dist/ui/panel-dialog.d.ts +10 -0
- package/dist/ui/panel-dialog.d.ts.map +1 -0
- package/dist/ui/panel-dialog.js +22 -0
- package/dist/ui/post-list-table.d.ts +11 -0
- package/dist/ui/post-list-table.d.ts.map +1 -0
- package/dist/ui/post-list-table.js +43 -0
- package/dist/ui/use-blog-admin.d.ts +41 -0
- package/dist/ui/use-blog-admin.d.ts.map +1 -0
- package/dist/ui/use-blog-admin.js +150 -0
- 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
|
|
package/SETUP_CHECKLIST.md
CHANGED
|
@@ -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)
|
package/db_setup_postgres.sql
CHANGED
|
@@ -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);
|
package/db_setup_sqlite.sql
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
|
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"}
|