@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.
- package/README.md +3 -2
- package/dist/components/markdown/index.d.cts +15 -2
- package/dist/components/markdown/index.d.mts +15 -2
- package/dist/components/markdown/index.d.ts +15 -2
- package/dist/packages/stack/src/plugins/blog/client/components/forms/image-field.cjs +30 -1
- package/dist/packages/stack/src/plugins/blog/client/components/forms/image-field.mjs +30 -1
- package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.cjs +49 -9
- package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.mjs +50 -10
- package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.cjs +77 -9
- package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.mjs +77 -9
- package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.cjs +24 -5
- package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.mjs +24 -5
- package/dist/packages/stack/src/plugins/cms/client/components/forms/file-upload.cjs +47 -13
- package/dist/packages/stack/src/plugins/cms/client/components/forms/file-upload.mjs +47 -13
- package/dist/packages/stack/src/plugins/kanban/client/components/forms/board-form.cjs +1 -1
- package/dist/packages/stack/src/plugins/kanban/client/components/forms/board-form.mjs +1 -1
- package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.cjs +6 -2
- package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.mjs +6 -2
- package/dist/packages/stack/src/plugins/media/api/adapters/local.cjs +55 -0
- package/dist/packages/stack/src/plugins/media/api/adapters/local.mjs +37 -0
- package/dist/packages/stack/src/plugins/media/api/getters.cjs +83 -0
- package/dist/packages/stack/src/plugins/media/api/getters.mjs +78 -0
- package/dist/packages/stack/src/plugins/media/api/mutations.cjs +88 -0
- package/dist/packages/stack/src/plugins/media/api/mutations.mjs +82 -0
- package/dist/packages/stack/src/plugins/media/api/plugin.cjs +525 -0
- package/dist/packages/stack/src/plugins/media/api/plugin.mjs +523 -0
- package/dist/packages/stack/src/plugins/media/api/query-key-defs.cjs +19 -0
- package/dist/packages/stack/src/plugins/media/api/query-key-defs.mjs +16 -0
- package/dist/packages/stack/src/plugins/media/api/serializers.cjs +17 -0
- package/dist/packages/stack/src/plugins/media/api/serializers.mjs +14 -0
- package/dist/packages/stack/src/plugins/media/api/storage-adapter.cjs +15 -0
- package/dist/packages/stack/src/plugins/media/api/storage-adapter.mjs +11 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-card.cjs +129 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-card.mjs +127 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-preview-button.cjs +58 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-preview-button.mjs +56 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/browse-tab.cjs +94 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/browse-tab.mjs +92 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/folder-tree.cjs +171 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/folder-tree.mjs +168 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/index.cjs +308 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/index.mjs +305 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/upload-tab.cjs +104 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/upload-tab.mjs +102 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/url-tab.cjs +70 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/url-tab.mjs +68 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/utils.cjs +21 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/utils.mjs +17 -0
- package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.cjs +35 -0
- package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.internal.cjs +125 -0
- package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.internal.mjs +123 -0
- package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.mjs +33 -0
- package/dist/packages/stack/src/plugins/media/client/hooks/use-media.cjs +222 -0
- package/dist/packages/stack/src/plugins/media/client/hooks/use-media.mjs +214 -0
- package/dist/packages/stack/src/plugins/media/client/plugin.cjs +94 -0
- package/dist/packages/stack/src/plugins/media/client/plugin.mjs +92 -0
- package/dist/packages/stack/src/plugins/media/client/upload.cjs +121 -0
- package/dist/packages/stack/src/plugins/media/client/upload.mjs +119 -0
- package/dist/packages/stack/src/plugins/media/client/utils/image-compression.cjs +67 -0
- package/dist/packages/stack/src/plugins/media/client/utils/image-compression.mjs +65 -0
- package/dist/packages/stack/src/plugins/media/db.cjs +62 -0
- package/dist/packages/stack/src/plugins/media/db.mjs +60 -0
- package/dist/packages/stack/src/plugins/media/schemas.cjs +41 -0
- package/dist/packages/stack/src/plugins/media/schemas.mjs +35 -0
- package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.cjs +18 -1
- package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.mjs +19 -2
- package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-dialog.cjs +2 -2
- package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-dialog.mjs +2 -2
- package/dist/packages/ui/src/components/minimal-tiptap/components/section/five.cjs +3 -2
- package/dist/packages/ui/src/components/minimal-tiptap/components/section/five.mjs +3 -2
- package/dist/packages/ui/src/components/minimal-tiptap/minimal-tiptap.cjs +12 -5
- package/dist/packages/ui/src/components/minimal-tiptap/minimal-tiptap.mjs +12 -5
- package/dist/plugins/blog/client/index.d.cts +58 -1
- package/dist/plugins/blog/client/index.d.mts +58 -1
- package/dist/plugins/blog/client/index.d.ts +58 -1
- package/dist/plugins/cms/client/index.d.cts +73 -3
- package/dist/plugins/cms/client/index.d.mts +73 -3
- package/dist/plugins/cms/client/index.d.ts +73 -3
- package/dist/plugins/kanban/api/index.d.cts +1 -1
- package/dist/plugins/kanban/api/index.d.mts +1 -1
- package/dist/plugins/kanban/api/index.d.ts +1 -1
- package/dist/plugins/kanban/client/hooks/index.d.cts +1 -1
- package/dist/plugins/kanban/client/hooks/index.d.mts +1 -1
- package/dist/plugins/kanban/client/hooks/index.d.ts +1 -1
- package/dist/plugins/kanban/client/index.d.cts +1 -1
- package/dist/plugins/kanban/client/index.d.mts +1 -1
- package/dist/plugins/kanban/client/index.d.ts +1 -1
- package/dist/plugins/kanban/query-keys.d.cts +1 -1
- package/dist/plugins/kanban/query-keys.d.mts +1 -1
- package/dist/plugins/kanban/query-keys.d.ts +1 -1
- package/dist/plugins/media/api/adapters/s3.cjs +106 -0
- package/dist/plugins/media/api/adapters/s3.d.cts +60 -0
- package/dist/plugins/media/api/adapters/s3.d.mts +60 -0
- package/dist/plugins/media/api/adapters/s3.d.ts +60 -0
- package/dist/plugins/media/api/adapters/s3.mjs +104 -0
- package/dist/plugins/media/api/adapters/vercel-blob.cjs +53 -0
- package/dist/plugins/media/api/adapters/vercel-blob.d.cts +41 -0
- package/dist/plugins/media/api/adapters/vercel-blob.d.mts +41 -0
- package/dist/plugins/media/api/adapters/vercel-blob.d.ts +41 -0
- package/dist/plugins/media/api/adapters/vercel-blob.mjs +51 -0
- package/dist/plugins/media/api/index.cjs +26 -0
- package/dist/plugins/media/api/index.d.cts +116 -0
- package/dist/plugins/media/api/index.d.mts +116 -0
- package/dist/plugins/media/api/index.d.ts +116 -0
- package/dist/plugins/media/api/index.mjs +6 -0
- package/dist/plugins/media/client/components/index.cjs +10 -0
- package/dist/plugins/media/client/components/index.d.cts +55 -0
- package/dist/plugins/media/client/components/index.d.mts +55 -0
- package/dist/plugins/media/client/components/index.d.ts +55 -0
- package/dist/plugins/media/client/components/index.mjs +2 -0
- package/dist/plugins/media/client/hooks/index.cjs +13 -0
- package/dist/plugins/media/client/hooks/index.d.cts +53 -0
- package/dist/plugins/media/client/hooks/index.d.mts +53 -0
- package/dist/plugins/media/client/hooks/index.d.ts +53 -0
- package/dist/plugins/media/client/hooks/index.mjs +1 -0
- package/dist/plugins/media/client/index.cjs +9 -0
- package/dist/plugins/media/client/index.d.cts +242 -0
- package/dist/plugins/media/client/index.d.mts +242 -0
- package/dist/plugins/media/client/index.d.ts +242 -0
- package/dist/plugins/media/client/index.mjs +2 -0
- package/dist/plugins/media/client.css +1 -0
- package/dist/plugins/media/query-keys.cjs +72 -0
- package/dist/plugins/media/query-keys.d.cts +49 -0
- package/dist/plugins/media/query-keys.d.mts +49 -0
- package/dist/plugins/media/query-keys.d.ts +49 -0
- package/dist/plugins/media/query-keys.mjs +70 -0
- package/dist/plugins/media/style.css +1 -0
- package/dist/shared/{stack.DRpeDS6X.d.ts → stack.BMx2QYOK.d.ts} +25 -0
- package/dist/shared/stack.BttDsJJn.d.cts +109 -0
- package/dist/shared/stack.BttDsJJn.d.mts +109 -0
- package/dist/shared/stack.BttDsJJn.d.ts +109 -0
- package/dist/shared/stack.C7vfOBmO.d.mts +63 -0
- package/dist/shared/stack.CAni8dnD.d.cts +63 -0
- package/dist/shared/stack.CI8iRKKi.d.cts +286 -0
- package/dist/shared/stack.CLcnSF_b.d.cts +25 -0
- package/dist/shared/stack.CLcnSF_b.d.mts +25 -0
- package/dist/shared/stack.CLcnSF_b.d.ts +25 -0
- package/dist/shared/stack.CYSwntXC.d.ts +63 -0
- package/dist/shared/{stack.Jb0kQDJC.d.mts → stack.Cd6McBu1.d.mts} +25 -0
- package/dist/shared/stack.DJDjdG64.d.ts +286 -0
- package/dist/shared/{stack.BxFl46lB.d.cts → stack.DxQl8Wa1.d.cts} +25 -0
- package/dist/shared/stack.FgBVDSPi.d.mts +286 -0
- package/package.json +113 -4
- package/src/plugins/blog/client/components/forms/image-field.tsx +35 -4
- package/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx +67 -12
- package/src/plugins/blog/client/components/forms/markdown-editor.tsx +106 -10
- package/src/plugins/blog/client/overrides.ts +58 -1
- package/src/plugins/cms/client/components/forms/content-form.tsx +26 -7
- package/src/plugins/cms/client/components/forms/file-upload.tsx +73 -15
- package/src/plugins/cms/client/overrides.ts +57 -2
- package/src/plugins/kanban/client/components/forms/board-form.tsx +1 -1
- package/src/plugins/kanban/client/components/forms/task-form.tsx +7 -1
- package/src/plugins/kanban/client/overrides.ts +25 -0
- package/src/plugins/media/__tests__/__stubs__/vercel-blob-server.ts +9 -0
- package/src/plugins/media/__tests__/getters.test.ts +274 -0
- package/src/plugins/media/__tests__/mutations.test.ts +299 -0
- package/src/plugins/media/__tests__/plugin.test.ts +752 -0
- package/src/plugins/media/__tests__/query-key-defs.test.ts +54 -0
- package/src/plugins/media/__tests__/storage-adapters.test.ts +351 -0
- package/src/plugins/media/api/adapters/local.ts +79 -0
- package/src/plugins/media/api/adapters/s3.ts +198 -0
- package/src/plugins/media/api/adapters/vercel-blob.ts +131 -0
- package/src/plugins/media/api/getters.ts +174 -0
- package/src/plugins/media/api/index.ts +41 -0
- package/src/plugins/media/api/mutations.ts +179 -0
- package/src/plugins/media/api/plugin.ts +855 -0
- package/src/plugins/media/api/query-key-defs.ts +41 -0
- package/src/plugins/media/api/serializers.ts +28 -0
- package/src/plugins/media/api/storage-adapter.ts +139 -0
- package/src/plugins/media/client/components/index.tsx +6 -0
- package/src/plugins/media/client/components/media-picker/asset-card.tsx +150 -0
- package/src/plugins/media/client/components/media-picker/asset-preview-button.tsx +67 -0
- package/src/plugins/media/client/components/media-picker/browse-tab.tsx +116 -0
- package/src/plugins/media/client/components/media-picker/folder-tree.tsx +188 -0
- package/src/plugins/media/client/components/media-picker/index.tsx +347 -0
- package/src/plugins/media/client/components/media-picker/upload-tab.tsx +108 -0
- package/src/plugins/media/client/components/media-picker/url-tab.tsx +72 -0
- package/src/plugins/media/client/components/media-picker/utils.ts +17 -0
- package/src/plugins/media/client/components/pages/library-page.internal.tsx +134 -0
- package/src/plugins/media/client/components/pages/library-page.tsx +42 -0
- package/src/plugins/media/client/hooks/index.tsx +9 -0
- package/src/plugins/media/client/hooks/use-media.tsx +289 -0
- package/src/plugins/media/client/index.ts +4 -0
- package/src/plugins/media/client/overrides.ts +127 -0
- package/src/plugins/media/client/plugin.tsx +184 -0
- package/src/plugins/media/client/upload.ts +171 -0
- package/src/plugins/media/client/utils/image-compression.ts +131 -0
- package/src/plugins/media/client.css +1 -0
- package/src/plugins/media/db.ts +62 -0
- package/src/plugins/media/query-keys.ts +96 -0
- package/src/plugins/media/schemas.ts +37 -0
- package/src/plugins/media/style.css +1 -0
- package/src/plugins/media/types.ts +26 -0
- package/dist/shared/{stack.BOokfhZD.d.cts → stack.B6S3cgwN.d.cts} +16 -16
- package/dist/shared/{stack.CWxAl9K3.d.mts → stack.Bzfx-_lq.d.mts} +16 -16
- package/dist/shared/{stack.BvCR4-9H.d.ts → stack.j5SFLC1d.d.ts} +16 -16
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
useState,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
type ChangeEvent,
|
|
8
|
+
type ComponentType,
|
|
9
|
+
} from "react";
|
|
4
10
|
import { toast } from "sonner";
|
|
5
11
|
import type { AutoFormInputComponentProps } from "@workspace/ui/components/auto-form/types";
|
|
6
12
|
import { Input } from "@workspace/ui/components/input";
|
|
@@ -23,6 +29,20 @@ export interface CMSFileUploadProps extends AutoFormInputComponentProps {
|
|
|
23
29
|
* This is required - consumers must provide an upload implementation.
|
|
24
30
|
*/
|
|
25
31
|
uploadImage: (file: File) => Promise<string>;
|
|
32
|
+
/**
|
|
33
|
+
* Optional custom component for the image field.
|
|
34
|
+
* When provided, it replaces the default file-upload input entirely.
|
|
35
|
+
*/
|
|
36
|
+
imageInputField?: ComponentType<{
|
|
37
|
+
value: string;
|
|
38
|
+
onChange: (value: string) => void;
|
|
39
|
+
isRequired?: boolean;
|
|
40
|
+
}>;
|
|
41
|
+
/**
|
|
42
|
+
* Optional trigger component for a media picker.
|
|
43
|
+
* When provided, it is rendered as a "Browse media" option.
|
|
44
|
+
*/
|
|
45
|
+
imagePicker?: ComponentType<{ onSelect: (url: string) => void }>;
|
|
26
46
|
}
|
|
27
47
|
|
|
28
48
|
/**
|
|
@@ -54,6 +74,8 @@ export function CMSFileUpload({
|
|
|
54
74
|
fieldProps,
|
|
55
75
|
field,
|
|
56
76
|
uploadImage,
|
|
77
|
+
imageInputField: ImageInputField,
|
|
78
|
+
imagePicker: ImagePickerTrigger,
|
|
57
79
|
}: CMSFileUploadProps) {
|
|
58
80
|
// Exclude showLabel and value from props spread
|
|
59
81
|
// File inputs cannot have their value set programmatically (browser security)
|
|
@@ -63,6 +85,8 @@ export function CMSFileUpload({
|
|
|
63
85
|
...safeFieldProps
|
|
64
86
|
} = fieldProps;
|
|
65
87
|
const showLabel = _showLabel === undefined ? true : _showLabel;
|
|
88
|
+
|
|
89
|
+
// All hooks must be called unconditionally before any early return.
|
|
66
90
|
const [isUploading, setIsUploading] = useState(false);
|
|
67
91
|
const [previewUrl, setPreviewUrl] = useState<string | null>(
|
|
68
92
|
field.value || null,
|
|
@@ -80,7 +104,6 @@ export function CMSFileUpload({
|
|
|
80
104
|
const file = e.target.files?.[0];
|
|
81
105
|
if (!file) return;
|
|
82
106
|
|
|
83
|
-
// Check if it's an image
|
|
84
107
|
if (!file.type.startsWith("image/")) {
|
|
85
108
|
toast.error("Please select an image file");
|
|
86
109
|
return;
|
|
@@ -106,6 +129,29 @@ export function CMSFileUpload({
|
|
|
106
129
|
field.onChange("");
|
|
107
130
|
}, [field]);
|
|
108
131
|
|
|
132
|
+
// When a custom imageInputField component is provided via overrides, delegate to it.
|
|
133
|
+
if (ImageInputField) {
|
|
134
|
+
return (
|
|
135
|
+
<FormItem>
|
|
136
|
+
{showLabel && (
|
|
137
|
+
<AutoFormLabel
|
|
138
|
+
label={fieldConfigItem?.label || label}
|
|
139
|
+
isRequired={isRequired}
|
|
140
|
+
/>
|
|
141
|
+
)}
|
|
142
|
+
<FormControl>
|
|
143
|
+
<ImageInputField
|
|
144
|
+
value={field.value || ""}
|
|
145
|
+
onChange={field.onChange}
|
|
146
|
+
isRequired={isRequired}
|
|
147
|
+
/>
|
|
148
|
+
</FormControl>
|
|
149
|
+
<AutoFormTooltip fieldConfigItem={fieldConfigItem} />
|
|
150
|
+
<FormMessage />
|
|
151
|
+
</FormItem>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
109
155
|
return (
|
|
110
156
|
<FormItem>
|
|
111
157
|
{showLabel && (
|
|
@@ -116,19 +162,31 @@ export function CMSFileUpload({
|
|
|
116
162
|
)}
|
|
117
163
|
{!previewUrl && (
|
|
118
164
|
<FormControl>
|
|
119
|
-
<div className="
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
<
|
|
165
|
+
<div className="space-y-2">
|
|
166
|
+
<div className="relative">
|
|
167
|
+
<Input
|
|
168
|
+
type="file"
|
|
169
|
+
accept="image/*"
|
|
170
|
+
{...safeFieldProps}
|
|
171
|
+
onChange={handleFileChange}
|
|
172
|
+
disabled={isUploading}
|
|
173
|
+
className="cursor-pointer"
|
|
174
|
+
data-testid="image-upload-input"
|
|
175
|
+
/>
|
|
176
|
+
{isUploading && (
|
|
177
|
+
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
|
178
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
{ImagePickerTrigger && (
|
|
183
|
+
<div data-testid="image-picker-trigger">
|
|
184
|
+
<ImagePickerTrigger
|
|
185
|
+
onSelect={(url: string) => {
|
|
186
|
+
setPreviewUrl(url);
|
|
187
|
+
field.onChange(url);
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
132
190
|
</div>
|
|
133
191
|
)}
|
|
134
192
|
</div>
|
|
@@ -2,6 +2,18 @@ import type { ComponentType } from "react";
|
|
|
2
2
|
import type { CMSLocalization } from "./localization";
|
|
3
3
|
import type { AutoFormInputComponentProps } from "@workspace/ui/components/auto-form/types";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Props for the overridable CMS image input field component.
|
|
7
|
+
*/
|
|
8
|
+
export interface CmsImageInputFieldProps {
|
|
9
|
+
/** Current image URL value */
|
|
10
|
+
value: string;
|
|
11
|
+
/** Called when the image URL changes */
|
|
12
|
+
onChange: (value: string) => void;
|
|
13
|
+
/** Whether the field is required */
|
|
14
|
+
isRequired?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
5
17
|
/**
|
|
6
18
|
* Context passed to lifecycle hooks
|
|
7
19
|
*/
|
|
@@ -46,11 +58,54 @@ export interface CMSPluginOverrides {
|
|
|
46
58
|
>;
|
|
47
59
|
|
|
48
60
|
/**
|
|
49
|
-
* Function used to upload
|
|
50
|
-
* Used by the default "file" field component
|
|
61
|
+
* Function used to upload a new image file and return its URL.
|
|
62
|
+
* Used by the default "file" field component when not selecting an existing
|
|
63
|
+
* asset via `imagePicker` or `imageInputField`.
|
|
51
64
|
*/
|
|
52
65
|
uploadImage?: (file: File) => Promise<string>;
|
|
53
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Optional custom component for image fields (fieldType: "file").
|
|
69
|
+
*
|
|
70
|
+
* When provided it replaces the default file-upload input entirely.
|
|
71
|
+
* The component receives `value` (current URL string) and `onChange` (setter).
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```tsx
|
|
75
|
+
* imageInputField: ({ value, onChange }) =>
|
|
76
|
+
* value ? (
|
|
77
|
+
* <div>
|
|
78
|
+
* <img src={value} alt="Preview" />
|
|
79
|
+
* <MediaPicker trigger={<button>Change</button>} accept={["image/*"]}
|
|
80
|
+
* onSelect={(assets) => onChange(assets[0].url)} />
|
|
81
|
+
* </div>
|
|
82
|
+
* ) : (
|
|
83
|
+
* <MediaPicker trigger={<button>Browse media</button>} accept={["image/*"]}
|
|
84
|
+
* onSelect={(assets) => onChange(assets[0].url)} />
|
|
85
|
+
* )
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
imageInputField?: ComponentType<CmsImageInputFieldProps>;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Optional trigger component for a media picker.
|
|
92
|
+
* When provided, it is rendered inside the default "file" field component as a
|
|
93
|
+
* "Browse media" option, letting users select a previously uploaded asset.
|
|
94
|
+
* Receives `onSelect(url)` — the URL is set as the field value.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```tsx
|
|
98
|
+
* imagePicker: ({ onSelect }) => (
|
|
99
|
+
* <MediaPicker
|
|
100
|
+
* trigger={<Button size="sm" variant="outline">Browse media</Button>}
|
|
101
|
+
* accept={["image/*"]}
|
|
102
|
+
* onSelect={(assets) => onSelect(assets[0].url)}
|
|
103
|
+
* />
|
|
104
|
+
* )
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
imagePicker?: ComponentType<{ onSelect: (url: string) => void }>;
|
|
108
|
+
|
|
54
109
|
/**
|
|
55
110
|
* Custom field components for AutoForm fields.
|
|
56
111
|
*
|
|
@@ -50,7 +50,7 @@ export function BoardForm({ board, onClose, onSuccess }: BoardFormProps) {
|
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
return (
|
|
53
|
-
<form onSubmit={handleSubmit} className="space-y-4">
|
|
53
|
+
<form onSubmit={handleSubmit} className="space-y-4 overflow-x-hidden">
|
|
54
54
|
<div className="space-y-2">
|
|
55
55
|
<Label htmlFor="name">Name *</Label>
|
|
56
56
|
<Input
|
|
@@ -14,7 +14,9 @@ import {
|
|
|
14
14
|
} from "@workspace/ui/components/select";
|
|
15
15
|
import { MinimalTiptapEditor } from "@workspace/ui/components/minimal-tiptap";
|
|
16
16
|
import SearchSelect from "@workspace/ui/components/search-select";
|
|
17
|
+
import { usePluginOverrides } from "@btst/stack/context";
|
|
17
18
|
import { useTaskMutations, useSearchUsers } from "../../hooks/kanban-hooks";
|
|
19
|
+
import type { KanbanPluginOverrides } from "../../overrides";
|
|
18
20
|
import { PRIORITY_OPTIONS } from "../../../utils";
|
|
19
21
|
import type {
|
|
20
22
|
SerializedColumn,
|
|
@@ -44,6 +46,8 @@ export function TaskForm({
|
|
|
44
46
|
onDelete,
|
|
45
47
|
}: TaskFormProps) {
|
|
46
48
|
const isEditing = !!taskId;
|
|
49
|
+
const { uploadImage, imagePicker: imagePickerTrigger } =
|
|
50
|
+
usePluginOverrides<KanbanPluginOverrides>("kanban");
|
|
47
51
|
const {
|
|
48
52
|
createTask,
|
|
49
53
|
updateTask,
|
|
@@ -155,7 +159,7 @@ export function TaskForm({
|
|
|
155
159
|
};
|
|
156
160
|
|
|
157
161
|
return (
|
|
158
|
-
<form onSubmit={handleSubmit} className="space-y-4">
|
|
162
|
+
<form onSubmit={handleSubmit} className="space-y-4 overflow-x-hidden">
|
|
159
163
|
<div className="space-y-2">
|
|
160
164
|
<Label htmlFor="title">Title *</Label>
|
|
161
165
|
<Input
|
|
@@ -227,6 +231,8 @@ export function TaskForm({
|
|
|
227
231
|
output="markdown"
|
|
228
232
|
placeholder="Describe the task..."
|
|
229
233
|
className="min-h-[150px]"
|
|
234
|
+
uploader={uploadImage}
|
|
235
|
+
imagePickerTrigger={imagePickerTrigger}
|
|
230
236
|
/>
|
|
231
237
|
</div>
|
|
232
238
|
|
|
@@ -73,6 +73,31 @@ export interface KanbanPluginOverrides {
|
|
|
73
73
|
*/
|
|
74
74
|
headers?: HeadersInit;
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Function used to upload a new image file from the task description editor
|
|
78
|
+
* and return its URL. This is separate from `imagePicker`, which selects an
|
|
79
|
+
* existing asset URL.
|
|
80
|
+
*/
|
|
81
|
+
uploadImage?: (file: File) => Promise<string>;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Optional trigger component for a media picker.
|
|
85
|
+
* When provided, it appears inside the image insertion dialog of the task description editor,
|
|
86
|
+
* letting users browse and select previously uploaded assets.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```tsx
|
|
90
|
+
* imagePicker: ({ onSelect }) => (
|
|
91
|
+
* <MediaPicker
|
|
92
|
+
* trigger={<Button size="sm" variant="outline">Browse media</Button>}
|
|
93
|
+
* accept={["image/*"]}
|
|
94
|
+
* onSelect={(assets) => onSelect(assets[0].url)}
|
|
95
|
+
* />
|
|
96
|
+
* )
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
imagePicker?: ComponentType<{ onSelect: (url: string) => void }>;
|
|
100
|
+
|
|
76
101
|
// ============ User Resolution (required for assignee features) ============
|
|
77
102
|
|
|
78
103
|
/**
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stub for @vercel/blob/server — used in tests only.
|
|
3
|
+
* The real module is an optional peer dependency.
|
|
4
|
+
*/
|
|
5
|
+
export async function handleUpload(_options: unknown): Promise<unknown> {
|
|
6
|
+
throw new Error(
|
|
7
|
+
"handleUpload is not available in the installed @vercel/blob version. BTST requires a version that exports @vercel/blob/server.",
|
|
8
|
+
);
|
|
9
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { createMemoryAdapter } from "@btst/adapter-memory";
|
|
3
|
+
import { defineDb } from "@btst/db";
|
|
4
|
+
import type { DBAdapter as Adapter } from "@btst/db";
|
|
5
|
+
import { mediaSchema } from "../db";
|
|
6
|
+
import {
|
|
7
|
+
listAssets,
|
|
8
|
+
getAssetById,
|
|
9
|
+
listFolders,
|
|
10
|
+
getFolderById,
|
|
11
|
+
} from "../api/getters";
|
|
12
|
+
|
|
13
|
+
const createTestAdapter = (): Adapter => {
|
|
14
|
+
const db = defineDb({}).use(mediaSchema);
|
|
15
|
+
return createMemoryAdapter(db)({});
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const makeAsset = (
|
|
19
|
+
overrides: Partial<{
|
|
20
|
+
filename: string;
|
|
21
|
+
originalName: string;
|
|
22
|
+
mimeType: string;
|
|
23
|
+
size: number;
|
|
24
|
+
url: string;
|
|
25
|
+
folderId: string | undefined;
|
|
26
|
+
alt: string | undefined;
|
|
27
|
+
}> = {},
|
|
28
|
+
) => ({
|
|
29
|
+
filename: "image.jpg",
|
|
30
|
+
originalName: "My Image.jpg",
|
|
31
|
+
mimeType: "image/jpeg",
|
|
32
|
+
size: 1024,
|
|
33
|
+
url: "https://example.com/image.jpg",
|
|
34
|
+
folderId: undefined,
|
|
35
|
+
alt: undefined,
|
|
36
|
+
createdAt: new Date(),
|
|
37
|
+
...overrides,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const makeFolder = (
|
|
41
|
+
overrides: Partial<{
|
|
42
|
+
name: string;
|
|
43
|
+
parentId: string | undefined;
|
|
44
|
+
}> = {},
|
|
45
|
+
) => ({
|
|
46
|
+
name: "My Folder",
|
|
47
|
+
parentId: undefined,
|
|
48
|
+
createdAt: new Date(),
|
|
49
|
+
...overrides,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("media getters", () => {
|
|
53
|
+
let adapter: Adapter;
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
adapter = createTestAdapter();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ── listAssets ────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
describe("listAssets", () => {
|
|
62
|
+
it("returns empty result when no assets exist", async () => {
|
|
63
|
+
const result = await listAssets(adapter);
|
|
64
|
+
expect(result.items).toEqual([]);
|
|
65
|
+
expect(result.total).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns all assets with correct fields", async () => {
|
|
69
|
+
await adapter.create({
|
|
70
|
+
model: "mediaAsset",
|
|
71
|
+
data: makeAsset({
|
|
72
|
+
filename: "photo.jpg",
|
|
73
|
+
url: "https://example.com/photo.jpg",
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const result = await listAssets(adapter);
|
|
78
|
+
expect(result.items).toHaveLength(1);
|
|
79
|
+
expect(result.total).toBe(1);
|
|
80
|
+
expect(result.items[0]!.filename).toBe("photo.jpg");
|
|
81
|
+
expect(result.items[0]!.mimeType).toBe("image/jpeg");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("filters assets by folderId", async () => {
|
|
85
|
+
await adapter.create({
|
|
86
|
+
model: "mediaFolder",
|
|
87
|
+
data: makeFolder({ name: "Photos" }),
|
|
88
|
+
});
|
|
89
|
+
const folder = await adapter.findOne<{ id: string }>({
|
|
90
|
+
model: "mediaFolder",
|
|
91
|
+
where: [{ field: "name", value: "Photos", operator: "eq" }],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await adapter.create({
|
|
95
|
+
model: "mediaAsset",
|
|
96
|
+
data: makeAsset({ filename: "in-folder.jpg", folderId: folder!.id }),
|
|
97
|
+
});
|
|
98
|
+
await adapter.create({
|
|
99
|
+
model: "mediaAsset",
|
|
100
|
+
data: makeAsset({ filename: "no-folder.jpg" }),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const result = await listAssets(adapter, { folderId: folder!.id });
|
|
104
|
+
expect(result.items).toHaveLength(1);
|
|
105
|
+
expect(result.items[0]!.filename).toBe("in-folder.jpg");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("filters assets by mimeType", async () => {
|
|
109
|
+
await adapter.create({
|
|
110
|
+
model: "mediaAsset",
|
|
111
|
+
data: makeAsset({ filename: "image.jpg", mimeType: "image/jpeg" }),
|
|
112
|
+
});
|
|
113
|
+
await adapter.create({
|
|
114
|
+
model: "mediaAsset",
|
|
115
|
+
data: makeAsset({ filename: "doc.pdf", mimeType: "application/pdf" }),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const images = await listAssets(adapter, { mimeType: "image/jpeg" });
|
|
119
|
+
expect(images.items).toHaveLength(1);
|
|
120
|
+
expect(images.items[0]!.filename).toBe("image.jpg");
|
|
121
|
+
|
|
122
|
+
const pdfs = await listAssets(adapter, { mimeType: "application/pdf" });
|
|
123
|
+
expect(pdfs.items).toHaveLength(1);
|
|
124
|
+
expect(pdfs.items[0]!.filename).toBe("doc.pdf");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("searches assets by query string across filename, originalName, and alt", async () => {
|
|
128
|
+
await adapter.create({
|
|
129
|
+
model: "mediaAsset",
|
|
130
|
+
data: makeAsset({
|
|
131
|
+
filename: "holiday-photo.jpg",
|
|
132
|
+
originalName: "Holiday Photo.jpg",
|
|
133
|
+
alt: "Beach sunset",
|
|
134
|
+
}),
|
|
135
|
+
});
|
|
136
|
+
await adapter.create({
|
|
137
|
+
model: "mediaAsset",
|
|
138
|
+
data: makeAsset({
|
|
139
|
+
filename: "logo.png",
|
|
140
|
+
originalName: "Company Logo.png",
|
|
141
|
+
alt: "Brand logo",
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const holidayResult = await listAssets(adapter, { query: "holiday" });
|
|
146
|
+
expect(holidayResult.items).toHaveLength(1);
|
|
147
|
+
expect(holidayResult.items[0]!.filename).toBe("holiday-photo.jpg");
|
|
148
|
+
|
|
149
|
+
// "beach" matches only the alt text of the first asset
|
|
150
|
+
const beachResult = await listAssets(adapter, { query: "beach" });
|
|
151
|
+
expect(beachResult.items).toHaveLength(1);
|
|
152
|
+
expect(beachResult.items[0]!.alt).toBe("Beach sunset");
|
|
153
|
+
|
|
154
|
+
// "logo" matches only the second asset (filename, originalName, alt)
|
|
155
|
+
const logoResult = await listAssets(adapter, { query: "logo" });
|
|
156
|
+
expect(logoResult.items).toHaveLength(1);
|
|
157
|
+
expect(logoResult.items[0]!.filename).toBe("logo.png");
|
|
158
|
+
|
|
159
|
+
// "photo" matches only the first asset via filename and originalName
|
|
160
|
+
const photoResult = await listAssets(adapter, { query: "photo" });
|
|
161
|
+
expect(photoResult.items).toHaveLength(1);
|
|
162
|
+
expect(photoResult.items[0]!.filename).toBe("holiday-photo.jpg");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("paginates results with limit and offset", async () => {
|
|
166
|
+
for (let i = 0; i < 5; i++) {
|
|
167
|
+
await adapter.create({
|
|
168
|
+
model: "mediaAsset",
|
|
169
|
+
data: makeAsset({
|
|
170
|
+
filename: `asset-${i}.jpg`,
|
|
171
|
+
url: `https://example.com/${i}.jpg`,
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const page1 = await listAssets(adapter, { limit: 2, offset: 0 });
|
|
177
|
+
expect(page1.items).toHaveLength(2);
|
|
178
|
+
expect(page1.total).toBe(5);
|
|
179
|
+
|
|
180
|
+
const page2 = await listAssets(adapter, { limit: 2, offset: 2 });
|
|
181
|
+
expect(page2.items).toHaveLength(2);
|
|
182
|
+
expect(page2.total).toBe(5);
|
|
183
|
+
|
|
184
|
+
const page3 = await listAssets(adapter, { limit: 2, offset: 4 });
|
|
185
|
+
expect(page3.items).toHaveLength(1);
|
|
186
|
+
expect(page3.total).toBe(5);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ── getAssetById ─────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
describe("getAssetById", () => {
|
|
193
|
+
it("returns null when asset does not exist", async () => {
|
|
194
|
+
const result = await getAssetById(adapter, "nonexistent-id");
|
|
195
|
+
expect(result).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("returns the asset by ID", async () => {
|
|
199
|
+
const created = await adapter.create<{ id: string }>({
|
|
200
|
+
model: "mediaAsset",
|
|
201
|
+
data: makeAsset({ filename: "found.jpg" }),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const result = await getAssetById(adapter, created.id);
|
|
205
|
+
expect(result).not.toBeNull();
|
|
206
|
+
expect(result!.filename).toBe("found.jpg");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ── listFolders ───────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
describe("listFolders", () => {
|
|
213
|
+
it("returns empty array when no folders exist", async () => {
|
|
214
|
+
const result = await listFolders(adapter);
|
|
215
|
+
expect(result).toEqual([]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("returns all folders sorted by name", async () => {
|
|
219
|
+
await adapter.create({
|
|
220
|
+
model: "mediaFolder",
|
|
221
|
+
data: makeFolder({ name: "Zeta" }),
|
|
222
|
+
});
|
|
223
|
+
await adapter.create({
|
|
224
|
+
model: "mediaFolder",
|
|
225
|
+
data: makeFolder({ name: "Alpha" }),
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const result = await listFolders(adapter);
|
|
229
|
+
expect(result).toHaveLength(2);
|
|
230
|
+
expect(result[0]!.name).toBe("Alpha");
|
|
231
|
+
expect(result[1]!.name).toBe("Zeta");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("filters folders by parentId", async () => {
|
|
235
|
+
const root = await adapter.create<{ id: string }>({
|
|
236
|
+
model: "mediaFolder",
|
|
237
|
+
data: makeFolder({ name: "Root" }),
|
|
238
|
+
});
|
|
239
|
+
await adapter.create({
|
|
240
|
+
model: "mediaFolder",
|
|
241
|
+
data: makeFolder({ name: "Child", parentId: root.id }),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// No params — returns ALL folders
|
|
245
|
+
const allFolders = await listFolders(adapter);
|
|
246
|
+
expect(allFolders).toHaveLength(2);
|
|
247
|
+
|
|
248
|
+
// Filter children of root
|
|
249
|
+
const childFolders = await listFolders(adapter, { parentId: root.id });
|
|
250
|
+
expect(childFolders).toHaveLength(1);
|
|
251
|
+
expect(childFolders[0]!.name).toBe("Child");
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ── getFolderById ─────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
describe("getFolderById", () => {
|
|
258
|
+
it("returns null when folder does not exist", async () => {
|
|
259
|
+
const result = await getFolderById(adapter, "nonexistent-id");
|
|
260
|
+
expect(result).toBeNull();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("returns the folder by ID", async () => {
|
|
264
|
+
const created = await adapter.create<{ id: string }>({
|
|
265
|
+
model: "mediaFolder",
|
|
266
|
+
data: makeFolder({ name: "Test Folder" }),
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const result = await getFolderById(adapter, created.id);
|
|
270
|
+
expect(result).not.toBeNull();
|
|
271
|
+
expect(result!.name).toBe("Test Folder");
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
});
|