@btst/stack 2.8.1 → 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 +113 -4
  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,17 @@
1
+ export function matchesAccept(mimeType: string, accept?: string[]) {
2
+ if (!accept || accept.length === 0) return true;
3
+ return accept.some((a) => {
4
+ if (a.endsWith("/*")) return mimeType.startsWith(a.slice(0, -1));
5
+ return mimeType === a;
6
+ });
7
+ }
8
+
9
+ export function isImage(mimeType: string) {
10
+ return mimeType.startsWith("image/");
11
+ }
12
+
13
+ export function formatBytes(bytes: number) {
14
+ if (bytes < 1024) return `${bytes} B`;
15
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
16
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
17
+ }
@@ -0,0 +1,134 @@
1
+ "use client";
2
+ import { useState, useCallback, useRef } from "react";
3
+ import { useDeleteAsset, useUploadAsset } from "../../hooks/use-media";
4
+ import { Button } from "@workspace/ui/components/button";
5
+ import { Upload, Loader2 } from "lucide-react";
6
+ import { cn } from "@workspace/ui/lib/utils";
7
+ import { toast } from "sonner";
8
+ import { usePluginOverrides } from "@btst/stack/context";
9
+ import type { MediaPluginOverrides } from "../../overrides";
10
+ import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle";
11
+ import { BrowseTab } from "../media-picker/browse-tab";
12
+ import { FolderTree } from "../media-picker/folder-tree";
13
+
14
+ export function LibraryPage() {
15
+ const overrides = usePluginOverrides<
16
+ MediaPluginOverrides,
17
+ Partial<MediaPluginOverrides>
18
+ >("media", {});
19
+
20
+ useRouteLifecycle({
21
+ routeName: "library",
22
+ context: {
23
+ path: "/media",
24
+ isSSR: typeof window === "undefined",
25
+ },
26
+ overrides,
27
+ beforeRenderHook: (overrides, context) => {
28
+ if (overrides.onBeforeLibraryPageRendered) {
29
+ return overrides.onBeforeLibraryPageRendered(context);
30
+ }
31
+ return true;
32
+ },
33
+ });
34
+
35
+ const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
36
+ const [dragging, setDragging] = useState(false);
37
+ const fileInputRef = useRef<HTMLInputElement>(null);
38
+ const { mutateAsync: uploadAsset, isPending: isUploading } = useUploadAsset();
39
+ const { mutateAsync: deleteAsset } = useDeleteAsset();
40
+ const { apiBaseURL = "" } = overrides;
41
+
42
+ const handleUpload = useCallback(
43
+ async (files: FileList | File[]) => {
44
+ const arr = Array.from(files);
45
+ for (const file of arr) {
46
+ try {
47
+ await uploadAsset({ file, folderId: selectedFolder ?? undefined });
48
+ toast.success(`Uploaded ${file.name}`);
49
+ } catch (err) {
50
+ toast.error(err instanceof Error ? err.message : "Upload failed");
51
+ }
52
+ }
53
+ },
54
+ [selectedFolder, uploadAsset],
55
+ );
56
+
57
+ const handleDelete = async (id: string) => {
58
+ if (!confirm("Delete this asset?")) return;
59
+ try {
60
+ await deleteAsset(id);
61
+ toast.success("Deleted");
62
+ } catch (err) {
63
+ toast.error(err instanceof Error ? err.message : "Delete failed");
64
+ }
65
+ };
66
+
67
+ return (
68
+ <div className="flex h-[calc(100dvh-4rem)] flex-col overflow-hidden md:flex-row">
69
+ <div className="max-h-48 shrink-0 overflow-hidden border-b bg-muted/20 md:h-full md:max-h-none md:w-52 md:border-b-0 md:border-r">
70
+ <FolderTree selectedId={selectedFolder} onSelect={setSelectedFolder} />
71
+ </div>
72
+
73
+ <div
74
+ className={cn(
75
+ "relative flex flex-1 flex-col overflow-hidden border-t md:border-t-0",
76
+ dragging && "ring-2 ring-inset ring-ring",
77
+ )}
78
+ onDragOver={(e) => {
79
+ e.preventDefault();
80
+ setDragging(true);
81
+ }}
82
+ onDragLeave={() => setDragging(false)}
83
+ onDrop={(e) => {
84
+ e.preventDefault();
85
+ setDragging(false);
86
+ void handleUpload(e.dataTransfer.files);
87
+ }}
88
+ >
89
+ {/* Toolbar */}
90
+ <div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-end">
91
+ <Button
92
+ size="sm"
93
+ onClick={() => fileInputRef.current?.click()}
94
+ disabled={isUploading}
95
+ className="w-full sm:w-auto"
96
+ >
97
+ {isUploading ? (
98
+ <Loader2 className="mr-2 size-3.5 animate-spin" />
99
+ ) : (
100
+ <Upload className="mr-2 size-3.5" />
101
+ )}
102
+ Upload
103
+ </Button>
104
+ <input
105
+ ref={fileInputRef}
106
+ type="file"
107
+ multiple
108
+ className="hidden"
109
+ onChange={(e) => e.target.files && handleUpload(e.target.files)}
110
+ />
111
+ </div>
112
+
113
+ {/* Drop overlay */}
114
+ {dragging && (
115
+ <div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-background/80">
116
+ <div className="rounded-lg border-2 border-dashed border-ring p-8 text-center">
117
+ <Upload className="mx-auto mb-2 size-10 text-ring" />
118
+ <p className="font-medium">Drop files to upload</p>
119
+ </div>
120
+ </div>
121
+ )}
122
+
123
+ <div className="flex-1 min-h-0 p-3 sm:p-4">
124
+ <BrowseTab
125
+ folderId={selectedFolder}
126
+ onDelete={handleDelete}
127
+ apiBaseURL={apiBaseURL}
128
+ emptyMessage="No files yet. Drag & drop or click Upload."
129
+ />
130
+ </div>
131
+ </div>
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,42 @@
1
+ "use client";
2
+ import { lazy } from "react";
3
+ import type { FallbackProps } from "react-error-boundary";
4
+ import { usePluginOverrides } from "@btst/stack/context";
5
+ import { ComposedRoute } from "@btst/stack/client/components";
6
+ import type { MediaPluginOverrides } from "../../overrides";
7
+ import { Loader2 } from "lucide-react";
8
+
9
+ const LibraryPage = lazy(() =>
10
+ import("./library-page.internal").then((m) => ({ default: m.LibraryPage })),
11
+ );
12
+
13
+ function LibraryLoading() {
14
+ return (
15
+ <div className="flex h-96 items-center justify-center">
16
+ <Loader2 className="size-8 animate-spin text-muted-foreground" />
17
+ </div>
18
+ );
19
+ }
20
+
21
+ function LibraryError({ error }: FallbackProps) {
22
+ const message = error instanceof Error ? error.message : String(error);
23
+ return (
24
+ <div className="flex h-96 items-center justify-center p-8 text-destructive">
25
+ <p className="text-sm">{message}</p>
26
+ </div>
27
+ );
28
+ }
29
+
30
+ export function LibraryPageComponent() {
31
+ usePluginOverrides<MediaPluginOverrides>("media");
32
+ return (
33
+ <ComposedRoute
34
+ path="/media"
35
+ PageComponent={LibraryPage}
36
+ ErrorComponent={LibraryError}
37
+ LoadingComponent={LibraryLoading}
38
+ NotFoundComponent={() => null}
39
+ onError={(error) => console.error("[btst/media] Library error:", error)}
40
+ />
41
+ );
42
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ useAssets,
3
+ useFolders,
4
+ useUploadAsset,
5
+ useRegisterAsset,
6
+ useDeleteAsset,
7
+ useCreateFolder,
8
+ useDeleteFolder,
9
+ } from "./use-media";
@@ -0,0 +1,289 @@
1
+ "use client";
2
+ import {
3
+ useInfiniteQuery,
4
+ useQuery,
5
+ useMutation,
6
+ useQueryClient,
7
+ } from "@tanstack/react-query";
8
+ import { usePluginOverrides } from "@btst/stack/context";
9
+ import { createApiClient } from "@btst/stack/plugins/client";
10
+ import type { MediaApiRouter } from "../../api/plugin";
11
+ import type { MediaPluginOverrides } from "../overrides";
12
+ import { createMediaQueryKeys } from "../../query-keys";
13
+ import type { AssetListParams } from "../../api/getters";
14
+ import type { SerializedAsset, SerializedFolder } from "../../types";
15
+ import { uploadAsset } from "../upload";
16
+
17
+ function useMediaConfig() {
18
+ return usePluginOverrides<MediaPluginOverrides>("media");
19
+ }
20
+
21
+ function useMediaApiClient() {
22
+ const { apiBaseURL, apiBasePath, headers } = useMediaConfig();
23
+ const client = createApiClient<MediaApiRouter>({
24
+ baseURL: apiBaseURL,
25
+ basePath: apiBasePath,
26
+ });
27
+ return { client, headers };
28
+ }
29
+
30
+ /**
31
+ * Infinite-scroll list of assets, optionally filtered by folder / MIME type / search.
32
+ */
33
+ export function useAssets(params?: AssetListParams) {
34
+ const { client, headers } = useMediaApiClient();
35
+ const queries = createMediaQueryKeys(client, headers);
36
+ const { queryClient } = useMediaConfig();
37
+
38
+ const limit = params?.limit ?? 20;
39
+
40
+ return useInfiniteQuery(
41
+ {
42
+ ...queries.mediaAssets.list(params),
43
+ initialPageParam: 0,
44
+ refetchOnMount: "always",
45
+ getNextPageParam: (
46
+ lastPage: {
47
+ items: SerializedAsset[];
48
+ total: number;
49
+ limit?: number;
50
+ offset?: number;
51
+ },
52
+ _allPages: any[],
53
+ lastPageParam: number,
54
+ ) => {
55
+ const offset = (lastPage.offset ?? 0) + lastPage.items.length;
56
+ return offset < lastPage.total ? offset : undefined;
57
+ },
58
+ },
59
+ queryClient,
60
+ );
61
+ }
62
+
63
+ /**
64
+ * List of folders, optionally filtered by parentId.
65
+ * Pass `null` for root-level folders, `undefined` for all folders.
66
+ */
67
+ export function useFolders(parentId?: string | null) {
68
+ const { client, headers } = useMediaApiClient();
69
+ const queries = createMediaQueryKeys(client, headers);
70
+ const { queryClient } = useMediaConfig();
71
+
72
+ return useQuery(
73
+ {
74
+ ...queries.mediaFolders.list(parentId),
75
+ },
76
+ queryClient,
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Upload an asset — adapter-aware. Handles direct, S3, and Vercel Blob flows.
82
+ */
83
+ export function useUploadAsset() {
84
+ const {
85
+ apiBaseURL,
86
+ apiBasePath,
87
+ headers,
88
+ uploadMode = "direct",
89
+ imageCompression,
90
+ queryClient: qc,
91
+ } = useMediaConfig();
92
+ const reactQueryClient = useQueryClient(qc);
93
+
94
+ return useMutation(
95
+ {
96
+ mutationFn: async ({
97
+ file,
98
+ folderId,
99
+ }: {
100
+ file: File;
101
+ folderId?: string;
102
+ }): Promise<SerializedAsset> =>
103
+ uploadAsset(
104
+ {
105
+ apiBaseURL,
106
+ apiBasePath,
107
+ headers,
108
+ uploadMode,
109
+ imageCompression,
110
+ },
111
+ { file, folderId },
112
+ ),
113
+ onSuccess: () => {
114
+ reactQueryClient.invalidateQueries({ queryKey: ["mediaAssets"] });
115
+ },
116
+ },
117
+ qc,
118
+ );
119
+ }
120
+
121
+ /**
122
+ * Register an asset URL directly (for when the URL already exists).
123
+ */
124
+ export function useRegisterAsset() {
125
+ const {
126
+ apiBaseURL,
127
+ apiBasePath,
128
+ headers,
129
+ queryClient: qc,
130
+ } = useMediaConfig();
131
+ const reactQueryClient = useQueryClient(qc);
132
+
133
+ return useMutation(
134
+ {
135
+ mutationFn: async (input: {
136
+ url: string;
137
+ filename: string;
138
+ mimeType?: string;
139
+ size?: number;
140
+ folderId?: string;
141
+ }): Promise<SerializedAsset> => {
142
+ const base = `${apiBaseURL}${apiBasePath}`;
143
+ const headersObj = new Headers(headers as HeadersInit | undefined);
144
+ const res = await fetch(`${base}/media/assets`, {
145
+ method: "POST",
146
+ headers: {
147
+ ...Object.fromEntries(headersObj.entries()),
148
+ "Content-Type": "application/json",
149
+ },
150
+ body: JSON.stringify({
151
+ filename: input.filename,
152
+ originalName: input.filename,
153
+ mimeType: input.mimeType ?? "application/octet-stream",
154
+ size: input.size ?? 0,
155
+ url: input.url,
156
+ folderId: input.folderId,
157
+ }),
158
+ });
159
+ if (!res.ok) {
160
+ const err = await res
161
+ .json()
162
+ .catch(() => ({ message: res.statusText }));
163
+ throw new Error(err.message ?? "Failed to register asset");
164
+ }
165
+ return res.json();
166
+ },
167
+ onSuccess: () => {
168
+ reactQueryClient.invalidateQueries({ queryKey: ["mediaAssets"] });
169
+ },
170
+ },
171
+ qc,
172
+ );
173
+ }
174
+
175
+ /**
176
+ * Delete an asset by ID.
177
+ */
178
+ export function useDeleteAsset() {
179
+ const {
180
+ apiBaseURL,
181
+ apiBasePath,
182
+ headers,
183
+ queryClient: qc,
184
+ } = useMediaConfig();
185
+ const reactQueryClient = useQueryClient(qc);
186
+
187
+ return useMutation(
188
+ {
189
+ mutationFn: async (id: string) => {
190
+ const base = `${apiBaseURL}${apiBasePath}`;
191
+ const headersObj = new Headers(headers as HeadersInit | undefined);
192
+ const res = await fetch(`${base}/media/assets/${id}`, {
193
+ method: "DELETE",
194
+ headers: headersObj,
195
+ });
196
+ if (!res.ok) {
197
+ const err = await res
198
+ .json()
199
+ .catch(() => ({ message: res.statusText }));
200
+ throw new Error(err.message ?? "Delete failed");
201
+ }
202
+ },
203
+ onSuccess: () => {
204
+ reactQueryClient.invalidateQueries({ queryKey: ["mediaAssets"] });
205
+ },
206
+ },
207
+ qc,
208
+ );
209
+ }
210
+
211
+ /**
212
+ * Create a new folder.
213
+ */
214
+ export function useCreateFolder() {
215
+ const {
216
+ apiBaseURL,
217
+ apiBasePath,
218
+ headers,
219
+ queryClient: qc,
220
+ } = useMediaConfig();
221
+ const reactQueryClient = useQueryClient(qc);
222
+
223
+ return useMutation(
224
+ {
225
+ mutationFn: async (input: {
226
+ name: string;
227
+ parentId?: string;
228
+ }): Promise<SerializedFolder> => {
229
+ const base = `${apiBaseURL}${apiBasePath}`;
230
+ const headersObj = new Headers(headers as HeadersInit | undefined);
231
+ const res = await fetch(`${base}/media/folders`, {
232
+ method: "POST",
233
+ headers: {
234
+ ...Object.fromEntries(headersObj.entries()),
235
+ "Content-Type": "application/json",
236
+ },
237
+ body: JSON.stringify(input),
238
+ });
239
+ if (!res.ok) {
240
+ const err = await res
241
+ .json()
242
+ .catch(() => ({ message: res.statusText }));
243
+ throw new Error(err.message ?? "Failed to create folder");
244
+ }
245
+ return res.json();
246
+ },
247
+ onSuccess: () => {
248
+ reactQueryClient.invalidateQueries({ queryKey: ["mediaFolders"] });
249
+ },
250
+ },
251
+ qc,
252
+ );
253
+ }
254
+
255
+ /**
256
+ * Delete a folder by ID.
257
+ */
258
+ export function useDeleteFolder() {
259
+ const {
260
+ apiBaseURL,
261
+ apiBasePath,
262
+ headers,
263
+ queryClient: qc,
264
+ } = useMediaConfig();
265
+ const reactQueryClient = useQueryClient(qc);
266
+
267
+ return useMutation(
268
+ {
269
+ mutationFn: async (id: string) => {
270
+ const base = `${apiBaseURL}${apiBasePath}`;
271
+ const headersObj = new Headers(headers as HeadersInit | undefined);
272
+ const res = await fetch(`${base}/media/folders/${id}`, {
273
+ method: "DELETE",
274
+ headers: headersObj,
275
+ });
276
+ if (!res.ok) {
277
+ const err = await res
278
+ .json()
279
+ .catch(() => ({ message: res.statusText }));
280
+ throw new Error(err.message ?? "Failed to delete folder");
281
+ }
282
+ },
283
+ onSuccess: () => {
284
+ reactQueryClient.invalidateQueries({ queryKey: ["mediaFolders"] });
285
+ },
286
+ },
287
+ qc,
288
+ );
289
+ }
@@ -0,0 +1,4 @@
1
+ export { mediaClientPlugin } from "./plugin";
2
+ export type { MediaPluginOverrides, MediaUploadMode } from "./overrides";
3
+ export { uploadAsset } from "./upload";
4
+ export type { MediaUploadClientConfig, UploadAssetInput } from "./upload";
@@ -0,0 +1,127 @@
1
+ import type { ComponentType } from "react";
2
+ import type { QueryClient } from "@tanstack/react-query";
3
+ import type { ImageCompressionOptions } from "./utils/image-compression";
4
+
5
+ /**
6
+ * Upload mode — must match the storage adapter configured in mediaBackendPlugin.
7
+ * - `"direct"` — local filesystem adapter, files are uploaded via `POST /media/upload`
8
+ * - `"s3"` — AWS S3 / R2 / MinIO, the client fetches a presigned token then PUTs directly to S3
9
+ * - `"vercel-blob"` — Vercel Blob, uses the `@vercel/blob/client` SDK for direct upload
10
+ */
11
+ export type MediaUploadMode = "direct" | "s3" | "vercel-blob";
12
+
13
+ /**
14
+ * Overridable components and functions for the Media plugin.
15
+ *
16
+ * External consumers provide these when registering the media client plugin
17
+ * via the StackProvider overrides.
18
+ */
19
+ export interface MediaPluginOverrides {
20
+ /**
21
+ * Base URL for API calls (e.g., "http://localhost:3000").
22
+ */
23
+ apiBaseURL: string;
24
+
25
+ /**
26
+ * Path where the API is mounted (e.g., "/api/data").
27
+ */
28
+ apiBasePath: string;
29
+
30
+ /**
31
+ * React Query client — used by the MediaPicker to cache and fetch assets.
32
+ */
33
+ queryClient: QueryClient;
34
+
35
+ /**
36
+ * Upload mode — must match the storageAdapter configured in mediaBackendPlugin.
37
+ * @default "direct"
38
+ */
39
+ uploadMode?: MediaUploadMode;
40
+
41
+ /**
42
+ * Optional headers to pass with API requests (e.g., for SSR auth).
43
+ */
44
+ headers?: HeadersInit;
45
+
46
+ /**
47
+ * Navigation function for programmatic navigation.
48
+ */
49
+ navigate: (path: string) => void | Promise<void>;
50
+
51
+ /**
52
+ * Link component for navigation within the media library page.
53
+ */
54
+ Link?: ComponentType<React.ComponentProps<"a"> & Record<string, any>>;
55
+
56
+ /**
57
+ * Image component for rendering asset thumbnails and previews.
58
+ *
59
+ * When provided, replaces the default `<img>` element in asset cards,
60
+ * the media library grid, and the ImageInputField preview. Use this
61
+ * to plug in Next.js `<Image>` for automatic optimisation.
62
+ *
63
+ * @example
64
+ * ```tsx
65
+ * Image: (props) => <NextImage {...props} />
66
+ * ```
67
+ */
68
+ Image?: ComponentType<
69
+ React.ImgHTMLAttributes<HTMLImageElement> & Record<string, any>
70
+ >;
71
+
72
+ /**
73
+ * Client-side image compression applied before upload via the Canvas API.
74
+ *
75
+ * Images are scaled down to fit within `maxWidth` × `maxHeight` (preserving
76
+ * aspect ratio) and re-encoded at `quality`. SVG and GIF files are always
77
+ * passed through unchanged.
78
+ *
79
+ * Set to `false` to disable compression entirely.
80
+ *
81
+ * @default { maxWidth: 2048, maxHeight: 2048, quality: 0.85 }
82
+ */
83
+ imageCompression?: ImageCompressionOptions | false;
84
+
85
+ // ============ Lifecycle Hooks ============
86
+
87
+ /**
88
+ * Called when a media route is rendered.
89
+ */
90
+ onRouteRender?: (
91
+ routeName: string,
92
+ context: MediaRouteContext,
93
+ ) => void | Promise<void>;
94
+
95
+ /**
96
+ * Called when a media route encounters an error.
97
+ */
98
+ onRouteError?: (
99
+ routeName: string,
100
+ error: Error,
101
+ context: MediaRouteContext,
102
+ ) => void | Promise<void>;
103
+
104
+ /**
105
+ * Called before the media library page is rendered.
106
+ * Return `false` to prevent rendering (e.g., redirect unauthenticated users).
107
+ *
108
+ * @example
109
+ * ```ts
110
+ * media: {
111
+ * onBeforeLibraryPageRendered: (context) => !!currentUser?.isAdmin,
112
+ * onRouteError: (routeName, error, context) => navigate("/login"),
113
+ * }
114
+ * ```
115
+ */
116
+ onBeforeLibraryPageRendered?: (context: MediaRouteContext) => boolean;
117
+ }
118
+
119
+ export interface MediaRouteContext {
120
+ /** Current route path */
121
+ path: string;
122
+ /** Route parameters */
123
+ params?: Record<string, string>;
124
+ /** Whether rendering on server (true) or client (false) */
125
+ isSSR: boolean;
126
+ [key: string]: unknown;
127
+ }