hazo_blog 1.1.0 → 1.2.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 CHANGED
@@ -3,6 +3,33 @@
3
3
  All notable changes are documented here. This project follows
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## 1.2.0 — 2026-09-12
7
+
8
+ **Side preview in the post editor (`PanelDialog` + `PostPreview`)**
9
+
10
+ The admin edit dialog now supports a split-pane layout showing the published article alongside the
11
+ editor. When editing a published post, a "Preview" toggle appears in the dialog header; clicking it
12
+ expands the dialog to `max-w-7xl` and loads the published article in an iframe on the right half.
13
+
14
+ **`PanelDialog`** gained two new optional props:
15
+ - `sideContent?: ReactNode` — when provided, the dialog widens and renders a two-column layout
16
+ (children on the left, sideContent on the right).
17
+ - `headerActions?: ReactNode` — rendered inline after the title, used by `BlogAdminPanel` for the
18
+ preview toggle button.
19
+
20
+ **`PostPreview`** — new component exported from `hazo_blog/ui`. A lightweight iframe wrapper that
21
+ takes a `url` prop (the post's public URL), shows a loading state, and provides an "Open in new
22
+ tab" link. Token-free Tailwind, same isolation rule as the rest of `src/ui/*`.
23
+
24
+ **`BlogAdminPanel`** wires it up automatically: when the `dialogTarget` is a published post, the
25
+ preview toggle and `PostPreview` are passed to `PanelDialog`. The iframe URL is constructed from
26
+ `blogBasePath` + slug (e.g. `/guides/my-post`), so consuming apps that set a custom `blogBasePath`
27
+ get correct preview URLs out of the box.
28
+
29
+ **Back-compat: zero break.** Both new `PanelDialog` props are optional; `PostPreview` is additive.
30
+ The dialog renders identically to before when `sideContent` is not provided (the only layout
31
+ difference is the title now sits in a border-bottom header row — visually near-identical).
32
+
6
33
  ## 1.1.0 — 2026-09-11
7
34
 
8
35
  **`onPostSaved` config hook + `createBlogManageSlugRoutes` factory**
@@ -1 +1 @@
1
- {"version":3,"file":"blog-admin-panel.d.ts","sourceRoot":"","sources":["../../src/ui/blog-admin-panel.tsx"],"names":[],"mappings":"AAmBA,MAAM,WAAW,mBAAmB;IAClC,0EAA0E;IAC1E,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IACjE,sFAAsF;IACtF,QAAQ,EAAE,MAAM,CAAC;IACjB,8DAA8D;IAC9D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,sDAAsD;IACtD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,wDAAwD;IACxD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAKD,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,QAAQ,EACR,YAAsB,EACtB,YAAY,EACZ,eAAe,EACf,SAAS,GACV,EAAE,mBAAmB,2CAwFrB"}
1
+ {"version":3,"file":"blog-admin-panel.d.ts","sourceRoot":"","sources":["../../src/ui/blog-admin-panel.tsx"],"names":[],"mappings":"AAoBA,MAAM,WAAW,mBAAmB;IAClC,0EAA0E;IAC1E,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IACjE,sFAAsF;IACtF,QAAQ,EAAE,MAAM,CAAC;IACjB,8DAA8D;IAC9D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,sDAAsD;IACtD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,wDAAwD;IACxD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAKD,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,QAAQ,EACR,YAAsB,EACtB,YAAY,EACZ,eAAe,EACf,SAAS,GACV,EAAE,mBAAmB,2CAoHrB"}
@@ -13,10 +13,12 @@ import { PostForm } from "../components/admin/post-form.js";
13
13
  import { PostListTable } from "./post-list-table.js";
14
14
  import { LayoutSelector } from "./layout-selector.js";
15
15
  import { PanelDialog } from "./panel-dialog.js";
16
+ import { PostPreview } from "./post-preview.js";
16
17
  import { useBlogAdmin } from "./use-blog-admin.js";
17
18
  export function BlogAdminPanel({ fetchFn, basePath, blogBasePath = "/blog", settingsPath, imageUploadPath, className, }) {
18
19
  const admin = useBlogAdmin({ fetchFn, basePath, settingsPath, imageUploadPath });
19
20
  const [dialogTarget, setDialogTarget] = useState(undefined);
21
+ const [showPreview, setShowPreview] = useState(false);
20
22
  const dialogOpen = dialogTarget !== undefined;
21
23
  const resolvedImageUploadPath = imageUploadPath ?? `${basePath}/images`;
22
24
  function openCreate() {
@@ -27,6 +29,7 @@ export function BlogAdminPanel({ fetchFn, basePath, blogBasePath = "/blog", sett
27
29
  }
28
30
  function closeDialog() {
29
31
  setDialogTarget(undefined);
32
+ setShowPreview(false);
30
33
  }
31
34
  async function handleDelete(post) {
32
35
  if (typeof window !== "undefined" && !window.confirm(`Delete "${post.title || post.slug}"? This cannot be undone.`)) {
@@ -41,7 +44,9 @@ export function BlogAdminPanel({ fetchFn, basePath, blogBasePath = "/blog", sett
41
44
  window.alert(message);
42
45
  }
43
46
  }
44
- return (_jsxs("div", { className: `space-y-8 text-zinc-900 dark:text-zinc-100 ${className ?? ""}`, children: [_jsxs("section", { className: "space-y-3", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("h2", { className: "text-lg font-semibold", children: "Posts" }), _jsx("button", { type: "button", onClick: openCreate, className: "rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:opacity-90 dark:bg-zinc-100 dark:text-zinc-900", children: "New Post" })] }), admin.postsError && (_jsxs("p", { className: "text-sm text-red-600 dark:text-red-400", children: ["Error: ", admin.postsError] })), admin.postsLoading && admin.posts.length === 0 ? (_jsx("p", { className: "text-sm text-zinc-500 dark:text-zinc-400", children: "Loading posts\u2026" })) : (_jsx(PostListTable, { posts: admin.posts, blogBasePath: blogBasePath, onEdit: openEdit, onDelete: handleDelete }))] }), _jsxs("section", { children: [_jsx("h2", { className: "mb-3 text-lg font-semibold", children: "Index Layout" }), admin.settingsError && (_jsxs("p", { className: "mb-2 text-sm text-red-600 dark:text-red-400", children: ["Error: ", admin.settingsError] })), admin.settingsState && (_jsx(LayoutSelector, { value: admin.settingsState.settings.indexLayout, persisted: admin.settingsState.persisted, onSelect: (next) => admin.updateSettings({ indexLayout: next }) }))] }), _jsx(PanelDialog, { open: dialogOpen, onClose: closeDialog, title: dialogTarget ? "Edit Post" : "New Post", children: _jsx(PostForm, { post: dialogTarget ?? null, categories: admin.categories, endpoint: basePath, imageUploadEndpoint: resolvedImageUploadPath, fetchFn: fetchFn, onCancel: closeDialog, onSaved: () => {
47
+ return (_jsxs("div", { className: `space-y-8 text-zinc-900 dark:text-zinc-100 ${className ?? ""}`, children: [_jsxs("section", { className: "space-y-3", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("h2", { className: "text-lg font-semibold", children: "Posts" }), _jsx("button", { type: "button", onClick: openCreate, className: "rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:opacity-90 dark:bg-zinc-100 dark:text-zinc-900", children: "New Post" })] }), admin.postsError && (_jsxs("p", { className: "text-sm text-red-600 dark:text-red-400", children: ["Error: ", admin.postsError] })), admin.postsLoading && admin.posts.length === 0 ? (_jsx("p", { className: "text-sm text-zinc-500 dark:text-zinc-400", children: "Loading posts\u2026" })) : (_jsx(PostListTable, { posts: admin.posts, blogBasePath: blogBasePath, onEdit: openEdit, onDelete: handleDelete }))] }), _jsxs("section", { children: [_jsx("h2", { className: "mb-3 text-lg font-semibold", children: "Index Layout" }), admin.settingsError && (_jsxs("p", { className: "mb-2 text-sm text-red-600 dark:text-red-400", children: ["Error: ", admin.settingsError] })), admin.settingsState && (_jsx(LayoutSelector, { value: admin.settingsState.settings.indexLayout, persisted: admin.settingsState.persisted, onSelect: (next) => admin.updateSettings({ indexLayout: next }) }))] }), _jsx(PanelDialog, { open: dialogOpen, onClose: closeDialog, title: dialogTarget ? "Edit Post" : "New Post", headerActions: dialogTarget?.status === "published" ? (_jsx("button", { type: "button", onClick: () => setShowPreview((p) => !p), className: `rounded-md border px-3 py-1 text-xs font-medium transition-colors ${showPreview
48
+ ? "border-zinc-900 bg-zinc-900 text-white dark:border-zinc-100 dark:bg-zinc-100 dark:text-zinc-900"
49
+ : "border-zinc-200 text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"}`, children: showPreview ? "Hide preview" : "Preview" })) : undefined, sideContent: showPreview && dialogTarget?.slug ? (_jsx(PostPreview, { url: `${blogBasePath.replace(/\/$/, "")}/${dialogTarget.slug}` })) : undefined, children: _jsx(PostForm, { post: dialogTarget ?? null, categories: admin.categories, endpoint: basePath, imageUploadEndpoint: resolvedImageUploadPath, fetchFn: fetchFn, onCancel: closeDialog, onSaved: () => {
45
50
  closeDialog();
46
51
  void admin.refetchPosts();
47
52
  } }) })] }));
@@ -2,5 +2,6 @@ export { BlogAdminPanel, type BlogAdminPanelProps } from "./blog-admin-panel.js"
2
2
  export { PostListTable, type PostListTableProps } from "./post-list-table.js";
3
3
  export { LayoutSelector, type LayoutSelectorProps } from "./layout-selector.js";
4
4
  export { PanelDialog, type PanelDialogProps } from "./panel-dialog.js";
5
+ export { PostPreview, type PostPreviewProps } from "./post-preview.js";
5
6
  export { useBlogAdmin, type UseBlogAdminOptions, type UseBlogAdminResult, type BlogSettingsState, } from "./use-blog-admin.js";
6
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ui/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AACjF,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAC9E,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAChF,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACvE,OAAO,EACL,YAAY,EACZ,KAAK,mBAAmB,EACxB,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,GACvB,MAAM,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ui/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AACjF,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAC9E,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAChF,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACvE,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACvE,OAAO,EACL,YAAY,EACZ,KAAK,mBAAmB,EACxB,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,GACvB,MAAM,qBAAqB,CAAC"}
package/dist/ui/index.js CHANGED
@@ -6,4 +6,5 @@ export { BlogAdminPanel } from "./blog-admin-panel.js";
6
6
  export { PostListTable } from "./post-list-table.js";
7
7
  export { LayoutSelector } from "./layout-selector.js";
8
8
  export { PanelDialog } from "./panel-dialog.js";
9
+ export { PostPreview } from "./post-preview.js";
9
10
  export { useBlogAdmin, } from "./use-blog-admin.js";
@@ -4,7 +4,11 @@ export interface PanelDialogProps {
4
4
  onClose: () => void;
5
5
  title?: string;
6
6
  children: ReactNode;
7
+ /** When provided, dialog expands to a two-column layout with this on the right. */
8
+ sideContent?: ReactNode;
9
+ /** Rendered inline after the title (e.g. a toggle button). */
10
+ headerActions?: ReactNode;
7
11
  className?: string;
8
12
  }
9
- export declare function PanelDialog({ open, onClose, title, children, className }: PanelDialogProps): import("react/jsx-runtime").JSX.Element | null;
13
+ export declare function PanelDialog({ open, onClose, title, children, sideContent, headerActions, className }: PanelDialogProps): import("react/jsx-runtime").JSX.Element | null;
10
14
  //# sourceMappingURL=panel-dialog.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"panel-dialog.d.ts","sourceRoot":"","sources":["../../src/ui/panel-dialog.tsx"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,SAAS,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,EAAE,gBAAgB,kDAkC1F"}
1
+ {"version":3,"file":"panel-dialog.d.ts","sourceRoot":"","sources":["../../src/ui/panel-dialog.tsx"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,SAAS,CAAC;IACpB,mFAAmF;IACnF,WAAW,CAAC,EAAE,SAAS,CAAC;IACxB,8DAA8D;IAC9D,aAAa,CAAC,EAAE,SAAS,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,aAAa,EAAE,SAAS,EAAE,EAAE,gBAAgB,kDAkDtH"}
@@ -4,8 +4,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // Deliberately NOT hazo_ui's Dialog (token-dependent — see the isolation
5
5
  // rule for src/ui/*). A plain fixed-position overlay + centered panel is
6
6
  // enough for an admin-panel context; no portal library needed.
7
+ //
8
+ // `sideContent` enables a split-pane layout: children on the left,
9
+ // sideContent on the right, dialog widens to max-w-7xl. Used by
10
+ // BlogAdminPanel to show a live article preview alongside the editor.
7
11
  import { useEffect } from "react";
8
- export function PanelDialog({ open, onClose, title, children, className }) {
12
+ export function PanelDialog({ open, onClose, title, children, sideContent, headerActions, className }) {
9
13
  useEffect(() => {
10
14
  if (!open)
11
15
  return;
@@ -18,5 +22,6 @@ export function PanelDialog({ open, onClose, title, children, className }) {
18
22
  }, [open, onClose]);
19
23
  if (!open)
20
24
  return null;
21
- return (_jsxs("div", { className: "fixed inset-0 z-[1000] flex items-center justify-center p-4", children: [_jsx("div", { className: "fixed inset-0 bg-black/50", onClick: onClose, "aria-hidden": "true" }), _jsxs("div", { role: "dialog", "aria-modal": "true", "aria-label": title, className: `relative z-10 max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-800 dark:bg-zinc-900 ${className ?? ""}`, children: [title && (_jsx("h2", { className: "mb-4 text-lg font-semibold text-zinc-900 dark:text-zinc-100", children: title })), children] })] }));
25
+ const wide = !!sideContent;
26
+ return (_jsxs("div", { className: "fixed inset-0 z-[1000] flex items-center justify-center p-4", children: [_jsx("div", { className: "fixed inset-0 bg-black/50", onClick: onClose, "aria-hidden": "true" }), _jsxs("div", { role: "dialog", "aria-modal": "true", "aria-label": title, className: `relative z-10 max-h-[90vh] w-full rounded-lg border border-zinc-200 bg-white shadow-xl dark:border-zinc-800 dark:bg-zinc-900 ${wide ? "max-w-7xl" : "max-w-2xl"} ${className ?? ""}`, children: [(title || headerActions) && (_jsxs("div", { className: "flex items-center justify-between border-b border-zinc-100 px-6 py-4 dark:border-zinc-800", children: [title && (_jsx("h2", { className: "text-lg font-semibold text-zinc-900 dark:text-zinc-100", children: title })), headerActions && _jsx("div", { className: "flex items-center gap-2", children: headerActions })] })), wide ? (_jsxs("div", { className: "flex max-h-[calc(90vh-60px)] min-h-0", children: [_jsx("div", { className: "w-1/2 shrink-0 overflow-y-auto border-r border-zinc-100 p-6 dark:border-zinc-800", children: children }), _jsx("div", { className: "flex w-1/2 min-w-0 flex-col", children: sideContent })] })) : (_jsx("div", { className: "max-h-[calc(90vh-60px)] overflow-y-auto p-6", children: children }))] })] }));
22
27
  }
@@ -0,0 +1,7 @@
1
+ export interface PostPreviewProps {
2
+ /** Full URL of the published post (e.g. "/guides/my-post"). */
3
+ url: string;
4
+ className?: string;
5
+ }
6
+ export declare function PostPreview({ url, className }: PostPreviewProps): import("react/jsx-runtime").JSX.Element;
7
+ //# sourceMappingURL=post-preview.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"post-preview.d.ts","sourceRoot":"","sources":["../../src/ui/post-preview.tsx"],"names":[],"mappings":"AAQA,MAAM,WAAW,gBAAgB;IAC/B,+DAA+D;IAC/D,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,WAAW,CAAC,EAAE,GAAG,EAAE,SAAS,EAAE,EAAE,gBAAgB,2CAiC/D"}
@@ -0,0 +1,10 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // hazo_blog/src/ui/post-preview.tsx — iframe-based published-article
4
+ // preview for the admin edit dialog's side panel. TOKEN-FREE Tailwind
5
+ // (same isolation rule as the rest of src/ui/*).
6
+ import { useState } from "react";
7
+ export function PostPreview({ url, className }) {
8
+ const [loading, setLoading] = useState(true);
9
+ return (_jsxs("div", { className: `flex h-full flex-col ${className ?? ""}`, children: [_jsxs("div", { className: "flex items-center justify-between border-b border-zinc-100 px-4 py-2 dark:border-zinc-800", children: [_jsx("span", { className: "text-xs font-medium text-zinc-500 dark:text-zinc-400", children: "Published preview" }), _jsx("a", { href: url, target: "_blank", rel: "noopener noreferrer", className: "text-xs font-medium text-zinc-600 hover:text-zinc-900 hover:underline dark:text-zinc-400 dark:hover:text-zinc-200", children: "Open in new tab \u2197" })] }), _jsxs("div", { className: "relative flex-1", children: [loading && (_jsx("div", { className: "absolute inset-0 flex items-center justify-center bg-zinc-50 dark:bg-zinc-950", children: _jsx("span", { className: "text-sm text-zinc-400 dark:text-zinc-500", children: "Loading preview\u2026" }) })), _jsx("iframe", { src: url, title: "Post preview", className: "h-full w-full border-0", onLoad: () => setLoading(false) })] })] }));
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hazo_blog",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "SEO-optimized blogging package: posts, categories, tags, MDX content, and GA4/GSC/Bing-ready SEO.",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
@@ -52,9 +52,9 @@
52
52
  "build:test-app": "npm run build && cd test-app && npm run build"
53
53
  },
54
54
  "peerDependencies": {
55
- "hazo_core": "^2.2.0",
56
- "hazo_connect": "^4.1.0",
57
- "hazo_api": "^2.6.1",
55
+ "hazo_core": "^2.3.0",
56
+ "hazo_connect": "^4.2.0",
57
+ "hazo_api": "^2.7.0",
58
58
  "hazo_files": "^3.3.0",
59
59
  "hazo_theme": "^1.3.1",
60
60
  "hazo_ui": "^6.11.2",
@@ -99,9 +99,9 @@
99
99
  "react": "^19.0.0",
100
100
  "react-dom": "^19.0.0",
101
101
  "next": "^16.0.10",
102
- "hazo_core": "^2.2.0",
103
- "hazo_connect": "^4.1.0",
104
- "hazo_api": "^2.6.1",
102
+ "hazo_core": "^2.3.0",
103
+ "hazo_connect": "^4.2.0",
104
+ "hazo_api": "^2.7.0",
105
105
  "hazo_files": "^3.3.0",
106
106
  "hazo_ui": "^6.11.2",
107
107
  "tailwindcss": "^4.2.4",