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