@btst/stack 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/README.md +156 -709
  2. package/dist/api/index.cjs +2 -1
  3. package/dist/api/index.d.cts +4 -3
  4. package/dist/api/index.d.mts +4 -3
  5. package/dist/api/index.d.ts +4 -3
  6. package/dist/api/index.mjs +1 -1
  7. package/dist/client/components/compose.cjs +68 -0
  8. package/dist/client/components/compose.mjs +65 -0
  9. package/dist/client/components/error-boundary.cjs +24 -0
  10. package/dist/client/components/error-boundary.mjs +22 -0
  11. package/dist/client/components/index.cjs +10 -0
  12. package/dist/client/components/index.d.cts +52 -0
  13. package/dist/client/components/index.d.mts +52 -0
  14. package/dist/client/components/index.d.ts +52 -0
  15. package/dist/client/components/index.mjs +2 -0
  16. package/dist/client/index.cjs +24 -5
  17. package/dist/client/index.d.cts +125 -8
  18. package/dist/client/index.d.mts +125 -8
  19. package/dist/client/index.d.ts +125 -8
  20. package/dist/client/index.mjs +21 -4
  21. package/dist/client/meta-utils.cjs +162 -0
  22. package/dist/client/meta-utils.mjs +160 -0
  23. package/dist/client/path-utils.cjs +15 -0
  24. package/dist/client/path-utils.mjs +13 -0
  25. package/dist/client/sitemap-utils.cjs +14 -0
  26. package/dist/client/sitemap-utils.mjs +12 -0
  27. package/dist/context/index.cjs +6 -63
  28. package/dist/context/index.d.cts +21 -24
  29. package/dist/context/index.d.mts +21 -24
  30. package/dist/context/index.d.ts +21 -24
  31. package/dist/context/index.mjs +1 -61
  32. package/dist/context/provider.cjs +51 -0
  33. package/dist/context/provider.mjs +46 -0
  34. package/dist/index.cjs +2 -3
  35. package/dist/index.d.cts +3 -2
  36. package/dist/index.d.mts +3 -2
  37. package/dist/index.d.ts +3 -2
  38. package/dist/index.mjs +1 -2
  39. package/dist/plugins/api/index.cjs +13 -0
  40. package/dist/plugins/api/index.d.cts +40 -0
  41. package/dist/plugins/api/index.d.mts +40 -0
  42. package/dist/plugins/api/index.d.ts +40 -0
  43. package/dist/plugins/api/index.mjs +8 -0
  44. package/dist/plugins/blog/api/index.cjs +11 -0
  45. package/dist/plugins/blog/api/index.d.cts +7 -0
  46. package/dist/plugins/blog/api/index.d.mts +7 -0
  47. package/dist/plugins/blog/api/index.d.ts +7 -0
  48. package/dist/plugins/blog/api/index.mjs +2 -0
  49. package/dist/plugins/blog/api/plugin.cjs +569 -0
  50. package/dist/plugins/blog/api/plugin.mjs +565 -0
  51. package/dist/plugins/blog/client/components/forms/image-field.cjs +133 -0
  52. package/dist/plugins/blog/client/components/forms/image-field.mjs +131 -0
  53. package/dist/plugins/blog/client/components/forms/markdown-editor-styles.css +30 -0
  54. package/dist/plugins/blog/client/components/forms/markdown-editor.cjs +106 -0
  55. package/dist/plugins/blog/client/components/forms/markdown-editor.mjs +104 -0
  56. package/dist/plugins/blog/client/components/forms/post-forms.cjs +401 -0
  57. package/dist/plugins/blog/client/components/forms/post-forms.mjs +398 -0
  58. package/dist/plugins/blog/client/components/forms/tags-multiselect.cjs +71 -0
  59. package/dist/plugins/blog/client/components/forms/tags-multiselect.mjs +65 -0
  60. package/dist/plugins/blog/client/components/index.cjs +17 -0
  61. package/dist/plugins/blog/client/components/index.d.cts +22 -0
  62. package/dist/plugins/blog/client/components/index.d.mts +22 -0
  63. package/dist/plugins/blog/client/components/index.d.ts +22 -0
  64. package/dist/plugins/blog/client/components/index.mjs +12 -0
  65. package/dist/plugins/blog/client/components/loading/form-page-skeleton.cjs +62 -0
  66. package/dist/plugins/blog/client/components/loading/form-page-skeleton.mjs +60 -0
  67. package/dist/plugins/blog/client/components/loading/index.cjs +20 -0
  68. package/dist/plugins/blog/client/components/loading/index.mjs +16 -0
  69. package/dist/plugins/blog/client/components/loading/list-page-skeleton.cjs +26 -0
  70. package/dist/plugins/blog/client/components/loading/list-page-skeleton.mjs +24 -0
  71. package/dist/plugins/blog/client/components/loading/page-header-skeleton.cjs +13 -0
  72. package/dist/plugins/blog/client/components/loading/page-header-skeleton.mjs +11 -0
  73. package/dist/plugins/blog/client/components/loading/post-card-skeleton.cjs +22 -0
  74. package/dist/plugins/blog/client/components/loading/post-card-skeleton.mjs +20 -0
  75. package/dist/plugins/blog/client/components/loading/post-page-skeleton.cjs +56 -0
  76. package/dist/plugins/blog/client/components/loading/post-page-skeleton.mjs +54 -0
  77. package/dist/plugins/blog/client/components/pages/404-page.cjs +19 -0
  78. package/dist/plugins/blog/client/components/pages/404-page.mjs +17 -0
  79. package/dist/plugins/blog/client/components/pages/edit-post-page.cjs +41 -0
  80. package/dist/plugins/blog/client/components/pages/edit-post-page.internal.cjs +57 -0
  81. package/dist/plugins/blog/client/components/pages/edit-post-page.internal.mjs +55 -0
  82. package/dist/plugins/blog/client/components/pages/edit-post-page.mjs +39 -0
  83. package/dist/plugins/blog/client/components/pages/home-page.cjs +41 -0
  84. package/dist/plugins/blog/client/components/pages/home-page.internal.cjs +61 -0
  85. package/dist/plugins/blog/client/components/pages/home-page.internal.mjs +59 -0
  86. package/dist/plugins/blog/client/components/pages/home-page.mjs +39 -0
  87. package/dist/plugins/blog/client/components/pages/new-post-page.cjs +37 -0
  88. package/dist/plugins/blog/client/components/pages/new-post-page.internal.cjs +53 -0
  89. package/dist/plugins/blog/client/components/pages/new-post-page.internal.mjs +51 -0
  90. package/dist/plugins/blog/client/components/pages/new-post-page.mjs +35 -0
  91. package/dist/plugins/blog/client/components/pages/post-page.cjs +39 -0
  92. package/dist/plugins/blog/client/components/pages/post-page.internal.cjs +101 -0
  93. package/dist/plugins/blog/client/components/pages/post-page.internal.mjs +99 -0
  94. package/dist/plugins/blog/client/components/pages/post-page.mjs +37 -0
  95. package/dist/plugins/blog/client/components/pages/tag-page.cjs +39 -0
  96. package/dist/plugins/blog/client/components/pages/tag-page.internal.cjs +61 -0
  97. package/dist/plugins/blog/client/components/pages/tag-page.internal.mjs +59 -0
  98. package/dist/plugins/blog/client/components/pages/tag-page.mjs +37 -0
  99. package/dist/plugins/blog/client/components/shared/better-blog-attribution.cjs +24 -0
  100. package/dist/plugins/blog/client/components/shared/better-blog-attribution.mjs +22 -0
  101. package/dist/plugins/blog/client/components/shared/default-error.cjs +18 -0
  102. package/dist/plugins/blog/client/components/shared/default-error.mjs +16 -0
  103. package/dist/plugins/blog/client/components/shared/defaults.cjs +13 -0
  104. package/dist/plugins/blog/client/components/shared/defaults.mjs +10 -0
  105. package/dist/plugins/blog/client/components/shared/empty-list.cjs +21 -0
  106. package/dist/plugins/blog/client/components/shared/empty-list.mjs +19 -0
  107. package/dist/plugins/blog/client/components/shared/error-placeholder.cjs +24 -0
  108. package/dist/plugins/blog/client/components/shared/error-placeholder.mjs +22 -0
  109. package/dist/plugins/blog/client/components/shared/highlight-text.cjs +53 -0
  110. package/dist/plugins/blog/client/components/shared/highlight-text.mjs +51 -0
  111. package/dist/plugins/blog/client/components/shared/markdown-content-styles.css +328 -0
  112. package/dist/plugins/blog/client/components/shared/markdown-content.cjs +324 -0
  113. package/dist/plugins/blog/client/components/shared/markdown-content.mjs +315 -0
  114. package/dist/plugins/blog/client/components/shared/on-this-page.cjs +161 -0
  115. package/dist/plugins/blog/client/components/shared/on-this-page.mjs +158 -0
  116. package/dist/plugins/blog/client/components/shared/page-header.cjs +40 -0
  117. package/dist/plugins/blog/client/components/shared/page-header.mjs +38 -0
  118. package/dist/plugins/blog/client/components/shared/page-layout.cjs +24 -0
  119. package/dist/plugins/blog/client/components/shared/page-layout.mjs +22 -0
  120. package/dist/plugins/blog/client/components/shared/page-wrapper.cjs +23 -0
  121. package/dist/plugins/blog/client/components/shared/page-wrapper.mjs +21 -0
  122. package/dist/plugins/blog/client/components/shared/post-card.cjs +279 -0
  123. package/dist/plugins/blog/client/components/shared/post-card.mjs +277 -0
  124. package/dist/plugins/blog/client/components/shared/post-navigation.cjs +74 -0
  125. package/dist/plugins/blog/client/components/shared/post-navigation.mjs +72 -0
  126. package/dist/plugins/blog/client/components/shared/posts-list.cjs +48 -0
  127. package/dist/plugins/blog/client/components/shared/posts-list.mjs +46 -0
  128. package/dist/plugins/blog/client/components/shared/recent-posts-carousel.cjs +59 -0
  129. package/dist/plugins/blog/client/components/shared/recent-posts-carousel.mjs +57 -0
  130. package/dist/plugins/blog/client/components/shared/search-input.cjs +136 -0
  131. package/dist/plugins/blog/client/components/shared/search-input.mjs +117 -0
  132. package/dist/plugins/blog/client/components/shared/search-modal.cjs +135 -0
  133. package/dist/plugins/blog/client/components/shared/search-modal.mjs +116 -0
  134. package/dist/plugins/blog/client/components/shared/tags-list.cjs +22 -0
  135. package/dist/plugins/blog/client/components/shared/tags-list.mjs +20 -0
  136. package/dist/plugins/blog/client/components/shared/use-route-lifecycle.cjs +50 -0
  137. package/dist/plugins/blog/client/components/shared/use-route-lifecycle.mjs +48 -0
  138. package/dist/plugins/blog/client/hooks/blog-hooks.cjs +380 -0
  139. package/dist/plugins/blog/client/hooks/blog-hooks.mjs +368 -0
  140. package/dist/plugins/blog/client/hooks/index.cjs +17 -0
  141. package/dist/plugins/blog/client/hooks/index.d.cts +150 -0
  142. package/dist/plugins/blog/client/hooks/index.d.mts +150 -0
  143. package/dist/plugins/blog/client/hooks/index.d.ts +150 -0
  144. package/dist/plugins/blog/client/hooks/index.mjs +1 -0
  145. package/dist/plugins/blog/client/hooks/use-debounce.cjs +16 -0
  146. package/dist/plugins/blog/client/hooks/use-debounce.mjs +14 -0
  147. package/dist/plugins/blog/client/index.cjs +7 -0
  148. package/dist/plugins/blog/client/index.d.cts +414 -0
  149. package/dist/plugins/blog/client/index.d.mts +414 -0
  150. package/dist/plugins/blog/client/index.d.ts +414 -0
  151. package/dist/plugins/blog/client/index.mjs +1 -0
  152. package/dist/plugins/blog/client/localization/blog-card.cjs +7 -0
  153. package/dist/plugins/blog/client/localization/blog-card.mjs +5 -0
  154. package/dist/plugins/blog/client/localization/blog-common.cjs +10 -0
  155. package/dist/plugins/blog/client/localization/blog-common.mjs +8 -0
  156. package/dist/plugins/blog/client/localization/blog-forms.cjs +40 -0
  157. package/dist/plugins/blog/client/localization/blog-forms.mjs +38 -0
  158. package/dist/plugins/blog/client/localization/blog-list.cjs +18 -0
  159. package/dist/plugins/blog/client/localization/blog-list.mjs +16 -0
  160. package/dist/plugins/blog/client/localization/blog-post.cjs +13 -0
  161. package/dist/plugins/blog/client/localization/blog-post.mjs +11 -0
  162. package/dist/plugins/blog/client/localization/index.cjs +17 -0
  163. package/dist/plugins/blog/client/localization/index.mjs +15 -0
  164. package/dist/plugins/blog/client/plugin.cjs +462 -0
  165. package/dist/plugins/blog/client/plugin.mjs +460 -0
  166. package/dist/plugins/blog/client.css +3 -0
  167. package/dist/plugins/blog/db.cjs +90 -0
  168. package/dist/plugins/blog/db.mjs +88 -0
  169. package/dist/plugins/blog/query-keys.cjs +181 -0
  170. package/dist/plugins/blog/query-keys.d.cts +530 -0
  171. package/dist/plugins/blog/query-keys.d.mts +530 -0
  172. package/dist/plugins/blog/query-keys.d.ts +530 -0
  173. package/dist/plugins/blog/query-keys.mjs +179 -0
  174. package/dist/plugins/blog/schemas.cjs +39 -0
  175. package/dist/plugins/blog/schemas.mjs +35 -0
  176. package/dist/plugins/blog/style.css +22 -0
  177. package/dist/plugins/blog/utils.cjs +97 -0
  178. package/dist/plugins/blog/utils.mjs +87 -0
  179. package/dist/plugins/client/index.cjs +15 -0
  180. package/dist/plugins/client/index.d.cts +57 -0
  181. package/dist/plugins/client/index.d.mts +57 -0
  182. package/dist/plugins/client/index.d.ts +57 -0
  183. package/dist/plugins/client/index.mjs +9 -0
  184. package/dist/{shared/stack.3OUyGp_E.mjs → plugins/utils.mjs} +1 -1
  185. package/dist/shared/{stack.DORw_1ps.d.cts → stack.ByOugz9d.d.cts} +17 -1
  186. package/dist/shared/{stack.DORw_1ps.d.mts → stack.ByOugz9d.d.mts} +17 -1
  187. package/dist/shared/{stack.DORw_1ps.d.ts → stack.ByOugz9d.d.ts} +17 -1
  188. package/dist/shared/stack.CoPoHVfV.d.cts +76 -0
  189. package/dist/shared/stack.CoPoHVfV.d.mts +76 -0
  190. package/dist/shared/stack.CoPoHVfV.d.ts +76 -0
  191. package/package.json +102 -14
  192. package/src/__tests__/plugins.test.tsx +539 -0
  193. package/src/__tests__/sitemap.test.ts +60 -0
  194. package/src/api/index.ts +75 -0
  195. package/src/client/components/compose.tsx +116 -0
  196. package/src/client/components/error-boundary.tsx +30 -0
  197. package/src/client/components/index.tsx +2 -0
  198. package/src/client/index.ts +109 -0
  199. package/src/client/meta-utils.ts +228 -0
  200. package/src/client/path-utils.ts +38 -0
  201. package/src/client/sitemap-utils.ts +46 -0
  202. package/src/context/index.ts +1 -0
  203. package/src/context/provider.tsx +157 -0
  204. package/src/index.ts +1 -0
  205. package/src/plugins/api/index.ts +50 -0
  206. package/src/plugins/blog/api/index.ts +2 -0
  207. package/src/plugins/blog/api/plugin.ts +759 -0
  208. package/src/plugins/blog/client/components/forms/image-field.tsx +165 -0
  209. package/src/plugins/blog/client/components/forms/markdown-editor-styles.css +30 -0
  210. package/src/plugins/blog/client/components/forms/markdown-editor.tsx +136 -0
  211. package/src/plugins/blog/client/components/forms/post-forms.tsx +531 -0
  212. package/src/plugins/blog/client/components/forms/tags-multiselect.tsx +79 -0
  213. package/src/plugins/blog/client/components/index.tsx +11 -0
  214. package/src/plugins/blog/client/components/loading/form-page-skeleton.tsx +75 -0
  215. package/src/plugins/blog/client/components/loading/index.tsx +27 -0
  216. package/src/plugins/blog/client/components/loading/list-page-skeleton.tsx +38 -0
  217. package/src/plugins/blog/client/components/loading/page-header-skeleton.tsx +10 -0
  218. package/src/plugins/blog/client/components/loading/post-card-skeleton.tsx +30 -0
  219. package/src/plugins/blog/client/components/loading/post-page-skeleton.tsx +75 -0
  220. package/src/plugins/blog/client/components/pages/404-page.tsx +23 -0
  221. package/src/plugins/blog/client/components/pages/edit-post-page.internal.tsx +60 -0
  222. package/src/plugins/blog/client/components/pages/edit-post-page.tsx +40 -0
  223. package/src/plugins/blog/client/components/pages/home-page.internal.tsx +71 -0
  224. package/src/plugins/blog/client/components/pages/home-page.tsx +42 -0
  225. package/src/plugins/blog/client/components/pages/new-post-page.internal.tsx +59 -0
  226. package/src/plugins/blog/client/components/pages/new-post-page.tsx +36 -0
  227. package/src/plugins/blog/client/components/pages/post-page.internal.tsx +142 -0
  228. package/src/plugins/blog/client/components/pages/post-page.tsx +38 -0
  229. package/src/plugins/blog/client/components/pages/tag-page.internal.tsx +74 -0
  230. package/src/plugins/blog/client/components/pages/tag-page.tsx +38 -0
  231. package/src/plugins/blog/client/components/shared/better-blog-attribution.tsx +19 -0
  232. package/src/plugins/blog/client/components/shared/default-error.tsx +20 -0
  233. package/src/plugins/blog/client/components/shared/defaults.tsx +9 -0
  234. package/src/plugins/blog/client/components/shared/empty-list.tsx +25 -0
  235. package/src/plugins/blog/client/components/shared/error-placeholder.tsx +20 -0
  236. package/src/plugins/blog/client/components/shared/highlight-text.tsx +80 -0
  237. package/src/plugins/blog/client/components/shared/markdown-content-styles.css +328 -0
  238. package/src/plugins/blog/client/components/shared/markdown-content.tsx +448 -0
  239. package/src/plugins/blog/client/components/shared/on-this-page.tsx +234 -0
  240. package/src/plugins/blog/client/components/shared/page-header.tsx +35 -0
  241. package/src/plugins/blog/client/components/shared/page-layout.tsx +23 -0
  242. package/src/plugins/blog/client/components/shared/page-wrapper.tsx +32 -0
  243. package/src/plugins/blog/client/components/shared/post-card.tsx +308 -0
  244. package/src/plugins/blog/client/components/shared/post-navigation.tsx +98 -0
  245. package/src/plugins/blog/client/components/shared/posts-list.tsx +67 -0
  246. package/src/plugins/blog/client/components/shared/recent-posts-carousel.tsx +79 -0
  247. package/src/plugins/blog/client/components/shared/search-input.tsx +146 -0
  248. package/src/plugins/blog/client/components/shared/search-modal.tsx +162 -0
  249. package/src/plugins/blog/client/components/shared/tags-list.tsx +34 -0
  250. package/src/plugins/blog/client/components/shared/use-route-lifecycle.tsx +68 -0
  251. package/src/plugins/blog/client/hooks/blog-hooks.tsx +623 -0
  252. package/src/plugins/blog/client/hooks/index.tsx +1 -0
  253. package/src/plugins/blog/client/hooks/use-debounce.ts +43 -0
  254. package/src/plugins/blog/client/index.ts +9 -0
  255. package/src/plugins/blog/client/localization/blog-card.ts +3 -0
  256. package/src/plugins/blog/client/localization/blog-common.ts +7 -0
  257. package/src/plugins/blog/client/localization/blog-forms.ts +45 -0
  258. package/src/plugins/blog/client/localization/blog-list.ts +14 -0
  259. package/src/plugins/blog/client/localization/blog-post.ts +9 -0
  260. package/src/plugins/blog/client/localization/index.ts +15 -0
  261. package/src/plugins/blog/client/overrides.ts +123 -0
  262. package/src/plugins/blog/client/plugin.tsx +672 -0
  263. package/src/plugins/blog/client.css +3 -0
  264. package/src/plugins/blog/db.ts +90 -0
  265. package/src/plugins/blog/query-keys.ts +267 -0
  266. package/src/plugins/blog/schemas.ts +39 -0
  267. package/src/plugins/blog/style.css +22 -0
  268. package/src/plugins/blog/types.ts +37 -0
  269. package/src/plugins/blog/utils.ts +144 -0
  270. package/src/plugins/client/index.ts +53 -0
  271. package/src/plugins/index.ts +0 -0
  272. package/src/plugins/utils.ts +35 -0
  273. package/src/types.ts +209 -0
  274. package/dist/plugins/index.cjs +0 -15
  275. package/dist/plugins/index.d.cts +0 -64
  276. package/dist/plugins/index.d.mts +0 -64
  277. package/dist/plugins/index.d.ts +0 -64
  278. package/dist/plugins/index.mjs +0 -11
  279. package/dist/shared/stack.DrUAVfIH.d.cts +0 -17
  280. package/dist/shared/stack.DrUAVfIH.d.mts +0 -17
  281. package/dist/shared/stack.DrUAVfIH.d.ts +0 -17
  282. /package/dist/{shared/stack.CktCg4PJ.cjs → plugins/utils.cjs} +0 -0
@@ -0,0 +1,623 @@
1
+ "use client";
2
+
3
+ import { createApiClient } from "@btst/stack/plugins/client";
4
+ import {
5
+ useInfiniteQuery,
6
+ useMutation,
7
+ useQuery,
8
+ useQueryClient,
9
+ useSuspenseInfiniteQuery,
10
+ useSuspenseQuery,
11
+ type InfiniteData,
12
+ } from "@tanstack/react-query";
13
+ import type { SerializedPost, SerializedTag } from "../../types";
14
+ import type { BlogApiRouter } from "../../api/plugin";
15
+ import { useDebounce } from "./use-debounce";
16
+ import { useEffect, useRef } from "react";
17
+ import { z } from "zod";
18
+ import { useInView } from "react-intersection-observer";
19
+ import { createPostSchema, updatePostSchema } from "../../schemas";
20
+ import { createBlogQueryKeys } from "../../query-keys";
21
+ import { usePluginOverrides } from "@btst/stack/context";
22
+ import type { BlogPluginOverrides } from "../overrides";
23
+
24
+ /**
25
+ * Shared React Query configuration for all blog queries
26
+ * Prevents automatic refetching to avoid hydration mismatches in SSR
27
+ */
28
+ const SHARED_QUERY_CONFIG = {
29
+ retry: false,
30
+ refetchOnWindowFocus: false,
31
+ refetchOnMount: false,
32
+ refetchOnReconnect: false,
33
+ staleTime: 1000 * 60 * 5, // 5 minutes
34
+ gcTime: 1000 * 60 * 10, // 10 minutes
35
+ } as const;
36
+
37
+ export interface UsePostsOptions {
38
+ tag?: string;
39
+ tagSlug?: string;
40
+ limit?: number;
41
+ enabled?: boolean;
42
+ query?: string;
43
+ published?: boolean;
44
+ slug?: string;
45
+ }
46
+
47
+ export interface UsePostsResult {
48
+ posts: SerializedPost[];
49
+ isLoading: boolean;
50
+ error: Error | null;
51
+ loadMore: () => void;
52
+ hasMore: boolean;
53
+ isLoadingMore: boolean;
54
+ refetch: () => void;
55
+ }
56
+
57
+ export interface UsePostSearchOptions {
58
+ query: string;
59
+ enabled?: boolean;
60
+ debounceMs?: number;
61
+ limit?: number;
62
+ published?: boolean;
63
+ }
64
+
65
+ export interface UsePostSearchResult {
66
+ posts: SerializedPost[];
67
+ data: SerializedPost[];
68
+ isLoading: boolean;
69
+ error: Error | null;
70
+ refetch: () => void;
71
+ isSearching: boolean;
72
+ searchQuery: string;
73
+ }
74
+
75
+ export interface UsePostResult {
76
+ post: SerializedPost | null;
77
+ isLoading: boolean;
78
+ error: Error | null;
79
+ refetch: () => void;
80
+ }
81
+
82
+ export type PostCreateInput = z.infer<typeof createPostSchema>;
83
+ export type PostUpdateInput = z.infer<typeof updatePostSchema>;
84
+
85
+ /**
86
+ * Hook for fetching paginated posts with load more functionality
87
+ */
88
+ export function usePosts(options: UsePostsOptions = {}): UsePostsResult {
89
+ const { apiBaseURL, apiBasePath } =
90
+ usePluginOverrides<BlogPluginOverrides>("blog");
91
+ const client = createApiClient<BlogApiRouter>({
92
+ baseURL: apiBaseURL,
93
+ basePath: apiBasePath,
94
+ });
95
+ const {
96
+ tag,
97
+ tagSlug,
98
+ limit = 10,
99
+ enabled = true,
100
+ query,
101
+ published,
102
+ } = options;
103
+ const queries = createBlogQueryKeys(client);
104
+
105
+ const queryParams = {
106
+ tag,
107
+ tagSlug,
108
+ limit,
109
+ query,
110
+ published,
111
+ };
112
+
113
+ const basePosts = queries.posts.list(queryParams);
114
+
115
+ const {
116
+ data,
117
+ isLoading,
118
+ error,
119
+ fetchNextPage,
120
+ hasNextPage,
121
+ isFetchingNextPage,
122
+ refetch,
123
+ } = useInfiniteQuery({
124
+ ...basePosts,
125
+ ...SHARED_QUERY_CONFIG,
126
+ initialPageParam: 0,
127
+ getNextPageParam: (lastPage, allPages) => {
128
+ const posts = lastPage as SerializedPost[];
129
+ if (posts.length < limit) return undefined;
130
+ return allPages.length * limit;
131
+ },
132
+ enabled: enabled && !!client,
133
+ });
134
+
135
+ const posts = ((
136
+ data as InfiniteData<SerializedPost[], number> | undefined
137
+ )?.pages?.flat() ?? []) as SerializedPost[];
138
+
139
+ return {
140
+ posts,
141
+ isLoading,
142
+ error,
143
+ loadMore: fetchNextPage,
144
+ hasMore: !!hasNextPage,
145
+ isLoadingMore: isFetchingNextPage,
146
+ refetch,
147
+ };
148
+ }
149
+
150
+ /** Suspense variant of usePosts */
151
+ export function useSuspensePosts(options: UsePostsOptions = {}): {
152
+ posts: SerializedPost[];
153
+ loadMore: () => Promise<unknown>;
154
+ hasMore: boolean;
155
+ isLoadingMore: boolean;
156
+ refetch: () => Promise<unknown>;
157
+ } {
158
+ const { apiBaseURL, apiBasePath } =
159
+ usePluginOverrides<BlogPluginOverrides>("blog");
160
+ const client = createApiClient<BlogApiRouter>({
161
+ baseURL: apiBaseURL,
162
+ basePath: apiBasePath,
163
+ });
164
+ const {
165
+ tag,
166
+ tagSlug,
167
+ limit = 10,
168
+ enabled = true,
169
+ query,
170
+ published,
171
+ } = options;
172
+ const queries = createBlogQueryKeys(client);
173
+
174
+ const queryParams = { tag, tagSlug, limit, query, published };
175
+ const basePosts = queries.posts.list(queryParams);
176
+
177
+ const {
178
+ data,
179
+ fetchNextPage,
180
+ hasNextPage,
181
+ isFetchingNextPage,
182
+ refetch,
183
+ error,
184
+ isFetching,
185
+ } = useSuspenseInfiniteQuery({
186
+ ...basePosts,
187
+ ...SHARED_QUERY_CONFIG,
188
+ initialPageParam: 0,
189
+ getNextPageParam: (lastPage, allPages) => {
190
+ const posts = lastPage as SerializedPost[];
191
+ if (posts.length < limit) return undefined;
192
+ return allPages.length * limit;
193
+ },
194
+ });
195
+
196
+ // Manually throw errors for Error Boundaries (per React Query Suspense docs)
197
+ // useSuspenseQuery only throws errors if there's no data, but we want to throw always
198
+ if (error && !isFetching) {
199
+ throw error;
200
+ }
201
+
202
+ const posts = (data.pages?.flat() ?? []) as SerializedPost[];
203
+
204
+ return {
205
+ posts,
206
+ loadMore: fetchNextPage,
207
+ hasMore: !!hasNextPage,
208
+ isLoadingMore: isFetchingNextPage,
209
+ refetch,
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Hook for fetching a single post by slug
215
+ */
216
+ export function usePost(slug?: string): UsePostResult {
217
+ const { apiBaseURL, apiBasePath } =
218
+ usePluginOverrides<BlogPluginOverrides>("blog");
219
+ const client = createApiClient<BlogApiRouter>({
220
+ baseURL: apiBaseURL,
221
+ basePath: apiBasePath,
222
+ });
223
+ const queries = createBlogQueryKeys(client);
224
+
225
+ const basePost = queries.posts.detail(slug ?? "");
226
+ const { data, isLoading, error, refetch } = useQuery<
227
+ SerializedPost | null,
228
+ Error,
229
+ SerializedPost | null,
230
+ typeof basePost.queryKey
231
+ >({
232
+ ...basePost,
233
+ ...SHARED_QUERY_CONFIG,
234
+ enabled: !!client && !!slug,
235
+ });
236
+
237
+ return {
238
+ post: data || null,
239
+ isLoading,
240
+ error,
241
+ refetch,
242
+ };
243
+ }
244
+
245
+ /** Suspense variant of usePost */
246
+ export function useSuspensePost(slug: string): {
247
+ post: SerializedPost | null;
248
+ refetch: () => Promise<unknown>;
249
+ } {
250
+ const { apiBaseURL, apiBasePath } =
251
+ usePluginOverrides<BlogPluginOverrides>("blog");
252
+ const client = createApiClient<BlogApiRouter>({
253
+ baseURL: apiBaseURL,
254
+ basePath: apiBasePath,
255
+ });
256
+ const queries = createBlogQueryKeys(client);
257
+ const basePost = queries.posts.detail(slug);
258
+ const { data, refetch, error, isFetching } = useSuspenseQuery<
259
+ SerializedPost | null,
260
+ Error,
261
+ SerializedPost | null,
262
+ typeof basePost.queryKey
263
+ >({
264
+ ...basePost,
265
+ ...SHARED_QUERY_CONFIG,
266
+ });
267
+
268
+ // Manually throw errors for Error Boundaries (per React Query Suspense docs)
269
+ // useSuspenseQuery only throws errors if there's no data, but we want to throw always
270
+ if (error && !isFetching) {
271
+ throw error;
272
+ }
273
+
274
+ return { post: data || null, refetch };
275
+ }
276
+
277
+ /**
278
+ * Hook for fetching all unique tags across posts
279
+ */
280
+ export function useTags(): {
281
+ tags: SerializedTag[];
282
+ isLoading: boolean;
283
+ error: Error | null;
284
+ refetch: () => void;
285
+ } {
286
+ const { apiBaseURL, apiBasePath } =
287
+ usePluginOverrides<BlogPluginOverrides>("blog");
288
+ const client = createApiClient<BlogApiRouter>({
289
+ baseURL: apiBaseURL,
290
+ basePath: apiBasePath,
291
+ });
292
+ const queries = createBlogQueryKeys(client);
293
+ const baseTags = queries.tags.list();
294
+ const { data, isLoading, error, refetch } = useQuery<
295
+ SerializedTag[] | null,
296
+ Error,
297
+ SerializedTag[] | null,
298
+ typeof baseTags.queryKey
299
+ >({
300
+ ...baseTags,
301
+ ...SHARED_QUERY_CONFIG,
302
+ enabled: !!client,
303
+ });
304
+
305
+ return {
306
+ tags: data ?? [],
307
+ isLoading,
308
+ error,
309
+ refetch,
310
+ };
311
+ }
312
+
313
+ /** Suspense variant of useTags */
314
+ export function useSuspenseTags(): {
315
+ tags: SerializedTag[];
316
+ refetch: () => Promise<unknown>;
317
+ } {
318
+ const { apiBaseURL, apiBasePath } =
319
+ usePluginOverrides<BlogPluginOverrides>("blog");
320
+ const client = createApiClient<BlogApiRouter>({
321
+ baseURL: apiBaseURL,
322
+ basePath: apiBasePath,
323
+ });
324
+ const queries = createBlogQueryKeys(client);
325
+ const baseTags = queries.tags.list();
326
+ const { data, refetch, error, isFetching } = useSuspenseQuery<
327
+ SerializedTag[] | null,
328
+ Error,
329
+ SerializedTag[] | null,
330
+ typeof baseTags.queryKey
331
+ >({
332
+ ...baseTags,
333
+ ...SHARED_QUERY_CONFIG,
334
+ });
335
+
336
+ // Manually throw errors for Error Boundaries (per React Query Suspense docs)
337
+ // useSuspenseQuery only throws errors if there's no data, but we want to throw always
338
+ if (error && !isFetching) {
339
+ throw error;
340
+ }
341
+
342
+ return {
343
+ tags: data ?? [],
344
+ refetch,
345
+ };
346
+ }
347
+
348
+ /** Create a new post */
349
+ export function useCreatePost() {
350
+ const { refresh, apiBaseURL, apiBasePath } =
351
+ usePluginOverrides<BlogPluginOverrides>("blog");
352
+ const client = createApiClient<BlogApiRouter>({
353
+ baseURL: apiBaseURL,
354
+ basePath: apiBasePath,
355
+ });
356
+ const queryClient = useQueryClient();
357
+ const queries = createBlogQueryKeys(client);
358
+
359
+ return useMutation<SerializedPost | null, Error, PostCreateInput>({
360
+ mutationKey: [...queries.posts._def, "create"],
361
+ mutationFn: async (postData: PostCreateInput) => {
362
+ const response = await client("@post/posts", {
363
+ method: "POST",
364
+ body: postData,
365
+ });
366
+ return response.data as SerializedPost | null;
367
+ },
368
+ onSuccess: async (created) => {
369
+ // Update detail cache if available
370
+ if (created?.slug) {
371
+ queryClient.setQueryData(
372
+ queries.posts.detail(created.slug).queryKey,
373
+ created,
374
+ );
375
+ }
376
+ // Invalidate lists scoped to posts and drafts - wait for completion
377
+ await queryClient.invalidateQueries({
378
+ queryKey: queries.posts.list._def,
379
+ });
380
+ await queryClient.invalidateQueries({
381
+ queryKey: queries.drafts.list._def,
382
+ });
383
+ // Refresh server-side cache (Next.js router cache)
384
+ if (refresh) {
385
+ await refresh();
386
+ }
387
+ },
388
+ });
389
+ }
390
+
391
+ /** Update an existing post by id */
392
+ export function useUpdatePost() {
393
+ const { refresh, apiBaseURL, apiBasePath } =
394
+ usePluginOverrides<BlogPluginOverrides>("blog");
395
+
396
+ const client = createApiClient<BlogApiRouter>({
397
+ baseURL: apiBaseURL,
398
+ basePath: apiBasePath,
399
+ });
400
+
401
+ const queryClient = useQueryClient();
402
+ const queries = createBlogQueryKeys(client);
403
+
404
+ return useMutation<
405
+ SerializedPost | null,
406
+ Error,
407
+ { id: string; data: PostUpdateInput }
408
+ >({
409
+ mutationKey: [...queries.posts._def, "update"],
410
+ mutationFn: async ({ id, data }: { id: string; data: PostUpdateInput }) => {
411
+ const response = await client(`@put/posts/:id`, {
412
+ method: "PUT",
413
+ params: { id },
414
+ body: data,
415
+ });
416
+ return response.data as SerializedPost | null;
417
+ },
418
+ onSuccess: async (updated) => {
419
+ // Update detail cache if available
420
+ if (updated?.slug) {
421
+ queryClient.setQueryData(
422
+ queries.posts.detail(updated.slug).queryKey,
423
+ updated,
424
+ );
425
+ }
426
+ // Invalidate lists scoped to posts and drafts - wait for completion
427
+ await queryClient.invalidateQueries({
428
+ queryKey: queries.posts.list._def,
429
+ });
430
+ await queryClient.invalidateQueries({
431
+ queryKey: queries.drafts.list._def,
432
+ });
433
+ // Refresh server-side cache (Next.js router cache)
434
+ if (refresh) {
435
+ await refresh();
436
+ }
437
+ },
438
+ });
439
+ }
440
+
441
+ /**
442
+ * Hook for searching posts by a free-text query. Uses `usePosts` under the hood.
443
+ * Debounces the query and preserves last successful results to avoid flicker.
444
+ */
445
+ export function usePostSearch({
446
+ query,
447
+ enabled = true,
448
+ debounceMs = 300,
449
+ limit = 10,
450
+ published = true,
451
+ }: UsePostSearchOptions): UsePostSearchResult {
452
+ const debouncedQuery = useDebounce(query, debounceMs);
453
+ const shouldSearch = enabled && (query?.trim().length ?? 0) > 0;
454
+
455
+ const lastResultsRef = useRef<SerializedPost[]>([]);
456
+
457
+ // Only enable the query when there is an actual search term
458
+ // This prevents empty searches from using the base posts query
459
+ const { posts, isLoading, error, refetch } = usePosts({
460
+ query: debouncedQuery,
461
+ limit,
462
+ enabled: shouldSearch && debouncedQuery.trim() !== "",
463
+ published,
464
+ });
465
+
466
+ // If search is disabled or query is empty, always return empty results
467
+ const effectivePosts = shouldSearch ? posts : [];
468
+
469
+ useEffect(() => {
470
+ if (!isLoading && posts && posts.length >= 0) {
471
+ lastResultsRef.current = posts;
472
+ }
473
+ }, [posts, isLoading]);
474
+
475
+ const isDebouncing = enabled && debounceMs > 0 && debouncedQuery !== query;
476
+ const effectiveLoading = isLoading || isDebouncing;
477
+ // During loading, use the last results
478
+ // For empty searches or when disabled, use empty array
479
+ const dataToReturn = !shouldSearch
480
+ ? []
481
+ : effectiveLoading
482
+ ? lastResultsRef.current
483
+ : effectivePosts;
484
+
485
+ return {
486
+ posts: dataToReturn,
487
+ // compatibility alias similar to tanstack useQuery
488
+ data: dataToReturn,
489
+ isLoading: effectiveLoading,
490
+ error,
491
+ refetch,
492
+ isSearching: effectiveLoading,
493
+ searchQuery: debouncedQuery,
494
+ };
495
+ }
496
+
497
+ export interface UseNextPreviousPostsOptions {
498
+ enabled?: boolean;
499
+ }
500
+
501
+ export interface UseNextPreviousPostsResult {
502
+ previousPost: SerializedPost | null;
503
+ nextPost: SerializedPost | null;
504
+ isLoading: boolean;
505
+ error: Error | null;
506
+ refetch: () => void;
507
+ }
508
+
509
+ /**
510
+ * Hook for fetching previous and next posts relative to a given date
511
+ * Uses useInView to only fetch when the component is in view
512
+ */
513
+ export function useNextPreviousPosts(
514
+ createdAt: string | Date,
515
+ options: UseNextPreviousPostsOptions = {},
516
+ ): UseNextPreviousPostsResult & {
517
+ ref: (node: Element | null) => void;
518
+ inView: boolean;
519
+ } {
520
+ const { apiBaseURL, apiBasePath } =
521
+ usePluginOverrides<BlogPluginOverrides>("blog");
522
+ const client = createApiClient<BlogApiRouter>({
523
+ baseURL: apiBaseURL,
524
+ basePath: apiBasePath,
525
+ });
526
+ const queries = createBlogQueryKeys(client);
527
+
528
+ const { ref, inView } = useInView({
529
+ // start a little early so the data is ready as it scrolls in
530
+ rootMargin: "200px 0px",
531
+ // run once; keep data cached after
532
+ triggerOnce: true,
533
+ });
534
+
535
+ const dateValue =
536
+ typeof createdAt === "string" ? new Date(createdAt) : createdAt;
537
+ const baseQuery = queries.posts.nextPrevious(dateValue);
538
+
539
+ const { data, isLoading, error, refetch } = useQuery<
540
+ { previous: SerializedPost | null; next: SerializedPost | null },
541
+ Error,
542
+ { previous: SerializedPost | null; next: SerializedPost | null },
543
+ typeof baseQuery.queryKey
544
+ >({
545
+ ...baseQuery,
546
+ ...SHARED_QUERY_CONFIG,
547
+ enabled: (options.enabled ?? true) && inView && !!client,
548
+ });
549
+
550
+ return {
551
+ previousPost: data?.previous ?? null,
552
+ nextPost: data?.next ?? null,
553
+ isLoading,
554
+ error,
555
+ refetch,
556
+ ref,
557
+ inView,
558
+ };
559
+ }
560
+
561
+ export interface UseRecentPostsOptions {
562
+ limit?: number;
563
+ excludeSlug?: string;
564
+ enabled?: boolean;
565
+ }
566
+
567
+ export interface UseRecentPostsResult {
568
+ recentPosts: SerializedPost[];
569
+ isLoading: boolean;
570
+ error: Error | null;
571
+ refetch: () => void;
572
+ }
573
+
574
+ /**
575
+ * Hook for fetching recent posts
576
+ * Uses useInView to only fetch when the component is in view
577
+ */
578
+ export function useRecentPosts(
579
+ options: UseRecentPostsOptions = {},
580
+ ): UseRecentPostsResult & {
581
+ ref: (node: Element | null) => void;
582
+ inView: boolean;
583
+ } {
584
+ const { apiBaseURL, apiBasePath } =
585
+ usePluginOverrides<BlogPluginOverrides>("blog");
586
+ const client = createApiClient<BlogApiRouter>({
587
+ baseURL: apiBaseURL,
588
+ basePath: apiBasePath,
589
+ });
590
+ const queries = createBlogQueryKeys(client);
591
+
592
+ const { ref, inView } = useInView({
593
+ // start a little early so the data is ready as it scrolls in
594
+ rootMargin: "200px 0px",
595
+ // run once; keep data cached after
596
+ triggerOnce: true,
597
+ });
598
+
599
+ const baseQuery = queries.posts.recent({
600
+ limit: options.limit ?? 5,
601
+ excludeSlug: options.excludeSlug,
602
+ });
603
+
604
+ const { data, isLoading, error, refetch } = useQuery<
605
+ SerializedPost[],
606
+ Error,
607
+ SerializedPost[],
608
+ typeof baseQuery.queryKey
609
+ >({
610
+ ...baseQuery,
611
+ ...SHARED_QUERY_CONFIG,
612
+ enabled: (options.enabled ?? true) && inView && !!client,
613
+ });
614
+
615
+ return {
616
+ recentPosts: data ?? [],
617
+ isLoading,
618
+ error,
619
+ refetch,
620
+ ref,
621
+ inView,
622
+ };
623
+ }
@@ -0,0 +1 @@
1
+ export * from "./blog-hooks";
@@ -0,0 +1,43 @@
1
+ import { throttle } from "../../utils";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+
4
+ export function useDebounce<T>(value: T, delay?: number): T {
5
+ const [debouncedValue, setDebouncedValue] = useState<T>(value);
6
+
7
+ useEffect(() => {
8
+ const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
9
+
10
+ return () => {
11
+ clearTimeout(timer);
12
+ };
13
+ }, [value, delay]);
14
+
15
+ return debouncedValue;
16
+ }
17
+
18
+ export function useThrottle<T>(value: T, wait?: number): T {
19
+ const [throttledValue, setThrottledValue] = useState<T>(value);
20
+ const valueRef = useRef(value);
21
+
22
+ valueRef.current = value;
23
+
24
+ const throttledSetter = useMemo(() => {
25
+ return throttle((next: T) => {
26
+ setThrottledValue(next);
27
+ }, wait ?? 500);
28
+ // eslint-disable-next-line react-hooks/exhaustive-deps
29
+ }, [wait]);
30
+
31
+ useEffect(() => {
32
+ throttledSetter(valueRef.current);
33
+ return () => {
34
+ throttledSetter.cancel();
35
+ };
36
+ }, [throttledSetter]);
37
+
38
+ useEffect(() => {
39
+ throttledSetter(value);
40
+ }, [value, throttledSetter]);
41
+
42
+ return throttledValue;
43
+ }
@@ -0,0 +1,9 @@
1
+ export { blogClientPlugin } from "./plugin";
2
+ export type {
3
+ BlogClientConfig,
4
+ BlogClientHooks,
5
+ RouteContext,
6
+ LoaderContext,
7
+ } from "./plugin";
8
+ export type { UsePostsOptions, UsePostsResult } from "./hooks";
9
+ export type { BlogPluginOverrides } from "./overrides";
@@ -0,0 +1,3 @@
1
+ export const BLOG_CARD = {
2
+ BLOG_CARD_DRAFT_BADGE: "Draft",
3
+ };
@@ -0,0 +1,7 @@
1
+ export const BLOG_COMMON = {
2
+ BLOG_GENERIC_ERROR_TITLE: "Something went wrong",
3
+ BLOG_GENERIC_ERROR_MESSAGE: "An unexpected error occurred.",
4
+ BLOG_PAGE_NOT_FOUND_TITLE: "Not Found",
5
+ BLOG_PAGE_NOT_FOUND_DESCRIPTION:
6
+ "The page you are looking for does not exist.",
7
+ };