@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,188 @@
1
+ import { useState } from "react";
2
+ import {
3
+ useFolders,
4
+ useCreateFolder,
5
+ useDeleteFolder,
6
+ } from "../../hooks/use-media";
7
+ import type { SerializedFolder } from "../../../types";
8
+ import { FolderPlus } from "lucide-react";
9
+ import { Input } from "@workspace/ui/components/input";
10
+ import { Check, Folder, Trash2, ChevronRight, FolderOpen } from "lucide-react";
11
+ import { cn } from "@workspace/ui/lib/utils";
12
+
13
+ export function FolderTree({
14
+ selectedId,
15
+ onSelect,
16
+ }: {
17
+ selectedId: string | null;
18
+ onSelect: (id: string | null) => void;
19
+ }) {
20
+ const { data: rootFoldersRaw = [] } = useFolders(null);
21
+ const rootFolders =
22
+ rootFoldersRaw as import("../../../types").SerializedFolder[];
23
+ const [newFolderName, setNewFolderName] = useState("");
24
+ const [isCreating, setIsCreating] = useState(false);
25
+ const { mutateAsync: createFolder } = useCreateFolder();
26
+ const { mutateAsync: deleteFolder } = useDeleteFolder();
27
+
28
+ const handleCreateFolder = async () => {
29
+ const name = newFolderName.trim();
30
+ if (!name) return;
31
+ try {
32
+ await createFolder({ name, parentId: selectedId ?? undefined });
33
+ setNewFolderName("");
34
+ setIsCreating(false);
35
+ } catch (err) {
36
+ console.error("[btst/media] Failed to create folder", err);
37
+ }
38
+ };
39
+
40
+ return (
41
+ <div className="flex h-full min-h-0 flex-col">
42
+ <div className="flex items-center justify-between px-2 py-2">
43
+ <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
44
+ Folders
45
+ </span>
46
+ <button
47
+ type="button"
48
+ title="New folder"
49
+ onClick={() => setIsCreating((v) => !v)}
50
+ className="rounded p-0.5 hover:bg-muted"
51
+ >
52
+ <FolderPlus className="size-3.5 text-muted-foreground" />
53
+ </button>
54
+ </div>
55
+
56
+ {isCreating && (
57
+ <div className="flex gap-1 px-2 pb-1">
58
+ <Input
59
+ autoFocus
60
+ value={newFolderName}
61
+ onChange={(e) => setNewFolderName(e.target.value)}
62
+ placeholder="Folder name"
63
+ className="h-6 text-xs"
64
+ onKeyDown={(e) => {
65
+ if (e.key === "Enter") void handleCreateFolder();
66
+ if (e.key === "Escape") setIsCreating(false);
67
+ }}
68
+ />
69
+ <button
70
+ type="button"
71
+ onClick={handleCreateFolder}
72
+ className="rounded px-1 py-0.5 text-xs hover:bg-muted"
73
+ >
74
+ <Check className="size-3" />
75
+ </button>
76
+ </div>
77
+ )}
78
+
79
+ <div className="flex-1 overflow-y-auto overscroll-contain">
80
+ {/* All assets (root) */}
81
+ <button
82
+ type="button"
83
+ onClick={() => onSelect(null)}
84
+ className={cn(
85
+ "flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm hover:bg-muted",
86
+ selectedId === null && "bg-muted font-medium",
87
+ )}
88
+ >
89
+ <span className="size-3" />
90
+ <Folder className="size-3.5 shrink-0 text-muted-foreground" />
91
+ <span className="truncate">All files</span>
92
+ </button>
93
+
94
+ {rootFolders.map((folder) => (
95
+ <FolderTreeItem
96
+ key={folder.id}
97
+ folder={folder}
98
+ selectedId={selectedId}
99
+ onSelect={onSelect}
100
+ />
101
+ ))}
102
+ </div>
103
+
104
+ {selectedId && (
105
+ <div className="border-t px-2 py-1">
106
+ <button
107
+ type="button"
108
+ onClick={async () => {
109
+ if (
110
+ confirm("Delete this folder? Assets inside will be unaffected.")
111
+ ) {
112
+ try {
113
+ await deleteFolder(selectedId);
114
+ onSelect(null);
115
+ } catch (err) {
116
+ console.error("[btst/media] Failed to delete folder", err);
117
+ }
118
+ }
119
+ }}
120
+ className="flex items-center gap-1 text-xs text-destructive hover:underline"
121
+ >
122
+ <Trash2 className="size-3" />
123
+ Delete folder
124
+ </button>
125
+ </div>
126
+ )}
127
+ </div>
128
+ );
129
+ }
130
+
131
+ export function FolderTreeItem({
132
+ folder,
133
+ selectedId,
134
+ onSelect,
135
+ depth = 0,
136
+ }: {
137
+ folder: SerializedFolder;
138
+ selectedId: string | null;
139
+ onSelect: (id: string | null) => void;
140
+ depth?: number;
141
+ }) {
142
+ const [expanded, setExpanded] = useState(false);
143
+ const { data: children = [] } = useFolders(folder.id);
144
+
145
+ return (
146
+ <div>
147
+ <button
148
+ type="button"
149
+ onClick={() => {
150
+ onSelect(folder.id);
151
+ setExpanded((v) => !v);
152
+ }}
153
+ className={cn(
154
+ "flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm hover:bg-muted",
155
+ selectedId === folder.id && "bg-muted font-medium",
156
+ )}
157
+ style={{ paddingLeft: `${8 + depth * 12}px` }}
158
+ >
159
+ {children.length > 0 ? (
160
+ <ChevronRight
161
+ className={cn(
162
+ "size-3 shrink-0 transition-transform",
163
+ expanded && "rotate-90",
164
+ )}
165
+ />
166
+ ) : (
167
+ <span className="size-3" />
168
+ )}
169
+ {expanded ? (
170
+ <FolderOpen className="size-3.5 shrink-0 text-amber-500" />
171
+ ) : (
172
+ <Folder className="size-3.5 shrink-0 text-amber-500" />
173
+ )}
174
+ <span className="truncate">{folder.name}</span>
175
+ </button>
176
+ {expanded &&
177
+ children.map((child) => (
178
+ <FolderTreeItem
179
+ key={child.id}
180
+ folder={child}
181
+ selectedId={selectedId}
182
+ onSelect={onSelect}
183
+ depth={depth + 1}
184
+ />
185
+ ))}
186
+ </div>
187
+ );
188
+ }
@@ -0,0 +1,347 @@
1
+ "use client";
2
+ import { useState, type ReactNode } from "react";
3
+ import {
4
+ Popover,
5
+ PopoverContent,
6
+ PopoverTrigger,
7
+ } from "@workspace/ui/components/popover";
8
+ import { Button } from "@workspace/ui/components/button";
9
+ import {
10
+ Tabs,
11
+ TabsContent,
12
+ TabsList,
13
+ TabsTrigger,
14
+ } from "@workspace/ui/components/tabs";
15
+ import { Image, Upload, Link, X } from "lucide-react";
16
+ import type { SerializedAsset } from "../../../types";
17
+ import { FolderTree } from "./folder-tree";
18
+ import { BrowseTab } from "./browse-tab";
19
+ import { UploadTab } from "./upload-tab";
20
+ import { UrlTab } from "./url-tab";
21
+ import type { MediaPluginOverrides } from "../../overrides";
22
+ import { usePluginOverrides } from "@btst/stack/context";
23
+
24
+ export interface MediaPickerProps {
25
+ /**
26
+ * Element that triggers opening the picker. Required.
27
+ */
28
+ trigger: ReactNode;
29
+ /**
30
+ * Called when the user confirms their selection.
31
+ */
32
+ onSelect: (assets: SerializedAsset[]) => void;
33
+ /**
34
+ * Allow multiple selection.
35
+ * @default false
36
+ */
37
+ multiple?: boolean;
38
+ /**
39
+ * Filter displayed assets by MIME type prefix (e.g. "image/").
40
+ */
41
+ accept?: string[];
42
+ }
43
+
44
+ /**
45
+ * MediaPicker — a Popover-based media browser.
46
+ *
47
+ * Reads API config from the `media` plugin overrides context (set up in StackProvider).
48
+ * Must be rendered inside a `StackProvider` that includes media overrides.
49
+ *
50
+ * @example
51
+ * ```tsx
52
+ * <MediaPicker
53
+ * trigger={<Button size="sm">Browse media</Button>}
54
+ * accept={["image/*"]}
55
+ * onSelect={(assets) => form.setValue("image", assets[0].url)}
56
+ * />
57
+ * ```
58
+ */
59
+ export function MediaPicker({
60
+ trigger,
61
+ onSelect,
62
+ multiple = false,
63
+ accept,
64
+ }: MediaPickerProps) {
65
+ const [open, setOpen] = useState(false);
66
+ const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
67
+ const [selectedAssets, setSelectedAssets] = useState<SerializedAsset[]>([]);
68
+ const [activeTab, setActiveTab] = useState<"browse" | "upload" | "url">(
69
+ "browse",
70
+ );
71
+
72
+ const handleClose = () => {
73
+ setOpen(false);
74
+ setSelectedAssets([]);
75
+ };
76
+
77
+ const handleConfirm = () => {
78
+ if (selectedAssets.length === 0) return;
79
+ // Copy selection before clearing; defer onSelect so the popover has time
80
+ // to start its close animation before any parent state updates that might
81
+ // unmount this component (e.g. CMSFileUpload hiding when previewUrl is set).
82
+ const toSelect = [...selectedAssets];
83
+ handleClose();
84
+ setTimeout(() => onSelect(toSelect), 0);
85
+ };
86
+
87
+ const handleToggleAsset = (asset: SerializedAsset) => {
88
+ if (multiple) {
89
+ setSelectedAssets((prev) =>
90
+ prev.some((a) => a.id === asset.id)
91
+ ? prev.filter((a) => a.id !== asset.id)
92
+ : [...prev, asset],
93
+ );
94
+ } else {
95
+ setSelectedAssets([asset]);
96
+ }
97
+ };
98
+
99
+ const handleUploaded = (asset: SerializedAsset) => {
100
+ if (multiple) {
101
+ setSelectedAssets((prev) => [...prev, asset]);
102
+ } else {
103
+ setSelectedAssets([asset]);
104
+ setActiveTab("browse");
105
+ }
106
+ };
107
+
108
+ const handleUrlRegistered = (asset: SerializedAsset) => {
109
+ // Close the popover first, then notify parent — same deferral as handleConfirm.
110
+ const toSelect = asset;
111
+ handleClose();
112
+ setTimeout(() => onSelect([toSelect]), 0);
113
+ };
114
+
115
+ return (
116
+ <Popover
117
+ open={open}
118
+ onOpenChange={(v) => {
119
+ if (!v) handleClose();
120
+ else setOpen(true);
121
+ }}
122
+ >
123
+ <PopoverTrigger asChild>{trigger}</PopoverTrigger>
124
+ <PopoverContent
125
+ className="w-[calc(100vw-1rem)] max-w-[calc(100vw-1rem)] overflow-hidden p-0 sm:w-[820px]"
126
+ align="start"
127
+ sideOffset={8}
128
+ collisionPadding={8}
129
+ style={{
130
+ maxWidth: "min(820px, calc(100vw - 1rem))",
131
+ height: "min(640px, calc(100dvh - 2rem))",
132
+ }}
133
+ >
134
+ <div className="flex h-full flex-col overflow-hidden rounded-md">
135
+ {/* Header */}
136
+ <div className="flex items-center justify-between border-b px-3 py-2">
137
+ <span className="text-sm font-semibold">Media Library</span>
138
+ <button
139
+ type="button"
140
+ onClick={handleClose}
141
+ className="rounded p-0.5 hover:bg-muted"
142
+ >
143
+ <X className="size-4" />
144
+ </button>
145
+ </div>
146
+
147
+ {/* Body */}
148
+ <div className="flex min-h-0 flex-1 flex-col md:flex-row">
149
+ {/* Folder sidebar */}
150
+ <div className="max-h-40 w-full shrink-0 overflow-hidden border-b bg-muted/20 md:max-h-none md:w-44 md:border-b-0 md:border-r">
151
+ <FolderTree
152
+ selectedId={selectedFolder}
153
+ onSelect={setSelectedFolder}
154
+ />
155
+ </div>
156
+
157
+ {/* Main panel */}
158
+ <div className="flex min-w-0 flex-1 flex-col p-3 overflow-y-hidden">
159
+ <Tabs
160
+ value={activeTab}
161
+ onValueChange={(v) => setActiveTab(v as any)}
162
+ className="flex flex-1 flex-col min-h-0"
163
+ >
164
+ <TabsList className="grid h-auto w-full shrink-0 grid-cols-3 md:flex md:w-fit">
165
+ <TabsTrigger
166
+ value="browse"
167
+ className="h-8 px-2 text-xs md:h-6 md:px-3"
168
+ >
169
+ <Image className="mr-1 size-3" />
170
+ Browse
171
+ </TabsTrigger>
172
+ <TabsTrigger
173
+ value="upload"
174
+ className="h-8 px-2 text-xs md:h-6 md:px-3"
175
+ >
176
+ <Upload className="mr-1 size-3" />
177
+ Upload
178
+ </TabsTrigger>
179
+ <TabsTrigger
180
+ value="url"
181
+ className="h-8 px-2 text-xs md:h-6 md:px-3"
182
+ >
183
+ <Link className="mr-1 size-3" />
184
+ URL
185
+ </TabsTrigger>
186
+ </TabsList>
187
+
188
+ <div className="mt-2 min-h-0 flex-1">
189
+ <TabsContent
190
+ value="browse"
191
+ className="m-0 h-full min-h-0 data-[state=active]:flex data-[state=active]:flex-col"
192
+ >
193
+ <BrowseTab
194
+ folderId={selectedFolder}
195
+ selected={selectedAssets}
196
+ accept={accept}
197
+ onToggle={handleToggleAsset}
198
+ />
199
+ </TabsContent>
200
+ <TabsContent
201
+ value="upload"
202
+ className="m-0 h-full min-h-0 data-[state=active]:flex data-[state=active]:flex-col"
203
+ >
204
+ <UploadTab
205
+ folderId={selectedFolder}
206
+ accept={accept}
207
+ onUploaded={handleUploaded}
208
+ />
209
+ </TabsContent>
210
+ <TabsContent
211
+ value="url"
212
+ className="m-0 h-full min-h-0 data-[state=active]:flex data-[state=active]:flex-col"
213
+ >
214
+ <UrlTab
215
+ folderId={selectedFolder}
216
+ onRegistered={handleUrlRegistered}
217
+ />
218
+ </TabsContent>
219
+ </div>
220
+ </Tabs>
221
+ </div>
222
+ </div>
223
+
224
+ {/* Footer */}
225
+ <div className="flex flex-col gap-2 border-t px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
226
+ <span className="text-xs text-muted-foreground">
227
+ {selectedAssets.length > 0
228
+ ? `${selectedAssets.length} selected`
229
+ : "Click a file to select it"}
230
+ </span>
231
+ <div className="flex w-full gap-2 sm:w-auto">
232
+ <Button
233
+ type="button"
234
+ variant="ghost"
235
+ size="sm"
236
+ onClick={handleClose}
237
+ className="flex-1 sm:flex-none"
238
+ >
239
+ Cancel
240
+ </Button>
241
+ <Button
242
+ type="button"
243
+ size="sm"
244
+ data-testid="media-select-button"
245
+ onClick={handleConfirm}
246
+ disabled={selectedAssets.length === 0}
247
+ className="flex-1 sm:flex-none"
248
+ >
249
+ {multiple
250
+ ? `Select ${selectedAssets.length > 0 ? `(${selectedAssets.length})` : ""}`
251
+ : "Select"}
252
+ </Button>
253
+ </div>
254
+ </div>
255
+ </div>
256
+ </PopoverContent>
257
+ </Popover>
258
+ );
259
+ }
260
+
261
+ /**
262
+ * ImageInputField — displays an image preview with change/remove actions, or a
263
+ * "Browse Media" button that opens the full MediaPicker popover (Browse / Upload / URL tabs).
264
+ *
265
+ * Upload mode, folder selection, and multi-mode cloud support are all handled inside
266
+ * the MediaPicker's UploadTab — this component is purely a thin wrapper.
267
+ */
268
+ export function ImageInputField({
269
+ value,
270
+ onChange,
271
+ }: {
272
+ value: string;
273
+ onChange: (v: string) => void;
274
+ }) {
275
+ const { Image: ImageComponent } = usePluginOverrides<
276
+ MediaPluginOverrides,
277
+ Partial<MediaPluginOverrides>
278
+ >("media", {});
279
+
280
+ if (value) {
281
+ return (
282
+ <div className="space-y-2">
283
+ {ImageComponent ? (
284
+ <ImageComponent
285
+ src={value}
286
+ alt="Featured image preview"
287
+ className="h-auto w-full max-w-xs rounded-md border object-cover"
288
+ width={400}
289
+ height={300}
290
+ data-testid="image-preview"
291
+ />
292
+ ) : (
293
+ <img
294
+ src={value}
295
+ alt="Featured image preview"
296
+ className="h-auto w-full max-w-xs rounded-md border object-cover"
297
+ data-testid="image-preview"
298
+ />
299
+ )}
300
+ <div className="flex gap-2">
301
+ <MediaPicker
302
+ trigger={
303
+ <Button
304
+ variant="outline"
305
+ size="sm"
306
+ type="button"
307
+ data-testid="open-media-picker"
308
+ >
309
+ Change Image
310
+ </Button>
311
+ }
312
+ accept={["image/*"]}
313
+ onSelect={(assets) => onChange(assets[0]?.url ?? "")}
314
+ />
315
+ <Button
316
+ type="button"
317
+ variant="destructive"
318
+ size="sm"
319
+ data-testid="remove-image-button"
320
+ onClick={() => onChange("")}
321
+ >
322
+ Remove
323
+ </Button>
324
+ </div>
325
+ </div>
326
+ );
327
+ }
328
+
329
+ return (
330
+ <div className="flex flex-wrap gap-2 items-center">
331
+ <MediaPicker
332
+ trigger={
333
+ <Button
334
+ variant="outline"
335
+ size="sm"
336
+ type="button"
337
+ data-testid="open-media-picker"
338
+ >
339
+ Browse Media
340
+ </Button>
341
+ }
342
+ accept={["image/*"]}
343
+ onSelect={(assets) => onChange(assets[0]?.url ?? "")}
344
+ />
345
+ </div>
346
+ );
347
+ }
@@ -0,0 +1,108 @@
1
+ import { useState, useCallback, useRef } from "react";
2
+ import { useUploadAsset } from "../../hooks/use-media";
3
+ import type { SerializedAsset } from "../../../types";
4
+ import { Button } from "@workspace/ui/components/button";
5
+ import { Loader2, Upload } from "lucide-react";
6
+ import { cn } from "@workspace/ui/lib/utils";
7
+ import { matchesAccept } from "./utils";
8
+
9
+ export function UploadTab({
10
+ folderId,
11
+ accept,
12
+ onUploaded,
13
+ }: {
14
+ folderId: string | null;
15
+ accept?: string[];
16
+ onUploaded: (asset: SerializedAsset) => void;
17
+ }) {
18
+ const [dragging, setDragging] = useState(false);
19
+ const [uploading, setUploading] = useState(false);
20
+ const [error, setError] = useState<string | null>(null);
21
+ const fileInputRef = useRef<HTMLInputElement>(null);
22
+ const { mutateAsync: uploadAsset } = useUploadAsset();
23
+
24
+ const acceptAttr = accept?.join(",") ?? undefined;
25
+
26
+ const handleFiles = useCallback(
27
+ async (files: FileList | File[]) => {
28
+ const fileArr = Array.from(files);
29
+ if (fileArr.length === 0) return;
30
+ setError(null);
31
+ setUploading(true);
32
+ try {
33
+ for (const file of fileArr) {
34
+ if (accept && !matchesAccept(file.type, accept)) {
35
+ setError(`File type ${file.type} is not accepted.`);
36
+ continue;
37
+ }
38
+ const asset = await uploadAsset({
39
+ file,
40
+ folderId: folderId ?? undefined,
41
+ });
42
+ onUploaded(asset);
43
+ }
44
+ } catch (err) {
45
+ setError(err instanceof Error ? err.message : "Upload failed");
46
+ } finally {
47
+ setUploading(false);
48
+ }
49
+ },
50
+ [accept, folderId, uploadAsset, onUploaded],
51
+ );
52
+
53
+ return (
54
+ <div className="flex h-full flex-col gap-3">
55
+ <div
56
+ onDragOver={(e) => {
57
+ e.preventDefault();
58
+ setDragging(true);
59
+ }}
60
+ onDragLeave={() => setDragging(false)}
61
+ onDrop={(e) => {
62
+ e.preventDefault();
63
+ setDragging(false);
64
+ void handleFiles(e.dataTransfer.files);
65
+ }}
66
+ className={cn(
67
+ "flex flex-1 flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed px-4 py-6 text-center transition-colors sm:px-6",
68
+ dragging ? "border-ring bg-ring/5" : "border-muted-foreground/30",
69
+ )}
70
+ >
71
+ {uploading ? (
72
+ <>
73
+ <Loader2 className="size-8 animate-spin text-muted-foreground" />
74
+ <p className="text-sm text-muted-foreground">Uploading…</p>
75
+ </>
76
+ ) : (
77
+ <>
78
+ <Upload className="size-8 text-muted-foreground" />
79
+ <div className="text-center">
80
+ <p className="text-sm font-medium">Drop files here</p>
81
+ <p className="text-xs text-muted-foreground">
82
+ or click to browse
83
+ </p>
84
+ </div>
85
+ <Button
86
+ type="button"
87
+ variant="outline"
88
+ size="sm"
89
+ onClick={() => fileInputRef.current?.click()}
90
+ >
91
+ Choose files
92
+ </Button>
93
+ </>
94
+ )}
95
+ </div>
96
+ {error && <p className="text-sm text-destructive">{error}</p>}
97
+ <input
98
+ ref={fileInputRef}
99
+ type="file"
100
+ accept={acceptAttr}
101
+ multiple
102
+ className="hidden"
103
+ data-testid="media-upload-input"
104
+ onChange={(e) => e.target.files && handleFiles(e.target.files)}
105
+ />
106
+ </div>
107
+ );
108
+ }
@@ -0,0 +1,72 @@
1
+ import { useState } from "react";
2
+ import { useRegisterAsset } 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, Check } from "lucide-react";
7
+
8
+ export function UrlTab({
9
+ folderId,
10
+ onRegistered,
11
+ }: {
12
+ folderId: string | null;
13
+ onRegistered: (asset: SerializedAsset) => void;
14
+ }) {
15
+ const [url, setUrl] = useState("");
16
+ const [error, setError] = useState<string | null>(null);
17
+ const { mutateAsync: registerAsset, isPending } = useRegisterAsset();
18
+
19
+ const handleSubmit = async (e: React.FormEvent) => {
20
+ e.preventDefault();
21
+ setError(null);
22
+ const trimmed = url.trim();
23
+ if (!trimmed) return;
24
+ try {
25
+ const filename = trimmed.split("/").pop() ?? "asset";
26
+ const asset = await registerAsset({
27
+ url: trimmed,
28
+ filename,
29
+ folderId: folderId ?? undefined,
30
+ });
31
+ setUrl("");
32
+ onRegistered(asset);
33
+ } catch (err) {
34
+ setError(err instanceof Error ? err.message : "Failed to register URL");
35
+ }
36
+ };
37
+
38
+ return (
39
+ <div className="flex h-full flex-col gap-3 pt-2">
40
+ <p className="text-sm text-muted-foreground">
41
+ Paste a public URL to register it as an asset without uploading a file.
42
+ </p>
43
+ <form onSubmit={handleSubmit} className="flex flex-col gap-2">
44
+ <div className="flex flex-col gap-2 sm:flex-row">
45
+ <Input
46
+ type="url"
47
+ value={url}
48
+ onChange={(e) => setUrl(e.target.value)}
49
+ placeholder="https://example.com/image.png"
50
+ className="flex-1"
51
+ data-testid="media-url-input"
52
+ autoFocus
53
+ />
54
+ <Button
55
+ type="submit"
56
+ size="sm"
57
+ disabled={isPending || !url.trim()}
58
+ className="w-full sm:w-auto"
59
+ >
60
+ {isPending ? (
61
+ <Loader2 className="mr-1 size-4 animate-spin" />
62
+ ) : (
63
+ <Check className="mr-1 size-4" />
64
+ )}
65
+ Use URL
66
+ </Button>
67
+ </div>
68
+ {error && <p className="text-sm text-destructive">{error}</p>}
69
+ </form>
70
+ </div>
71
+ );
72
+ }