@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
|
@@ -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
|
+
}
|