@btst/stack 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) 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/api/index.d.cts +2 -2
  74. package/dist/plugins/blog/api/index.d.mts +2 -2
  75. package/dist/plugins/blog/api/index.d.ts +2 -2
  76. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  77. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  78. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  79. package/dist/plugins/blog/client/index.d.cts +60 -3
  80. package/dist/plugins/blog/client/index.d.mts +60 -3
  81. package/dist/plugins/blog/client/index.d.ts +60 -3
  82. package/dist/plugins/blog/query-keys.d.cts +2 -2
  83. package/dist/plugins/blog/query-keys.d.mts +2 -2
  84. package/dist/plugins/blog/query-keys.d.ts +2 -2
  85. package/dist/plugins/cms/client/index.d.cts +73 -3
  86. package/dist/plugins/cms/client/index.d.mts +73 -3
  87. package/dist/plugins/cms/client/index.d.ts +73 -3
  88. package/dist/plugins/kanban/api/index.d.cts +1 -1
  89. package/dist/plugins/kanban/api/index.d.mts +1 -1
  90. package/dist/plugins/kanban/api/index.d.ts +1 -1
  91. package/dist/plugins/kanban/client/hooks/index.d.cts +1 -1
  92. package/dist/plugins/kanban/client/hooks/index.d.mts +1 -1
  93. package/dist/plugins/kanban/client/hooks/index.d.ts +1 -1
  94. package/dist/plugins/kanban/client/index.d.cts +1 -1
  95. package/dist/plugins/kanban/client/index.d.mts +1 -1
  96. package/dist/plugins/kanban/client/index.d.ts +1 -1
  97. package/dist/plugins/kanban/query-keys.d.cts +1 -1
  98. package/dist/plugins/kanban/query-keys.d.mts +1 -1
  99. package/dist/plugins/kanban/query-keys.d.ts +1 -1
  100. package/dist/plugins/media/api/adapters/s3.cjs +106 -0
  101. package/dist/plugins/media/api/adapters/s3.d.cts +60 -0
  102. package/dist/plugins/media/api/adapters/s3.d.mts +60 -0
  103. package/dist/plugins/media/api/adapters/s3.d.ts +60 -0
  104. package/dist/plugins/media/api/adapters/s3.mjs +104 -0
  105. package/dist/plugins/media/api/adapters/vercel-blob.cjs +54 -0
  106. package/dist/plugins/media/api/adapters/vercel-blob.d.cts +41 -0
  107. package/dist/plugins/media/api/adapters/vercel-blob.d.mts +41 -0
  108. package/dist/plugins/media/api/adapters/vercel-blob.d.ts +41 -0
  109. package/dist/plugins/media/api/adapters/vercel-blob.mjs +52 -0
  110. package/dist/plugins/media/api/index.cjs +26 -0
  111. package/dist/plugins/media/api/index.d.cts +116 -0
  112. package/dist/plugins/media/api/index.d.mts +116 -0
  113. package/dist/plugins/media/api/index.d.ts +116 -0
  114. package/dist/plugins/media/api/index.mjs +6 -0
  115. package/dist/plugins/media/client/components/index.cjs +10 -0
  116. package/dist/plugins/media/client/components/index.d.cts +55 -0
  117. package/dist/plugins/media/client/components/index.d.mts +55 -0
  118. package/dist/plugins/media/client/components/index.d.ts +55 -0
  119. package/dist/plugins/media/client/components/index.mjs +2 -0
  120. package/dist/plugins/media/client/hooks/index.cjs +13 -0
  121. package/dist/plugins/media/client/hooks/index.d.cts +53 -0
  122. package/dist/plugins/media/client/hooks/index.d.mts +53 -0
  123. package/dist/plugins/media/client/hooks/index.d.ts +53 -0
  124. package/dist/plugins/media/client/hooks/index.mjs +1 -0
  125. package/dist/plugins/media/client/index.cjs +9 -0
  126. package/dist/plugins/media/client/index.d.cts +242 -0
  127. package/dist/plugins/media/client/index.d.mts +242 -0
  128. package/dist/plugins/media/client/index.d.ts +242 -0
  129. package/dist/plugins/media/client/index.mjs +2 -0
  130. package/dist/plugins/media/client.css +1 -0
  131. package/dist/plugins/media/query-keys.cjs +72 -0
  132. package/dist/plugins/media/query-keys.d.cts +49 -0
  133. package/dist/plugins/media/query-keys.d.mts +49 -0
  134. package/dist/plugins/media/query-keys.d.ts +49 -0
  135. package/dist/plugins/media/query-keys.mjs +70 -0
  136. package/dist/plugins/media/style.css +1 -0
  137. package/dist/shared/{stack.DOZ1EXjM.d.mts → stack.6mEHS2WH.d.mts} +3 -3
  138. package/dist/shared/{stack.DX-tQ93o.d.cts → stack.AJTXI7kw.d.cts} +3 -3
  139. package/dist/shared/{stack.DRpeDS6X.d.ts → stack.BMx2QYOK.d.ts} +25 -0
  140. package/dist/shared/stack.BUTXWiG-.d.ts +286 -0
  141. package/dist/shared/stack.C7Y9sBDg.d.mts +286 -0
  142. package/dist/shared/stack.C7vfOBmO.d.mts +63 -0
  143. package/dist/shared/stack.CAni8dnD.d.cts +63 -0
  144. package/dist/shared/stack.CLcnSF_b.d.cts +25 -0
  145. package/dist/shared/stack.CLcnSF_b.d.mts +25 -0
  146. package/dist/shared/stack.CLcnSF_b.d.ts +25 -0
  147. package/dist/shared/stack.CYSwntXC.d.ts +63 -0
  148. package/dist/shared/{stack.Jb0kQDJC.d.mts → stack.Cd6McBu1.d.mts} +25 -0
  149. package/dist/shared/stack.CoBj86jf.d.cts +109 -0
  150. package/dist/shared/stack.CoBj86jf.d.mts +109 -0
  151. package/dist/shared/stack.CoBj86jf.d.ts +109 -0
  152. package/dist/shared/{stack.BXxrFL9R.d.ts → stack.D7HSzZdG.d.ts} +5 -5
  153. package/dist/shared/{stack.DzOhpIYM.d.mts → stack.DjgpFWq3.d.cts} +5 -5
  154. package/dist/shared/{stack.BxFl46lB.d.cts → stack.DxQl8Wa1.d.cts} +25 -0
  155. package/dist/shared/{stack.BSqJrCTM.d.cts → stack.IUeyQKrm.d.mts} +5 -5
  156. package/dist/shared/{stack.VF6FhyZw.d.ts → stack.QYn-Px94.d.ts} +3 -3
  157. package/dist/shared/stack.vxskCkim.d.cts +286 -0
  158. package/package.json +115 -6
  159. package/src/plugins/blog/client/components/forms/image-field.tsx +35 -4
  160. package/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx +67 -12
  161. package/src/plugins/blog/client/components/forms/markdown-editor.tsx +106 -10
  162. package/src/plugins/blog/client/overrides.ts +58 -1
  163. package/src/plugins/cms/client/components/forms/content-form.tsx +26 -7
  164. package/src/plugins/cms/client/components/forms/file-upload.tsx +73 -15
  165. package/src/plugins/cms/client/overrides.ts +57 -2
  166. package/src/plugins/kanban/client/components/forms/board-form.tsx +1 -1
  167. package/src/plugins/kanban/client/components/forms/task-form.tsx +7 -1
  168. package/src/plugins/kanban/client/overrides.ts +25 -0
  169. package/src/plugins/media/__tests__/__stubs__/vercel-blob-server.ts +9 -0
  170. package/src/plugins/media/__tests__/getters.test.ts +274 -0
  171. package/src/plugins/media/__tests__/mutations.test.ts +299 -0
  172. package/src/plugins/media/__tests__/plugin.test.ts +752 -0
  173. package/src/plugins/media/__tests__/query-key-defs.test.ts +54 -0
  174. package/src/plugins/media/__tests__/storage-adapters.test.ts +351 -0
  175. package/src/plugins/media/api/adapters/local.ts +79 -0
  176. package/src/plugins/media/api/adapters/s3.ts +198 -0
  177. package/src/plugins/media/api/adapters/vercel-blob.ts +132 -0
  178. package/src/plugins/media/api/getters.ts +174 -0
  179. package/src/plugins/media/api/index.ts +41 -0
  180. package/src/plugins/media/api/mutations.ts +179 -0
  181. package/src/plugins/media/api/plugin.ts +855 -0
  182. package/src/plugins/media/api/query-key-defs.ts +41 -0
  183. package/src/plugins/media/api/serializers.ts +28 -0
  184. package/src/plugins/media/api/storage-adapter.ts +139 -0
  185. package/src/plugins/media/client/components/index.tsx +6 -0
  186. package/src/plugins/media/client/components/media-picker/asset-card.tsx +150 -0
  187. package/src/plugins/media/client/components/media-picker/asset-preview-button.tsx +67 -0
  188. package/src/plugins/media/client/components/media-picker/browse-tab.tsx +116 -0
  189. package/src/plugins/media/client/components/media-picker/folder-tree.tsx +188 -0
  190. package/src/plugins/media/client/components/media-picker/index.tsx +347 -0
  191. package/src/plugins/media/client/components/media-picker/upload-tab.tsx +108 -0
  192. package/src/plugins/media/client/components/media-picker/url-tab.tsx +72 -0
  193. package/src/plugins/media/client/components/media-picker/utils.ts +17 -0
  194. package/src/plugins/media/client/components/pages/library-page.internal.tsx +134 -0
  195. package/src/plugins/media/client/components/pages/library-page.tsx +42 -0
  196. package/src/plugins/media/client/hooks/index.tsx +9 -0
  197. package/src/plugins/media/client/hooks/use-media.tsx +289 -0
  198. package/src/plugins/media/client/index.ts +4 -0
  199. package/src/plugins/media/client/overrides.ts +127 -0
  200. package/src/plugins/media/client/plugin.tsx +184 -0
  201. package/src/plugins/media/client/upload.ts +171 -0
  202. package/src/plugins/media/client/utils/image-compression.ts +131 -0
  203. package/src/plugins/media/client.css +1 -0
  204. package/src/plugins/media/db.ts +62 -0
  205. package/src/plugins/media/query-keys.ts +96 -0
  206. package/src/plugins/media/schemas.ts +37 -0
  207. package/src/plugins/media/style.css +1 -0
  208. package/src/plugins/media/types.ts +26 -0
  209. package/dist/shared/{stack.BWp0hcm9.d.ts → stack.BQmuNl5p.d.cts} +3 -3
  210. package/dist/shared/{stack.BWp0hcm9.d.cts → stack.BQmuNl5p.d.mts} +3 -3
  211. package/dist/shared/{stack.BWp0hcm9.d.mts → stack.BQmuNl5p.d.ts} +3 -3
  212. package/dist/shared/{stack.BvCR4-9H.d.ts → stack.D4Cea8II.d.ts} +3 -3
  213. package/dist/shared/{stack.CWxAl9K3.d.mts → stack.HE_IvqV5.d.mts} +3 -3
  214. package/dist/shared/{stack.BOokfhZD.d.cts → stack.Rtcvl8sS.d.cts} +3 -3
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Internal query key constants for the media plugin.
3
+ * Shared between query-keys.ts (HTTP path) and any SSR/SSG prefetching
4
+ * to prevent key drift between client and server.
5
+ */
6
+
7
+ import type { AssetListParams } from "./getters";
8
+
9
+ /**
10
+ * Discriminator for the asset list cache key.
11
+ */
12
+ export interface AssetListDiscriminator {
13
+ folderId: string | undefined;
14
+ mimeType: string | undefined;
15
+ query: string | undefined;
16
+ limit: number | undefined;
17
+ offset: number | undefined;
18
+ }
19
+
20
+ export function assetListDiscriminator(
21
+ params?: AssetListParams,
22
+ ): AssetListDiscriminator {
23
+ return {
24
+ folderId: params?.folderId,
25
+ mimeType: params?.mimeType,
26
+ query: params?.query,
27
+ limit: params?.limit,
28
+ offset: params?.offset,
29
+ };
30
+ }
31
+
32
+ /** Full query key builders — use these with `queryClient.setQueryData()`. */
33
+ export const MEDIA_QUERY_KEYS = {
34
+ assetsList: (params?: AssetListParams) =>
35
+ ["media", "assets", "list", assetListDiscriminator(params)] as const,
36
+
37
+ assetDetail: (id: string) => ["media", "assets", "detail", id] as const,
38
+
39
+ foldersList: (parentId?: string | null) =>
40
+ ["media", "folders", "list", parentId ?? "root"] as const,
41
+ };
@@ -0,0 +1,28 @@
1
+ import type {
2
+ Asset,
3
+ Folder,
4
+ SerializedAsset,
5
+ SerializedFolder,
6
+ } from "../types";
7
+
8
+ /**
9
+ * Serialize an Asset for SSR/SSG use (convert dates to strings).
10
+ * Pure function — no DB access, no hooks.
11
+ */
12
+ export function serializeAsset(asset: Asset): SerializedAsset {
13
+ return {
14
+ ...asset,
15
+ createdAt: asset.createdAt.toISOString(),
16
+ };
17
+ }
18
+
19
+ /**
20
+ * Serialize a Folder for SSR/SSG use (convert dates to strings).
21
+ * Pure function — no DB access, no hooks.
22
+ */
23
+ export function serializeFolder(folder: Folder): SerializedFolder {
24
+ return {
25
+ ...folder,
26
+ createdAt: folder.createdAt.toISOString(),
27
+ };
28
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Options provided to storage adapters when initiating an upload.
3
+ */
4
+ export interface UploadOptions {
5
+ filename: string;
6
+ mimeType: string;
7
+ size: number;
8
+ folderId?: string;
9
+ }
10
+
11
+ /**
12
+ * Local storage adapter — backend receives and stores file bytes directly.
13
+ * Suitable for development and self-hosted deployments.
14
+ */
15
+ export interface DirectStorageAdapter {
16
+ readonly type: "local";
17
+ /**
18
+ * Store the file buffer and return the public URL.
19
+ */
20
+ upload(buffer: Buffer, options: UploadOptions): Promise<{ url: string }>;
21
+ /**
22
+ * Remove the stored file given its public URL.
23
+ */
24
+ delete(url: string): Promise<void>;
25
+ }
26
+
27
+ /**
28
+ * Token returned by the S3 adapter.
29
+ * The client performs a `PUT` to `payload.uploadUrl` with the file body,
30
+ * then saves `payload.publicUrl` as the asset URL.
31
+ */
32
+ export interface S3UploadToken {
33
+ type: "presigned-url";
34
+ payload: {
35
+ uploadUrl: string;
36
+ publicUrl: string;
37
+ key: string;
38
+ method: "PUT";
39
+ headers: Record<string, string>;
40
+ };
41
+ }
42
+
43
+ /**
44
+ * S3 storage adapter — server issues a short-lived presigned PUT URL;
45
+ * the browser uploads directly to S3 (or R2 / MinIO).
46
+ */
47
+ export interface S3StorageAdapter {
48
+ readonly type: "s3";
49
+ /**
50
+ * The public base URL prefix for all assets in this bucket
51
+ * (e.g. `"https://assets.example.com"`). Used by the plugin to
52
+ * automatically validate client-supplied URLs when no explicit
53
+ * `allowedUrlPrefixes` are configured.
54
+ */
55
+ readonly urlPrefix: string;
56
+ /**
57
+ * Generate a presigned PUT URL for direct client upload.
58
+ */
59
+ generateUploadToken(options: UploadOptions): Promise<S3UploadToken>;
60
+ /**
61
+ * Remove the stored object given its public URL.
62
+ */
63
+ delete(url: string): Promise<void>;
64
+ }
65
+
66
+ /**
67
+ * Options returned from onBeforeGenerateToken and passed to Vercel Blob's handleUpload.
68
+ */
69
+ export interface VercelBlobTokenOptions {
70
+ addRandomSuffix?: boolean;
71
+ allowedContentTypes?: string[];
72
+ maximumSizeInBytes?: number;
73
+ }
74
+
75
+ /**
76
+ * Callbacks provided to the Vercel Blob adapter when handling a request.
77
+ */
78
+ export interface VercelBlobHandlerCallbacks {
79
+ /**
80
+ * Called before a client token is generated.
81
+ * Throw to reject the upload (auth gate).
82
+ * Return options to enforce allowedContentTypes and maximumSizeInBytes at the edge.
83
+ */
84
+ onBeforeGenerateToken?: (
85
+ pathname: string,
86
+ clientPayload: string | null,
87
+ ) => Promise<VercelBlobTokenOptions | void> | VercelBlobTokenOptions | void;
88
+ }
89
+
90
+ /**
91
+ * Vercel Blob storage adapter — uses the `@vercel/blob/server` `handleUpload`
92
+ * protocol. The same endpoint handles both token generation and upload
93
+ * completion notifications from Vercel's servers.
94
+ */
95
+ export interface VercelBlobStorageAdapter {
96
+ readonly type: "vercel-blob";
97
+ /**
98
+ * Hostname suffix that all Vercel Blob public URLs end with.
99
+ * Used by the plugin to automatically validate client-supplied URLs.
100
+ * Always `".public.blob.vercel-storage.com"`.
101
+ */
102
+ readonly urlHostnameSuffix: string;
103
+ /**
104
+ * Process a raw request from `@vercel/blob/client`'s `upload()` or from
105
+ * Vercel Blob's upload-completion webhook. Returns a JSON-serialisable object
106
+ * that should be sent back as the response body.
107
+ */
108
+ handleRequest(
109
+ request: Request,
110
+ callbacks: VercelBlobHandlerCallbacks,
111
+ ): Promise<unknown>;
112
+ /**
113
+ * Remove the stored blob given its public URL.
114
+ */
115
+ delete(url: string): Promise<void>;
116
+ }
117
+
118
+ export type StorageAdapter =
119
+ | DirectStorageAdapter
120
+ | S3StorageAdapter
121
+ | VercelBlobStorageAdapter;
122
+
123
+ export function isDirectAdapter(
124
+ adapter: StorageAdapter,
125
+ ): adapter is DirectStorageAdapter {
126
+ return adapter.type === "local";
127
+ }
128
+
129
+ export function isS3Adapter(
130
+ adapter: StorageAdapter,
131
+ ): adapter is S3StorageAdapter {
132
+ return adapter.type === "s3";
133
+ }
134
+
135
+ export function isVercelBlobAdapter(
136
+ adapter: StorageAdapter,
137
+ ): adapter is VercelBlobStorageAdapter {
138
+ return adapter.type === "vercel-blob";
139
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ MediaPicker,
3
+ ImageInputField,
4
+ type MediaPickerProps,
5
+ } from "./media-picker";
6
+ export { LibraryPageComponent } from "./pages/library-page";
@@ -0,0 +1,150 @@
1
+ import { useDeleteAsset } from "../../hooks/use-media";
2
+ import type { SerializedAsset } from "../../../types";
3
+ import { cn } from "@workspace/ui/lib/utils";
4
+ import { File, Check, Copy, Trash2 } from "lucide-react";
5
+ import { isImage, formatBytes } from "./utils";
6
+ import { usePluginOverrides } from "@btst/stack/context";
7
+ import type { MediaPluginOverrides } from "../../overrides";
8
+ import { AssetPreviewButton } from "./asset-preview-button";
9
+ import { toast } from "sonner";
10
+
11
+ export function AssetCard({
12
+ asset,
13
+ onToggle,
14
+ selected = false,
15
+ onDelete,
16
+ apiBaseURL,
17
+ }: {
18
+ asset: SerializedAsset;
19
+ selected?: boolean;
20
+ onToggle?: () => void;
21
+ onDelete?: (id: string) => void | Promise<void>;
22
+ apiBaseURL?: string;
23
+ }) {
24
+ const { mutateAsync: deleteAsset } = useDeleteAsset();
25
+ const { Image: ImageComponent } = usePluginOverrides<
26
+ MediaPluginOverrides,
27
+ Partial<MediaPluginOverrides>
28
+ >("media", {});
29
+ const imageAsset = isImage(asset.mimeType);
30
+ const selectable = typeof onToggle === "function";
31
+
32
+ const copyUrl = () => {
33
+ let fullUrl: string;
34
+ try {
35
+ fullUrl = new URL(asset.url, apiBaseURL).href;
36
+ } catch {
37
+ fullUrl = asset.url;
38
+ }
39
+ navigator.clipboard
40
+ .writeText(fullUrl)
41
+ .then(() => toast.success("URL copied"));
42
+ };
43
+
44
+ const handleDelete = () => {
45
+ if (onDelete) {
46
+ return onDelete(asset.id);
47
+ }
48
+
49
+ if (confirm(`Delete "${asset.originalName}"?`)) {
50
+ return deleteAsset(asset.id).catch(console.error);
51
+ }
52
+ };
53
+
54
+ return (
55
+ <div
56
+ role={selectable ? "button" : undefined}
57
+ tabIndex={selectable ? 0 : undefined}
58
+ data-testid="media-asset-item"
59
+ onClick={onToggle}
60
+ onKeyDown={(e) => {
61
+ if (selectable && (e.key === "Enter" || e.key === " ")) {
62
+ e.preventDefault();
63
+ onToggle();
64
+ }
65
+ }}
66
+ className={cn(
67
+ "group relative cursor-pointer rounded-md border bg-muted/30 p-1 transition-all hover:border-ring hover:shadow-sm",
68
+ !selectable && "cursor-default",
69
+ selected && "border-ring ring-1 ring-ring",
70
+ )}
71
+ >
72
+ {/* Thumbnail */}
73
+ <div className="flex h-28 items-center justify-center overflow-hidden rounded bg-muted">
74
+ {imageAsset ? (
75
+ ImageComponent ? (
76
+ <ImageComponent
77
+ src={asset.url}
78
+ alt={asset.alt || asset.originalName}
79
+ className="h-full w-full object-cover"
80
+ width={160}
81
+ height={80}
82
+ />
83
+ ) : (
84
+ <img
85
+ src={asset.url}
86
+ alt={asset.alt || asset.originalName}
87
+ className="h-full w-full object-cover"
88
+ loading="lazy"
89
+ />
90
+ )
91
+ ) : (
92
+ <File className="size-8 text-muted-foreground" />
93
+ )}
94
+ </div>
95
+
96
+ {/* Name + size */}
97
+ <div className="mt-1 px-0.5">
98
+ <p
99
+ className="truncate text-xs font-medium leading-tight"
100
+ title={asset.originalName}
101
+ >
102
+ {asset.originalName}
103
+ </p>
104
+ <p className="text-[10px] text-muted-foreground">
105
+ {formatBytes(asset.size)}
106
+ </p>
107
+ </div>
108
+
109
+ {/* Selection indicator */}
110
+ {selected && (
111
+ <div className="absolute right-1 top-1 rounded-full bg-primary p-0.5 text-primary-foreground">
112
+ <Check className="size-3" />
113
+ </div>
114
+ )}
115
+
116
+ <div className="absolute right-1 top-1 hidden gap-1 group-hover:flex">
117
+ {apiBaseURL ? (
118
+ <button
119
+ type="button"
120
+ title="Copy URL"
121
+ onClick={(e) => {
122
+ e.stopPropagation();
123
+ copyUrl();
124
+ }}
125
+ className="rounded bg-background/80 p-0.5 shadow hover:bg-background"
126
+ >
127
+ <Copy className="size-3" />
128
+ </button>
129
+ ) : null}
130
+ {imageAsset ? (
131
+ <AssetPreviewButton
132
+ asset={asset}
133
+ className="rounded bg-background/80 p-0.5 shadow hover:bg-background"
134
+ />
135
+ ) : null}
136
+ <button
137
+ type="button"
138
+ title="Delete"
139
+ onClick={(e) => {
140
+ e.stopPropagation();
141
+ void handleDelete();
142
+ }}
143
+ className="rounded bg-destructive/80 p-0.5 text-white hover:bg-destructive"
144
+ >
145
+ <Trash2 className="size-3" />
146
+ </button>
147
+ </div>
148
+ </div>
149
+ );
150
+ }
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import {
5
+ Dialog,
6
+ DialogClose,
7
+ DialogContent,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from "@workspace/ui/components/dialog";
11
+ import { Eye, X } from "lucide-react";
12
+ import type { SerializedAsset } from "../../../types";
13
+
14
+ export function AssetPreviewButton({
15
+ asset,
16
+ className,
17
+ }: {
18
+ asset: SerializedAsset;
19
+ className: string;
20
+ }) {
21
+ const [open, setOpen] = useState(false);
22
+
23
+ return (
24
+ <>
25
+ <button
26
+ type="button"
27
+ title="Preview"
28
+ aria-label={`Preview ${asset.originalName}`}
29
+ onClick={(event) => {
30
+ event.stopPropagation();
31
+ setOpen(true);
32
+ }}
33
+ className={className}
34
+ >
35
+ <Eye className="size-3" />
36
+ </button>
37
+
38
+ <Dialog open={open} onOpenChange={setOpen}>
39
+ <DialogContent
40
+ showCloseButton={false}
41
+ className="h-screen w-screen max-w-none border-0 bg-black/95 p-4 shadow-none sm:max-w-none sm:rounded-none sm:p-6"
42
+ >
43
+ <DialogHeader className="sr-only">
44
+ <DialogTitle>{asset.alt || asset.originalName}</DialogTitle>
45
+ </DialogHeader>
46
+
47
+ <DialogClose
48
+ className="absolute right-4 top-4 z-10 rounded bg-black/60 p-2 text-white transition hover:bg-black/80"
49
+ aria-label="Close preview"
50
+ >
51
+ <X className="size-4" />
52
+ </DialogClose>
53
+
54
+ <div className="h-full w-full overflow-auto">
55
+ <div className="flex min-h-full w-full items-start justify-center">
56
+ <img
57
+ src={asset.url}
58
+ alt={asset.alt || asset.originalName}
59
+ className="block h-auto w-auto max-w-none"
60
+ />
61
+ </div>
62
+ </div>
63
+ </DialogContent>
64
+ </Dialog>
65
+ </>
66
+ );
67
+ }
@@ -0,0 +1,116 @@
1
+ import { useState, useRef } from "react";
2
+ import { useAssets } from "../../hooks/use-media";
3
+ import type { SerializedAsset } from "../../../types";
4
+ import { Input } from "@workspace/ui/components/input";
5
+ import { Button } from "@workspace/ui/components/button";
6
+ import { Loader2, Search, X, Image } from "lucide-react";
7
+ import { AssetCard } from "./asset-card";
8
+ import { matchesAccept } from "./utils";
9
+
10
+ export function BrowseTab({
11
+ folderId,
12
+ selected = [],
13
+ accept,
14
+ onToggle,
15
+ onDelete,
16
+ apiBaseURL,
17
+ emptyMessage = "No files found",
18
+ }: {
19
+ folderId: string | null;
20
+ selected?: SerializedAsset[];
21
+ accept?: string[];
22
+ onToggle?: (asset: SerializedAsset) => void;
23
+ onDelete?: (id: string) => void | Promise<void>;
24
+ apiBaseURL?: string;
25
+ emptyMessage?: string;
26
+ }) {
27
+ const [search, setSearch] = useState("");
28
+ const [debouncedSearch, setDebouncedSearch] = useState("");
29
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
30
+ const selectable = typeof onToggle === "function";
31
+
32
+ const handleSearch = (v: string) => {
33
+ setSearch(v);
34
+ if (debounceRef.current) clearTimeout(debounceRef.current);
35
+ debounceRef.current = setTimeout(() => setDebouncedSearch(v), 300);
36
+ };
37
+
38
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
39
+ useAssets({
40
+ folderId: folderId ?? undefined,
41
+ query: debouncedSearch || undefined,
42
+ limit: 40,
43
+ });
44
+
45
+ const allAssets = data?.pages.flatMap((p) => p.items) ?? [];
46
+ const filtered = accept
47
+ ? allAssets.filter((a) => matchesAccept(a.mimeType, accept))
48
+ : allAssets;
49
+
50
+ return (
51
+ <div className="flex h-full min-h-0 flex-col gap-2">
52
+ <div className="relative">
53
+ <Search className="absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
54
+ <Input
55
+ value={search}
56
+ onChange={(e) => handleSearch(e.target.value)}
57
+ placeholder="Search files…"
58
+ className="h-8 pl-7 text-sm"
59
+ />
60
+ {search && (
61
+ <button
62
+ type="button"
63
+ onClick={() => {
64
+ setSearch("");
65
+ setDebouncedSearch("");
66
+ }}
67
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
68
+ >
69
+ <X className="size-3.5" />
70
+ </button>
71
+ )}
72
+ </div>
73
+
74
+ {isLoading ? (
75
+ <div className="flex flex-1 items-center justify-center">
76
+ <Loader2 className="size-6 animate-spin text-muted-foreground" />
77
+ </div>
78
+ ) : filtered.length === 0 ? (
79
+ <div className="flex flex-1 flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
80
+ <Image className="size-8" />
81
+ <p>{emptyMessage}</p>
82
+ </div>
83
+ ) : (
84
+ <div className="flex-1 overflow-y-auto overscroll-contain">
85
+ <div className="grid grid-cols-2 gap-2 pb-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
86
+ {filtered.map((asset) => (
87
+ <AssetCard
88
+ key={asset.id}
89
+ asset={asset}
90
+ selected={selected.some((s) => s.id === asset.id)}
91
+ onToggle={selectable ? () => onToggle(asset) : undefined}
92
+ onDelete={onDelete}
93
+ apiBaseURL={apiBaseURL}
94
+ />
95
+ ))}
96
+ </div>
97
+ {hasNextPage && (
98
+ <div className="flex justify-center py-2">
99
+ <Button
100
+ variant="ghost"
101
+ size="sm"
102
+ onClick={() => fetchNextPage()}
103
+ disabled={isFetchingNextPage}
104
+ >
105
+ {isFetchingNextPage ? (
106
+ <Loader2 className="mr-1 size-3 animate-spin" />
107
+ ) : null}
108
+ Load more
109
+ </Button>
110
+ </div>
111
+ )}
112
+ </div>
113
+ )}
114
+ </div>
115
+ );
116
+ }