@btst/stack 2.8.1 → 2.9.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 (196) hide show
  1. package/README.md +3 -2
  2. package/dist/components/markdown/index.d.cts +15 -2
  3. package/dist/components/markdown/index.d.mts +15 -2
  4. package/dist/components/markdown/index.d.ts +15 -2
  5. package/dist/packages/stack/src/plugins/blog/client/components/forms/image-field.cjs +30 -1
  6. package/dist/packages/stack/src/plugins/blog/client/components/forms/image-field.mjs +30 -1
  7. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.cjs +49 -9
  8. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.mjs +50 -10
  9. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.cjs +77 -9
  10. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.mjs +77 -9
  11. package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.cjs +24 -5
  12. package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.mjs +24 -5
  13. package/dist/packages/stack/src/plugins/cms/client/components/forms/file-upload.cjs +47 -13
  14. package/dist/packages/stack/src/plugins/cms/client/components/forms/file-upload.mjs +47 -13
  15. package/dist/packages/stack/src/plugins/kanban/client/components/forms/board-form.cjs +1 -1
  16. package/dist/packages/stack/src/plugins/kanban/client/components/forms/board-form.mjs +1 -1
  17. package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.cjs +6 -2
  18. package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.mjs +6 -2
  19. package/dist/packages/stack/src/plugins/media/api/adapters/local.cjs +55 -0
  20. package/dist/packages/stack/src/plugins/media/api/adapters/local.mjs +37 -0
  21. package/dist/packages/stack/src/plugins/media/api/getters.cjs +83 -0
  22. package/dist/packages/stack/src/plugins/media/api/getters.mjs +78 -0
  23. package/dist/packages/stack/src/plugins/media/api/mutations.cjs +88 -0
  24. package/dist/packages/stack/src/plugins/media/api/mutations.mjs +82 -0
  25. package/dist/packages/stack/src/plugins/media/api/plugin.cjs +525 -0
  26. package/dist/packages/stack/src/plugins/media/api/plugin.mjs +523 -0
  27. package/dist/packages/stack/src/plugins/media/api/query-key-defs.cjs +19 -0
  28. package/dist/packages/stack/src/plugins/media/api/query-key-defs.mjs +16 -0
  29. package/dist/packages/stack/src/plugins/media/api/serializers.cjs +17 -0
  30. package/dist/packages/stack/src/plugins/media/api/serializers.mjs +14 -0
  31. package/dist/packages/stack/src/plugins/media/api/storage-adapter.cjs +15 -0
  32. package/dist/packages/stack/src/plugins/media/api/storage-adapter.mjs +11 -0
  33. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-card.cjs +129 -0
  34. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-card.mjs +127 -0
  35. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-preview-button.cjs +58 -0
  36. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-preview-button.mjs +56 -0
  37. package/dist/packages/stack/src/plugins/media/client/components/media-picker/browse-tab.cjs +94 -0
  38. package/dist/packages/stack/src/plugins/media/client/components/media-picker/browse-tab.mjs +92 -0
  39. package/dist/packages/stack/src/plugins/media/client/components/media-picker/folder-tree.cjs +171 -0
  40. package/dist/packages/stack/src/plugins/media/client/components/media-picker/folder-tree.mjs +168 -0
  41. package/dist/packages/stack/src/plugins/media/client/components/media-picker/index.cjs +308 -0
  42. package/dist/packages/stack/src/plugins/media/client/components/media-picker/index.mjs +305 -0
  43. package/dist/packages/stack/src/plugins/media/client/components/media-picker/upload-tab.cjs +104 -0
  44. package/dist/packages/stack/src/plugins/media/client/components/media-picker/upload-tab.mjs +102 -0
  45. package/dist/packages/stack/src/plugins/media/client/components/media-picker/url-tab.cjs +70 -0
  46. package/dist/packages/stack/src/plugins/media/client/components/media-picker/url-tab.mjs +68 -0
  47. package/dist/packages/stack/src/plugins/media/client/components/media-picker/utils.cjs +21 -0
  48. package/dist/packages/stack/src/plugins/media/client/components/media-picker/utils.mjs +17 -0
  49. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.cjs +35 -0
  50. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.internal.cjs +125 -0
  51. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.internal.mjs +123 -0
  52. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.mjs +33 -0
  53. package/dist/packages/stack/src/plugins/media/client/hooks/use-media.cjs +222 -0
  54. package/dist/packages/stack/src/plugins/media/client/hooks/use-media.mjs +214 -0
  55. package/dist/packages/stack/src/plugins/media/client/plugin.cjs +94 -0
  56. package/dist/packages/stack/src/plugins/media/client/plugin.mjs +92 -0
  57. package/dist/packages/stack/src/plugins/media/client/upload.cjs +121 -0
  58. package/dist/packages/stack/src/plugins/media/client/upload.mjs +119 -0
  59. package/dist/packages/stack/src/plugins/media/client/utils/image-compression.cjs +67 -0
  60. package/dist/packages/stack/src/plugins/media/client/utils/image-compression.mjs +65 -0
  61. package/dist/packages/stack/src/plugins/media/db.cjs +62 -0
  62. package/dist/packages/stack/src/plugins/media/db.mjs +60 -0
  63. package/dist/packages/stack/src/plugins/media/schemas.cjs +41 -0
  64. package/dist/packages/stack/src/plugins/media/schemas.mjs +35 -0
  65. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.cjs +18 -1
  66. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.mjs +19 -2
  67. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-dialog.cjs +2 -2
  68. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-dialog.mjs +2 -2
  69. package/dist/packages/ui/src/components/minimal-tiptap/components/section/five.cjs +3 -2
  70. package/dist/packages/ui/src/components/minimal-tiptap/components/section/five.mjs +3 -2
  71. package/dist/packages/ui/src/components/minimal-tiptap/minimal-tiptap.cjs +12 -5
  72. package/dist/packages/ui/src/components/minimal-tiptap/minimal-tiptap.mjs +12 -5
  73. package/dist/plugins/blog/client/index.d.cts +58 -1
  74. package/dist/plugins/blog/client/index.d.mts +58 -1
  75. package/dist/plugins/blog/client/index.d.ts +58 -1
  76. package/dist/plugins/cms/client/index.d.cts +73 -3
  77. package/dist/plugins/cms/client/index.d.mts +73 -3
  78. package/dist/plugins/cms/client/index.d.ts +73 -3
  79. package/dist/plugins/kanban/api/index.d.cts +1 -1
  80. package/dist/plugins/kanban/api/index.d.mts +1 -1
  81. package/dist/plugins/kanban/api/index.d.ts +1 -1
  82. package/dist/plugins/kanban/client/hooks/index.d.cts +1 -1
  83. package/dist/plugins/kanban/client/hooks/index.d.mts +1 -1
  84. package/dist/plugins/kanban/client/hooks/index.d.ts +1 -1
  85. package/dist/plugins/kanban/client/index.d.cts +1 -1
  86. package/dist/plugins/kanban/client/index.d.mts +1 -1
  87. package/dist/plugins/kanban/client/index.d.ts +1 -1
  88. package/dist/plugins/kanban/query-keys.d.cts +1 -1
  89. package/dist/plugins/kanban/query-keys.d.mts +1 -1
  90. package/dist/plugins/kanban/query-keys.d.ts +1 -1
  91. package/dist/plugins/media/api/adapters/s3.cjs +106 -0
  92. package/dist/plugins/media/api/adapters/s3.d.cts +60 -0
  93. package/dist/plugins/media/api/adapters/s3.d.mts +60 -0
  94. package/dist/plugins/media/api/adapters/s3.d.ts +60 -0
  95. package/dist/plugins/media/api/adapters/s3.mjs +104 -0
  96. package/dist/plugins/media/api/adapters/vercel-blob.cjs +53 -0
  97. package/dist/plugins/media/api/adapters/vercel-blob.d.cts +41 -0
  98. package/dist/plugins/media/api/adapters/vercel-blob.d.mts +41 -0
  99. package/dist/plugins/media/api/adapters/vercel-blob.d.ts +41 -0
  100. package/dist/plugins/media/api/adapters/vercel-blob.mjs +51 -0
  101. package/dist/plugins/media/api/index.cjs +26 -0
  102. package/dist/plugins/media/api/index.d.cts +116 -0
  103. package/dist/plugins/media/api/index.d.mts +116 -0
  104. package/dist/plugins/media/api/index.d.ts +116 -0
  105. package/dist/plugins/media/api/index.mjs +6 -0
  106. package/dist/plugins/media/client/components/index.cjs +10 -0
  107. package/dist/plugins/media/client/components/index.d.cts +55 -0
  108. package/dist/plugins/media/client/components/index.d.mts +55 -0
  109. package/dist/plugins/media/client/components/index.d.ts +55 -0
  110. package/dist/plugins/media/client/components/index.mjs +2 -0
  111. package/dist/plugins/media/client/hooks/index.cjs +13 -0
  112. package/dist/plugins/media/client/hooks/index.d.cts +53 -0
  113. package/dist/plugins/media/client/hooks/index.d.mts +53 -0
  114. package/dist/plugins/media/client/hooks/index.d.ts +53 -0
  115. package/dist/plugins/media/client/hooks/index.mjs +1 -0
  116. package/dist/plugins/media/client/index.cjs +9 -0
  117. package/dist/plugins/media/client/index.d.cts +242 -0
  118. package/dist/plugins/media/client/index.d.mts +242 -0
  119. package/dist/plugins/media/client/index.d.ts +242 -0
  120. package/dist/plugins/media/client/index.mjs +2 -0
  121. package/dist/plugins/media/client.css +1 -0
  122. package/dist/plugins/media/query-keys.cjs +72 -0
  123. package/dist/plugins/media/query-keys.d.cts +49 -0
  124. package/dist/plugins/media/query-keys.d.mts +49 -0
  125. package/dist/plugins/media/query-keys.d.ts +49 -0
  126. package/dist/plugins/media/query-keys.mjs +70 -0
  127. package/dist/plugins/media/style.css +1 -0
  128. package/dist/shared/{stack.DRpeDS6X.d.ts → stack.BMx2QYOK.d.ts} +25 -0
  129. package/dist/shared/stack.BttDsJJn.d.cts +109 -0
  130. package/dist/shared/stack.BttDsJJn.d.mts +109 -0
  131. package/dist/shared/stack.BttDsJJn.d.ts +109 -0
  132. package/dist/shared/stack.C7vfOBmO.d.mts +63 -0
  133. package/dist/shared/stack.CAni8dnD.d.cts +63 -0
  134. package/dist/shared/stack.CI8iRKKi.d.cts +286 -0
  135. package/dist/shared/stack.CLcnSF_b.d.cts +25 -0
  136. package/dist/shared/stack.CLcnSF_b.d.mts +25 -0
  137. package/dist/shared/stack.CLcnSF_b.d.ts +25 -0
  138. package/dist/shared/stack.CYSwntXC.d.ts +63 -0
  139. package/dist/shared/{stack.Jb0kQDJC.d.mts → stack.Cd6McBu1.d.mts} +25 -0
  140. package/dist/shared/stack.DJDjdG64.d.ts +286 -0
  141. package/dist/shared/{stack.BxFl46lB.d.cts → stack.DxQl8Wa1.d.cts} +25 -0
  142. package/dist/shared/stack.FgBVDSPi.d.mts +286 -0
  143. package/package.json +113 -4
  144. package/src/plugins/blog/client/components/forms/image-field.tsx +35 -4
  145. package/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx +67 -12
  146. package/src/plugins/blog/client/components/forms/markdown-editor.tsx +106 -10
  147. package/src/plugins/blog/client/overrides.ts +58 -1
  148. package/src/plugins/cms/client/components/forms/content-form.tsx +26 -7
  149. package/src/plugins/cms/client/components/forms/file-upload.tsx +73 -15
  150. package/src/plugins/cms/client/overrides.ts +57 -2
  151. package/src/plugins/kanban/client/components/forms/board-form.tsx +1 -1
  152. package/src/plugins/kanban/client/components/forms/task-form.tsx +7 -1
  153. package/src/plugins/kanban/client/overrides.ts +25 -0
  154. package/src/plugins/media/__tests__/__stubs__/vercel-blob-server.ts +9 -0
  155. package/src/plugins/media/__tests__/getters.test.ts +274 -0
  156. package/src/plugins/media/__tests__/mutations.test.ts +299 -0
  157. package/src/plugins/media/__tests__/plugin.test.ts +752 -0
  158. package/src/plugins/media/__tests__/query-key-defs.test.ts +54 -0
  159. package/src/plugins/media/__tests__/storage-adapters.test.ts +351 -0
  160. package/src/plugins/media/api/adapters/local.ts +79 -0
  161. package/src/plugins/media/api/adapters/s3.ts +198 -0
  162. package/src/plugins/media/api/adapters/vercel-blob.ts +131 -0
  163. package/src/plugins/media/api/getters.ts +174 -0
  164. package/src/plugins/media/api/index.ts +41 -0
  165. package/src/plugins/media/api/mutations.ts +179 -0
  166. package/src/plugins/media/api/plugin.ts +855 -0
  167. package/src/plugins/media/api/query-key-defs.ts +41 -0
  168. package/src/plugins/media/api/serializers.ts +28 -0
  169. package/src/plugins/media/api/storage-adapter.ts +139 -0
  170. package/src/plugins/media/client/components/index.tsx +6 -0
  171. package/src/plugins/media/client/components/media-picker/asset-card.tsx +150 -0
  172. package/src/plugins/media/client/components/media-picker/asset-preview-button.tsx +67 -0
  173. package/src/plugins/media/client/components/media-picker/browse-tab.tsx +116 -0
  174. package/src/plugins/media/client/components/media-picker/folder-tree.tsx +188 -0
  175. package/src/plugins/media/client/components/media-picker/index.tsx +347 -0
  176. package/src/plugins/media/client/components/media-picker/upload-tab.tsx +108 -0
  177. package/src/plugins/media/client/components/media-picker/url-tab.tsx +72 -0
  178. package/src/plugins/media/client/components/media-picker/utils.ts +17 -0
  179. package/src/plugins/media/client/components/pages/library-page.internal.tsx +134 -0
  180. package/src/plugins/media/client/components/pages/library-page.tsx +42 -0
  181. package/src/plugins/media/client/hooks/index.tsx +9 -0
  182. package/src/plugins/media/client/hooks/use-media.tsx +289 -0
  183. package/src/plugins/media/client/index.ts +4 -0
  184. package/src/plugins/media/client/overrides.ts +127 -0
  185. package/src/plugins/media/client/plugin.tsx +184 -0
  186. package/src/plugins/media/client/upload.ts +171 -0
  187. package/src/plugins/media/client/utils/image-compression.ts +131 -0
  188. package/src/plugins/media/client.css +1 -0
  189. package/src/plugins/media/db.ts +62 -0
  190. package/src/plugins/media/query-keys.ts +96 -0
  191. package/src/plugins/media/schemas.ts +37 -0
  192. package/src/plugins/media/style.css +1 -0
  193. package/src/plugins/media/types.ts +26 -0
  194. package/dist/shared/{stack.BOokfhZD.d.cts → stack.B6S3cgwN.d.cts} +16 -16
  195. package/dist/shared/{stack.CWxAl9K3.d.mts → stack.Bzfx-_lq.d.mts} +16 -16
  196. package/dist/shared/{stack.BvCR4-9H.d.ts → stack.j5SFLC1d.d.ts} +16 -16
@@ -0,0 +1,184 @@
1
+ import {
2
+ defineClientPlugin,
3
+ createApiClient,
4
+ isConnectionError,
5
+ } from "@btst/stack/plugins/client";
6
+ import { createRoute } from "@btst/yar";
7
+ import type { ComponentType } from "react";
8
+ import type { QueryClient } from "@tanstack/react-query";
9
+ import { LibraryPageComponent } from "./components/pages/library-page";
10
+ import { createMediaQueryKeys } from "../query-keys";
11
+ import type { MediaApiRouter } from "../api/plugin";
12
+
13
+ export interface MediaLoaderContext {
14
+ path: string;
15
+ isSSR: boolean;
16
+ apiBaseURL: string;
17
+ apiBasePath: string;
18
+ headers?: HeadersInit;
19
+ }
20
+
21
+ export interface MediaClientHooks {
22
+ /** Called before the media library data is fetched during SSR. Throw to cancel. */
23
+ beforeLoadLibrary?: (context: MediaLoaderContext) => Promise<void> | void;
24
+
25
+ /** Called after the media library data is fetched during SSR. */
26
+ afterLoadLibrary?: (context: MediaLoaderContext) => Promise<void> | void;
27
+
28
+ /** Called when an error occurs during the SSR loader. */
29
+ onLoadError?: (
30
+ error: Error,
31
+ context: MediaLoaderContext,
32
+ ) => Promise<void> | void;
33
+ }
34
+
35
+ export interface MediaClientConfig {
36
+ /** Base URL for API calls (e.g., "http://localhost:3000") */
37
+ apiBaseURL: string;
38
+ /** Path where the API is mounted (e.g., "/api/data") */
39
+ apiBasePath: string;
40
+ /** Base URL of your site for SEO meta tags */
41
+ siteBaseURL: string;
42
+ /** Path where pages are mounted (e.g., "/pages") */
43
+ siteBasePath: string;
44
+ /** React Query client — used by the SSR loader to prefetch data */
45
+ queryClient: QueryClient;
46
+ /** Optional headers forwarded with SSR API requests (e.g. auth cookies) */
47
+ headers?: HeadersInit;
48
+ /** Optional lifecycle hooks for the media client plugin */
49
+ hooks?: MediaClientHooks;
50
+ /**
51
+ * Optional page component overrides.
52
+ * Replace any plugin page with a custom React component.
53
+ * The built-in component is used as the fallback when not provided.
54
+ */
55
+ pageComponents?: {
56
+ /** Replaces the media library page */
57
+ library?: ComponentType;
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Media client plugin.
63
+ * Registers the /media library route.
64
+ *
65
+ * Configure overrides in StackProvider:
66
+ * ```tsx
67
+ * <StackProvider overrides={{ media: { apiBaseURL, apiBasePath, queryClient, uploadMode: "direct", navigate } }}>
68
+ * ```
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * import { mediaClientPlugin } from "@btst/stack/plugins/media/client"
73
+ *
74
+ * const clientPlugins = [
75
+ * mediaClientPlugin({ apiBaseURL, apiBasePath, siteBaseURL, siteBasePath, queryClient }),
76
+ * // ...other plugins
77
+ * ]
78
+ * ```
79
+ */
80
+ export const mediaClientPlugin = (config: MediaClientConfig) =>
81
+ defineClientPlugin({
82
+ name: "media",
83
+
84
+ routes: () => ({
85
+ library: createRoute("/media", () => {
86
+ const CustomLibrary = config.pageComponents?.library;
87
+ return {
88
+ PageComponent: CustomLibrary ?? LibraryPageComponent,
89
+ loader: createMediaLibraryLoader(config),
90
+ meta: createMediaLibraryMeta(config),
91
+ };
92
+ }),
93
+ }),
94
+ });
95
+
96
+ function createMediaLibraryLoader(config: MediaClientConfig) {
97
+ return async () => {
98
+ if (typeof window === "undefined") {
99
+ const { queryClient, apiBasePath, apiBaseURL, hooks, headers } = config;
100
+
101
+ const context: MediaLoaderContext = {
102
+ path: "/media",
103
+ isSSR: true,
104
+ apiBaseURL,
105
+ apiBasePath,
106
+ headers,
107
+ };
108
+
109
+ try {
110
+ if (hooks?.beforeLoadLibrary) {
111
+ await hooks.beforeLoadLibrary(context);
112
+ }
113
+
114
+ const client = createApiClient<MediaApiRouter>({
115
+ baseURL: apiBaseURL,
116
+ basePath: apiBasePath,
117
+ });
118
+ const queries = createMediaQueryKeys(client, headers);
119
+
120
+ // Prefetch initial asset grid (infinite query — root folder, default limit)
121
+ await queryClient.prefetchInfiniteQuery({
122
+ ...queries.mediaAssets.list({ limit: 40 }),
123
+ initialPageParam: 0,
124
+ });
125
+
126
+ // Prefetch root-level folders for the sidebar tree
127
+ await queryClient.prefetchQuery(queries.mediaFolders.list(null));
128
+
129
+ if (hooks?.afterLoadLibrary) {
130
+ await hooks.afterLoadLibrary(context);
131
+ }
132
+
133
+ const queryState = queryClient.getQueryState(
134
+ queries.mediaAssets.list({ limit: 40 }).queryKey,
135
+ );
136
+ if (queryState?.error && hooks?.onLoadError) {
137
+ const error =
138
+ queryState.error instanceof Error
139
+ ? queryState.error
140
+ : new Error(String(queryState.error));
141
+ await hooks.onLoadError(error, context);
142
+ }
143
+ } catch (error) {
144
+ if (isConnectionError(error)) {
145
+ console.warn(
146
+ "[btst/media] route.loader() failed — no server running at build time. " +
147
+ "The media library does not support SSG.",
148
+ );
149
+ }
150
+ if (hooks?.onLoadError) {
151
+ await hooks.onLoadError(error as Error, context);
152
+ }
153
+ }
154
+ }
155
+ };
156
+ }
157
+
158
+ function createMediaLibraryMeta(config: MediaClientConfig) {
159
+ return () => {
160
+ const { siteBaseURL, siteBasePath } = config;
161
+ const fullUrl = `${siteBaseURL}${siteBasePath}/media`;
162
+ const title = "Media Library";
163
+
164
+ return [
165
+ { title },
166
+ { name: "title", content: title },
167
+ { name: "description", content: "Manage your media assets" },
168
+ { name: "robots", content: "noindex, nofollow" },
169
+
170
+ // Open Graph
171
+ { property: "og:type", content: "website" },
172
+ { property: "og:title", content: title },
173
+ {
174
+ property: "og:description",
175
+ content: "Manage your media assets",
176
+ },
177
+ { property: "og:url", content: fullUrl },
178
+
179
+ // Twitter
180
+ { name: "twitter:card", content: "summary" },
181
+ { name: "twitter:title", content: title },
182
+ ];
183
+ };
184
+ }
@@ -0,0 +1,171 @@
1
+ "use client";
2
+
3
+ import type { SerializedAsset } from "../types";
4
+ import type { MediaPluginOverrides } from "./overrides";
5
+ import { compressImage } from "./utils/image-compression";
6
+
7
+ export type MediaUploadClientConfig = Pick<
8
+ MediaPluginOverrides,
9
+ "apiBaseURL" | "apiBasePath" | "headers" | "uploadMode" | "imageCompression"
10
+ >;
11
+
12
+ export interface UploadAssetInput {
13
+ file: File;
14
+ folderId?: string;
15
+ }
16
+
17
+ const DEFAULT_IMAGE_COMPRESSION = {
18
+ maxWidth: 2048,
19
+ maxHeight: 2048,
20
+ quality: 0.85,
21
+ } as const;
22
+
23
+ /**
24
+ * Upload an asset using the media plugin's configured storage mode.
25
+ *
26
+ * Use this in non-React contexts like editor `uploadImage` callbacks. React
27
+ * components should usually prefer `useUploadAsset()`, which wraps this helper
28
+ * and handles cache invalidation.
29
+ */
30
+ export async function uploadAsset(
31
+ config: MediaUploadClientConfig,
32
+ input: UploadAssetInput,
33
+ ): Promise<SerializedAsset> {
34
+ const {
35
+ apiBaseURL,
36
+ apiBasePath,
37
+ headers,
38
+ uploadMode = "direct",
39
+ imageCompression,
40
+ } = config;
41
+ const { file, folderId } = input;
42
+
43
+ const processedFile =
44
+ imageCompression === false
45
+ ? file
46
+ : await compressImage(
47
+ file,
48
+ imageCompression ?? DEFAULT_IMAGE_COMPRESSION,
49
+ );
50
+
51
+ const base = `${apiBaseURL}${apiBasePath}`;
52
+ const headersObj = new Headers(headers as HeadersInit | undefined);
53
+
54
+ if (uploadMode === "direct") {
55
+ const formData = new FormData();
56
+ formData.append("file", processedFile);
57
+ if (folderId) formData.append("folderId", folderId);
58
+
59
+ const res = await fetch(`${base}/media/upload`, {
60
+ method: "POST",
61
+ headers: headersObj,
62
+ body: formData,
63
+ });
64
+ if (!res.ok) {
65
+ const err = await res.json().catch(() => ({ message: res.statusText }));
66
+ throw new Error(err.message ?? "Upload failed");
67
+ }
68
+ return res.json();
69
+ }
70
+
71
+ if (uploadMode === "s3") {
72
+ const tokenRes = await fetch(`${base}/media/upload/token`, {
73
+ method: "POST",
74
+ headers: {
75
+ ...Object.fromEntries(headersObj.entries()),
76
+ "Content-Type": "application/json",
77
+ },
78
+ body: JSON.stringify({
79
+ filename: processedFile.name,
80
+ mimeType: processedFile.type,
81
+ size: processedFile.size,
82
+ folderId,
83
+ }),
84
+ });
85
+ if (!tokenRes.ok) {
86
+ const err = await tokenRes
87
+ .json()
88
+ .catch(() => ({ message: tokenRes.statusText }));
89
+ throw new Error(err.message ?? "Failed to get upload token");
90
+ }
91
+
92
+ const token = (await tokenRes.json()) as {
93
+ type: "presigned-url";
94
+ payload: {
95
+ uploadUrl: string;
96
+ publicUrl: string;
97
+ key: string;
98
+ method: "PUT";
99
+ headers: Record<string, string>;
100
+ };
101
+ };
102
+
103
+ const putRes = await fetch(token.payload.uploadUrl, {
104
+ method: "PUT",
105
+ headers: token.payload.headers,
106
+ body: processedFile,
107
+ });
108
+ if (!putRes.ok) throw new Error("Failed to upload to S3");
109
+
110
+ const assetRes = await fetch(`${base}/media/assets`, {
111
+ method: "POST",
112
+ headers: {
113
+ ...Object.fromEntries(headersObj.entries()),
114
+ "Content-Type": "application/json",
115
+ },
116
+ body: JSON.stringify({
117
+ filename: processedFile.name,
118
+ originalName: file.name,
119
+ mimeType: processedFile.type,
120
+ size: processedFile.size,
121
+ url: token.payload.publicUrl,
122
+ folderId,
123
+ }),
124
+ });
125
+ if (!assetRes.ok) {
126
+ const err = await assetRes
127
+ .json()
128
+ .catch(() => ({ message: assetRes.statusText }));
129
+ throw new Error(err.message ?? "Failed to register asset");
130
+ }
131
+ return assetRes.json();
132
+ }
133
+
134
+ if (uploadMode === "vercel-blob") {
135
+ // Dynamic import keeps @vercel/blob/client optional.
136
+ const { upload } = await import("@vercel/blob/client");
137
+ const blob = await upload(processedFile.name, processedFile, {
138
+ access: "public",
139
+ handleUploadUrl: `${base}/media/upload/vercel-blob`,
140
+ clientPayload: JSON.stringify({
141
+ mimeType: processedFile.type,
142
+ size: processedFile.size,
143
+ }),
144
+ });
145
+
146
+ const assetRes = await fetch(`${base}/media/assets`, {
147
+ method: "POST",
148
+ headers: {
149
+ ...Object.fromEntries(headersObj.entries()),
150
+ "Content-Type": "application/json",
151
+ },
152
+ body: JSON.stringify({
153
+ filename: processedFile.name,
154
+ originalName: file.name,
155
+ mimeType: processedFile.type,
156
+ size: processedFile.size,
157
+ url: blob.url,
158
+ folderId,
159
+ }),
160
+ });
161
+ if (!assetRes.ok) {
162
+ const err = await assetRes
163
+ .json()
164
+ .catch(() => ({ message: assetRes.statusText }));
165
+ throw new Error(err.message ?? "Failed to register asset");
166
+ }
167
+ return assetRes.json();
168
+ }
169
+
170
+ throw new Error(`Unknown uploadMode: ${uploadMode}`);
171
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Canvas-based client-side image compression.
3
+ *
4
+ * Skips SVG and GIF (vector data / animation would be lost on a canvas round-trip).
5
+ * All other image/* types are scaled down to fit within maxWidth × maxHeight
6
+ * (preserving aspect ratio) and re-encoded at the configured quality.
7
+ */
8
+
9
+ export interface ImageCompressionOptions {
10
+ /**
11
+ * Maximum width in pixels. Images wider than this are scaled down.
12
+ * @default 2048
13
+ */
14
+ maxWidth?: number;
15
+
16
+ /**
17
+ * Maximum height in pixels. Images taller than this are scaled down.
18
+ * @default 2048
19
+ */
20
+ maxHeight?: number;
21
+
22
+ /**
23
+ * Encoding quality (0–1). Applies to JPEG and WebP.
24
+ * @default 0.85
25
+ */
26
+ quality?: number;
27
+
28
+ /**
29
+ * Output MIME type. Defaults to the source image's MIME type.
30
+ * Set to `"image/webp"` for better compression at the cost of format change.
31
+ */
32
+ outputFormat?: string;
33
+ }
34
+
35
+ function loadImage(file: File): Promise<HTMLImageElement> {
36
+ return new Promise((resolve, reject) => {
37
+ const url = URL.createObjectURL(file);
38
+ const img = new Image();
39
+ img.onload = () => {
40
+ URL.revokeObjectURL(url);
41
+ resolve(img);
42
+ };
43
+ img.onerror = () => {
44
+ URL.revokeObjectURL(url);
45
+ reject(new Error(`Failed to load image: ${file.name}`));
46
+ };
47
+ img.src = url;
48
+ });
49
+ }
50
+
51
+ const SKIP_TYPES = new Set(["image/svg+xml", "image/gif"]);
52
+
53
+ /**
54
+ * Compresses an image file client-side using the Canvas API.
55
+ *
56
+ * Returns the original file unchanged if:
57
+ * - The file is not an image
58
+ * - The MIME type is SVG or GIF (would lose vector data / animation)
59
+ * - The browser does not support canvas (SSR guard)
60
+ */
61
+ export async function compressImage(
62
+ file: File,
63
+ options: ImageCompressionOptions = {},
64
+ ): Promise<File> {
65
+ if (!file.type.startsWith("image/") || SKIP_TYPES.has(file.type)) {
66
+ return file;
67
+ }
68
+
69
+ // SSR guard — canvas is only available in the browser
70
+ if (typeof document === "undefined") return file;
71
+
72
+ const {
73
+ maxWidth = 2048,
74
+ maxHeight = 2048,
75
+ quality = 0.85,
76
+ outputFormat,
77
+ } = options;
78
+
79
+ const img = await loadImage(file);
80
+
81
+ let { width, height } = img;
82
+
83
+ const needsResize = width > maxWidth || height > maxHeight;
84
+ const needsFormatChange =
85
+ outputFormat !== undefined && outputFormat !== file.type;
86
+
87
+ // Skip canvas entirely if the image is already within the limits and no
88
+ // format conversion is needed — re-encoding a small image can make it larger.
89
+ if (!needsResize && !needsFormatChange) return file;
90
+
91
+ // Scale down proportionally if either dimension exceeds the max
92
+ if (needsResize) {
93
+ const ratio = Math.min(maxWidth / width, maxHeight / height);
94
+ width = Math.round(width * ratio);
95
+ height = Math.round(height * ratio);
96
+ }
97
+
98
+ const canvas = document.createElement("canvas");
99
+ canvas.width = width;
100
+ canvas.height = height;
101
+
102
+ const ctx = canvas.getContext("2d");
103
+ if (!ctx) return file;
104
+
105
+ ctx.drawImage(img, 0, 0, width, height);
106
+
107
+ const mimeType = outputFormat ?? file.type;
108
+
109
+ return new Promise<File>((resolve, reject) => {
110
+ canvas.toBlob(
111
+ (blob) => {
112
+ if (!blob) {
113
+ reject(new Error("canvas.toBlob returned null"));
114
+ return;
115
+ }
116
+
117
+ // Preserve the original filename, updating extension only if
118
+ // the output format changed from the source.
119
+ let name = file.name;
120
+ if (outputFormat && outputFormat !== file.type) {
121
+ const ext = outputFormat.split("/")[1] ?? "jpg";
122
+ name = name.replace(/\.[^.]+$/, `.${ext}`);
123
+ }
124
+
125
+ resolve(new File([blob], name, { type: mimeType }));
126
+ },
127
+ mimeType,
128
+ quality,
129
+ );
130
+ });
131
+ }
@@ -0,0 +1 @@
1
+ /* Media plugin client styles — included in consumer CSS via @import "@btst/stack/plugins/media/css" */
@@ -0,0 +1,62 @@
1
+ import { createDbPlugin } from "@btst/db";
2
+
3
+ /**
4
+ * Media plugin schema
5
+ * Defines the database tables for media assets and folders
6
+ */
7
+ export const mediaSchema = createDbPlugin("media", {
8
+ asset: {
9
+ modelName: "mediaAsset",
10
+ fields: {
11
+ filename: {
12
+ type: "string",
13
+ required: true,
14
+ },
15
+ originalName: {
16
+ type: "string",
17
+ required: true,
18
+ },
19
+ mimeType: {
20
+ type: "string",
21
+ required: true,
22
+ },
23
+ size: {
24
+ type: "number",
25
+ required: true,
26
+ },
27
+ url: {
28
+ type: "string",
29
+ required: true,
30
+ },
31
+ folderId: {
32
+ type: "string",
33
+ required: false,
34
+ },
35
+ alt: {
36
+ type: "string",
37
+ required: false,
38
+ },
39
+ createdAt: {
40
+ type: "date",
41
+ defaultValue: () => new Date(),
42
+ },
43
+ },
44
+ },
45
+ folder: {
46
+ modelName: "mediaFolder",
47
+ fields: {
48
+ name: {
49
+ type: "string",
50
+ required: true,
51
+ },
52
+ parentId: {
53
+ type: "string",
54
+ required: false,
55
+ },
56
+ createdAt: {
57
+ type: "date",
58
+ defaultValue: () => new Date(),
59
+ },
60
+ },
61
+ },
62
+ });
@@ -0,0 +1,96 @@
1
+ import {
2
+ mergeQueryKeys,
3
+ createQueryKeys,
4
+ } from "@lukemorales/query-key-factory";
5
+ import { createApiClient } from "@btst/stack/plugins/client";
6
+ import type { MediaApiRouter } from "./api/plugin";
7
+ import type { SerializedAsset, SerializedFolder } from "./types";
8
+ import { assetListDiscriminator } from "./api/query-key-defs";
9
+ import type { AssetListParams } from "./api/getters";
10
+
11
+ function isErrorResponse(response: unknown): response is { error: unknown } {
12
+ return (
13
+ typeof response === "object" &&
14
+ response !== null &&
15
+ "error" in response &&
16
+ (response as Record<string, unknown>).error !== null &&
17
+ (response as Record<string, unknown>).error !== undefined
18
+ );
19
+ }
20
+
21
+ function toError(error: unknown): Error {
22
+ if (error instanceof Error) return error;
23
+ if (typeof error === "object" && error !== null) {
24
+ const errorObj = error as Record<string, unknown>;
25
+ const message =
26
+ (typeof errorObj.message === "string" ? errorObj.message : null) ||
27
+ JSON.stringify(error);
28
+ const err = new Error(message);
29
+ Object.assign(err, error);
30
+ return err;
31
+ }
32
+ return new Error(String(error));
33
+ }
34
+
35
+ export function createMediaQueryKeys(
36
+ client: ReturnType<typeof createApiClient<MediaApiRouter>>,
37
+ headers?: HeadersInit,
38
+ ) {
39
+ return mergeQueryKeys(
40
+ createQueryKeys("mediaAssets", {
41
+ list: (params?: AssetListParams) => ({
42
+ queryKey: [assetListDiscriminator(params)],
43
+ queryFn: async ({ pageParam }: { pageParam?: number }) => {
44
+ const response = await (client as any)("/media/assets", {
45
+ method: "GET",
46
+ query: {
47
+ folderId: params?.folderId,
48
+ mimeType: params?.mimeType,
49
+ query: params?.query,
50
+ offset: pageParam ?? params?.offset ?? 0,
51
+ limit: params?.limit ?? 20,
52
+ },
53
+ headers,
54
+ });
55
+ if (isErrorResponse(response)) throw toError(response.error);
56
+ const data = (response as any).data as {
57
+ items: SerializedAsset[];
58
+ total: number;
59
+ limit?: number;
60
+ offset?: number;
61
+ };
62
+ return data;
63
+ },
64
+ }),
65
+ detail: (id: string) => ({
66
+ queryKey: [id],
67
+ queryFn: async () => {
68
+ const response = await (client as any)("/media/assets", {
69
+ method: "GET",
70
+ query: { id },
71
+ headers,
72
+ });
73
+ if (isErrorResponse(response)) throw toError(response.error);
74
+ return (response as any).data as SerializedAsset | null;
75
+ },
76
+ }),
77
+ }),
78
+ createQueryKeys("mediaFolders", {
79
+ list: (parentId?: string | null) => ({
80
+ queryKey: [parentId ?? "root"],
81
+ queryFn: async () => {
82
+ const response = await (client as any)("/media/folders", {
83
+ method: "GET",
84
+ query:
85
+ parentId !== undefined ? { parentId: parentId ?? undefined } : {},
86
+ headers,
87
+ });
88
+ if (isErrorResponse(response)) throw toError(response.error);
89
+ return (response as any).data as SerializedFolder[];
90
+ },
91
+ }),
92
+ }),
93
+ );
94
+ }
95
+
96
+ export type MediaQueryKeys = ReturnType<typeof createMediaQueryKeys>;
@@ -0,0 +1,37 @@
1
+ import { z } from "zod";
2
+
3
+ export const AssetListQuerySchema = z.object({
4
+ folderId: z.string().optional(),
5
+ mimeType: z.string().optional(),
6
+ query: z.string().optional(),
7
+ offset: z.coerce.number().int().min(0).optional(),
8
+ limit: z.coerce.number().int().min(1).max(100).optional(),
9
+ });
10
+
11
+ export const createAssetSchema = z.object({
12
+ filename: z.string().min(1),
13
+ originalName: z.string().min(1),
14
+ mimeType: z.string().min(1),
15
+ // Allow 0 for URL-registered assets where size is unknown at registration time.
16
+ size: z.number().int().min(0),
17
+ url: z.httpUrl(),
18
+ folderId: z.string().optional(),
19
+ alt: z.string().optional(),
20
+ });
21
+
22
+ export const updateAssetSchema = z.object({
23
+ alt: z.string().optional(),
24
+ folderId: z.string().nullable().optional(),
25
+ });
26
+
27
+ export const createFolderSchema = z.object({
28
+ name: z.string().min(1),
29
+ parentId: z.string().optional(),
30
+ });
31
+
32
+ export const uploadTokenRequestSchema = z.object({
33
+ filename: z.string().min(1),
34
+ mimeType: z.string().min(1),
35
+ size: z.number().int().positive(),
36
+ folderId: z.string().optional(),
37
+ });
@@ -0,0 +1 @@
1
+ @source "./client/**/*.{ts,tsx}";