diff --git a/docs/content/docs/i18n.mdx b/docs/content/docs/i18n.mdx new file mode 100644 index 00000000..44295b8f --- /dev/null +++ b/docs/content/docs/i18n.mdx @@ -0,0 +1,116 @@ +--- +title: i18n Keys +description: Translation key conventions and the reference catalog of plugin i18n keys +--- + +Plugins render every user-visible string through `useTranslate()` from `@btst/stack/context`: + +```tsx +const t = useTranslate(); + +t("blog.forms.titleLabel", "Title"); +t("blog.list.tagPageTitle", "{{tag}} Posts", { tag: tag.name }); +``` + +Without an `i18n` provider on `StackProvider`, `t()` returns the English default (with `{{param}}` interpolation), so apps with no provider behave exactly as before. With a provider, your `translate(key, defaultValue, params)` implementation decides what to render. + +## Key conventions + +- Keys are namespaced `..`, e.g. `blog.forms.titleLabel`. Areas group related UI (`common`, `list`, `card`, `post`, `forms`, `search`, ...). +- The second argument is always a **string literal** English default, so the key catalog can be regenerated mechanically: + +```bash +pnpm exec tsx packages/stack/scripts/extract-i18n-keys.ts +``` + +- Parameters use `{{param}}` interpolation in the default value. +- Plugins that support a legacy per-plugin `localization` override object resolve it with higher precedence than `t()`: + +```tsx +localization?.BLOG_FORMS_TITLE_LABEL ?? t("blog.forms.titleLabel", "Title"); +``` + +An app-provided `localization` override wins byte-for-byte; otherwise the string goes through the i18n provider. + +## Blog + +| Key | Default | +| --- | --- | +| `blog.card.draftBadge` | Draft | +| `blog.common.genericErrorMessage` | An unexpected error occurred. | +| `blog.common.genericErrorTitle` | Something went wrong | +| `blog.common.pageNotFoundDescription` | The page you are looking for does not exist. | +| `blog.common.pageNotFoundTitle` | Not Found | +| `blog.common.tagsShowAll` | Show all tags | +| `blog.common.tagsShowLess` | Show fewer tags | +| `blog.forms.cancelButton` | Cancel | +| `blog.forms.contentLabel` | Content | +| `blog.forms.deleteButton` | Delete Post | +| `blog.forms.deleteDialogCancel` | Cancel | +| `blog.forms.deleteDialogConfirm` | Delete | +| `blog.forms.deleteDialogDescription` | Are you sure you want to delete this post? This action cannot be undone. | +| `blog.forms.deleteDialogTitle` | Delete Post | +| `blog.forms.deletePending` | Deleting... | +| `blog.forms.editorPlaceholder` | Write something... | +| `blog.forms.excerptLabel` | Excerpt | +| `blog.forms.excerptPlaceholder` | Brief summary of your post... | +| `blog.forms.featuredImageErrorNotImage` | Please select an image file | +| `blog.forms.featuredImageErrorTooLarge` | Image size must be less than 4MB | +| `blog.forms.featuredImageInputPlaceholder` | Image URL or upload below... | +| `blog.forms.featuredImageLabel` | Image | +| `blog.forms.featuredImagePreviewAlt` | Featured image preview | +| `blog.forms.featuredImageRequiredAsterisk` |  * | +| `blog.forms.featuredImageToastFailure` | Failed to upload image | +| `blog.forms.featuredImageToastSuccess` | Image uploaded successfully | +| `blog.forms.featuredImageUploadButton` | Upload | +| `blog.forms.featuredImageUploadingButton` | Uploading... | +| `blog.forms.featuredImageUploadingText` | Uploading image... | +| `blog.forms.publishedDescription` | Toggle to publish immediately | +| `blog.forms.publishedLabel` | Published | +| `blog.forms.requiredAsterisk` |  * | +| `blog.forms.slugLabel` | Slug | +| `blog.forms.slugPlaceholder` | url-friendly-slug | +| `blog.forms.submitCreateIdle` | Create Post | +| `blog.forms.submitCreatePending` | Creating... | +| `blog.forms.submitUpdateIdle` | Update Post | +| `blog.forms.submitUpdatePending` | Updating... | +| `blog.forms.tagsLabel` | Tags | +| `blog.forms.tagsPlaceholder` | Enter your post tags... | +| `blog.forms.tagsSearchPlaceholder` | Search or create tags... | +| `blog.forms.titleLabel` | Title | +| `blog.forms.titlePlaceholder` | Enter your post title... | +| `blog.forms.toastCreateSuccess` | Post created successfully | +| `blog.forms.toastDeleteFailure` | Failed to delete post | +| `blog.forms.toastDeleteSuccess` | Post deleted successfully | +| `blog.forms.toastUpdateSuccess` | Post updated successfully | +| `blog.forms.validation.contentRequired` | Content is required | +| `blog.forms.validation.excerptRequired` | Excerpt is required | +| `blog.forms.validation.slugRequired` | Slug is required | +| `blog.forms.validation.titleRequired` | Title is required | +| `blog.list.draftsTitle` | Draft Posts | +| `blog.list.empty` | There are no posts here yet. | +| `blog.list.loadMore` | Load more posts | +| `blog.list.loadingMore` | Loading more... | +| `blog.list.searchButton` | Search Posts | +| `blog.list.searchEmpty` | No blog posts found. | +| `blog.list.searchPlaceholder` | Search Blog Posts... | +| `blog.list.tagNotFound` | Tag not found | +| `blog.list.tagNotFoundDescription` | The tag you are looking for does not exist. | +| `blog.list.tagPageDescription` | Browse all posts with this tag | +| `blog.list.tagPageTitle` | \{\{tag\}\} Posts | +| `blog.list.title` | Blog Posts | +| `blog.post.addDescription` | Create a new blog post. | +| `blog.post.addTitle` | Add New Post | +| `blog.post.editDescription` | Update your blog post. | +| `blog.post.editTitle` | Edit Post | +| `blog.post.keepReading` | Keep Reading | +| `blog.post.next` | Next | +| `blog.post.onThisPage` | In This Post | +| `blog.post.previous` | Previous | +| `blog.post.viewAll` | View all | +| `blog.search.button` | Search | +| `blog.search.empty` | No results found. | +| `blog.search.placeholder` | Type to search... | +| `blog.search.searching` | Searching... | + +Other plugins adopt the same convention as their phase-2 sweeps land. diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index 50f9178e..8acc8336 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -27,6 +27,7 @@ "databases/adapters", "---[BookOpenCheck]Concepts---", "auth", + "i18n", "cli", "api-reference", "standalone-components", diff --git a/e2e/tests/smoke.ui-builder.spec.ts b/e2e/tests/smoke.ui-builder.spec.ts index b1ed547d..c8392840 100644 --- a/e2e/tests/smoke.ui-builder.spec.ts +++ b/e2e/tests/smoke.ui-builder.spec.ts @@ -128,8 +128,11 @@ test.describe("UI Builder Plugin - Admin Pages", () => { timeout: 10000, }); - // Wait for first toast to disappear before proceeding - await expect(page.locator("text=/saved/i")).not.toBeVisible({ + // Wait for the toast(s) to disappear before proceeding. Use .first() + // like the visibility check above: the UI Builder embeds its own + // sonner Toaster, so the same toast can render twice (strict mode). + // All copies share one store and dismiss together. + await expect(page.locator("text=/saved/i").first()).not.toBeVisible({ timeout: 10000, }); diff --git a/packages/stack/registry/btst-blog.json b/packages/stack/registry/btst-blog.json index 28379d4f..6b92d1e6 100644 --- a/packages/stack/registry/btst-blog.json +++ b/packages/stack/registry/btst-blog.json @@ -58,7 +58,7 @@ { "path": "btst/blog/client/components/forms/image-field.tsx", "type": "registry:component", - "content": "import { Button } from \"@/components/ui/button\";\nimport {\n\tFormControl,\n\tFormDescription,\n\tFormItem,\n\tFormLabel,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { Loader2, Upload } from \"lucide-react\";\nimport { useRef, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\n\nexport function FeaturedImageField({\n\tisRequired,\n\tvalue,\n\tonChange,\n\tsetFeaturedImageUploading,\n}: {\n\tisRequired?: boolean;\n\tvalue?: string;\n\tonChange: (value: string) => void;\n\tsetFeaturedImageUploading: (uploading: boolean) => void;\n}) {\n\tconst fileInputRef = useRef(null);\n\tconst [isUploading, setIsUploading] = useState(false);\n\n\tconst {\n\t\tuploadImage,\n\t\tImage,\n\t\tlocalization,\n\t\timageInputField: ImageInput,\n\t} = usePluginOverrides>(\n\t\t\"blog\",\n\t\t{ localization: BLOG_LOCALIZATION },\n\t);\n\n\tconst ImageComponent = Image ? Image : DefaultImage;\n\n\t// When a custom imageInput component is provided via overrides, delegate to it.\n\tif (ImageInput) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t{localization.BLOG_FORMS_FEATURED_IMAGE_LABEL}\n\t\t\t\t\t{isRequired && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{\" \"}\n\t\t\t\t\t\t\t{localization.BLOG_FORMS_FEATURED_IMAGE_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\tconst handleImageUpload = async (\n\t\tevent: React.ChangeEvent,\n\t) => {\n\t\tconst file = event.target.files?.[0];\n\t\tif (!file) return;\n\n\t\tif (!file.type.startsWith(\"image/\")) {\n\t\t\ttoast.error(localization.BLOG_FORMS_FEATURED_IMAGE_ERROR_NOT_IMAGE);\n\t\t\treturn;\n\t\t}\n\n\t\tif (file.size > 4 * 1024 * 1024) {\n\t\t\ttoast.error(localization.BLOG_FORMS_FEATURED_IMAGE_ERROR_TOO_LARGE);\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tsetIsUploading(true);\n\t\t\tsetFeaturedImageUploading(true);\n\t\t\tconst url = await uploadImage(file);\n\t\t\tonChange(url);\n\t\t\ttoast.success(localization.BLOG_FORMS_FEATURED_IMAGE_TOAST_SUCCESS);\n\t\t} catch (error) {\n\t\t\ttoast.error(localization.BLOG_FORMS_FEATURED_IMAGE_TOAST_FAILURE);\n\t\t\tconsole.error(\"Failed to upload image:\", error);\n\t\t\ttoast.error(localization.BLOG_FORMS_FEATURED_IMAGE_TOAST_FAILURE);\n\t\t} finally {\n\t\t\tsetIsUploading(false);\n\t\t\tsetFeaturedImageUploading(false);\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{localization.BLOG_FORMS_FEATURED_IMAGE_LABEL}\n\t\t\t\t{isRequired && (\n\t\t\t\t\t\n\t\t\t\t\t\t{\" \"}\n\t\t\t\t\t\t{localization.BLOG_FORMS_FEATURED_IMAGE_REQUIRED_ASTERISK}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t onChange(e.target.value)}\n\t\t\t\t\t\t\tdisabled={isUploading}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t fileInputRef.current?.click()}\n\t\t\t\t\t\t\tdisabled={isUploading}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{isUploading ? (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_FEATURED_IMAGE_UPLOADING_BUTTON}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_FEATURED_IMAGE_UPLOAD_BUTTON}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t{isUploading && (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.BLOG_FORMS_FEATURED_IMAGE_UPLOADING_TEXT}\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t\t{value && !isUploading && (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n\nfunction DefaultImage({\n\tsrc,\n\talt,\n\tclassName,\n\twidth,\n\theight,\n}: {\n\tsrc: string;\n\talt: string;\n\tclassName?: string;\n\twidth?: number;\n\theight?: number;\n}) {\n\treturn (\n\t\t\n\t);\n}\n", + "content": "import { Button } from \"@/components/ui/button\";\nimport {\n\tFormControl,\n\tFormDescription,\n\tFormItem,\n\tFormLabel,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n\tuseNotify,\n\tusePluginOverrides,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { Loader2, Upload } from \"lucide-react\";\nimport { useRef, useState } from \"react\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\n\nexport function FeaturedImageField({\n\tisRequired,\n\tvalue,\n\tonChange,\n\tsetFeaturedImageUploading,\n}: {\n\tisRequired?: boolean;\n\tvalue?: string;\n\tonChange: (value: string) => void;\n\tsetFeaturedImageUploading: (uploading: boolean) => void;\n}) {\n\tconst fileInputRef = useRef(null);\n\tconst [isUploading, setIsUploading] = useState(false);\n\tconst notify = useNotify();\n\tconst t = useTranslate();\n\n\tconst {\n\t\tuploadImage,\n\t\tImage,\n\t\tlocalization,\n\t\timageInputField: ImageInput,\n\t} = usePluginOverrides(\"blog\");\n\n\tconst ImageComponent = Image ? Image : DefaultImage;\n\n\tconst label = (\n\t\t\n\t\t\t{localization?.BLOG_FORMS_FEATURED_IMAGE_LABEL ??\n\t\t\t\tt(\"blog.forms.featuredImageLabel\", \"Image\")}\n\t\t\t{isRequired && (\n\t\t\t\t\n\t\t\t\t\t{\" \"}\n\t\t\t\t\t{localization?.BLOG_FORMS_FEATURED_IMAGE_REQUIRED_ASTERISK ??\n\t\t\t\t\t\tt(\"blog.forms.featuredImageRequiredAsterisk\", \" *\")}\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n\n\t// When a custom imageInput component is provided via overrides, delegate to it.\n\tif (ImageInput) {\n\t\treturn (\n\t\t\t\n\t\t\t\t{label}\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\tconst handleImageUpload = async (\n\t\tevent: React.ChangeEvent,\n\t) => {\n\t\tconst file = event.target.files?.[0];\n\t\tif (!file) return;\n\n\t\tif (!file.type.startsWith(\"image/\")) {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.BLOG_FORMS_FEATURED_IMAGE_ERROR_NOT_IMAGE ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"blog.forms.featuredImageErrorNotImage\",\n\t\t\t\t\t\t\"Please select an image file\",\n\t\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tif (file.size > 4 * 1024 * 1024) {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.BLOG_FORMS_FEATURED_IMAGE_ERROR_TOO_LARGE ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"blog.forms.featuredImageErrorTooLarge\",\n\t\t\t\t\t\t\"Image size must be less than 4MB\",\n\t\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tsetIsUploading(true);\n\t\t\tsetFeaturedImageUploading(true);\n\t\t\tconst url = await uploadImage(file);\n\t\t\tonChange(url);\n\t\t\tnotify.success(\n\t\t\t\tlocalization?.BLOG_FORMS_FEATURED_IMAGE_TOAST_SUCCESS ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"blog.forms.featuredImageToastSuccess\",\n\t\t\t\t\t\t\"Image uploaded successfully\",\n\t\t\t\t\t),\n\t\t\t);\n\t\t} catch (error) {\n\t\t\tconsole.error(\"Failed to upload image:\", error);\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.BLOG_FORMS_FEATURED_IMAGE_TOAST_FAILURE ??\n\t\t\t\t\tt(\"blog.forms.featuredImageToastFailure\", \"Failed to upload image\"),\n\t\t\t);\n\t\t} finally {\n\t\t\tsetIsUploading(false);\n\t\t\tsetFeaturedImageUploading(false);\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t\t\t{label}\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t onChange(e.target.value)}\n\t\t\t\t\t\t\tdisabled={isUploading}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t fileInputRef.current?.click()}\n\t\t\t\t\t\t\tdisabled={isUploading}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{isUploading ? (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.BLOG_FORMS_FEATURED_IMAGE_UPLOADING_BUTTON ??\n\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\"blog.forms.featuredImageUploadingButton\",\n\t\t\t\t\t\t\t\t\t\t\t\"Uploading...\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.BLOG_FORMS_FEATURED_IMAGE_UPLOAD_BUTTON ??\n\t\t\t\t\t\t\t\t\t\tt(\"blog.forms.featuredImageUploadButton\", \"Upload\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t{isUploading && (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.BLOG_FORMS_FEATURED_IMAGE_UPLOADING_TEXT ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"blog.forms.featuredImageUploadingText\",\n\t\t\t\t\t\t\t\t\t\"Uploading image...\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t\t{value && !isUploading && (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n\nfunction DefaultImage({\n\tsrc,\n\talt,\n\tclassName,\n\twidth,\n\theight,\n}: {\n\tsrc: string;\n\talt: string;\n\tclassName?: string;\n\twidth?: number;\n\theight?: number;\n}) {\n\treturn (\n\t\t\n\t);\n}\n", "target": "src/components/btst/blog/client/components/forms/image-field.tsx" }, { @@ -70,7 +70,7 @@ { "path": "btst/blog/client/components/forms/markdown-editor-with-overrides.tsx", "type": "registry:component", - "content": "\"use client\";\nimport { useCallback, useRef } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { MarkdownEditor, type MarkdownEditorProps } from \"./markdown-editor\";\n\ntype MarkdownEditorWithOverridesProps = Omit<\n\tMarkdownEditorProps,\n\t| \"uploadImage\"\n\t| \"placeholder\"\n\t| \"insertImageRef\"\n\t| \"openMediaPickerForImageBlock\"\n>;\n\nexport function MarkdownEditorWithOverrides(\n\tprops: MarkdownEditorWithOverridesProps,\n) {\n\tconst {\n\t\tuploadImage,\n\t\timagePicker: ImagePickerTrigger,\n\t\tlocalization,\n\t} = usePluginOverrides>(\n\t\t\"blog\",\n\t\t{ localization: BLOG_LOCALIZATION },\n\t);\n\n\tconst insertImageRef = useRef<((url: string) => void) | null>(null);\n\t// Holds the Crepe-image-block `setUrl` callback while the picker is open.\n\tconst pendingInsertUrlRef = useRef<((url: string) => void) | null>(null);\n\t// Ref to the trigger wrapper so we can programmatically click the picker button.\n\tconst triggerContainerRef = useRef(null);\n\n\t// Single onSelect handler for ImagePickerTrigger.\n\t// URLs returned by the media plugin are already percent-encoded at the\n\t// source (storage adapter), so no additional encoding is applied here.\n\tconst handleSelect = useCallback((url: string) => {\n\t\tif (pendingInsertUrlRef.current) {\n\t\t\t// Crepe image block flow: set the URL into the block's link input.\n\t\t\tpendingInsertUrlRef.current(url);\n\t\t\tpendingInsertUrlRef.current = null;\n\t\t} else {\n\t\t\t// Normal flow: insert image at end of markdown content.\n\t\t\tinsertImageRef.current?.(url);\n\t\t}\n\t}, []);\n\n\t// Called by MarkdownEditor's click interceptor when the user clicks a Crepe\n\t// image-block upload placeholder.\n\tconst openMediaPickerForImageBlock = useCallback(\n\t\t(setUrl: (url: string) => void) => {\n\t\t\tpendingInsertUrlRef.current = setUrl;\n\t\t\t// Programmatically click the visible picker trigger button.\n\t\t\tconst btn = triggerContainerRef.current?.querySelector(\n\t\t\t\t'[data-testid=\"open-media-picker\"]',\n\t\t\t) as HTMLButtonElement | null;\n\t\t\tbtn?.click();\n\t\t},\n\t\t[],\n\t);\n\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t{ImagePickerTrigger && (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\t\t\n\t);\n}\n", + "content": "\"use client\";\nimport { useCallback, useRef } from \"react\";\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { MarkdownEditor, type MarkdownEditorProps } from \"./markdown-editor\";\n\ntype MarkdownEditorWithOverridesProps = Omit<\n\tMarkdownEditorProps,\n\t| \"uploadImage\"\n\t| \"placeholder\"\n\t| \"insertImageRef\"\n\t| \"openMediaPickerForImageBlock\"\n>;\n\nexport function MarkdownEditorWithOverrides(\n\tprops: MarkdownEditorWithOverridesProps,\n) {\n\tconst t = useTranslate();\n\tconst {\n\t\tuploadImage,\n\t\timagePicker: ImagePickerTrigger,\n\t\tlocalization,\n\t} = usePluginOverrides(\"blog\");\n\n\tconst insertImageRef = useRef<((url: string) => void) | null>(null);\n\t// Holds the Crepe-image-block `setUrl` callback while the picker is open.\n\tconst pendingInsertUrlRef = useRef<((url: string) => void) | null>(null);\n\t// Ref to the trigger wrapper so we can programmatically click the picker button.\n\tconst triggerContainerRef = useRef(null);\n\n\t// Single onSelect handler for ImagePickerTrigger.\n\t// URLs returned by the media plugin are already percent-encoded at the\n\t// source (storage adapter), so no additional encoding is applied here.\n\tconst handleSelect = useCallback((url: string) => {\n\t\tif (pendingInsertUrlRef.current) {\n\t\t\t// Crepe image block flow: set the URL into the block's link input.\n\t\t\tpendingInsertUrlRef.current(url);\n\t\t\tpendingInsertUrlRef.current = null;\n\t\t} else {\n\t\t\t// Normal flow: insert image at end of markdown content.\n\t\t\tinsertImageRef.current?.(url);\n\t\t}\n\t}, []);\n\n\t// Called by MarkdownEditor's click interceptor when the user clicks a Crepe\n\t// image-block upload placeholder.\n\tconst openMediaPickerForImageBlock = useCallback(\n\t\t(setUrl: (url: string) => void) => {\n\t\t\tpendingInsertUrlRef.current = setUrl;\n\t\t\t// Programmatically click the visible picker trigger button.\n\t\t\tconst btn = triggerContainerRef.current?.querySelector(\n\t\t\t\t'[data-testid=\"open-media-picker\"]',\n\t\t\t) as HTMLButtonElement | null;\n\t\t\tbtn?.click();\n\t\t},\n\t\t[],\n\t);\n\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t{ImagePickerTrigger && (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\t\t\n\t);\n}\n", "target": "src/components/btst/blog/client/components/forms/markdown-editor-with-overrides.tsx" }, { @@ -82,13 +82,13 @@ { "path": "btst/blog/client/components/forms/post-forms.tsx", "type": "registry:component", - "content": "\"use client\";\nimport {\n\tcreatePostSchema as PostCreateSchema,\n\tupdatePostSchema as PostUpdateSchema,\n} from \"../../../schemas\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport {\n\tForm,\n\tFormControl,\n\tFormDescription,\n\tFormField,\n\tFormItem,\n\tFormLabel,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\n\nimport { Switch } from \"@/components/ui/switch\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport {\n\tuseSuspensePost,\n\tuseDeletePost,\n\tusePostForm,\n} from \"@btst/stack/plugins/blog/client/hooks\";\nimport { slugify } from \"../../../utils\";\nimport type { SerializedPost } from \"../../../types\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n\tAlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Loader2 } from \"lucide-react\";\nimport { lazy, memo, Suspense, useEffect, useRef, useState } from \"react\";\nimport {\n\ttype FieldPath,\n\ttype FieldValues,\n\ttype SubmitHandler,\n\ttype UseFormReturn,\n\tuseForm,\n} from \"react-hook-form\";\nimport { z } from \"zod\";\nimport { FeaturedImageField } from \"./image-field\";\n\nconst MarkdownEditor = lazy(() =>\n\timport(\"./markdown-editor-with-overrides\").then((module) => ({\n\t\tdefault: module.MarkdownEditorWithOverrides,\n\t})),\n);\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { useNotify, usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { EmptyList } from \"../shared/empty-list\";\nimport { TagsMultiSelect } from \"./tags-multiselect\";\n\n/**\n * Applies server-side field validation errors (from `StackError.errors`)\n * onto react-hook-form field state, and clears previously applied server\n * errors that are no longer present (e.g. after a resubmit fails on\n * different fields).\n */\nfunction useServerFieldErrors(\n\tform: UseFormReturn,\n\tfieldErrors: Record,\n) {\n\tconst appliedFieldsRef = useRef([]);\n\n\tuseEffect(() => {\n\t\t// Clear stale server errors from fields that are no longer failing\n\t\tfor (const field of appliedFieldsRef.current) {\n\t\t\tif (field in fieldErrors) continue;\n\t\t\tconst { error } = form.getFieldState(field as FieldPath);\n\t\t\tif (error?.type === \"server\") {\n\t\t\t\tform.clearErrors(field as FieldPath);\n\t\t\t}\n\t\t}\n\t\tappliedFieldsRef.current = Object.keys(fieldErrors);\n\n\t\tfor (const [field, message] of Object.entries(fieldErrors)) {\n\t\t\tform.setError(field as FieldPath, {\n\t\t\t\ttype: \"server\",\n\t\t\t\tmessage: Array.isArray(message) ? message.join(\", \") : message,\n\t\t\t});\n\t\t}\n\t}, [fieldErrors, form]);\n}\n\ntype CommonPostFormValues = {\n\ttitle: string;\n\tcontent: string;\n\texcerpt?: string;\n\tslug?: string;\n\timage?: string;\n\tpublished?: boolean;\n\ttags?: Array<{ name: string } | { id: string; name: string; slug: string }>;\n};\n\nfunction PostFormBody({\n\tform,\n\tonSubmit,\n\tsubmitLabel,\n\tonCancel,\n\tdisabled,\n\terrorMessage,\n\tsetFeaturedImageUploading,\n\tinitialSlugTouched = false,\n}: {\n\tform: UseFormReturn;\n\tonSubmit: SubmitHandler;\n\tsubmitLabel: string;\n\tonCancel: () => void;\n\tdisabled: boolean;\n\terrorMessage?: string;\n\tsetFeaturedImageUploading: (uploading: boolean) => void;\n\tinitialSlugTouched?: boolean;\n}) {\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst [slugTouched, setSlugTouched] = useState(initialSlugTouched);\n\tconst nameTitle = \"title\" as FieldPath;\n\tconst nameSlug = \"slug\" as FieldPath;\n\tconst nameExcerpt = \"excerpt\" as FieldPath;\n\tconst nameImage = \"image\" as FieldPath;\n\tconst nameTags = \"tags\" as FieldPath;\n\tconst nameContent = \"content\" as FieldPath;\n\tconst namePublished = \"published\" as FieldPath;\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\t{errorMessage && (\n\t\t\t\t\t
\n\t\t\t\t\t\t{errorMessage}\n\t\t\t\t\t
\n\t\t\t\t)}\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_TITLE_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\tconst newTitle = e.target.value;\n\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t// Auto-slugify title if slug is not yet set\n\t\t\t\t\t\t\t\t\t\tif (!slugTouched) {\n\t\t\t\t\t\t\t\t\t\t\t// @ts-expect-error - slugify returns string which is compatible with slug field type\n\t\t\t\t\t\t\t\t\t\t\tform.setValue(nameSlug, slugify(newTitle));\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t {\n\t\t\t\t\t\tconst currentTitle = form.getValues(nameTitle);\n\t\t\t\t\t\tconst autoGeneratedSlug = slugify(String(currentTitle ?? \"\"));\n\t\t\t\t\t\tconst currentSlug = String(field.value ?? \"\");\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_SLUG_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tconst newSlug = e.target.value;\n\t\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t\t// Only mark as touched if the user manually edited to something different from auto-generated\n\t\t\t\t\t\t\t\t\t\t\t// This allows auto-generation to continue if the slug matches what would be generated\n\t\t\t\t\t\t\t\t\t\t\tif (newSlug !== autoGeneratedSlug) {\n\t\t\t\t\t\t\t\t\t\t\t\tsetSlugTouched(true);\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t);\n\t\t\t\t\t}}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_EXCERPT_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.BLOG_FORMS_TAGS_LABEL}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_CONTENT_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tfield.onChange(content);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_PUBLISHED_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_PUBLISHED_DESCRIPTION}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{localization.BLOG_FORMS_CANCEL_BUTTON}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\n\t\t\n\t);\n}\n\nconst CustomPostCreateSchema = PostCreateSchema.omit({\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\nconst CustomPostUpdateSchema = PostUpdateSchema.omit({\n\tid: true,\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\ntype AddPostFormProps = {\n\tonClose: () => void;\n\tonSuccess: (post: { published: boolean }) => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst addPostFormPropsAreEqual = (\n\tprevProps: AddPostFormProps,\n\tnextProps: AddPostFormProps,\n): boolean => {\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst AddPostFormComponent = ({\n\tonClose,\n\tonSuccess,\n\tonFormReady,\n}: AddPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\n\t// const { uploadImage } = useBlogContext()\n\n\tconst schema = CustomPostCreateSchema;\n\n\ttype AddPostFormValues = z.input;\n\n\tconst resourceForm = usePostForm({\n\t\taction: \"create\",\n\t\tsuccessMessage: localization.BLOG_FORMS_TOAST_CREATE_SUCCESS,\n\t\ttoCreateVars: (data) => ({\n\t\t\ttitle: data.title,\n\t\t\tcontent: data.content,\n\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\t// Auto-generate slug from title if not provided\n\t\t\tslug: data.slug || slugify(data.title),\n\t\t\tpublished: data.published ?? false,\n\t\t\tpublishedAt: data.published ? new Date() : undefined,\n\t\t\timage: data.image,\n\t\t\ttags: data.tags || [],\n\t\t}),\n\t\tonSuccess: (createdPost) => {\n\t\t\t// Navigate only after mutation (including invalidation) completes\n\t\t\tonSuccess({ published: createdPost?.published ?? false });\n\t\t},\n\t});\n\n\tconst onSubmit = async (data: AddPostFormValues) => {\n\t\tawait resourceForm.submit(data);\n\t};\n\n\t// For compatibility with resolver types that require certain required fields,\n\t// cast the generics to the exact inferred input type to avoid mismatch on optional slug\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: undefined,\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t});\n\n\t// Server-side Zod validation failures land on the matching form fields\n\tuseServerFieldErrors(form, resourceForm.fieldErrors);\n\tconst hasFieldErrors = Object.keys(resourceForm.fieldErrors).length > 0;\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\treturn (\n\t\t\n\t);\n};\n\nexport const AddPostForm = memo(AddPostFormComponent, addPostFormPropsAreEqual);\n\ntype EditPostFormProps = {\n\tpostSlug: string;\n\tonClose: () => void;\n\tonSuccess: (post: { slug: string; published: boolean }) => void;\n\tonDelete?: () => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst editPostFormPropsAreEqual = (\n\tprevProps: EditPostFormProps,\n\tnextProps: EditPostFormProps,\n): boolean => {\n\tif (prevProps.postSlug !== nextProps.postSlug) return false;\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onDelete !== nextProps.onDelete) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst EditPostFormComponent = ({\n\tpostSlug,\n\tonClose,\n\tonSuccess,\n\tonDelete,\n\tonFormReady,\n}: EditPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst [deleteDialogOpen, setDeleteDialogOpen] = useState(false);\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\t// const { uploadImage } = useBlogContext()\n\n\tconst { post } = useSuspensePost(postSlug);\n\tconst notify = useNotify();\n\n\tconst schema = CustomPostUpdateSchema;\n\n\ttype EditPostFormValues = z.input;\n\n\tconst resourceForm = usePostForm({\n\t\taction: \"edit\",\n\t\t// Record comes from the suspense hook above — skips useForm's own fetch\n\t\trecord: post,\n\t\tsuccessMessage: localization.BLOG_FORMS_TOAST_UPDATE_SUCCESS,\n\t\tdefaults: (record) =>\n\t\t\t(record\n\t\t\t\t? {\n\t\t\t\t\t\ttitle: record.title,\n\t\t\t\t\t\tcontent: record.content,\n\t\t\t\t\t\texcerpt: record.excerpt,\n\t\t\t\t\t\tslug: record.slug,\n\t\t\t\t\t\tpublished: record.published,\n\t\t\t\t\t\timage: record.image || \"\",\n\t\t\t\t\t\ttags: record.tags.map((tag) => ({\n\t\t\t\t\t\t\tid: tag.id,\n\t\t\t\t\t\t\tname: tag.name,\n\t\t\t\t\t\t\tslug: tag.slug,\n\t\t\t\t\t\t})),\n\t\t\t\t\t}\n\t\t\t\t: {}) as EditPostFormValues,\n\t\ttoUpdateVars: (data, record) => ({\n\t\t\tid: (record as SerializedPost).id,\n\t\t\tdata: {\n\t\t\t\tid: (record as SerializedPost).id,\n\t\t\t\ttitle: data.title,\n\t\t\t\tcontent: data.content,\n\t\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\t\tslug: data.slug,\n\t\t\t\tpublished: data.published ?? false,\n\t\t\t\tpublishedAt:\n\t\t\t\t\tdata.published && !record?.published\n\t\t\t\t\t\t? new Date()\n\t\t\t\t\t\t: record?.publishedAt\n\t\t\t\t\t\t\t? new Date(record.publishedAt)\n\t\t\t\t\t\t\t: undefined,\n\t\t\t\timage: data.image,\n\t\t\t\ttags: data.tags || [],\n\t\t\t},\n\t\t}),\n\t\tonSuccess: (updatedPost) => {\n\t\t\t// Navigate only after mutation (including invalidation) completes\n\t\t\tonSuccess({\n\t\t\t\tslug: updatedPost?.slug ?? \"\",\n\t\t\t\tpublished: updatedPost?.published ?? false,\n\t\t\t});\n\t\t},\n\t});\n\n\tconst { mutateAsync: deletePost, isPending: isDeletingPost } =\n\t\tuseDeletePost();\n\n\tconst onSubmit = async (data: EditPostFormValues) => {\n\t\tawait resourceForm.submit(data);\n\t};\n\n\tconst handleDelete = async () => {\n\t\tif (!post?.id) return;\n\n\t\ttry {\n\t\t\tawait deletePost({ id: post.id });\n\t\t} catch (error) {\n\t\t\tnotify.error(\n\t\t\t\terror instanceof Error\n\t\t\t\t\t? error.message\n\t\t\t\t\t: localization.BLOG_FORMS_TOAST_DELETE_FAILURE,\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(localization.BLOG_FORMS_TOAST_DELETE_SUCCESS);\n\t\tsetDeleteDialogOpen(false);\n\n\t\t// Call onDelete callback if provided, otherwise use onClose\n\t\tif (onDelete) {\n\t\t\tonDelete();\n\t\t} else {\n\t\t\tonClose();\n\t\t}\n\t};\n\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: \"\",\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t\tvalues: resourceForm.defaultValues as z.input,\n\t});\n\n\t// Server-side Zod validation failures land on the matching form fields\n\tuseServerFieldErrors(form, resourceForm.fieldErrors);\n\tconst hasFieldErrors = Object.keys(resourceForm.fieldErrors).length > 0;\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\tif (!post) {\n\t\treturn ;\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_BUTTON}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_TITLE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_DESCRIPTION}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_CANCEL}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\t\tvoid handleDelete();\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tdisabled={isDeletingPost}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isDeletingPost\n\t\t\t\t\t\t\t\t\t? localization.BLOG_FORMS_DELETE_PENDING\n\t\t\t\t\t\t\t\t\t: localization.BLOG_FORMS_DELETE_DIALOG_CONFIRM}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\t\t\n\t);\n};\n\nexport const EditPostForm = memo(\n\tEditPostFormComponent,\n\teditPostFormPropsAreEqual,\n);\n", + "content": "\"use client\";\nimport {\n\tcreatePostSchema as PostCreateSchema,\n\tupdatePostSchema as PostUpdateSchema,\n} from \"../../../schemas\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport {\n\tForm,\n\tFormControl,\n\tFormDescription,\n\tFormField,\n\tFormItem,\n\tFormLabel,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\n\nimport { Switch } from \"@/components/ui/switch\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport {\n\tuseSuspensePost,\n\tuseDeletePost,\n\tusePostForm,\n} from \"@btst/stack/plugins/blog/client/hooks\";\nimport { slugify } from \"../../../utils\";\nimport type { SerializedPost } from \"../../../types\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n\tAlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Loader2 } from \"lucide-react\";\nimport {\n\tlazy,\n\tmemo,\n\tSuspense,\n\tuseEffect,\n\tuseMemo,\n\tuseRef,\n\tuseState,\n} from \"react\";\nimport {\n\ttype FieldPath,\n\ttype FieldValues,\n\ttype SubmitHandler,\n\ttype UseFormReturn,\n\tuseForm,\n} from \"react-hook-form\";\nimport { z } from \"zod\";\nimport { FeaturedImageField } from \"./image-field\";\n\nconst MarkdownEditor = lazy(() =>\n\timport(\"./markdown-editor-with-overrides\").then((module) => ({\n\t\tdefault: module.MarkdownEditorWithOverrides,\n\t})),\n);\nimport {\n\tCanAccess,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseTranslate,\n\ttype TranslateFn,\n} from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { EmptyList } from \"../shared/empty-list\";\nimport { TagsMultiSelect } from \"./tags-multiselect\";\n\n/**\n * Applies server-side field validation errors (from `StackError.errors`)\n * onto react-hook-form field state, and clears previously applied server\n * errors that are no longer present (e.g. after a resubmit fails on\n * different fields).\n */\nfunction useServerFieldErrors(\n\tform: UseFormReturn,\n\tfieldErrors: Record,\n) {\n\tconst appliedFieldsRef = useRef([]);\n\n\tuseEffect(() => {\n\t\t// Clear stale server errors from fields that are no longer failing\n\t\tfor (const field of appliedFieldsRef.current) {\n\t\t\tif (field in fieldErrors) continue;\n\t\t\tconst { error } = form.getFieldState(field as FieldPath);\n\t\t\tif (error?.type === \"server\") {\n\t\t\t\tform.clearErrors(field as FieldPath);\n\t\t\t}\n\t\t}\n\t\tappliedFieldsRef.current = Object.keys(fieldErrors);\n\n\t\tfor (const [field, message] of Object.entries(fieldErrors)) {\n\t\t\tform.setError(field as FieldPath, {\n\t\t\t\ttype: \"server\",\n\t\t\t\tmessage: Array.isArray(message) ? message.join(\", \") : message,\n\t\t\t});\n\t\t}\n\t}, [fieldErrors, form]);\n}\n\ntype CommonPostFormValues = {\n\ttitle: string;\n\tcontent: string;\n\texcerpt?: string;\n\tslug?: string;\n\timage?: string;\n\tpublished?: boolean;\n\ttags?: Array<{ name: string } | { id: string; name: string; slug: string }>;\n};\n\nfunction PostFormBody({\n\tform,\n\tonSubmit,\n\tsubmitLabel,\n\tonCancel,\n\tdisabled,\n\terrorMessage,\n\tsetFeaturedImageUploading,\n\tinitialSlugTouched = false,\n}: {\n\tform: UseFormReturn;\n\tonSubmit: SubmitHandler;\n\tsubmitLabel: string;\n\tonCancel: () => void;\n\tdisabled: boolean;\n\terrorMessage?: string;\n\tsetFeaturedImageUploading: (uploading: boolean) => void;\n\tinitialSlugTouched?: boolean;\n}) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\"blog\");\n\tconst [slugTouched, setSlugTouched] = useState(initialSlugTouched);\n\tconst requiredAsterisk =\n\t\tlocalization?.BLOG_FORMS_REQUIRED_ASTERISK ??\n\t\tt(\"blog.forms.requiredAsterisk\", \" *\");\n\tconst nameTitle = \"title\" as FieldPath;\n\tconst nameSlug = \"slug\" as FieldPath;\n\tconst nameExcerpt = \"excerpt\" as FieldPath;\n\tconst nameImage = \"image\" as FieldPath;\n\tconst nameTags = \"tags\" as FieldPath;\n\tconst nameContent = \"content\" as FieldPath;\n\tconst namePublished = \"published\" as FieldPath;\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\t{errorMessage && (\n\t\t\t\t\t
\n\t\t\t\t\t\t{errorMessage}\n\t\t\t\t\t
\n\t\t\t\t)}\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.BLOG_FORMS_TITLE_LABEL ??\n\t\t\t\t\t\t\t\t\tt(\"blog.forms.titleLabel\", \"Title\")}\n\t\t\t\t\t\t\t\t{requiredAsterisk}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\tconst newTitle = e.target.value;\n\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t// Auto-slugify title if slug is not yet set\n\t\t\t\t\t\t\t\t\t\tif (!slugTouched) {\n\t\t\t\t\t\t\t\t\t\t\t// @ts-expect-error - slugify returns string which is compatible with slug field type\n\t\t\t\t\t\t\t\t\t\t\tform.setValue(nameSlug, slugify(newTitle));\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t {\n\t\t\t\t\t\tconst currentTitle = form.getValues(nameTitle);\n\t\t\t\t\t\tconst autoGeneratedSlug = slugify(String(currentTitle ?? \"\"));\n\t\t\t\t\t\tconst currentSlug = String(field.value ?? \"\");\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.BLOG_FORMS_SLUG_LABEL ??\n\t\t\t\t\t\t\t\t\t\tt(\"blog.forms.slugLabel\", \"Slug\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tconst newSlug = e.target.value;\n\t\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t\t// Only mark as touched if the user manually edited to something different from auto-generated\n\t\t\t\t\t\t\t\t\t\t\t// This allows auto-generation to continue if the slug matches what would be generated\n\t\t\t\t\t\t\t\t\t\t\tif (newSlug !== autoGeneratedSlug) {\n\t\t\t\t\t\t\t\t\t\t\t\tsetSlugTouched(true);\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t);\n\t\t\t\t\t}}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.BLOG_FORMS_EXCERPT_LABEL ??\n\t\t\t\t\t\t\t\t\tt(\"blog.forms.excerptLabel\", \"Excerpt\")}\n\t\t\t\t\t\t\t\t{requiredAsterisk}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.BLOG_FORMS_TAGS_LABEL ??\n\t\t\t\t\t\t\t\t\tt(\"blog.forms.tagsLabel\", \"Tags\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.BLOG_FORMS_CONTENT_LABEL ??\n\t\t\t\t\t\t\t\t\tt(\"blog.forms.contentLabel\", \"Content\")}\n\t\t\t\t\t\t\t\t{requiredAsterisk}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tfield.onChange(content);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.BLOG_FORMS_PUBLISHED_LABEL ??\n\t\t\t\t\t\t\t\t\t\tt(\"blog.forms.publishedLabel\", \"Published\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.BLOG_FORMS_PUBLISHED_DESCRIPTION ??\n\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\"blog.forms.publishedDescription\",\n\t\t\t\t\t\t\t\t\t\t\t\"Toggle to publish immediately\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{localization?.BLOG_FORMS_CANCEL_BUTTON ??\n\t\t\t\t\t\t\tt(\"blog.forms.cancelButton\", \"Cancel\")}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\n\t\t\n\t);\n}\n\nconst CustomPostCreateSchema = PostCreateSchema.omit({\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\nconst CustomPostUpdateSchema = PostUpdateSchema.omit({\n\tid: true,\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\n/**\n * Required-field validators with translatable messages for the client-side\n * resolver. Server-side validation (`schemas.ts`) is unchanged.\n */\nfunction localizedRequiredFields(t: TranslateFn) {\n\treturn {\n\t\ttitle: z\n\t\t\t.string()\n\t\t\t.min(1, t(\"blog.forms.validation.titleRequired\", \"Title is required\")),\n\t\tcontent: z\n\t\t\t.string()\n\t\t\t.min(\n\t\t\t\t1,\n\t\t\t\tt(\"blog.forms.validation.contentRequired\", \"Content is required\"),\n\t\t\t),\n\t\texcerpt: z\n\t\t\t.string()\n\t\t\t.min(\n\t\t\t\t1,\n\t\t\t\tt(\"blog.forms.validation.excerptRequired\", \"Excerpt is required\"),\n\t\t\t),\n\t\tslug: z\n\t\t\t.string()\n\t\t\t.min(1, t(\"blog.forms.validation.slugRequired\", \"Slug is required\")),\n\t};\n}\n\ntype AddPostFormProps = {\n\tonClose: () => void;\n\tonSuccess: (post: { published: boolean }) => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst addPostFormPropsAreEqual = (\n\tprevProps: AddPostFormProps,\n\tnextProps: AddPostFormProps,\n): boolean => {\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst AddPostFormComponent = ({\n\tonClose,\n\tonSuccess,\n\tonFormReady,\n}: AddPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\"blog\");\n\n\tconst schema = useMemo(() => {\n\t\tconst required = localizedRequiredFields(t);\n\t\treturn CustomPostCreateSchema.extend({\n\t\t\t...required,\n\t\t\tslug: required.slug.optional(),\n\t\t});\n\t}, [t]);\n\n\ttype AddPostFormValues = z.input;\n\n\tconst resourceForm = usePostForm({\n\t\taction: \"create\",\n\t\tsuccessMessage:\n\t\t\tlocalization?.BLOG_FORMS_TOAST_CREATE_SUCCESS ??\n\t\t\tt(\"blog.forms.toastCreateSuccess\", \"Post created successfully\"),\n\t\ttoCreateVars: (data) => ({\n\t\t\ttitle: data.title,\n\t\t\tcontent: data.content,\n\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\t// Auto-generate slug from title if not provided\n\t\t\tslug: data.slug || slugify(data.title),\n\t\t\tpublished: data.published ?? false,\n\t\t\tpublishedAt: data.published ? new Date() : undefined,\n\t\t\timage: data.image,\n\t\t\ttags: data.tags || [],\n\t\t}),\n\t\tonSuccess: (createdPost) => {\n\t\t\t// Navigate only after mutation (including invalidation) completes\n\t\t\tonSuccess({ published: createdPost?.published ?? false });\n\t\t},\n\t});\n\n\tconst onSubmit = async (data: AddPostFormValues) => {\n\t\tawait resourceForm.submit(data);\n\t};\n\n\t// For compatibility with resolver types that require certain required fields,\n\t// cast the generics to the exact inferred input type to avoid mismatch on optional slug\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: undefined,\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t});\n\n\t// Server-side Zod validation failures land on the matching form fields\n\tuseServerFieldErrors(form, resourceForm.fieldErrors);\n\tconst hasFieldErrors = Object.keys(resourceForm.fieldErrors).length > 0;\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\treturn (\n\t\t\n\t);\n};\n\nexport const AddPostForm = memo(AddPostFormComponent, addPostFormPropsAreEqual);\n\ntype EditPostFormProps = {\n\tpostSlug: string;\n\tonClose: () => void;\n\tonSuccess: (post: { slug: string; published: boolean }) => void;\n\tonDelete?: () => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst editPostFormPropsAreEqual = (\n\tprevProps: EditPostFormProps,\n\tnextProps: EditPostFormProps,\n): boolean => {\n\tif (prevProps.postSlug !== nextProps.postSlug) return false;\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onDelete !== nextProps.onDelete) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst EditPostFormComponent = ({\n\tpostSlug,\n\tonClose,\n\tonSuccess,\n\tonDelete,\n\tonFormReady,\n}: EditPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst [deleteDialogOpen, setDeleteDialogOpen] = useState(false);\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\"blog\");\n\n\tconst { post } = useSuspensePost(postSlug);\n\tconst notify = useNotify();\n\n\tconst schema = useMemo(\n\t\t() => CustomPostUpdateSchema.extend(localizedRequiredFields(t)),\n\t\t[t],\n\t);\n\n\ttype EditPostFormValues = z.input;\n\n\tconst resourceForm = usePostForm({\n\t\taction: \"edit\",\n\t\t// Record comes from the suspense hook above — skips useForm's own fetch\n\t\trecord: post,\n\t\tsuccessMessage:\n\t\t\tlocalization?.BLOG_FORMS_TOAST_UPDATE_SUCCESS ??\n\t\t\tt(\"blog.forms.toastUpdateSuccess\", \"Post updated successfully\"),\n\t\tdefaults: (record) =>\n\t\t\t(record\n\t\t\t\t? {\n\t\t\t\t\t\ttitle: record.title,\n\t\t\t\t\t\tcontent: record.content,\n\t\t\t\t\t\texcerpt: record.excerpt,\n\t\t\t\t\t\tslug: record.slug,\n\t\t\t\t\t\tpublished: record.published,\n\t\t\t\t\t\timage: record.image || \"\",\n\t\t\t\t\t\ttags: record.tags.map((tag) => ({\n\t\t\t\t\t\t\tid: tag.id,\n\t\t\t\t\t\t\tname: tag.name,\n\t\t\t\t\t\t\tslug: tag.slug,\n\t\t\t\t\t\t})),\n\t\t\t\t\t}\n\t\t\t\t: {}) as EditPostFormValues,\n\t\ttoUpdateVars: (data, record) => ({\n\t\t\tid: (record as SerializedPost).id,\n\t\t\tdata: {\n\t\t\t\tid: (record as SerializedPost).id,\n\t\t\t\ttitle: data.title,\n\t\t\t\tcontent: data.content,\n\t\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\t\tslug: data.slug,\n\t\t\t\tpublished: data.published ?? false,\n\t\t\t\tpublishedAt:\n\t\t\t\t\tdata.published && !record?.published\n\t\t\t\t\t\t? new Date()\n\t\t\t\t\t\t: record?.publishedAt\n\t\t\t\t\t\t\t? new Date(record.publishedAt)\n\t\t\t\t\t\t\t: undefined,\n\t\t\t\timage: data.image,\n\t\t\t\ttags: data.tags || [],\n\t\t\t},\n\t\t}),\n\t\tonSuccess: (updatedPost) => {\n\t\t\t// Navigate only after mutation (including invalidation) completes\n\t\t\tonSuccess({\n\t\t\t\tslug: updatedPost?.slug ?? \"\",\n\t\t\t\tpublished: updatedPost?.published ?? false,\n\t\t\t});\n\t\t},\n\t});\n\n\tconst { mutateAsync: deletePost, isPending: isDeletingPost } =\n\t\tuseDeletePost();\n\n\tconst onSubmit = async (data: EditPostFormValues) => {\n\t\tawait resourceForm.submit(data);\n\t};\n\n\tconst handleDelete = async () => {\n\t\tif (!post?.id) return;\n\n\t\ttry {\n\t\t\tawait deletePost({ id: post.id });\n\t\t} catch (error) {\n\t\t\tnotify.error(\n\t\t\t\terror instanceof Error\n\t\t\t\t\t? error.message\n\t\t\t\t\t: (localization?.BLOG_FORMS_TOAST_DELETE_FAILURE ??\n\t\t\t\t\t\t\tt(\"blog.forms.toastDeleteFailure\", \"Failed to delete post\")),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(\n\t\t\tlocalization?.BLOG_FORMS_TOAST_DELETE_SUCCESS ??\n\t\t\t\tt(\"blog.forms.toastDeleteSuccess\", \"Post deleted successfully\"),\n\t\t);\n\t\tsetDeleteDialogOpen(false);\n\n\t\t// Call onDelete callback if provided, otherwise use onClose\n\t\tif (onDelete) {\n\t\t\tonDelete();\n\t\t} else {\n\t\t\tonClose();\n\t\t}\n\t};\n\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: \"\",\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t\tvalues: resourceForm.defaultValues as z.input,\n\t});\n\n\t// Server-side Zod validation failures land on the matching form fields\n\tuseServerFieldErrors(form, resourceForm.fieldErrors);\n\tconst hasFieldErrors = Object.keys(resourceForm.fieldErrors).length > 0;\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\tif (!post) {\n\t\treturn (\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.BLOG_FORMS_DELETE_BUTTON ??\n\t\t\t\t\t\t\t\t\tt(\"blog.forms.deleteButton\", \"Delete Post\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.BLOG_FORMS_DELETE_DIALOG_TITLE ??\n\t\t\t\t\t\t\t\t\t\tt(\"blog.forms.deleteDialogTitle\", \"Delete Post\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.BLOG_FORMS_DELETE_DIALOG_DESCRIPTION ??\n\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\"blog.forms.deleteDialogDescription\",\n\t\t\t\t\t\t\t\t\t\t\t\"Are you sure you want to delete this post? This action cannot be undone.\",\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.BLOG_FORMS_DELETE_DIALOG_CANCEL ??\n\t\t\t\t\t\t\t\t\t\tt(\"blog.forms.deleteDialogCancel\", \"Cancel\")}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\t\t\tvoid handleDelete();\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tdisabled={isDeletingPost}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{isDeletingPost\n\t\t\t\t\t\t\t\t\t\t? (localization?.BLOG_FORMS_DELETE_PENDING ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"blog.forms.deletePending\", \"Deleting...\"))\n\t\t\t\t\t\t\t\t\t\t: (localization?.BLOG_FORMS_DELETE_DIALOG_CONFIRM ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"blog.forms.deleteDialogConfirm\", \"Delete\"))}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t
\n\t\t\n\t);\n};\n\nexport const EditPostForm = memo(\n\tEditPostFormComponent,\n\teditPostFormPropsAreEqual,\n);\n", "target": "src/components/btst/blog/client/components/forms/post-forms.tsx" }, { "path": "btst/blog/client/components/forms/tags-multiselect.tsx", "type": "registry:component", - "content": "import MultipleSelector, {\n\ttype Option,\n} from \"@/components/ui/multi-select\";\nimport { useTags } from \"@btst/stack/plugins/blog/client/hooks\";\nimport type { SerializedTag } from \"../../../types\";\n\nexport function TagsMultiSelect({\n\tvalue,\n\tonChange,\n\tplaceholder,\n}: {\n\tvalue: Array<{ name: string } | { id: string; name: string; slug: string }>;\n\tonChange: (\n\t\tvalue: Array<{ name: string } | { id: string; name: string; slug: string }>,\n\t) => void;\n\tplaceholder?: string;\n}) {\n\tconst { tags } = useTags();\n\n\tconst tagMap = new Map();\n\tconst idToTagMap = new Map();\n\t(tags || []).forEach((tag) => {\n\t\ttagMap.set(tag.name.toLowerCase(), tag);\n\t\ttagMap.set(tag.slug, tag);\n\t\tidToTagMap.set(tag.id, tag);\n\t});\n\n\tconst options: Option[] = (tags || []).map((tag) => ({\n\t\tvalue: tag.id,\n\t\tlabel: tag.name,\n\t}));\n\n\tconst selectedOptions: Option[] = (value || []).map((tag) => {\n\t\tif (\"id\" in tag && tag.id) {\n\t\t\treturn {\n\t\t\t\tvalue: tag.id,\n\t\t\t\tlabel: tag.name,\n\t\t\t};\n\t\t}\n\t\tconst existingTag = tagMap.get(tag.name.toLowerCase());\n\t\treturn {\n\t\t\tvalue: existingTag?.id || tag.name,\n\t\t\tlabel: tag.name,\n\t\t};\n\t});\n\n\tconst handleChange = (newOptions: Option[]) => {\n\t\tconst tagObjects = newOptions.map((option) => {\n\t\t\tconst existingTag =\n\t\t\t\tidToTagMap.get(option.value) ||\n\t\t\t\tArray.from(tagMap.values()).find(\n\t\t\t\t\t(tag) => tag.name.toLowerCase() === option.value.toLowerCase(),\n\t\t\t\t);\n\n\t\t\tif (existingTag) {\n\t\t\t\treturn {\n\t\t\t\t\tid: existingTag.id,\n\t\t\t\t\tname: existingTag.name,\n\t\t\t\t\tslug: existingTag.slug,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\treturn { name: option.value };\n\t\t});\n\t\tonChange(tagObjects);\n\t};\n\n\treturn (\n\t\t\n\t);\n}\n", + "content": "import MultipleSelector, {\n\ttype Option,\n} from \"@/components/ui/multi-select\";\nimport { useTranslate } from \"@btst/stack/context\";\nimport { useTags } from \"@btst/stack/plugins/blog/client/hooks\";\nimport type { SerializedTag } from \"../../../types\";\n\nexport function TagsMultiSelect({\n\tvalue,\n\tonChange,\n\tplaceholder,\n}: {\n\tvalue: Array<{ name: string } | { id: string; name: string; slug: string }>;\n\tonChange: (\n\t\tvalue: Array<{ name: string } | { id: string; name: string; slug: string }>,\n\t) => void;\n\tplaceholder?: string;\n}) {\n\tconst t = useTranslate();\n\tconst { tags } = useTags();\n\n\tconst tagMap = new Map();\n\tconst idToTagMap = new Map();\n\t(tags || []).forEach((tag) => {\n\t\ttagMap.set(tag.name.toLowerCase(), tag);\n\t\ttagMap.set(tag.slug, tag);\n\t\tidToTagMap.set(tag.id, tag);\n\t});\n\n\tconst options: Option[] = (tags || []).map((tag) => ({\n\t\tvalue: tag.id,\n\t\tlabel: tag.name,\n\t}));\n\n\tconst selectedOptions: Option[] = (value || []).map((tag) => {\n\t\tif (\"id\" in tag && tag.id) {\n\t\t\treturn {\n\t\t\t\tvalue: tag.id,\n\t\t\t\tlabel: tag.name,\n\t\t\t};\n\t\t}\n\t\tconst existingTag = tagMap.get(tag.name.toLowerCase());\n\t\treturn {\n\t\t\tvalue: existingTag?.id || tag.name,\n\t\t\tlabel: tag.name,\n\t\t};\n\t});\n\n\tconst handleChange = (newOptions: Option[]) => {\n\t\tconst tagObjects = newOptions.map((option) => {\n\t\t\tconst existingTag =\n\t\t\t\tidToTagMap.get(option.value) ||\n\t\t\t\tArray.from(tagMap.values()).find(\n\t\t\t\t\t(tag) => tag.name.toLowerCase() === option.value.toLowerCase(),\n\t\t\t\t);\n\n\t\t\tif (existingTag) {\n\t\t\t\treturn {\n\t\t\t\t\tid: existingTag.id,\n\t\t\t\t\tname: existingTag.name,\n\t\t\t\t\tslug: existingTag.slug,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\treturn { name: option.value };\n\t\t});\n\t\tonChange(tagObjects);\n\t};\n\n\treturn (\n\t\t\n\t);\n}\n", "target": "src/components/btst/blog/client/components/forms/tags-multiselect.tsx" }, { @@ -142,19 +142,19 @@ { "path": "btst/blog/client/components/pages/404-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { ErrorPlaceholder } from \"../shared/error-placeholder\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\n\nexport function NotFoundPage({ message }: { message: string }) {\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst title = localization.BLOG_PAGE_NOT_FOUND_TITLE;\n\tconst desc = message || localization.BLOG_PAGE_NOT_FOUND_DESCRIPTION;\n\treturn (\n\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport { ErrorPlaceholder } from \"../shared/error-placeholder\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\n\nexport function NotFoundPage({ message }: { message: string }) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\"blog\");\n\tconst title =\n\t\tlocalization?.BLOG_PAGE_NOT_FOUND_TITLE ??\n\t\tt(\"blog.common.pageNotFoundTitle\", \"Not Found\");\n\tconst desc =\n\t\tmessage ||\n\t\t(localization?.BLOG_PAGE_NOT_FOUND_DESCRIPTION ??\n\t\t\tt(\n\t\t\t\t\"blog.common.pageNotFoundDescription\",\n\t\t\t\t\"The page you are looking for does not exist.\",\n\t\t\t));\n\treturn (\n\t\t\n\t\t\t\n\t\t\n\t);\n}\n", "target": "src/components/btst/blog/client/components/pages/404-page.tsx" }, { "path": "btst/blog/client/components/pages/edit-post-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useBasePath, usePluginOverrides } from \"@btst/stack/context\";\nimport { EditPostForm } from \"../forms/post-forms\";\nimport { PageHeader } from \"../shared/page-header\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\nimport { useRef, useCallback } from \"react\";\nimport type { UseFormReturn } from \"react-hook-form\";\nimport { createFillBlogFormHandler } from \"./fill-blog-form-handler\";\n\n// Internal component with actual page content\nexport function EditPostPage({ slug }: { slug: string }) {\n\tconst overrides = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst { localization, navigate } = overrides;\n\tconst basePath = useBasePath();\n\n\t// Call lifecycle hooks\n\tuseRouteLifecycle({\n\t\trouteName: \"editPost\",\n\t\tcontext: {\n\t\t\tpath: `/blog/${slug}/edit`,\n\t\t\tparams: { slug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (overrides.onBeforeEditPostPageRendered) {\n\t\t\t\treturn overrides.onBeforeEditPostPageRendered(slug, context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\t// Ref to capture the form instance from EditPostForm via onFormReady callback\n\tconst formRef = useRef | null>(null);\n\tconst handleFormReady = useCallback((form: UseFormReturn) => {\n\t\tformRef.current = form;\n\t}, []);\n\n\t// Register AI context so the chat can fill in the edit form\n\tuseRegisterPageAIContext({\n\t\trouteName: \"blog-edit-post\",\n\t\tpageDescription: `User is editing a blog post (slug: \"${slug}\") in the admin editor.`,\n\t\tsuggestions: [\n\t\t\t\"Improve this post's title\",\n\t\t\t\"Rewrite the intro paragraph\",\n\t\t\t\"Suggest better tags\",\n\t\t],\n\t\tclientTools: {\n\t\t\tfillBlogForm: createFillBlogFormHandler(\n\t\t\t\tformRef,\n\t\t\t\t\"Form updated successfully\",\n\t\t\t),\n\t\t},\n\t});\n\n\tconst handleClose = () => {\n\t\tnavigate(`${basePath}/blog`);\n\t};\n\n\tconst handleSuccess = (post: { slug: string; published: boolean }) => {\n\t\t// Navigate based on published status\n\t\tnavigate(`${basePath}/blog/${post.slug}`);\n\t};\n\n\tconst handleDelete = () => {\n\t\t// Navigate to blog list after deletion\n\t\tnavigate(`${basePath}/blog`);\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport {\n\tuseBasePath,\n\tusePluginOverrides,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { EditPostForm } from \"../forms/post-forms\";\nimport { PageHeader } from \"../shared/page-header\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\nimport { useRef, useCallback } from \"react\";\nimport type { UseFormReturn } from \"react-hook-form\";\nimport { createFillBlogFormHandler } from \"./fill-blog-form-handler\";\n\n// Internal component with actual page content\nexport function EditPostPage({ slug }: { slug: string }) {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides(\"blog\");\n\tconst { localization, navigate } = overrides;\n\tconst basePath = useBasePath();\n\n\t// Call lifecycle hooks\n\tuseRouteLifecycle({\n\t\trouteName: \"editPost\",\n\t\tcontext: {\n\t\t\tpath: `/blog/${slug}/edit`,\n\t\t\tparams: { slug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (overrides.onBeforeEditPostPageRendered) {\n\t\t\t\treturn overrides.onBeforeEditPostPageRendered(slug, context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\t// Ref to capture the form instance from EditPostForm via onFormReady callback\n\tconst formRef = useRef | null>(null);\n\tconst handleFormReady = useCallback((form: UseFormReturn) => {\n\t\tformRef.current = form;\n\t}, []);\n\n\t// Register AI context so the chat can fill in the edit form\n\tuseRegisterPageAIContext({\n\t\trouteName: \"blog-edit-post\",\n\t\tpageDescription: `User is editing a blog post (slug: \"${slug}\") in the admin editor.`,\n\t\tsuggestions: [\n\t\t\t\"Improve this post's title\",\n\t\t\t\"Rewrite the intro paragraph\",\n\t\t\t\"Suggest better tags\",\n\t\t],\n\t\tclientTools: {\n\t\t\tfillBlogForm: createFillBlogFormHandler(\n\t\t\t\tformRef,\n\t\t\t\t\"Form updated successfully\",\n\t\t\t),\n\t\t},\n\t});\n\n\tconst handleClose = () => {\n\t\tnavigate(`${basePath}/blog`);\n\t};\n\n\tconst handleSuccess = (post: { slug: string; published: boolean }) => {\n\t\t// Navigate based on published status\n\t\tnavigate(`${basePath}/blog/${post.slug}`);\n\t};\n\n\tconst handleDelete = () => {\n\t\t// Navigate to blog list after deletion\n\t\tnavigate(`${basePath}/blog`);\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", "target": "src/components/btst/blog/client/components/pages/edit-post-page.internal.tsx" }, { "path": "btst/blog/client/components/pages/edit-post-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { FormLoading } from \"../loading\";\nimport { NotFoundPage } from \"./404-page\";\n\n// Lazy load the internal component with actual page content\nconst EditPostPage = lazy(() =>\n\timport(\"./edit-post-page.internal\").then((m) => ({\n\t\tdefault: m.EditPostPage,\n\t})),\n);\n\n// Exported wrapped component with error and loading boundaries\nexport function EditPostPageComponent({ slug }: { slug: string }) {\n\tconst { onRouteError } = usePluginOverrides(\"blog\");\n\treturn (\n\t\t {\n\t\t\t\tif (onRouteError) {\n\t\t\t\t\tonRouteError(\"editPost\", error, {\n\t\t\t\t\t\tpath: `/blog/${slug}/edit`,\n\t\t\t\t\t\tisSSR: typeof window === \"undefined\",\n\t\t\t\t\t\tslug,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}}\n\t\t/>\n\t);\n}\n", + "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { FormLoading } from \"../loading\";\nimport { NotFoundPage } from \"./404-page\";\n\n// Lazy load the internal component with actual page content\nconst EditPostPage = lazy(() =>\n\timport(\"./edit-post-page.internal\").then((m) => ({\n\t\tdefault: m.EditPostPage,\n\t})),\n);\n\n// Exported wrapped component with error and loading boundaries\nexport function EditPostPageComponent({ slug }: { slug: string }) {\n\tconst { onRouteError } = usePluginOverrides(\"blog\");\n\treturn (\n\t\t {\n\t\t\t\tif (onRouteError) {\n\t\t\t\t\tonRouteError(\"editPost\", error, {\n\t\t\t\t\t\tpath: `/blog/${slug}/edit`,\n\t\t\t\t\t\tisSSR: typeof window === \"undefined\",\n\t\t\t\t\t\tslug,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}}\n\t\t/>\n\t);\n}\n", "target": "src/components/btst/blog/client/components/pages/edit-post-page.tsx" }, { @@ -166,31 +166,31 @@ { "path": "btst/blog/client/components/pages/home-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { PageHeader } from \"../shared/page-header\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { PostsList } from \"../shared/posts-list\";\nimport { TagsList } from \"../shared/tags-list\";\n\nimport { useSuspensePosts } from \"@btst/stack/plugins/blog/client/hooks\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\n// Internal component with actual page content\nexport function HomePage({ published }: { published: boolean }) {\n\tconst overrides = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst { localization } = overrides;\n\n\t// Call lifecycle hooks\n\tuseRouteLifecycle({\n\t\trouteName: published ? \"posts\" : \"drafts\",\n\t\tcontext: {\n\t\t\tpath: published ? \"/blog\" : \"/blog/drafts\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t\tpublished,\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (published && overrides.onBeforePostsPageRendered) {\n\t\t\t\treturn overrides.onBeforePostsPageRendered(context);\n\t\t\t}\n\t\t\tif (!published && overrides.onBeforeDraftsPageRendered) {\n\t\t\t\treturn overrides.onBeforeDraftsPageRendered(context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t}\n\t\t\t\t/>\n\t\t\t
\n\t\t\t\n\t\t
\n\t);\n}\n\nfunction Content({ published }: { published: boolean }) {\n\tconst { posts, loadMore, hasMore, isLoadingMore } = useSuspensePosts({\n\t\tpublished: published,\n\t});\n\treturn (\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { PageHeader } from \"../shared/page-header\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { PostsList } from \"../shared/posts-list\";\nimport { TagsList } from \"../shared/tags-list\";\n\nimport { useSuspensePosts } from \"@btst/stack/plugins/blog/client/hooks\";\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\n// Internal component with actual page content\nexport function HomePage({ published }: { published: boolean }) {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides(\"blog\");\n\tconst { localization } = overrides;\n\n\t// Call lifecycle hooks\n\tuseRouteLifecycle({\n\t\trouteName: published ? \"posts\" : \"drafts\",\n\t\tcontext: {\n\t\t\tpath: published ? \"/blog\" : \"/blog/drafts\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t\tpublished,\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (published && overrides.onBeforePostsPageRendered) {\n\t\t\t\treturn overrides.onBeforePostsPageRendered(context);\n\t\t\t}\n\t\t\tif (!published && overrides.onBeforeDraftsPageRendered) {\n\t\t\t\treturn overrides.onBeforeDraftsPageRendered(context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t}\n\t\t\t\t/>\n\t\t\t
\n\t\t\t\n\t\t
\n\t);\n}\n\nfunction Content({ published }: { published: boolean }) {\n\tconst { posts, loadMore, hasMore, isLoadingMore } = useSuspensePosts({\n\t\tpublished: published,\n\t});\n\treturn (\n\t\t\n\t);\n}\n", "target": "src/components/btst/blog/client/components/pages/home-page.internal.tsx" }, { "path": "btst/blog/client/components/pages/home-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { PostsLoading } from \"../loading\";\nimport { NotFoundPage } from \"./404-page\";\n\n// Lazy load the internal component with actual page content\nconst HomePage = lazy(() =>\n\timport(\"./home-page.internal\").then((m) => ({ default: m.HomePage })),\n);\n\n// Exported wrapped component with error and loading boundaries\nexport function HomePageComponent({\n\tpublished = true,\n}: {\n\tpublished?: boolean;\n}) {\n\tconst { onRouteError } = usePluginOverrides(\"blog\");\n\treturn (\n\t\t {\n\t\t\t\tif (onRouteError) {\n\t\t\t\t\tonRouteError(\"posts\", error, {\n\t\t\t\t\t\tpath: published ? \"/blog\" : \"/blog/drafts\",\n\t\t\t\t\t\tisSSR: typeof window === \"undefined\",\n\t\t\t\t\t\tpublished,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}}\n\t\t/>\n\t);\n}\n", + "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { PostsLoading } from \"../loading\";\nimport { NotFoundPage } from \"./404-page\";\n\n// Lazy load the internal component with actual page content\nconst HomePage = lazy(() =>\n\timport(\"./home-page.internal\").then((m) => ({ default: m.HomePage })),\n);\n\n// Exported wrapped component with error and loading boundaries\nexport function HomePageComponent({\n\tpublished = true,\n}: {\n\tpublished?: boolean;\n}) {\n\tconst { onRouteError } = usePluginOverrides(\"blog\");\n\treturn (\n\t\t {\n\t\t\t\tif (onRouteError) {\n\t\t\t\t\tonRouteError(\"posts\", error, {\n\t\t\t\t\t\tpath: published ? \"/blog\" : \"/blog/drafts\",\n\t\t\t\t\t\tisSSR: typeof window === \"undefined\",\n\t\t\t\t\t\tpublished,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}}\n\t\t/>\n\t);\n}\n", "target": "src/components/btst/blog/client/components/pages/home-page.tsx" }, { "path": "btst/blog/client/components/pages/new-post-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useBasePath, usePluginOverrides } from \"@btst/stack/context\";\nimport { AddPostForm } from \"../forms/post-forms\";\nimport { PageHeader } from \"../shared/page-header\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\nimport { useRef, useCallback } from \"react\";\nimport type { UseFormReturn } from \"react-hook-form\";\nimport { createFillBlogFormHandler } from \"./fill-blog-form-handler\";\n\n// Internal component with actual page content\nexport function NewPostPage() {\n\tconst overrides = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst { localization, navigate } = overrides;\n\tconst basePath = useBasePath();\n\n\t// Call lifecycle hooks\n\tuseRouteLifecycle({\n\t\trouteName: \"newPost\",\n\t\tcontext: {\n\t\t\tpath: \"/blog/new\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (overrides.onBeforeNewPostPageRendered) {\n\t\t\t\treturn overrides.onBeforeNewPostPageRendered(context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\t// Ref to capture the form instance from AddPostForm via onFormReady callback\n\tconst formRef = useRef | null>(null);\n\tconst handleFormReady = useCallback((form: UseFormReturn) => {\n\t\tformRef.current = form;\n\t}, []);\n\n\t// Register AI context so the chat can fill in the new post form\n\tuseRegisterPageAIContext({\n\t\trouteName: \"blog-new-post\",\n\t\tpageDescription:\n\t\t\t\"User is creating a new blog post in the admin editor. IMPORTANT: When asked to write, draft, or create a blog post, you MUST call the fillBlogForm tool to populate the form fields directly — do NOT just output the text in your response.\",\n\t\tsuggestions: [\n\t\t\t\"Write a post about AI trends\",\n\t\t\t\"Draft an intro paragraph\",\n\t\t\t\"Suggest 5 tags for this post\",\n\t\t],\n\t\tclientTools: {\n\t\t\tfillBlogForm: createFillBlogFormHandler(\n\t\t\t\tformRef,\n\t\t\t\t\"Form filled successfully\",\n\t\t\t),\n\t\t},\n\t});\n\n\tconst handleClose = () => {\n\t\tnavigate(`${basePath}/blog`);\n\t};\n\n\tconst handleSuccess = (post: { published: boolean }) => {\n\t\t// Navigate based on published status\n\t\tif (post.published) {\n\t\t\tnavigate(`${basePath}/blog`);\n\t\t} else {\n\t\t\tnavigate(`${basePath}/blog/drafts`);\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport {\n\tuseBasePath,\n\tusePluginOverrides,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { AddPostForm } from \"../forms/post-forms\";\nimport { PageHeader } from \"../shared/page-header\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\nimport { useRef, useCallback } from \"react\";\nimport type { UseFormReturn } from \"react-hook-form\";\nimport { createFillBlogFormHandler } from \"./fill-blog-form-handler\";\n\n// Internal component with actual page content\nexport function NewPostPage() {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides(\"blog\");\n\tconst { localization, navigate } = overrides;\n\tconst basePath = useBasePath();\n\n\t// Call lifecycle hooks\n\tuseRouteLifecycle({\n\t\trouteName: \"newPost\",\n\t\tcontext: {\n\t\t\tpath: \"/blog/new\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (overrides.onBeforeNewPostPageRendered) {\n\t\t\t\treturn overrides.onBeforeNewPostPageRendered(context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\t// Ref to capture the form instance from AddPostForm via onFormReady callback\n\tconst formRef = useRef | null>(null);\n\tconst handleFormReady = useCallback((form: UseFormReturn) => {\n\t\tformRef.current = form;\n\t}, []);\n\n\t// Register AI context so the chat can fill in the new post form\n\tuseRegisterPageAIContext({\n\t\trouteName: \"blog-new-post\",\n\t\tpageDescription:\n\t\t\t\"User is creating a new blog post in the admin editor. IMPORTANT: When asked to write, draft, or create a blog post, you MUST call the fillBlogForm tool to populate the form fields directly — do NOT just output the text in your response.\",\n\t\tsuggestions: [\n\t\t\t\"Write a post about AI trends\",\n\t\t\t\"Draft an intro paragraph\",\n\t\t\t\"Suggest 5 tags for this post\",\n\t\t],\n\t\tclientTools: {\n\t\t\tfillBlogForm: createFillBlogFormHandler(\n\t\t\t\tformRef,\n\t\t\t\t\"Form filled successfully\",\n\t\t\t),\n\t\t},\n\t});\n\n\tconst handleClose = () => {\n\t\tnavigate(`${basePath}/blog`);\n\t};\n\n\tconst handleSuccess = (post: { published: boolean }) => {\n\t\t// Navigate based on published status\n\t\tif (post.published) {\n\t\t\tnavigate(`${basePath}/blog`);\n\t\t} else {\n\t\t\tnavigate(`${basePath}/blog/drafts`);\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", "target": "src/components/btst/blog/client/components/pages/new-post-page.internal.tsx" }, { "path": "btst/blog/client/components/pages/new-post-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { FormLoading } from \"../loading\";\nimport { NotFoundPage } from \"./404-page\";\n\n// Lazy load the internal component with actual page content\nconst NewPostPage = lazy(() =>\n\timport(\"./new-post-page.internal\").then((m) => ({ default: m.NewPostPage })),\n);\n\n// Exported wrapped component with error and loading boundaries\nexport function NewPostPageComponent() {\n\tconst { onRouteError } = usePluginOverrides(\"blog\");\n\treturn (\n\t\t {\n\t\t\t\tif (onRouteError) {\n\t\t\t\t\tonRouteError(\"newPost\", error, {\n\t\t\t\t\t\tpath: `/blog/new`,\n\t\t\t\t\t\tisSSR: typeof window === \"undefined\",\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}}\n\t\t/>\n\t);\n}\n", + "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { FormLoading } from \"../loading\";\nimport { NotFoundPage } from \"./404-page\";\n\n// Lazy load the internal component with actual page content\nconst NewPostPage = lazy(() =>\n\timport(\"./new-post-page.internal\").then((m) => ({ default: m.NewPostPage })),\n);\n\n// Exported wrapped component with error and loading boundaries\nexport function NewPostPageComponent() {\n\tconst { onRouteError } = usePluginOverrides(\"blog\");\n\treturn (\n\t\t {\n\t\t\t\tif (onRouteError) {\n\t\t\t\t\tonRouteError(\"newPost\", error, {\n\t\t\t\t\t\tpath: `/blog/new`,\n\t\t\t\t\t\tisSSR: typeof window === \"undefined\",\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}}\n\t\t/>\n\t);\n}\n", "target": "src/components/btst/blog/client/components/pages/new-post-page.tsx" }, { "path": "btst/blog/client/components/pages/post-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { formatDate } from \"date-fns\";\nimport {\n\tuseSuspensePost,\n\tuseNextPreviousPosts,\n\tuseRecentPosts,\n} from \"@btst/stack/plugins/blog/client/hooks\";\nimport { EmptyList } from \"../shared/empty-list\";\nimport { MarkdownContent } from \"../shared/markdown-content\";\nimport { PageHeader } from \"../shared/page-header\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { DefaultImage } from \"../shared/defaults\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { PostNavigation } from \"../shared/post-navigation\";\nimport { RecentPostsCarousel } from \"../shared/recent-posts-carousel\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { OnThisPage, OnThisPageSelect } from \"../shared/on-this-page\";\nimport type { SerializedPost } from \"../../../types\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\nimport { WhenVisible } from \"@/components/ui/when-visible\";\nimport { PostNavigationSkeleton } from \"../loading/post-navigation-skeleton\";\nimport { RecentPostsCarouselSkeleton } from \"../loading/recent-posts-carousel-skeleton\";\nimport { CollapsibleTagList } from \"../shared/collapsible-tag-list\";\n\n// Internal component with actual page content\nexport function PostPage({ slug }: { slug: string }) {\n\tconst overrides = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tImage: DefaultImage,\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst { Image, localization } = overrides;\n\n\t// Call lifecycle hooks\n\tuseRouteLifecycle({\n\t\trouteName: \"post\",\n\t\tcontext: {\n\t\t\tpath: `/blog/${slug}`,\n\t\t\tparams: { slug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (overrides.onBeforePostPageRendered) {\n\t\t\t\treturn overrides.onBeforePostPageRendered(slug, context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\tconst { post } = useSuspensePost(slug ?? \"\");\n\n\tconst { previousPost, nextPost } = useNextPreviousPosts(\n\t\tpost?.createdAt ?? new Date(),\n\t\t{\n\t\t\tenabled: !!post,\n\t\t},\n\t);\n\n\tconst { recentPosts } = useRecentPosts({\n\t\tlimit: 5,\n\t\texcludeSlug: slug,\n\t\tenabled: !!post,\n\t});\n\n\t// Register page AI context so the chat can summarize and discuss this post\n\tuseRegisterPageAIContext(\n\t\tpost\n\t\t\t? {\n\t\t\t\t\trouteName: \"blog-post\",\n\t\t\t\t\tpageDescription:\n\t\t\t\t\t\t`Blog post: \"${post.title}\"\\nAuthor: ${post.authorId ?? \"Unknown\"}\\n\\n${post.content ?? \"\"}`.slice(\n\t\t\t\t\t\t\t0,\n\t\t\t\t\t\t\t16000,\n\t\t\t\t\t\t),\n\t\t\t\t\tsuggestions: [\n\t\t\t\t\t\t\"Summarize this post\",\n\t\t\t\t\t\t\"What are the key takeaways?\",\n\t\t\t\t\t\t\"Explain this in simpler terms\",\n\t\t\t\t\t],\n\t\t\t\t}\n\t\t\t: null,\n\t);\n\n\tif (!slug || !post) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\n\t);\n}\n\nfunction PostHeaderTop({ post }: { post: SerializedPost }) {\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\t{formatDate(post.createdAt, \"MMMM d, yyyy\")}\n\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport { formatDate } from \"date-fns\";\nimport {\n\tuseSuspensePost,\n\tuseNextPreviousPosts,\n\tuseRecentPosts,\n} from \"@btst/stack/plugins/blog/client/hooks\";\nimport { EmptyList } from \"../shared/empty-list\";\nimport { MarkdownContent } from \"../shared/markdown-content\";\nimport { PageHeader } from \"../shared/page-header\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { DefaultImage } from \"../shared/defaults\";\nimport { PostNavigation } from \"../shared/post-navigation\";\nimport { RecentPostsCarousel } from \"../shared/recent-posts-carousel\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { OnThisPage, OnThisPageSelect } from \"../shared/on-this-page\";\nimport type { SerializedPost } from \"../../../types\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\nimport { WhenVisible } from \"@/components/ui/when-visible\";\nimport { PostNavigationSkeleton } from \"../loading/post-navigation-skeleton\";\nimport { RecentPostsCarouselSkeleton } from \"../loading/recent-posts-carousel-skeleton\";\nimport { CollapsibleTagList } from \"../shared/collapsible-tag-list\";\n\n// Internal component with actual page content\nexport function PostPage({ slug }: { slug: string }) {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tImage: DefaultImage,\n\t});\n\tconst { Image, localization } = overrides;\n\n\t// Call lifecycle hooks\n\tuseRouteLifecycle({\n\t\trouteName: \"post\",\n\t\tcontext: {\n\t\t\tpath: `/blog/${slug}`,\n\t\t\tparams: { slug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (overrides.onBeforePostPageRendered) {\n\t\t\t\treturn overrides.onBeforePostPageRendered(slug, context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\tconst { post } = useSuspensePost(slug ?? \"\");\n\n\tconst { previousPost, nextPost } = useNextPreviousPosts(\n\t\tpost?.createdAt ?? new Date(),\n\t\t{\n\t\t\tenabled: !!post,\n\t\t},\n\t);\n\n\tconst { recentPosts } = useRecentPosts({\n\t\tlimit: 5,\n\t\texcludeSlug: slug,\n\t\tenabled: !!post,\n\t});\n\n\t// Register page AI context so the chat can summarize and discuss this post\n\tuseRegisterPageAIContext(\n\t\tpost\n\t\t\t? {\n\t\t\t\t\trouteName: \"blog-post\",\n\t\t\t\t\tpageDescription:\n\t\t\t\t\t\t`Blog post: \"${post.title}\"\\nAuthor: ${post.authorId ?? \"Unknown\"}\\n\\n${post.content ?? \"\"}`.slice(\n\t\t\t\t\t\t\t0,\n\t\t\t\t\t\t\t16000,\n\t\t\t\t\t\t),\n\t\t\t\t\tsuggestions: [\n\t\t\t\t\t\t\"Summarize this post\",\n\t\t\t\t\t\t\"What are the key takeaways?\",\n\t\t\t\t\t\t\"Explain this in simpler terms\",\n\t\t\t\t\t],\n\t\t\t\t}\n\t\t\t: null,\n\t);\n\n\tif (!slug || !post) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\n\t);\n}\n\nfunction PostHeaderTop({ post }: { post: SerializedPost }) {\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\t{formatDate(post.createdAt, \"MMMM d, yyyy\")}\n\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/blog/client/components/pages/post-page.internal.tsx" }, { @@ -202,7 +202,7 @@ { "path": "btst/blog/client/components/pages/tag-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { PageHeader } from \"../shared/page-header\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { PostsList } from \"../shared/posts-list\";\nimport { EmptyList } from \"../shared/empty-list\";\n\nimport { useSuspensePosts } from \"@btst/stack/plugins/blog/client/hooks\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { useTags } from \"@btst/stack/plugins/blog/client/hooks\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\n// Internal component with actual page content\nexport function TagPage({ tagSlug }: { tagSlug: string }) {\n\tconst overrides = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst { localization } = overrides;\n\n\t// Call lifecycle hooks\n\tuseRouteLifecycle({\n\t\trouteName: \"tag\",\n\t\tcontext: {\n\t\t\tpath: `/blog/tag/${tagSlug}`,\n\t\t\tparams: { tagSlug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t});\n\n\tconst { tags } = useTags();\n\tconst tag = tags?.find((t) => t.slug === tagSlug);\n\n\tif (!tag) {\n\t\treturn (\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t\t\n\t\t
\n\t);\n}\n\nfunction Content({ tagSlug }: { tagSlug: string }) {\n\tconst { posts, loadMore, hasMore, isLoadingMore } = useSuspensePosts({\n\t\tpublished: true,\n\t\ttagSlug: tagSlug,\n\t});\n\treturn (\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { PageHeader } from \"../shared/page-header\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { PostsList } from \"../shared/posts-list\";\nimport { EmptyList } from \"../shared/empty-list\";\n\nimport { useSuspensePosts } from \"@btst/stack/plugins/blog/client/hooks\";\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { useTags } from \"@btst/stack/plugins/blog/client/hooks\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\n// Internal component with actual page content\nexport function TagPage({ tagSlug }: { tagSlug: string }) {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides(\"blog\");\n\tconst { localization } = overrides;\n\n\t// Call lifecycle hooks\n\tuseRouteLifecycle({\n\t\trouteName: \"tag\",\n\t\tcontext: {\n\t\t\tpath: `/blog/tag/${tagSlug}`,\n\t\t\tparams: { tagSlug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t});\n\n\tconst { tags } = useTags();\n\tconst tag = tags?.find((t) => t.slug === tagSlug);\n\n\tif (!tag) {\n\t\treturn (\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t\t\n\t\t
\n\t);\n}\n\nfunction Content({ tagSlug }: { tagSlug: string }) {\n\tconst { posts, loadMore, hasMore, isLoadingMore } = useSuspensePosts({\n\t\tpublished: true,\n\t\ttagSlug: tagSlug,\n\t});\n\treturn (\n\t\t\n\t);\n}\n", "target": "src/components/btst/blog/client/components/pages/tag-page.internal.tsx" }, { @@ -214,13 +214,13 @@ { "path": "btst/blog/client/components/shared/collapsible-tag-list.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { DefaultLink } from \"./defaults\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { ChevronDown, ChevronUp } from \"lucide-react\";\nimport { useState } from \"react\";\nimport type { SerializedTag } from \"../../../types\";\n\nconst MAX_VISIBLE_TAGS = 15;\n\ninterface CollapsibleTagListProps {\n\ttags: SerializedTag[];\n\tmaxVisible?: number;\n}\n\nexport function CollapsibleTagList({\n\ttags,\n\tmaxVisible = MAX_VISIBLE_TAGS,\n}: CollapsibleTagListProps) {\n\tconst { Link, localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tLink: DefaultLink,\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst basePath = useBasePath();\n\tconst [showAll, setShowAll] = useState(false);\n\n\tif (!tags || tags.length === 0) {\n\t\treturn null;\n\t}\n\n\tconst hasMore = tags.length > maxVisible;\n\tconst visibleTags = showAll || !hasMore ? tags : tags.slice(0, maxVisible);\n\n\treturn (\n\t\t<>\n\t\t\t{visibleTags.map((tag) => (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{tag.name}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t))}\n\t\t\t{hasMore && (\n\t\t\t\t\n\t\t\t\t\t setShowAll((prev) => !prev)}\n\t\t\t\t\t\taria-expanded={showAll}\n\t\t\t\t\t\taria-label={\n\t\t\t\t\t\t\tshowAll\n\t\t\t\t\t\t\t\t? localization.BLOG_TAGS_SHOW_LESS\n\t\t\t\t\t\t\t\t: localization.BLOG_TAGS_SHOW_ALL\n\t\t\t\t\t\t}\n\t\t\t\t\t\ttitle={\n\t\t\t\t\t\t\tshowAll\n\t\t\t\t\t\t\t\t? localization.BLOG_TAGS_SHOW_LESS\n\t\t\t\t\t\t\t\t: localization.BLOG_TAGS_SHOW_ALL\n\t\t\t\t\t\t}\n\t\t\t\t\t>\n\t\t\t\t\t\t{showAll ? (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport {\n\tusePluginOverrides,\n\tuseBasePath,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { DefaultLink } from \"./defaults\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { ChevronDown, ChevronUp } from \"lucide-react\";\nimport { useState } from \"react\";\nimport type { SerializedTag } from \"../../../types\";\n\nconst MAX_VISIBLE_TAGS = 15;\n\ninterface CollapsibleTagListProps {\n\ttags: SerializedTag[];\n\tmaxVisible?: number;\n}\n\nexport function CollapsibleTagList({\n\ttags,\n\tmaxVisible = MAX_VISIBLE_TAGS,\n}: CollapsibleTagListProps) {\n\tconst t = useTranslate();\n\tconst { Link, localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tLink: DefaultLink,\n\t});\n\tconst basePath = useBasePath();\n\tconst [showAll, setShowAll] = useState(false);\n\tconst showLessLabel =\n\t\tlocalization?.BLOG_TAGS_SHOW_LESS ??\n\t\tt(\"blog.common.tagsShowLess\", \"Show fewer tags\");\n\tconst showAllLabel =\n\t\tlocalization?.BLOG_TAGS_SHOW_ALL ??\n\t\tt(\"blog.common.tagsShowAll\", \"Show all tags\");\n\n\tif (!tags || tags.length === 0) {\n\t\treturn null;\n\t}\n\n\tconst hasMore = tags.length > maxVisible;\n\tconst visibleTags = showAll || !hasMore ? tags : tags.slice(0, maxVisible);\n\n\treturn (\n\t\t<>\n\t\t\t{visibleTags.map((tag) => (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{tag.name}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t))}\n\t\t\t{hasMore && (\n\t\t\t\t\n\t\t\t\t\t setShowAll((prev) => !prev)}\n\t\t\t\t\t\taria-expanded={showAll}\n\t\t\t\t\t\taria-label={showAll ? showLessLabel : showAllLabel}\n\t\t\t\t\t\ttitle={showAll ? showLessLabel : showAllLabel}\n\t\t\t\t\t>\n\t\t\t\t\t\t{showAll ? (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n}\n", "target": "src/components/btst/blog/client/components/shared/collapsible-tag-list.tsx" }, { "path": "btst/blog/client/components/shared/default-error.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport type { FallbackProps } from \"react-error-boundary\";\nimport { ErrorPlaceholder } from \"./error-placeholder\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\n\n// Default error component for blog plugin routes\nexport function DefaultError({ error }: FallbackProps) {\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst title = localization.BLOG_GENERIC_ERROR_TITLE;\n\tconst message =\n\t\tprocess.env.NODE_ENV === \"production\"\n\t\t\t? localization.BLOG_GENERIC_ERROR_MESSAGE\n\t\t\t: ((error instanceof Error ? error.message : undefined) ??\n\t\t\t\tlocalization.BLOG_GENERIC_ERROR_MESSAGE);\n\treturn ;\n}\n", + "content": "\"use client\";\n\nimport type { FallbackProps } from \"react-error-boundary\";\nimport { ErrorPlaceholder } from \"./error-placeholder\";\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\n\n// Default error component for blog plugin routes\nexport function DefaultError({ error }: FallbackProps) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\"blog\");\n\tconst title =\n\t\tlocalization?.BLOG_GENERIC_ERROR_TITLE ??\n\t\tt(\"blog.common.genericErrorTitle\", \"Something went wrong\");\n\tconst genericMessage =\n\t\tlocalization?.BLOG_GENERIC_ERROR_MESSAGE ??\n\t\tt(\"blog.common.genericErrorMessage\", \"An unexpected error occurred.\");\n\tconst message =\n\t\tprocess.env.NODE_ENV === \"production\"\n\t\t\t? genericMessage\n\t\t\t: ((error instanceof Error ? error.message : undefined) ??\n\t\t\t\tgenericMessage);\n\treturn ;\n}\n", "target": "src/components/btst/blog/client/components/shared/default-error.tsx" }, { @@ -262,7 +262,7 @@ { "path": "btst/blog/client/components/shared/on-this-page.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useEffect, useState, useMemo, useRef } from \"react\";\nimport { cn, slugify } from \"../../../utils\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"@/components/ui/select\";\nimport { TextAlignStart } from \"lucide-react\";\n\ninterface Heading {\n\tid: string;\n\ttext: string;\n\tlevel: number;\n}\n\ninterface OnThisPageProps {\n\tmarkdown: string;\n\tclassName?: string;\n}\n\nexport function OnThisPage({ markdown, className }: OnThisPageProps) {\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst headings = useMemo(() => extractHeadings(markdown), [markdown]);\n\tconst activeId = useActiveHeading(headings);\n\n\tif (headings.length === 0) {\n\t\t// placeholder if no headings are found\n\t\treturn (\n\t\t\t
\n\t);\n}\n\nfunction extractHeadings(markdown: string): Heading[] {\n\tconst headings: Heading[] = [];\n\tconst lines = markdown.split(\"\\n\");\n\n\tfor (const line of lines) {\n\t\t// Match ATX-style headings (# Heading)\n\t\tconst match = line.match(/^(#{1,6})\\s+(.+)$/);\n\t\tif (match) {\n\t\t\tconst level = match[1]?.length ?? 0;\n\t\t\tconst text = match[2]?.trim() ?? \"\";\n\t\t\t// Remove any trailing #s or special chars\n\t\t\tconst cleanText = text.replace(/\\s*#+\\s*$/, \"\").trim();\n\t\t\t// Generate ID using the same slugify utility as the markdown renderer\n\t\t\tconst id = slugify(cleanText);\n\n\t\t\tif (id && cleanText) {\n\t\t\t\theadings.push({ id, text: cleanText, level });\n\t\t\t}\n\t\t}\n\t}\n\n\treturn headings;\n}\n\nfunction useActiveHeading(\n\theadings: Heading[],\n\tonActiveChange?: (id: string) => void,\n): string {\n\tconst [activeId, setActiveId] = useState(\"\");\n\tconst onActiveChangeRef = useRef(onActiveChange);\n\n\t// Keep the ref in sync with the callback\n\tuseEffect(() => {\n\t\tonActiveChangeRef.current = onActiveChange;\n\t}, [onActiveChange]);\n\n\tuseEffect(() => {\n\t\tif (headings.length === 0) return;\n\n\t\tconst observer = new IntersectionObserver(\n\t\t\t(entries) => {\n\t\t\t\t// Find the first heading that's in view\n\t\t\t\tfor (const entry of entries) {\n\t\t\t\t\tif (entry.isIntersecting) {\n\t\t\t\t\t\tsetActiveId(entry.target.id);\n\t\t\t\t\t\tonActiveChangeRef.current?.(entry.target.id);\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t},\n\t\t\t{\n\t\t\t\trootMargin: \"-80px 0px -80% 0px\",\n\t\t\t\tthreshold: 0,\n\t\t\t},\n\t\t);\n\n\t\t// Observe all heading elements\n\t\theadings.forEach(({ id }) => {\n\t\t\tconst element = document.getElementById(id);\n\t\t\tif (element) {\n\t\t\t\tobserver.observe(element);\n\t\t\t}\n\t\t});\n\n\t\treturn () => {\n\t\t\tobserver.disconnect();\n\t\t};\n\t}, [headings]);\n\n\treturn activeId;\n}\n", + "content": "\"use client\";\n\nimport { useEffect, useState, useMemo, useRef } from \"react\";\nimport { cn, slugify } from \"../../../utils\";\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"@/components/ui/select\";\nimport { TextAlignStart } from \"lucide-react\";\n\ninterface Heading {\n\tid: string;\n\ttext: string;\n\tlevel: number;\n}\n\ninterface OnThisPageProps {\n\tmarkdown: string;\n\tclassName?: string;\n}\n\nexport function OnThisPage({ markdown, className }: OnThisPageProps) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\"blog\");\n\tconst headings = useMemo(() => extractHeadings(markdown), [markdown]);\n\tconst activeId = useActiveHeading(headings);\n\n\tif (headings.length === 0) {\n\t\t// placeholder if no headings are found\n\t\treturn (\n\t\t\t
\n\t);\n}\n\nfunction extractHeadings(markdown: string): Heading[] {\n\tconst headings: Heading[] = [];\n\tconst lines = markdown.split(\"\\n\");\n\n\tfor (const line of lines) {\n\t\t// Match ATX-style headings (# Heading)\n\t\tconst match = line.match(/^(#{1,6})\\s+(.+)$/);\n\t\tif (match) {\n\t\t\tconst level = match[1]?.length ?? 0;\n\t\t\tconst text = match[2]?.trim() ?? \"\";\n\t\t\t// Remove any trailing #s or special chars\n\t\t\tconst cleanText = text.replace(/\\s*#+\\s*$/, \"\").trim();\n\t\t\t// Generate ID using the same slugify utility as the markdown renderer\n\t\t\tconst id = slugify(cleanText);\n\n\t\t\tif (id && cleanText) {\n\t\t\t\theadings.push({ id, text: cleanText, level });\n\t\t\t}\n\t\t}\n\t}\n\n\treturn headings;\n}\n\nfunction useActiveHeading(\n\theadings: Heading[],\n\tonActiveChange?: (id: string) => void,\n): string {\n\tconst [activeId, setActiveId] = useState(\"\");\n\tconst onActiveChangeRef = useRef(onActiveChange);\n\n\t// Keep the ref in sync with the callback\n\tuseEffect(() => {\n\t\tonActiveChangeRef.current = onActiveChange;\n\t}, [onActiveChange]);\n\n\tuseEffect(() => {\n\t\tif (headings.length === 0) return;\n\n\t\tconst observer = new IntersectionObserver(\n\t\t\t(entries) => {\n\t\t\t\t// Find the first heading that's in view\n\t\t\t\tfor (const entry of entries) {\n\t\t\t\t\tif (entry.isIntersecting) {\n\t\t\t\t\t\tsetActiveId(entry.target.id);\n\t\t\t\t\t\tonActiveChangeRef.current?.(entry.target.id);\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t},\n\t\t\t{\n\t\t\t\trootMargin: \"-80px 0px -80% 0px\",\n\t\t\t\tthreshold: 0,\n\t\t\t},\n\t\t);\n\n\t\t// Observe all heading elements\n\t\theadings.forEach(({ id }) => {\n\t\t\tconst element = document.getElementById(id);\n\t\t\tif (element) {\n\t\t\t\tobserver.observe(element);\n\t\t\t}\n\t\t});\n\n\t\treturn () => {\n\t\t\tobserver.disconnect();\n\t\t};\n\t}, [headings]);\n\n\treturn activeId;\n}\n", "target": "src/components/btst/blog/client/components/shared/on-this-page.tsx" }, { @@ -280,37 +280,37 @@ { "path": "btst/blog/client/components/shared/post-card.tsx", "type": "registry:component", - "content": "\"use client\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport { useBasePath, usePluginOverrides } from \"@btst/stack/context\";\nimport { formatDate } from \"date-fns\";\nimport type { SerializedPost } from \"../../../types\";\nimport { CalendarIcon } from \"lucide-react\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { DefaultLink, DefaultImage } from \"./defaults\";\n\n// Beautiful gradient color combinations\nconst GRADIENT_PALETTES = [\n\t{\n\t\tfrom: \"from-purple-500\",\n\t\tvia: \"via-pink-500\",\n\t\tto: \"to-orange-500\",\n\t\toverlay: \"from-blue-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-blue-500\",\n\t\tvia: \"via-cyan-500\",\n\t\tto: \"to-teal-500\",\n\t\toverlay: \"from-indigo-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-pink-500\",\n\t\tvia: \"via-rose-500\",\n\t\tto: \"to-orange-500\",\n\t\toverlay: \"from-purple-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-green-500\",\n\t\tvia: \"via-emerald-500\",\n\t\tto: \"to-teal-500\",\n\t\toverlay: \"from-cyan-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-indigo-500\",\n\t\tvia: \"via-purple-500\",\n\t\tto: \"to-pink-500\",\n\t\toverlay: \"from-violet-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-orange-500\",\n\t\tvia: \"via-amber-500\",\n\t\tto: \"to-yellow-500\",\n\t\toverlay: \"from-orange-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-cyan-500\",\n\t\tvia: \"via-blue-500\",\n\t\tto: \"to-indigo-500\",\n\t\toverlay: \"from-purple-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-violet-500\",\n\t\tvia: \"via-purple-500\",\n\t\tto: \"to-fuchsia-500\",\n\t\toverlay: \"from-pink-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-emerald-500\",\n\t\tvia: \"via-green-500\",\n\t\tto: \"to-lime-500\",\n\t\toverlay: \"from-teal-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-rose-500\",\n\t\tvia: \"via-pink-500\",\n\t\tto: \"to-fuchsia-500\",\n\t\toverlay: \"from-purple-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-sky-500\",\n\t\tvia: \"via-blue-500\",\n\t\tto: \"to-indigo-600\",\n\t\toverlay: \"from-purple-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-fuchsia-600\",\n\t\tvia: \"via-purple-600\",\n\t\tto: \"to-indigo-600\",\n\t\toverlay: \"from-pink-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-teal-500\",\n\t\tvia: \"via-emerald-500\",\n\t\tto: \"to-green-600\",\n\t\toverlay: \"from-cyan-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-amber-500\",\n\t\tvia: \"via-orange-500\",\n\t\tto: \"to-pink-500\",\n\t\toverlay: \"from-yellow-600/40\",\n\t},\n\t{\n\t\tfrom: \"from-indigo-600\",\n\t\tvia: \"via-blue-600\",\n\t\tto: \"to-purple-600\",\n\t\toverlay: \"from-violet-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-pink-600\",\n\t\tvia: \"via-rose-600\",\n\t\tto: \"to-orange-600\",\n\t\toverlay: \"from-fuchsia-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-cyan-600\",\n\t\tvia: \"via-teal-600\",\n\t\tto: \"to-emerald-600\",\n\t\toverlay: \"from-blue-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-purple-600\",\n\t\tvia: \"via-fuchsia-600\",\n\t\tto: \"to-pink-600\",\n\t\toverlay: \"from-violet-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-blue-600\",\n\t\tvia: \"via-indigo-600\",\n\t\tto: \"to-purple-600\",\n\t\toverlay: \"from-cyan-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-green-600\",\n\t\tvia: \"via-emerald-600\",\n\t\tto: \"to-teal-600\",\n\t\toverlay: \"from-lime-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-rose-600\",\n\t\tvia: \"via-pink-600\",\n\t\tto: \"to-fuchsia-600\",\n\t\toverlay: \"from-pink-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-orange-600\",\n\t\tvia: \"via-pink-600\",\n\t\tto: \"to-rose-600\",\n\t\toverlay: \"from-amber-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-violet-600\",\n\t\tvia: \"via-purple-600\",\n\t\tto: \"to-fuchsia-600\",\n\t\toverlay: \"from-indigo-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-teal-600\",\n\t\tvia: \"via-cyan-600\",\n\t\tto: \"to-blue-600\",\n\t\toverlay: \"from-emerald-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-emerald-600\",\n\t\tvia: \"via-teal-600\",\n\t\tto: \"to-cyan-600\",\n\t\toverlay: \"from-green-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-indigo-700\",\n\t\tvia: \"via-purple-700\",\n\t\tto: \"to-pink-700\",\n\t\toverlay: \"from-violet-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-blue-700\",\n\t\tvia: \"via-cyan-700\",\n\t\tto: \"to-teal-700\",\n\t\toverlay: \"from-indigo-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-purple-700\",\n\t\tvia: \"via-fuchsia-700\",\n\t\tto: \"to-pink-700\",\n\t\toverlay: \"from-violet-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-pink-700\",\n\t\tvia: \"via-rose-700\",\n\t\tto: \"to-orange-700\",\n\t\toverlay: \"from-fuchsia-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-cyan-700\",\n\t\tvia: \"via-blue-700\",\n\t\tto: \"to-indigo-700\",\n\t\toverlay: \"from-teal-800/30\",\n\t},\n] as const;\n\n// Simple deterministic hash function\nfunction hashString(str: string): number {\n\tlet hash = 0;\n\tfor (let i = 0; i < str.length; i++) {\n\t\tconst char = str.charCodeAt(i);\n\t\thash = (hash << 5) - hash + char;\n\t\thash = hash & hash; // Convert to 32-bit integer\n\t}\n\treturn Math.abs(hash);\n}\n\nfunction getGradientFromTitle(title: string) {\n\tconst hash = hashString(title);\n\tconst palette = GRADIENT_PALETTES[hash % GRADIENT_PALETTES.length];\n\treturn palette;\n}\n\nexport function PostCard({ post }: { post: SerializedPost }) {\n\tconst { Link, Image } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tLink: DefaultLink,\n\t\tImage: DefaultImage,\n\t});\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst basePath = useBasePath();\n\tconst blogPath = `${basePath}/blog/${post.slug}`;\n\tconst postDate = formatDate(\n\t\tpost.publishedAt || post.createdAt,\n\t\t\"MMMM d, yyyy\",\n\t);\n\tconst gradient = post.image ? null : getGradientFromTitle(post.title);\n\n\treturn (\n\t\t\n\t\t\t{/* Image or Placeholder */}\n\t\t\t\n\t\t\t\t{post.image ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{post.title}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t)}\n\t\t\t\n\n\t\t\t{!post.published && (\n\t\t\t\t\n\t\t\t\t\t{localization.BLOG_CARD_DRAFT_BADGE}\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{post.title}\n\t\t\t\t\t\n\t\t\t\t\n\n\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t{post.tags &&\n\t\t\t\t\t\t\tpost.tags.length > 0 &&\n\t\t\t\t\t\t\tpost.tags.map((tag) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{tag.name}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport {\n\tuseBasePath,\n\tusePluginOverrides,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { formatDate } from \"date-fns\";\nimport type { SerializedPost } from \"../../../types\";\nimport { CalendarIcon } from \"lucide-react\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { DefaultLink, DefaultImage } from \"./defaults\";\n\n// Beautiful gradient color combinations\nconst GRADIENT_PALETTES = [\n\t{\n\t\tfrom: \"from-purple-500\",\n\t\tvia: \"via-pink-500\",\n\t\tto: \"to-orange-500\",\n\t\toverlay: \"from-blue-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-blue-500\",\n\t\tvia: \"via-cyan-500\",\n\t\tto: \"to-teal-500\",\n\t\toverlay: \"from-indigo-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-pink-500\",\n\t\tvia: \"via-rose-500\",\n\t\tto: \"to-orange-500\",\n\t\toverlay: \"from-purple-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-green-500\",\n\t\tvia: \"via-emerald-500\",\n\t\tto: \"to-teal-500\",\n\t\toverlay: \"from-cyan-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-indigo-500\",\n\t\tvia: \"via-purple-500\",\n\t\tto: \"to-pink-500\",\n\t\toverlay: \"from-violet-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-orange-500\",\n\t\tvia: \"via-amber-500\",\n\t\tto: \"to-yellow-500\",\n\t\toverlay: \"from-orange-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-cyan-500\",\n\t\tvia: \"via-blue-500\",\n\t\tto: \"to-indigo-500\",\n\t\toverlay: \"from-purple-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-violet-500\",\n\t\tvia: \"via-purple-500\",\n\t\tto: \"to-fuchsia-500\",\n\t\toverlay: \"from-pink-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-emerald-500\",\n\t\tvia: \"via-green-500\",\n\t\tto: \"to-lime-500\",\n\t\toverlay: \"from-teal-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-rose-500\",\n\t\tvia: \"via-pink-500\",\n\t\tto: \"to-fuchsia-500\",\n\t\toverlay: \"from-purple-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-sky-500\",\n\t\tvia: \"via-blue-500\",\n\t\tto: \"to-indigo-600\",\n\t\toverlay: \"from-purple-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-fuchsia-600\",\n\t\tvia: \"via-purple-600\",\n\t\tto: \"to-indigo-600\",\n\t\toverlay: \"from-pink-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-teal-500\",\n\t\tvia: \"via-emerald-500\",\n\t\tto: \"to-green-600\",\n\t\toverlay: \"from-cyan-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-amber-500\",\n\t\tvia: \"via-orange-500\",\n\t\tto: \"to-pink-500\",\n\t\toverlay: \"from-yellow-600/40\",\n\t},\n\t{\n\t\tfrom: \"from-indigo-600\",\n\t\tvia: \"via-blue-600\",\n\t\tto: \"to-purple-600\",\n\t\toverlay: \"from-violet-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-pink-600\",\n\t\tvia: \"via-rose-600\",\n\t\tto: \"to-orange-600\",\n\t\toverlay: \"from-fuchsia-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-cyan-600\",\n\t\tvia: \"via-teal-600\",\n\t\tto: \"to-emerald-600\",\n\t\toverlay: \"from-blue-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-purple-600\",\n\t\tvia: \"via-fuchsia-600\",\n\t\tto: \"to-pink-600\",\n\t\toverlay: \"from-violet-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-blue-600\",\n\t\tvia: \"via-indigo-600\",\n\t\tto: \"to-purple-600\",\n\t\toverlay: \"from-cyan-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-green-600\",\n\t\tvia: \"via-emerald-600\",\n\t\tto: \"to-teal-600\",\n\t\toverlay: \"from-lime-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-rose-600\",\n\t\tvia: \"via-pink-600\",\n\t\tto: \"to-fuchsia-600\",\n\t\toverlay: \"from-pink-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-orange-600\",\n\t\tvia: \"via-pink-600\",\n\t\tto: \"to-rose-600\",\n\t\toverlay: \"from-amber-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-violet-600\",\n\t\tvia: \"via-purple-600\",\n\t\tto: \"to-fuchsia-600\",\n\t\toverlay: \"from-indigo-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-teal-600\",\n\t\tvia: \"via-cyan-600\",\n\t\tto: \"to-blue-600\",\n\t\toverlay: \"from-emerald-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-emerald-600\",\n\t\tvia: \"via-teal-600\",\n\t\tto: \"to-cyan-600\",\n\t\toverlay: \"from-green-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-indigo-700\",\n\t\tvia: \"via-purple-700\",\n\t\tto: \"to-pink-700\",\n\t\toverlay: \"from-violet-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-blue-700\",\n\t\tvia: \"via-cyan-700\",\n\t\tto: \"to-teal-700\",\n\t\toverlay: \"from-indigo-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-purple-700\",\n\t\tvia: \"via-fuchsia-700\",\n\t\tto: \"to-pink-700\",\n\t\toverlay: \"from-violet-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-pink-700\",\n\t\tvia: \"via-rose-700\",\n\t\tto: \"to-orange-700\",\n\t\toverlay: \"from-fuchsia-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-cyan-700\",\n\t\tvia: \"via-blue-700\",\n\t\tto: \"to-indigo-700\",\n\t\toverlay: \"from-teal-800/30\",\n\t},\n] as const;\n\n// Simple deterministic hash function\nfunction hashString(str: string): number {\n\tlet hash = 0;\n\tfor (let i = 0; i < str.length; i++) {\n\t\tconst char = str.charCodeAt(i);\n\t\thash = (hash << 5) - hash + char;\n\t\thash = hash & hash; // Convert to 32-bit integer\n\t}\n\treturn Math.abs(hash);\n}\n\nfunction getGradientFromTitle(title: string) {\n\tconst hash = hashString(title);\n\tconst palette = GRADIENT_PALETTES[hash % GRADIENT_PALETTES.length];\n\treturn palette;\n}\n\nexport function PostCard({ post }: { post: SerializedPost }) {\n\tconst t = useTranslate();\n\tconst { Link, Image, localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tLink: DefaultLink,\n\t\tImage: DefaultImage,\n\t});\n\tconst basePath = useBasePath();\n\tconst blogPath = `${basePath}/blog/${post.slug}`;\n\tconst postDate = formatDate(\n\t\tpost.publishedAt || post.createdAt,\n\t\t\"MMMM d, yyyy\",\n\t);\n\tconst gradient = post.image ? null : getGradientFromTitle(post.title);\n\n\treturn (\n\t\t\n\t\t\t{/* Image or Placeholder */}\n\t\t\t\n\t\t\t\t{post.image ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{post.title}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t)}\n\t\t\t\n\n\t\t\t{!post.published && (\n\t\t\t\t\n\t\t\t\t\t{localization?.BLOG_CARD_DRAFT_BADGE ??\n\t\t\t\t\t\tt(\"blog.card.draftBadge\", \"Draft\")}\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{post.title}\n\t\t\t\t\t\n\t\t\t\t\n\n\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t{post.tags &&\n\t\t\t\t\t\t\tpost.tags.length > 0 &&\n\t\t\t\t\t\t\tpost.tags.map((tag) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{tag.name}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/blog/client/components/shared/post-card.tsx" }, { "path": "btst/blog/client/components/shared/post-navigation.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport { Button } from \"@/components/ui/button\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { DefaultLink } from \"./defaults\";\nimport type { SerializedPost } from \"../../../types\";\n\ninterface PostNavigationProps {\n\tpreviousPost: SerializedPost | null;\n\tnextPost: SerializedPost | null;\n}\n\nexport function PostNavigation({\n\tpreviousPost,\n\tnextPost,\n}: PostNavigationProps) {\n\tconst { Link } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tLink: DefaultLink,\n\t});\n\tconst basePath = useBasePath();\n\tconst blogPath = `${basePath}/blog`;\n\n\treturn (\n\t\t<>\n\t\t\t{/* Only show navigation buttons if posts are available */}\n\t\t\t{(previousPost || nextPost) && (\n\t\t\t\t<>\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t{previousPost ? (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\tPrevious\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t\t{previousPost.title}\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{nextPost ? (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\tNext\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t\t{nextPost.title}\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t)}\n\t\t\t\t\t
\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport {\n\tusePluginOverrides,\n\tuseBasePath,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { Button } from \"@/components/ui/button\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { DefaultLink } from \"./defaults\";\nimport type { SerializedPost } from \"../../../types\";\n\ninterface PostNavigationProps {\n\tpreviousPost: SerializedPost | null;\n\tnextPost: SerializedPost | null;\n}\n\nexport function PostNavigation({\n\tpreviousPost,\n\tnextPost,\n}: PostNavigationProps) {\n\tconst t = useTranslate();\n\tconst { Link } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tLink: DefaultLink,\n\t});\n\tconst basePath = useBasePath();\n\tconst blogPath = `${basePath}/blog`;\n\n\treturn (\n\t\t<>\n\t\t\t{/* Only show navigation buttons if posts are available */}\n\t\t\t{(previousPost || nextPost) && (\n\t\t\t\t<>\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t{previousPost ? (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"blog.post.previous\", \"Previous\")}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t\t{previousPost.title}\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{nextPost ? (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{t(\"blog.post.next\", \"Next\")}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t\t{nextPost.title}\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t)}\n\t\t\t\t\t
\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n}\n", "target": "src/components/btst/blog/client/components/shared/post-navigation.tsx" }, { "path": "btst/blog/client/components/shared/posts-list.tsx", "type": "registry:component", - "content": "import { usePluginOverrides } from \"@btst/stack/context\";\nimport type { SerializedPost } from \"../../../types\";\nimport { Button } from \"@/components/ui/button\";\nimport { EmptyList } from \"./empty-list\";\nimport { SearchInput } from \"./search-input\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { PostCard as DefaultPostCard } from \"./post-card\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\n\ninterface PostsListProps {\n\tposts: SerializedPost[];\n\tonLoadMore?: () => void;\n\thasMore?: boolean;\n\tisLoadingMore?: boolean;\n}\n\nexport function PostsList({\n\tposts,\n\tonLoadMore,\n\thasMore,\n\tisLoadingMore,\n}: PostsListProps) {\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst { PostCard } = usePluginOverrides(\"blog\");\n\n\tconst PostCardComponent = PostCard || DefaultPostCard;\n\tif (posts.length === 0) {\n\t\treturn ;\n\t}\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t\t
\n\t\t\t\t{posts.map((post) => (\n\t\t\t\t\t\n\t\t\t\t))}\n\t\t\t
\n\n\t\t\t{onLoadMore && hasMore && (\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{isLoadingMore\n\t\t\t\t\t\t\t? localization.BLOG_LIST_LOADING_MORE\n\t\t\t\t\t\t\t: localization.BLOG_LIST_LOAD_MORE}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\t\t
\n\t);\n}\n", + "content": "import { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { SerializedPost } from \"../../../types\";\nimport { Button } from \"@/components/ui/button\";\nimport { EmptyList } from \"./empty-list\";\nimport { SearchInput } from \"./search-input\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { PostCard as DefaultPostCard } from \"./post-card\";\n\ninterface PostsListProps {\n\tposts: SerializedPost[];\n\tonLoadMore?: () => void;\n\thasMore?: boolean;\n\tisLoadingMore?: boolean;\n}\n\nexport function PostsList({\n\tposts,\n\tonLoadMore,\n\thasMore,\n\tisLoadingMore,\n}: PostsListProps) {\n\tconst t = useTranslate();\n\tconst { localization, PostCard } =\n\t\tusePluginOverrides(\"blog\");\n\n\tconst PostCardComponent = PostCard || DefaultPostCard;\n\tif (posts.length === 0) {\n\t\treturn (\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t\t
\n\t\t\t\t{posts.map((post) => (\n\t\t\t\t\t\n\t\t\t\t))}\n\t\t\t
\n\n\t\t\t{onLoadMore && hasMore && (\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{isLoadingMore\n\t\t\t\t\t\t\t? (localization?.BLOG_LIST_LOADING_MORE ??\n\t\t\t\t\t\t\t\tt(\"blog.list.loadingMore\", \"Loading more...\"))\n\t\t\t\t\t\t\t: (localization?.BLOG_LIST_LOAD_MORE ??\n\t\t\t\t\t\t\t\tt(\"blog.list.loadMore\", \"Load more posts\"))}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\t\t
\n\t);\n}\n", "target": "src/components/btst/blog/client/components/shared/posts-list.tsx" }, { "path": "btst/blog/client/components/shared/recent-posts-carousel.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useBasePath, usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport type { SerializedPost } from \"../../../types\";\nimport {\n\tCarousel,\n\tCarouselContent,\n\tCarouselItem,\n\tCarouselNext,\n\tCarouselPrevious,\n} from \"@/components/ui/carousel\";\nimport { PostCard as DefaultPostCard } from \"./post-card\";\nimport { DefaultLink } from \"./defaults\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\n\ninterface RecentPostsCarouselProps {\n\tposts: SerializedPost[];\n}\n\nexport function RecentPostsCarousel({ posts }: RecentPostsCarouselProps) {\n\tconst { PostCard, Link, localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tPostCard: DefaultPostCard,\n\t\tLink: DefaultLink,\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst PostCardComponent = PostCard || DefaultPostCard;\n\tconst basePath = useBasePath();\n\treturn (\n\t\t
\n\t\t\t{posts && posts.length > 0 && (\n\t\t\t\t<>\n\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{localization.BLOG_POST_KEEP_READING}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_POST_VIEW_ALL}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{posts.map((post) => (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\n\t\t\t)}\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport {\n\tuseBasePath,\n\tusePluginOverrides,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport type { SerializedPost } from \"../../../types\";\nimport {\n\tCarousel,\n\tCarouselContent,\n\tCarouselItem,\n\tCarouselNext,\n\tCarouselPrevious,\n} from \"@/components/ui/carousel\";\nimport { PostCard as DefaultPostCard } from \"./post-card\";\nimport { DefaultLink } from \"./defaults\";\n\ninterface RecentPostsCarouselProps {\n\tposts: SerializedPost[];\n}\n\nexport function RecentPostsCarousel({ posts }: RecentPostsCarouselProps) {\n\tconst t = useTranslate();\n\tconst { PostCard, Link, localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tPostCard: DefaultPostCard,\n\t\tLink: DefaultLink,\n\t});\n\tconst PostCardComponent = PostCard || DefaultPostCard;\n\tconst basePath = useBasePath();\n\treturn (\n\t\t
\n\t\t\t{posts && posts.length > 0 && (\n\t\t\t\t<>\n\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{localization?.BLOG_POST_KEEP_READING ??\n\t\t\t\t\t\t\t\t\tt(\"blog.post.keepReading\", \"Keep Reading\")}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.BLOG_POST_VIEW_ALL ??\n\t\t\t\t\t\t\t\t\tt(\"blog.post.viewAll\", \"View all\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{posts.map((post) => (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\n\t\t\t)}\n\t\t
\n\t);\n}\n", "target": "src/components/btst/blog/client/components/shared/recent-posts-carousel.tsx" }, { "path": "btst/blog/client/components/shared/search-input.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { usePostSearch } from \"@btst/stack/plugins/blog/client/hooks\";\nimport { stripHtml, stripMarkdown } from \"../../../utils\";\nimport { HighlightText } from \"./highlight-text\";\nimport { SearchModal, type SearchResult } from \"./search-modal\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { useBasePath, usePluginOverrides } from \"@btst/stack/context\";\n\n// Simplified blog post search result interface\ninterface BlogPostSearchResult extends SearchResult {\n\tslug: string;\n\tpublishedAt?: string | null;\n\tauthorName?: string;\n\tprocessedContent: string;\n\tprocessedExcerpt?: string;\n}\n\ninterface SearchInputProps {\n\tclassName?: string;\n\ttriggerClassName?: string;\n\tplaceholder?: string;\n\tbuttonText?: string;\n\temptyMessage?: string;\n}\n\nconst renderBlogResult = (\n\titem: BlogPostSearchResult,\n\tindex: number,\n\tquery: string,\n): React.ReactNode => {\n\tconst q = (query || \"\").toLowerCase();\n\tconst excerptMatches = item.processedExcerpt\n\t\t? item.processedExcerpt.toLowerCase().includes(q)\n\t\t: false;\n\tconst contentMatches = item.processedContent\n\t\t? item.processedContent.toLowerCase().includes(q)\n\t\t: false;\n\n\treturn (\n\t\t item.onClick?.()}\n\t\t>\n\t\t\t
\n\t\t\t\t\n\t\t\t\t{item.publishedAt && (\n\t\t\t\t\t\n\t\t\t\t\t\t{new Date(item.publishedAt).toLocaleDateString()}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t{excerptMatches && (\n\t\t\t\t\n\t\t\t)}\n\t\t\t{contentMatches && (\n\t\t\t\t\n\t\t\t)}\n\t\t\t{!excerptMatches && !contentMatches && (\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n};\n\nexport function SearchInput({\n\tclassName,\n\ttriggerClassName,\n\tplaceholder,\n\tbuttonText,\n\temptyMessage,\n}: SearchInputProps) {\n\tconst { navigate } = usePluginOverrides(\"blog\");\n\tconst basePath = useBasePath();\n\tconst [currentQuery, setCurrentQuery] = React.useState(\"\");\n\n\tconst { data: searchResults = [], isLoading } = usePostSearch({\n\t\tquery: currentQuery,\n\t\tenabled: currentQuery.trim().length > 0,\n\t\tdebounceMs: 300,\n\t\tpublished: true,\n\t});\n\n\tconst formattedResults: BlogPostSearchResult[] = React.useMemo(() => {\n\t\treturn searchResults.map((post) => ({\n\t\t\tid: post.id,\n\t\t\ttitle: post.title,\n\t\t\tslug: post.slug,\n\t\t\tpublishedAt: post.publishedAt,\n\t\t\tauthorName: \"\",\n\t\t\tprocessedContent: stripMarkdown(stripHtml(post.content || \"\")),\n\t\t\tprocessedExcerpt: stripMarkdown(stripHtml(post.excerpt || \"\")),\n\t\t\tonClick: () => navigate(`${basePath}/blog/${post.slug}`),\n\t\t}));\n\t}, [searchResults, navigate, basePath]);\n\n\t// Search function that updates our query state\n\tconst handleSearch = React.useCallback(\n\t\t(query: string): BlogPostSearchResult[] => {\n\t\t\tsetCurrentQuery(query);\n\t\t\treturn []; // Return empty since we use external async results\n\t\t},\n\t\t[],\n\t);\n\n\treturn (\n\t\t\n\t\t\tplaceholder={placeholder}\n\t\t\tbuttonText={buttonText}\n\t\t\temptyMessage={emptyMessage}\n\t\t\tsearchFn={handleSearch}\n\t\t\trenderResult={renderBlogResult}\n\t\t\tresults={formattedResults}\n\t\t\tisLoading={isLoading}\n\t\t\tclassName={className}\n\t\t\ttriggerClassName={triggerClassName}\n\t\t\tkeyboardShortcut=\"⌘K\"\n\t\t/>\n\t);\n}\n", + "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { usePostSearch } from \"@btst/stack/plugins/blog/client/hooks\";\nimport { stripHtml, stripMarkdown } from \"../../../utils\";\nimport { HighlightText } from \"./highlight-text\";\nimport { SearchModal, type SearchResult } from \"./search-modal\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { useBasePath, usePluginOverrides } from \"@btst/stack/context\";\nimport { useListState, type ListStateSchema } from \"@btst/stack/client\";\n\n// Simplified blog post search result interface\ninterface BlogPostSearchResult extends SearchResult {\n\tslug: string;\n\tpublishedAt?: string | null;\n\tauthorName?: string;\n\tprocessedContent: string;\n\tprocessedExcerpt?: string;\n}\n\ninterface SearchInputProps {\n\tclassName?: string;\n\ttriggerClassName?: string;\n\tplaceholder?: string;\n\tbuttonText?: string;\n\temptyMessage?: string;\n}\n\nconst renderBlogResult = (\n\titem: BlogPostSearchResult,\n\tindex: number,\n\tquery: string,\n): React.ReactNode => {\n\tconst q = (query || \"\").toLowerCase();\n\tconst excerptMatches = item.processedExcerpt\n\t\t? item.processedExcerpt.toLowerCase().includes(q)\n\t\t: false;\n\tconst contentMatches = item.processedContent\n\t\t? item.processedContent.toLowerCase().includes(q)\n\t\t: false;\n\n\treturn (\n\t\t item.onClick?.()}\n\t\t>\n\t\t\t
\n\t\t\t\t\n\t\t\t\t{item.publishedAt && (\n\t\t\t\t\t\n\t\t\t\t\t\t{new Date(item.publishedAt).toLocaleDateString()}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t{excerptMatches && (\n\t\t\t\t\n\t\t\t)}\n\t\t\t{contentMatches && (\n\t\t\t\t\n\t\t\t)}\n\t\t\t{!excerptMatches && !contentMatches && (\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n};\n\n// URL-synced search state: `?q=...` while typing (history: replace), clean URL\n// when the query is empty (the default is omitted from the URL).\nconst SEARCH_LIST_STATE_SCHEMA = {\n\tq: { type: \"string\", default: \"\", history: \"replace\" },\n} as const satisfies ListStateSchema;\n\nexport function SearchInput({\n\tclassName,\n\ttriggerClassName,\n\tplaceholder,\n\tbuttonText,\n\temptyMessage,\n}: SearchInputProps) {\n\tconst { navigate } = usePluginOverrides(\"blog\");\n\tconst basePath = useBasePath();\n\tconst [{ q: currentQuery }, setListState] = useListState(\n\t\t\"blog-posts\",\n\t\tSEARCH_LIST_STATE_SCHEMA,\n\t);\n\n\tconst { data: searchResults = [], isLoading } = usePostSearch({\n\t\tquery: currentQuery,\n\t\tenabled: currentQuery.trim().length > 0,\n\t\tdebounceMs: 300,\n\t\tpublished: true,\n\t});\n\n\tconst formattedResults: BlogPostSearchResult[] = React.useMemo(() => {\n\t\treturn searchResults.map((post) => ({\n\t\t\tid: post.id,\n\t\t\ttitle: post.title,\n\t\t\tslug: post.slug,\n\t\t\tpublishedAt: post.publishedAt,\n\t\t\tauthorName: \"\",\n\t\t\tprocessedContent: stripMarkdown(stripHtml(post.content || \"\")),\n\t\t\tprocessedExcerpt: stripMarkdown(stripHtml(post.excerpt || \"\")),\n\t\t\tonClick: () => navigate(`${basePath}/blog/${post.slug}`),\n\t\t}));\n\t}, [searchResults, navigate, basePath]);\n\n\t// Search function that updates the URL-synced query state\n\tconst handleSearch = React.useCallback(\n\t\t(query: string): BlogPostSearchResult[] => {\n\t\t\tsetListState({ q: query });\n\t\t\treturn []; // Return empty since we use external async results\n\t\t},\n\t\t[setListState],\n\t);\n\n\treturn (\n\t\t\n\t\t\tplaceholder={placeholder}\n\t\t\tbuttonText={buttonText}\n\t\t\temptyMessage={emptyMessage}\n\t\t\tinitialQuery={currentQuery}\n\t\t\tsearchFn={handleSearch}\n\t\t\trenderResult={renderBlogResult}\n\t\t\tresults={formattedResults}\n\t\t\tisLoading={isLoading}\n\t\t\tclassName={className}\n\t\t\ttriggerClassName={triggerClassName}\n\t\t\tkeyboardShortcut=\"⌘K\"\n\t\t/>\n\t);\n}\n", "target": "src/components/btst/blog/client/components/shared/search-input.tsx" }, { "path": "btst/blog/client/components/shared/search-modal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { SearchIcon } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useDebounce } from \"@btst/stack/plugins/blog/client/hooks\";\n\nimport {\n\tCommandDialog,\n\tCommandEmpty,\n\tCommandInput,\n\tCommandList,\n} from \"@/components/ui/command\";\n\nexport interface SearchResult {\n\tid: string;\n\ttitle: string;\n\tsubtitle?: string;\n\tcontent?: string;\n\tonClick?: () => void;\n}\n\nexport interface SearchModalProps {\n\tplaceholder?: string;\n\temptyMessage?: string;\n\tbuttonText?: string;\n\tkeyboardShortcut?: string;\n\tsearchFn: (query: string) => T[];\n\trenderResult: (item: T, index: number, query: string) => React.ReactNode;\n\tresults?: T[];\n\tisLoading?: boolean;\n\tgroupTitle?: string;\n\tclassName?: string;\n\ttriggerClassName?: string;\n}\n\nexport function SearchModal({\n\tplaceholder = \"Type to search...\",\n\temptyMessage = \"No results found.\",\n\tbuttonText = \"Search\",\n\tkeyboardShortcut = \"⌘K\",\n\tsearchFn,\n\trenderResult,\n\tresults: externalResults,\n\tisLoading = false,\n\tclassName,\n\ttriggerClassName,\n}: SearchModalProps) {\n\tconst [open, setOpen] = React.useState(false);\n\tconst [query, setQuery] = React.useState(\"\");\n\tconst [results, setResults] = React.useState([]);\n\n\t// Only debounce if not using external results\n\tconst shouldDebounce = externalResults === undefined;\n\tconst debouncedQuery = useDebounce(query, shouldDebounce ? 300 : 0);\n\n\t// Handle keyboard shortcut\n\tReact.useEffect(() => {\n\t\tconst down = (e: KeyboardEvent) => {\n\t\t\tconst cleanShortcut = keyboardShortcut\n\t\t\t\t.replace(\"⌘\", \"\")\n\t\t\t\t.replace(\"⇧\", \"\")\n\t\t\t\t.toLowerCase();\n\t\t\tif (e.key === cleanShortcut && (e.metaKey || e.ctrlKey)) {\n\t\t\t\te.preventDefault();\n\n\t\t\t\tsetOpen((open) => !open);\n\t\t\t}\n\t\t};\n\n\t\tdocument.addEventListener(\"keydown\", down);\n\t\treturn () => document.removeEventListener(\"keydown\", down);\n\t}, [keyboardShortcut]);\n\n\tReact.useEffect(() => {\n\t\tif (!open) {\n\t\t\tsetQuery(\"\");\n\t\t\tsetResults([]);\n\t\t\treturn;\n\t\t}\n\t}, [open]);\n\n\tReact.useEffect(() => {\n\t\tif (!open) return;\n\n\t\t// Always call searchFn to notify parent component of the search query\n\t\tconst searchResults = searchFn(debouncedQuery);\n\n\t\t// If external results are provided, use them; otherwise use searchFn results\n\t\tif (externalResults !== undefined) {\n\t\t\tsetResults(externalResults);\n\t\t} else {\n\t\t\tsetResults(searchResults);\n\t\t}\n\t}, [debouncedQuery, open, searchFn, externalResults]);\n\n\t// Base button classes for better readability\n\tconst buttonClasses = [\n\t\t\"border-input bg-background text-foreground\",\n\t\t\"placeholder:text-muted-foreground\",\n\t\t\"focus-visible:border-ring focus-visible:ring-ring/50\",\n\t\t\"inline-flex h-9 w-fit rounded-md border px-3 py-2 text-sm\",\n\t\t\"shadow-xs transition-[color,box-shadow] outline-none\",\n\t\t\"focus-visible:ring-[3px]\",\n\t\ttriggerClassName,\n\t]\n\t\t.filter(Boolean)\n\t\t.join(\" \");\n\n\t// Determine what to show based on state\n\tconst showEmpty = debouncedQuery && !isLoading && results.length === 0;\n\tconst currentEmptyMessage = isLoading ? \"Searching...\" : emptyMessage;\n\n\treturn (\n\t\t<>\n\t\t\t setOpen(true)}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{buttonText}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t{keyboardShortcut && (\n\t\t\t\t\t\n\t\t\t\t\t\t{keyboardShortcut}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{showEmpty && {currentEmptyMessage}}\n\n\t\t\t\t\t{results.length > 0 &&\n\t\t\t\t\t\tresults.map((item, index) =>\n\t\t\t\t\t\t\trenderResult(item, index, debouncedQuery),\n\t\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { SearchIcon } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useTranslate } from \"@btst/stack/context\";\nimport { useDebounce } from \"@btst/stack/plugins/blog/client/hooks\";\n\nimport {\n\tCommandDialog,\n\tCommandEmpty,\n\tCommandInput,\n\tCommandList,\n} from \"@/components/ui/command\";\n\nexport interface SearchResult {\n\tid: string;\n\ttitle: string;\n\tsubtitle?: string;\n\tcontent?: string;\n\tonClick?: () => void;\n}\n\nexport interface SearchModalProps {\n\tplaceholder?: string;\n\temptyMessage?: string;\n\tbuttonText?: string;\n\tkeyboardShortcut?: string;\n\tsearchFn: (query: string) => T[];\n\trenderResult: (item: T, index: number, query: string) => React.ReactNode;\n\tresults?: T[];\n\tisLoading?: boolean;\n\tgroupTitle?: string;\n\tclassName?: string;\n\ttriggerClassName?: string;\n\t/**\n\t * Query the input is seeded with when the dialog opens (e.g. an\n\t * externally persisted `?q=` value), so opening the modal never clears it.\n\t */\n\tinitialQuery?: string;\n}\n\nexport function SearchModal({\n\tplaceholder,\n\temptyMessage,\n\tbuttonText,\n\tkeyboardShortcut = \"⌘K\",\n\tsearchFn,\n\trenderResult,\n\tresults: externalResults,\n\tisLoading = false,\n\tclassName,\n\ttriggerClassName,\n\tinitialQuery = \"\",\n}: SearchModalProps) {\n\tconst t = useTranslate();\n\tconst resolvedPlaceholder =\n\t\tplaceholder ?? t(\"blog.search.placeholder\", \"Type to search...\");\n\tconst resolvedEmptyMessage =\n\t\temptyMessage ?? t(\"blog.search.empty\", \"No results found.\");\n\tconst resolvedButtonText = buttonText ?? t(\"blog.search.button\", \"Search\");\n\tconst [open, setOpen] = React.useState(false);\n\tconst [query, setQuery] = React.useState(initialQuery);\n\tconst [results, setResults] = React.useState([]);\n\n\t// Tracks the last query passed to searchFn so externally persisted state\n\t// (e.g. a `?q=` URL param) is not cleared by the seed value on open.\n\tconst lastSentQueryRef = React.useRef(initialQuery);\n\t// Latest seed value without retriggering the open handler.\n\tconst initialQueryRef = React.useRef(initialQuery);\n\tinitialQueryRef.current = initialQuery;\n\n\t// Seed the input in the same event that opens the dialog, so the first\n\t// open render (and its effects) already sees the persisted query and never\n\t// clears it back through searchFn.\n\tconst openRef = React.useRef(open);\n\topenRef.current = open;\n\tconst handleOpenChange = React.useCallback((nextOpen: boolean) => {\n\t\tif (nextOpen && !openRef.current) {\n\t\t\tconst seed = initialQueryRef.current;\n\t\t\tsetQuery(seed);\n\t\t\tlastSentQueryRef.current = seed;\n\t\t}\n\t\tsetOpen(nextOpen);\n\t}, []);\n\n\t// Only debounce if not using external results\n\tconst shouldDebounce = externalResults === undefined;\n\tconst debouncedQuery = useDebounce(query, shouldDebounce ? 300 : 0);\n\n\t// Handle keyboard shortcut\n\tReact.useEffect(() => {\n\t\tconst down = (e: KeyboardEvent) => {\n\t\t\tconst cleanShortcut = keyboardShortcut\n\t\t\t\t.replace(\"⌘\", \"\")\n\t\t\t\t.replace(\"⇧\", \"\")\n\t\t\t\t.toLowerCase();\n\t\t\tif (e.key === cleanShortcut && (e.metaKey || e.ctrlKey)) {\n\t\t\t\te.preventDefault();\n\n\t\t\t\thandleOpenChange(!openRef.current);\n\t\t\t}\n\t\t};\n\n\t\tdocument.addEventListener(\"keydown\", down);\n\t\treturn () => document.removeEventListener(\"keydown\", down);\n\t}, [keyboardShortcut, handleOpenChange]);\n\n\tReact.useEffect(() => {\n\t\tif (!open) {\n\t\t\tsetResults([]);\n\t\t}\n\t}, [open]);\n\n\t// Notify the parent of query changes. Deliberately NOT keyed on\n\t// `externalResults`: searchFn may update URL-synced state, and re-invoking\n\t// it whenever results change identity can restart an in-flight router\n\t// navigation on every render (infinite loop on React Router/Next.js).\n\tconst hasExternalResults = externalResults !== undefined;\n\tReact.useEffect(() => {\n\t\tif (!open) return;\n\n\t\tif (hasExternalResults) {\n\t\t\t// External mode: only notify once the debounce settled on a value the\n\t\t\t// parent hasn't seen, so opening the modal never overwrites the\n\t\t\t// persisted query with the transient empty/seed value.\n\t\t\tif (debouncedQuery !== query) return;\n\t\t\tif (debouncedQuery === lastSentQueryRef.current) return;\n\t\t\tlastSentQueryRef.current = debouncedQuery;\n\t\t\tsearchFn(debouncedQuery);\n\t\t\treturn;\n\t\t}\n\n\t\tsetResults(searchFn(debouncedQuery));\n\t}, [debouncedQuery, query, open, searchFn, hasExternalResults]);\n\n\t// Sync externally-provided (async) results into the list.\n\tReact.useEffect(() => {\n\t\tif (!open || externalResults === undefined) return;\n\t\tsetResults(externalResults);\n\t}, [open, externalResults]);\n\n\t// Base button classes for better readability\n\tconst buttonClasses = [\n\t\t\"border-input bg-background text-foreground\",\n\t\t\"placeholder:text-muted-foreground\",\n\t\t\"focus-visible:border-ring focus-visible:ring-ring/50\",\n\t\t\"inline-flex h-9 w-fit rounded-md border px-3 py-2 text-sm\",\n\t\t\"shadow-xs transition-[color,box-shadow] outline-none\",\n\t\t\"focus-visible:ring-[3px]\",\n\t\ttriggerClassName,\n\t]\n\t\t.filter(Boolean)\n\t\t.join(\" \");\n\n\t// Determine what to show based on state\n\tconst showEmpty = debouncedQuery && !isLoading && results.length === 0;\n\tconst currentEmptyMessage = isLoading\n\t\t? t(\"blog.search.searching\", \"Searching...\")\n\t\t: resolvedEmptyMessage;\n\n\treturn (\n\t\t<>\n\t\t\t handleOpenChange(true)}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{resolvedButtonText}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t{keyboardShortcut && (\n\t\t\t\t\t\n\t\t\t\t\t\t{keyboardShortcut}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{showEmpty && {currentEmptyMessage}}\n\n\t\t\t\t\t{results.length > 0 &&\n\t\t\t\t\t\tresults.map((item, index) =>\n\t\t\t\t\t\t\trenderResult(item, index, debouncedQuery),\n\t\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", "target": "src/components/btst/blog/client/components/shared/search-modal.tsx" }, { diff --git a/packages/stack/src/__tests__/use-list-state.test.tsx b/packages/stack/src/__tests__/use-list-state.test.tsx index cf905d51..f3ffd73d 100644 --- a/packages/stack/src/__tests__/use-list-state.test.tsx +++ b/packages/stack/src/__tests__/use-list-state.test.tsx @@ -260,4 +260,94 @@ describe("useListState", () => { expect(router.setSearchParams).not.toHaveBeenCalled(); }); + + it("falls back to local state without router search-param bindings", async () => { + let captured: any; + + function Probe() { + const [state, setState] = useListState("comments-moderation", schema); + captured = { state, setState }; + return null; + } + + // StackProvider without getSearchParams/setSearchParams on the router. + await act(async () => { + root.render( + + + , + ); + }); + + expect(captured.state).toEqual({ tab: "pending", page: 1, filter: "" }); + + await act(async () => { + captured.setState({ tab: "spam", page: 3 }); + }); + expect(captured.state).toEqual({ tab: "spam", page: 3, filter: "" }); + + await act(async () => { + captured.setState((prev: any) => ({ page: prev.page + 1 })); + }); + expect(captured.state).toEqual({ tab: "spam", page: 4, filter: "" }); + }); + + it("still reads the URL when the router binding is read-only", async () => { + // Router exposes getSearchParams but no setSearchParams. + let params = new URLSearchParams("tab=spam&page=2"); + const router = { + getSearchParams: () => new URLSearchParams(params.toString()), + }; + let captured: any; + + function Probe() { + const [state, setState] = useListState("comments-moderation", schema); + captured = { state, setState }; + return null; + } + await act(async () => { + root.render( + + + , + ); + }); + + // URL-driven reads work as before + expect(captured.state).toEqual({ tab: "spam", page: 2, filter: "" }); + + // Writes land in the local overlay instead of being dropped + await act(async () => { + captured.setState({ filter: "abc" }); + }); + expect(captured.state).toEqual({ tab: "spam", page: 2, filter: "abc" }); + + // External URL changes (e.g. back/forward) still flow into state + params = new URLSearchParams("tab=pending&page=7"); + await act(async () => { + window.dispatchEvent(new PopStateEvent("popstate")); + }); + expect(captured.state).toEqual({ tab: "pending", page: 7, filter: "abc" }); + }); + + it("falls back to local state without a StackProvider", async () => { + let captured: any; + + function Probe() { + const [state, setState] = useListState("comments-moderation", schema); + captured = { state, setState }; + return null; + } + + await act(async () => { + root.render(); + }); + + expect(captured.state).toEqual({ tab: "pending", page: 1, filter: "" }); + + await act(async () => { + captured.setState({ filter: "abc" }); + }); + expect(captured.state).toEqual({ tab: "pending", page: 1, filter: "abc" }); + }); }); diff --git a/packages/stack/src/client/hooks/use-list-state.ts b/packages/stack/src/client/hooks/use-list-state.ts index b180844a..0f5f6650 100644 --- a/packages/stack/src/client/hooks/use-list-state.ts +++ b/packages/stack/src/client/hooks/use-list-state.ts @@ -1,5 +1,5 @@ "use client"; -import { useCallback, useSyncExternalStore } from "react"; +import { useCallback, useState, useSyncExternalStore } from "react"; import { useStackOrNull } from "../../context/provider"; import type { InferListState, ListStateSchema } from "../../shared/list-state"; import { @@ -186,21 +186,42 @@ export function useListState( const router = stack?.router; const getSearchParams = router?.getSearchParams; const setSearchParams = router?.setSearchParams; + const hasWriteBinding = !!getSearchParams && !!setSearchParams; // Every instance re-renders whenever any instance flushes a URL update (or // on back/forward), so siblings sharing query keys never serve stale state. // Search params are re-read on every render, so router-driven changes are // also picked up even when `getSearchParams` is referentially stable. useSyncExternalStore(subscribeToUrl, getUrlVersion, getServerUrlVersion); - const state = parseListStateFromSearchParams( + const urlState = parseListStateFromSearchParams( namespace, schema, getSearchParams?.() ?? new URLSearchParams(), ); + // Without a URL write binding (no StackProvider router, or a custom router + // missing getSearchParams/setSearchParams) the hook degrades to local + // state instead of silently dropping updates. Local patches overlay the + // URL state so a read-only `getSearchParams` binding still drives reads + // (back/forward, external URL changes) exactly as before. + const [localPatch, setLocalPatch] = useState>>({}); + const setState = useCallback>( (updates, options) => { - if (!setSearchParams || !getSearchParams) return; + if (!setSearchParams || !getSearchParams) { + setLocalPatch((prevPatch) => { + const urlBase = parseListStateFromSearchParams( + namespace, + schema, + getSearchParams?.() ?? new URLSearchParams(), + ); + const prev = { ...urlBase, ...prevPatch }; + const patch = typeof updates === "function" ? updates(prev) : updates; + if (!patch || Object.keys(patch).length === 0) return prevPatch; + return { ...prevPatch, ...patch }; + }); + return; + } // Base state = URL state + patches already queued this tick, so // functional updaters see the values earlier calls just set. @@ -232,5 +253,8 @@ export function useListState( [namespace, schema, setSearchParams, getSearchParams], ); - return [state, setState]; + return [ + hasWriteBinding ? urlState : { ...urlState, ...localPatch }, + setState, + ]; } diff --git a/packages/stack/src/plugins/blog/__tests__/client-sweep.test.tsx b/packages/stack/src/plugins/blog/__tests__/client-sweep.test.tsx new file mode 100644 index 00000000..a7053a70 --- /dev/null +++ b/packages/stack/src/plugins/blog/__tests__/client-sweep.test.tsx @@ -0,0 +1,415 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useForm } from "react-hook-form"; +import { Form, FormField } from "@workspace/ui/components/form"; +// Core primitives MUST be imported from the package entry (not relative src +// paths) so they share module identity — and React context — with the blog +// components, which resolve `@btst/stack/*` via package self-reference. +import { + StackProvider, + type StackAuthProvider, + type StackI18nProvider, +} from "@btst/stack/context"; +import { FeaturedImageField } from "../client/components/forms/image-field"; +import { EditPostForm } from "../client/components/forms/post-forms"; +import { PostsList } from "../client/components/shared/posts-list"; +import { SearchInput } from "../client/components/shared/search-input"; +import type { SerializedPost } from "../types"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +// jsdom lacks these APIs used by Radix / cmdk +(globalThis as any).ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +}; +Element.prototype.scrollIntoView ??= () => {}; + +const hooks = vi.hoisted(() => ({ + useSuspensePost: vi.fn(), + useDeletePost: vi.fn(), + usePostForm: vi.fn(), + usePostSearch: vi.fn(), + useTags: vi.fn(), +})); + +vi.mock("../client/hooks/blog-hooks", () => hooks); + +// The markdown editor pulls in Milkdown/Crepe — irrelevant here and unusable +// in jsdom, so stub the lazy-loaded module. +vi.mock("../client/components/forms/markdown-editor-with-overrides", () => ({ + MarkdownEditorWithOverrides: () => null, +})); + +const post: SerializedPost = { + id: "p1", + title: "Hello World", + content: "# Hello", + excerpt: "An excerpt", + slug: "hello-world", + published: true, + image: "", + tags: [], + authorId: null, + publishedAt: new Date("2024-01-01").toISOString(), + createdAt: new Date("2024-01-01").toISOString(), + updatedAt: new Date("2024-01-01").toISOString(), +} as unknown as SerializedPost; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + hooks.useSuspensePost.mockReturnValue({ post, refetch: vi.fn() }); + hooks.useDeletePost.mockReturnValue({ + mutateAsync: vi.fn(), + isPending: false, + }); + hooks.usePostForm.mockReturnValue({ + submit: vi.fn(), + isSubmitting: false, + error: null, + fieldErrors: {}, + defaultValues: { + title: post.title, + content: post.content, + excerpt: post.excerpt, + slug: post.slug, + published: post.published, + image: "", + tags: [], + }, + }); + hooks.usePostSearch.mockReturnValue({ data: [], isLoading: false }); + hooks.useTags.mockReturnValue({ tags: [], isLoading: false, error: null }); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +async function render(ui: React.ReactElement) { + await act(async () => { + root.render(ui); + }); +} + +function texts(): string { + return container.textContent ?? ""; +} + +// Renders FeaturedImageField the way post-forms does: inside a react-hook-form +// FormField so the shadcn form primitives have their context. +function ImageFieldHarness() { + const form = useForm<{ image: string }>({ defaultValues: { image: "" } }); + return ( +
+ + ( + {}} + /> + )} + /> + + + ); +} + +async function selectFile(file: File) { + const input = container.querySelector( + 'input[type="file"]', + ) as HTMLInputElement; + expect(input).toBeTruthy(); + Object.defineProperty(input, "files", { value: [file], configurable: true }); + await act(async () => { + input.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +describe("FeaturedImageField notifications (useNotify)", () => { + it("notifies success through the configured notify provider on upload", async () => { + const notify = { success: vi.fn(), error: vi.fn() }; + const uploadImage = vi.fn().mockResolvedValue("https://cdn/img.png"); + + await render( + + + , + ); + + await selectFile(new File(["x"], "a.png", { type: "image/png" })); + + expect(uploadImage).toHaveBeenCalledOnce(); + expect(notify.success).toHaveBeenCalledWith("Image uploaded successfully"); + expect(notify.error).not.toHaveBeenCalled(); + }); + + it("notifies a single error when the upload fails", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const notify = { success: vi.fn(), error: vi.fn() }; + const uploadImage = vi.fn().mockRejectedValue(new Error("boom")); + + await render( + + + , + ); + + await selectFile(new File(["x"], "a.png", { type: "image/png" })); + + expect(notify.error).toHaveBeenCalledTimes(1); + expect(notify.error).toHaveBeenCalledWith("Failed to upload image"); + expect(notify.success).not.toHaveBeenCalled(); + }); + + it("rejects non-image files without uploading", async () => { + const notify = { success: vi.fn(), error: vi.fn() }; + const uploadImage = vi.fn(); + + await render( + + + , + ); + + await selectFile(new File(["x"], "a.txt", { type: "text/plain" })); + + expect(uploadImage).not.toHaveBeenCalled(); + expect(notify.error).toHaveBeenCalledWith("Please select an image file"); + }); +}); + +describe("EditPostForm delete control (CanAccess)", () => { + it("shows the delete button without an auth provider", async () => { + await render( + + {}} + onSuccess={() => {}} + /> + , + ); + + expect(texts()).toContain("Delete Post"); + }); + + it("hides the delete button when can() denies blog:post/delete", async () => { + const can = vi.fn( + ({ resource, action }: { resource: string; action: string }) => + !(resource === "blog:post" && action === "delete"), + ); + const auth: StackAuthProvider = { + getIdentity: () => ({ id: "user-1" }), + can, + }; + + await render( + + {}} + onSuccess={() => {}} + /> + , + ); + + // The rest of the form still renders; only the delete control is gone. + expect(texts()).toContain("Update Post"); + expect(texts()).not.toContain("Delete Post"); + expect(can).toHaveBeenCalledWith( + expect.objectContaining({ + resource: "blog:post", + action: "delete", + params: { id: post.id }, + }), + ); + }); +}); + +describe("SearchInput list state (useListState)", () => { + function createMockRouter(initial = "") { + let params = new URLSearchParams(initial); + const setSearchParams = vi.fn( + (next: URLSearchParams, _opts?: { replace?: boolean }) => { + params = new URLSearchParams(next.toString()); + }, + ); + return { + navigate: vi.fn(), + getSearchParams: () => new URLSearchParams(params.toString()), + setSearchParams, + }; + } + + it("seeds the search query from an initial ?q= URL param", async () => { + const router = createMockRouter("q=hello"); + + await render( + + + , + ); + + expect(hooks.usePostSearch).toHaveBeenCalledWith( + expect.objectContaining({ query: "hello", enabled: true }), + ); + // Nothing is written back for a read-only render + expect(router.setSearchParams).not.toHaveBeenCalled(); + }); + + it("writes typed queries to the URL with replace history", async () => { + const router = createMockRouter(); + + await render( + + + , + ); + + // Open the search modal + const trigger = container.querySelector( + '[data-testid="search-button"]', + ) as HTMLButtonElement; + expect(trigger).toBeTruthy(); + await act(async () => { + trigger.click(); + }); + + // Type into the cmdk input (rendered in a portal on document.body) + const input = document.querySelector( + '[data-testid="search-input"]', + ) as HTMLInputElement; + expect(input).toBeTruthy(); + const setValue = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!; + await act(async () => { + setValue.call(input, "typescript"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + // Wait out the modal's debounce, then the microtask URL flush + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 650)); + }); + + expect(router.setSearchParams).toHaveBeenCalled(); + const [written, opts] = router.setSearchParams.mock.calls.at(-1)!; + expect(written.get("q")).toBe("typescript"); + expect(opts).toEqual({ replace: true }); + expect(hooks.usePostSearch).toHaveBeenLastCalledWith( + expect.objectContaining({ query: "typescript" }), + ); + }); + + it("opening the modal seeds the input from ?q= and does not clear the URL", async () => { + const router = createMockRouter("q=hello"); + + await render( + + + , + ); + + const trigger = container.querySelector( + '[data-testid="search-button"]', + ) as HTMLButtonElement; + await act(async () => { + trigger.click(); + }); + + const input = document.querySelector( + '[data-testid="search-input"]', + ) as HTMLInputElement; + expect(input).toBeTruthy(); + expect(input.value).toBe("hello"); + + // Let the debounce settle: the persisted query must survive the open. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 650)); + }); + + expect(router.setSearchParams).not.toHaveBeenCalled(); + expect(router.getSearchParams().get("q")).toBe("hello"); + }); +}); + +describe("blog i18n precedence (useTranslate + overrides.localization)", () => { + it("renders the English default without providers", async () => { + await render( + + + , + ); + + expect(texts()).toContain("There are no posts here yet."); + }); + + it("routes strings through the i18n provider when configured", async () => { + const i18n: StackI18nProvider = { + translate: (key, defaultValue) => + key === "blog.list.empty" ? "Noch keine Posts." : defaultValue, + }; + + await render( + + + , + ); + + expect(texts()).toContain("Noch keine Posts."); + }); + + it("lets overrides.localization win over the i18n provider", async () => { + const translate = vi.fn( + (key: string, defaultValue: string) => `translated:${key}`, + ); + + await render( + + + , + ); + + expect(texts()).toContain("Custom empty state"); + expect(texts()).not.toContain("translated:blog.list.empty"); + }); +}); diff --git a/packages/stack/src/plugins/blog/api/plugin.ts b/packages/stack/src/plugins/blog/api/plugin.ts index 274528da..2fbbed3b 100644 --- a/packages/stack/src/plugins/blog/api/plugin.ts +++ b/packages/stack/src/plugins/blog/api/plugin.ts @@ -105,7 +105,7 @@ export const PostListQuerySchema = z.object({ tagSlug: z.string().optional(), offset: z.coerce.number().int().min(0).optional(), limit: z.coerce.number().int().min(1).max(100).optional(), - query: z.string().optional(), + query: z.string().max(200).optional(), published: z .string() .optional() diff --git a/packages/stack/src/plugins/blog/client/components/forms/image-field.tsx b/packages/stack/src/plugins/blog/client/components/forms/image-field.tsx index 8c553df8..b3c935c2 100644 --- a/packages/stack/src/plugins/blog/client/components/forms/image-field.tsx +++ b/packages/stack/src/plugins/blog/client/components/forms/image-field.tsx @@ -7,12 +7,14 @@ import { FormMessage, } from "@workspace/ui/components/form"; import { Input } from "@workspace/ui/components/input"; -import { usePluginOverrides } from "@btst/stack/context"; +import { + useNotify, + usePluginOverrides, + useTranslate, +} from "@btst/stack/context"; import { Loader2, Upload } from "lucide-react"; import { useRef, useState } from "react"; -import { toast } from "sonner"; import type { BlogPluginOverrides } from "../../overrides"; -import { BLOG_LOCALIZATION } from "../../localization"; export function FeaturedImageField({ isRequired, @@ -27,32 +29,37 @@ export function FeaturedImageField({ }) { const fileInputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); + const notify = useNotify(); + const t = useTranslate(); const { uploadImage, Image, localization, imageInputField: ImageInput, - } = usePluginOverrides>( - "blog", - { localization: BLOG_LOCALIZATION }, - ); + } = usePluginOverrides("blog"); const ImageComponent = Image ? Image : DefaultImage; + const label = ( + + {localization?.BLOG_FORMS_FEATURED_IMAGE_LABEL ?? + t("blog.forms.featuredImageLabel", "Image")} + {isRequired && ( + + {" "} + {localization?.BLOG_FORMS_FEATURED_IMAGE_REQUIRED_ASTERISK ?? + t("blog.forms.featuredImageRequiredAsterisk", " *")} + + )} + + ); + // When a custom imageInput component is provided via overrides, delegate to it. if (ImageInput) { return ( - - {localization.BLOG_FORMS_FEATURED_IMAGE_LABEL} - {isRequired && ( - - {" "} - {localization.BLOG_FORMS_FEATURED_IMAGE_REQUIRED_ASTERISK} - - )} - + {label} 4 * 1024 * 1024) { - toast.error(localization.BLOG_FORMS_FEATURED_IMAGE_ERROR_TOO_LARGE); + notify.error( + localization?.BLOG_FORMS_FEATURED_IMAGE_ERROR_TOO_LARGE ?? + t( + "blog.forms.featuredImageErrorTooLarge", + "Image size must be less than 4MB", + ), + ); return; } @@ -87,11 +106,19 @@ export function FeaturedImageField({ setFeaturedImageUploading(true); const url = await uploadImage(file); onChange(url); - toast.success(localization.BLOG_FORMS_FEATURED_IMAGE_TOAST_SUCCESS); + notify.success( + localization?.BLOG_FORMS_FEATURED_IMAGE_TOAST_SUCCESS ?? + t( + "blog.forms.featuredImageToastSuccess", + "Image uploaded successfully", + ), + ); } catch (error) { - toast.error(localization.BLOG_FORMS_FEATURED_IMAGE_TOAST_FAILURE); console.error("Failed to upload image:", error); - toast.error(localization.BLOG_FORMS_FEATURED_IMAGE_TOAST_FAILURE); + notify.error( + localization?.BLOG_FORMS_FEATURED_IMAGE_TOAST_FAILURE ?? + t("blog.forms.featuredImageToastFailure", "Failed to upload image"), + ); } finally { setIsUploading(false); setFeaturedImageUploading(false); @@ -100,21 +127,17 @@ export function FeaturedImageField({ return ( - - {localization.BLOG_FORMS_FEATURED_IMAGE_LABEL} - {isRequired && ( - - {" "} - {localization.BLOG_FORMS_FEATURED_IMAGE_REQUIRED_ASTERISK} - - )} - + {label}
onChange(e.target.value)} @@ -129,12 +152,17 @@ export function FeaturedImageField({ {isUploading ? ( <> - {localization.BLOG_FORMS_FEATURED_IMAGE_UPLOADING_BUTTON} + {localization?.BLOG_FORMS_FEATURED_IMAGE_UPLOADING_BUTTON ?? + t( + "blog.forms.featuredImageUploadingButton", + "Uploading...", + )} ) : ( <> - {localization.BLOG_FORMS_FEATURED_IMAGE_UPLOAD_BUTTON} + {localization?.BLOG_FORMS_FEATURED_IMAGE_UPLOAD_BUTTON ?? + t("blog.forms.featuredImageUploadButton", "Upload")} )} @@ -149,14 +177,24 @@ export function FeaturedImageField({ {isUploading && (
- {localization.BLOG_FORMS_FEATURED_IMAGE_UPLOADING_TEXT} + {localization?.BLOG_FORMS_FEATURED_IMAGE_UPLOADING_TEXT ?? + t( + "blog.forms.featuredImageUploadingText", + "Uploading image...", + )}
)} {value && !isUploading && (
>( - "blog", - { localization: BLOG_LOCALIZATION }, - ); + } = usePluginOverrides("blog"); const insertImageRef = useRef<((url: string) => void) | null>(null); // Holds the Crepe-image-block `setUrl` callback while the picker is open. @@ -64,7 +61,10 @@ export function MarkdownEditorWithOverrides( default: module.MarkdownEditorWithOverrides, })), ); -import { BLOG_LOCALIZATION } from "../../localization"; -import { useNotify, usePluginOverrides } from "@btst/stack/context"; +import { + CanAccess, + useNotify, + usePluginOverrides, + useTranslate, + type TranslateFn, +} from "@btst/stack/context"; import type { BlogPluginOverrides } from "../../overrides"; import { EmptyList } from "../shared/empty-list"; import { TagsMultiSelect } from "./tags-multiselect"; @@ -123,13 +136,12 @@ function PostFormBody({ setFeaturedImageUploading: (uploading: boolean) => void; initialSlugTouched?: boolean; }) { - const { localization } = usePluginOverrides< - BlogPluginOverrides, - Partial - >("blog", { - localization: BLOG_LOCALIZATION, - }); + const t = useTranslate(); + const { localization } = usePluginOverrides("blog"); const [slugTouched, setSlugTouched] = useState(initialSlugTouched); + const requiredAsterisk = + localization?.BLOG_FORMS_REQUIRED_ASTERISK ?? + t("blog.forms.requiredAsterisk", " *"); const nameTitle = "title" as FieldPath; const nameSlug = "slug" as FieldPath; const nameExcerpt = "excerpt" as FieldPath; @@ -152,14 +164,16 @@ function PostFormBody({ render={({ field }) => ( - {localization.BLOG_FORMS_TITLE_LABEL} - - {localization.BLOG_FORMS_REQUIRED_ASTERISK} - + {localization?.BLOG_FORMS_TITLE_LABEL ?? + t("blog.forms.titleLabel", "Title")} + {requiredAsterisk} { @@ -188,10 +202,16 @@ function PostFormBody({ return ( - {localization.BLOG_FORMS_SLUG_LABEL} + + {localization?.BLOG_FORMS_SLUG_LABEL ?? + t("blog.forms.slugLabel", "Slug")} + { @@ -217,14 +237,19 @@ function PostFormBody({ render={({ field }) => ( - {localization.BLOG_FORMS_EXCERPT_LABEL} - - {localization.BLOG_FORMS_REQUIRED_ASTERISK} - + {localization?.BLOG_FORMS_EXCERPT_LABEL ?? + t("blog.forms.excerptLabel", "Excerpt")} + {requiredAsterisk}