mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-18 03:17:04 +02:00
Add Blossom upload dialog with chat integration
- Create BlossomUploadDialog component with file picker, server selection, and preview - Create useBlossomUpload hook for easy integration in any component - Add insertText method to MentionEditor for programmatic text insertion - Integrate upload button (paperclip icon) in chat composer - Supports image, video, and audio uploads with drag-and-drop
This commit is contained in:
505
src/components/BlossomUploadDialog.tsx
Normal file
505
src/components/BlossomUploadDialog.tsx
Normal file
@@ -0,0 +1,505 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Upload,
|
||||
Loader2,
|
||||
HardDrive,
|
||||
Image as ImageIcon,
|
||||
Film,
|
||||
Music,
|
||||
FileIcon,
|
||||
FileText,
|
||||
Archive,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useEventStore } from "applesauce-react/hooks";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import accountManager from "@/services/accounts";
|
||||
import { addressLoader } from "@/services/loaders";
|
||||
import {
|
||||
USER_SERVER_LIST_KIND,
|
||||
getServersFromEvent,
|
||||
uploadBlobToServers,
|
||||
type UploadResult,
|
||||
} from "@/services/blossom";
|
||||
import type { Subscription } from "rxjs";
|
||||
|
||||
interface BlossomUploadDialogProps {
|
||||
/** Whether the dialog is open */
|
||||
open: boolean;
|
||||
/** Callback when dialog open state changes */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Called when upload completes successfully */
|
||||
onSuccess: (results: UploadResult[]) => void;
|
||||
/** Called when upload is cancelled */
|
||||
onCancel?: () => void;
|
||||
/** Called when upload fails */
|
||||
onError?: (error: Error) => void;
|
||||
/** File types to accept (e.g., "image/*,video/*,audio/*") */
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* BlossomUploadDialog - Modal dialog for uploading files to Blossom servers
|
||||
*
|
||||
* Features:
|
||||
* - File selection with drag & drop support
|
||||
* - Server selection from user's kind 10063 list
|
||||
* - Upload progress and results
|
||||
* - Preview for images/video/audio
|
||||
*/
|
||||
export function BlossomUploadDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
onError,
|
||||
accept = "image/*,video/*,audio/*",
|
||||
}: BlossomUploadDialogProps) {
|
||||
const eventStore = useEventStore();
|
||||
const activeAccount = use$(accountManager.active$);
|
||||
const pubkey = activeAccount?.pubkey;
|
||||
|
||||
const [servers, setServers] = useState<string[]>([]);
|
||||
const [selectedServers, setSelectedServers] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [loadingServers, setLoadingServers] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [uploadResults, setUploadResults] = useState<UploadResult[]>([]);
|
||||
const [uploadErrors, setUploadErrors] = useState<
|
||||
{ server: string; error: string }[]
|
||||
>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dropZoneRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedFile(null);
|
||||
setPreviewUrl(null);
|
||||
setUploadResults([]);
|
||||
setUploadErrors([]);
|
||||
setUploading(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Fetch servers when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open || !pubkey) {
|
||||
setLoadingServers(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingServers(true);
|
||||
let subscription: Subscription | null = null;
|
||||
|
||||
// Check existing event first
|
||||
const event = eventStore.getReplaceable(USER_SERVER_LIST_KIND, pubkey, "");
|
||||
if (event) {
|
||||
const s = getServersFromEvent(event);
|
||||
setServers(s);
|
||||
setSelectedServers(new Set(s)); // Select all by default
|
||||
setLoadingServers(false);
|
||||
}
|
||||
|
||||
// Also fetch from network
|
||||
subscription = addressLoader({
|
||||
kind: USER_SERVER_LIST_KIND,
|
||||
pubkey,
|
||||
identifier: "",
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
const e = eventStore.getReplaceable(USER_SERVER_LIST_KIND, pubkey, "");
|
||||
if (e) {
|
||||
const s = getServersFromEvent(e);
|
||||
setServers(s);
|
||||
setSelectedServers((prev) => (prev.size === 0 ? new Set(s) : prev));
|
||||
}
|
||||
setLoadingServers(false);
|
||||
},
|
||||
error: () => setLoadingServers(false),
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => setLoadingServers(false), 3000);
|
||||
|
||||
return () => {
|
||||
subscription?.unsubscribe();
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [open, pubkey, eventStore]);
|
||||
|
||||
// Create preview URL for selected file
|
||||
useEffect(() => {
|
||||
if (selectedFile && selectedFile.type.startsWith("image/")) {
|
||||
const url = URL.createObjectURL(selectedFile);
|
||||
setPreviewUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setPreviewUrl(null);
|
||||
}
|
||||
}, [selectedFile]);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
setSelectedFile(files[0]);
|
||||
setUploadResults([]);
|
||||
setUploadErrors([]);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
setSelectedFile(files[0]);
|
||||
setUploadResults([]);
|
||||
setUploadErrors([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const toggleServer = useCallback((server: string) => {
|
||||
setSelectedServers((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(server)) {
|
||||
newSet.delete(server);
|
||||
} else {
|
||||
newSet.add(server);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectAll = useCallback(
|
||||
() => setSelectedServers(new Set(servers)),
|
||||
[servers],
|
||||
);
|
||||
const selectNone = useCallback(() => setSelectedServers(new Set()), []);
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) {
|
||||
toast.error("No file selected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedServers.size === 0) {
|
||||
toast.error("Select at least one server");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadResults([]);
|
||||
setUploadErrors([]);
|
||||
|
||||
try {
|
||||
const { results, errors } = await uploadBlobToServers(
|
||||
selectedFile,
|
||||
Array.from(selectedServers),
|
||||
);
|
||||
|
||||
setUploadResults(results);
|
||||
setUploadErrors(errors);
|
||||
|
||||
if (results.length > 0) {
|
||||
toast.success(
|
||||
`Uploaded to ${results.length}/${selectedServers.size} servers`,
|
||||
);
|
||||
// Call success callback with results
|
||||
onSuccess(results);
|
||||
} else {
|
||||
const error = new Error("Upload failed on all servers");
|
||||
toast.error(error.message);
|
||||
onError?.(error);
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error("Upload failed");
|
||||
toast.error(err.message);
|
||||
onError?.(err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!uploading) {
|
||||
onCancel?.();
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
// No account logged in
|
||||
if (!pubkey) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload to Blossom</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sign in to upload files to your Blossom servers.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
||||
<Upload className="size-12 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Account required to upload files
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Upload className="size-5" />
|
||||
Upload to Blossom
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a file and choose which servers to upload to.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* File Selection / Drop Zone */}
|
||||
<div
|
||||
ref={dropZoneRef}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
className="border-2 border-dashed rounded-lg p-4 text-center transition-colors hover:border-primary/50 cursor-pointer"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
{selectedFile ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
className="max-h-32 max-w-full rounded object-contain"
|
||||
/>
|
||||
) : (
|
||||
getFileIcon(
|
||||
selectedFile.type,
|
||||
"size-12 text-muted-foreground",
|
||||
)
|
||||
)}
|
||||
<p className="font-medium text-sm truncate max-w-full">
|
||||
{selectedFile.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatSize(selectedFile.size)} •{" "}
|
||||
{selectedFile.type || "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 py-4">
|
||||
<Upload className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click or drop a file here
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Images, videos, or audio
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Server Selection */}
|
||||
{loadingServers ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : servers.length > 0 ? (
|
||||
<div className="border rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
Servers ({selectedServers.size}/{servers.length})
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={selectAll}
|
||||
disabled={uploading}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={selectNone}
|
||||
disabled={uploading}
|
||||
>
|
||||
None
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{servers.map((server) => (
|
||||
<label
|
||||
key={server}
|
||||
className="flex items-center gap-2 p-1.5 rounded hover:bg-muted/50 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedServers.has(server)}
|
||||
onCheckedChange={() => toggleServer(server)}
|
||||
disabled={uploading}
|
||||
/>
|
||||
<HardDrive className="size-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="font-mono text-xs truncate flex-1">
|
||||
{server}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg p-4 text-center">
|
||||
<HardDrive className="size-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No Blossom servers configured
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Publish a kind 10063 server list to use this feature
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Results */}
|
||||
{uploadResults.length > 0 && (
|
||||
<div className="border rounded-lg p-3 bg-green-50 dark:bg-green-950/30">
|
||||
<div className="flex items-center gap-2 text-green-600 mb-2">
|
||||
<CheckCircle className="size-4" />
|
||||
<span className="text-sm font-medium">
|
||||
Uploaded ({uploadResults.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{uploadResults.map((result) => (
|
||||
<code
|
||||
key={result.server}
|
||||
className="text-xs block truncate text-green-700 dark:text-green-400"
|
||||
>
|
||||
{result.blob.url}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Errors */}
|
||||
{uploadErrors.length > 0 && (
|
||||
<div className="border rounded-lg p-3 bg-red-50 dark:bg-red-950/30">
|
||||
<div className="flex items-center gap-2 text-red-600 mb-2">
|
||||
<XCircle className="size-4" />
|
||||
<span className="text-sm font-medium">
|
||||
Failed ({uploadErrors.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{uploadErrors.map((error) => (
|
||||
<div
|
||||
key={error.server}
|
||||
className="text-xs text-red-700 dark:text-red-400"
|
||||
>
|
||||
{new URL(error.server).hostname}: {error.error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={handleClose}
|
||||
disabled={uploading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleUpload}
|
||||
disabled={
|
||||
uploading || !selectedFile || selectedServers.size === 0
|
||||
}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="size-4 mr-2" />
|
||||
Upload
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon for file type
|
||||
*/
|
||||
function getFileIcon(mimeType?: string, className = "size-4") {
|
||||
if (!mimeType) return <FileIcon className={className} />;
|
||||
if (mimeType.startsWith("image/")) return <ImageIcon className={className} />;
|
||||
if (mimeType.startsWith("video/")) return <Film className={className} />;
|
||||
if (mimeType.startsWith("audio/")) return <Music className={className} />;
|
||||
if (mimeType.startsWith("text/")) return <FileText className={className} />;
|
||||
if (mimeType.includes("zip") || mimeType.includes("archive"))
|
||||
return <Archive className={className} />;
|
||||
return <FileIcon className={className} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size
|
||||
*/
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024)
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
@@ -2,7 +2,14 @@ import { useMemo, useState, memo, useCallback, useRef, useEffect } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import { from, catchError, of, map } from "rxjs";
|
||||
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
||||
import { Loader2, Reply, Zap, AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
Loader2,
|
||||
Reply,
|
||||
Zap,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Paperclip,
|
||||
} from "lucide-react";
|
||||
import { getZapRequest } from "applesauce-common/helpers/zap";
|
||||
import { toast } from "sonner";
|
||||
import accountManager from "@/services/accounts";
|
||||
@@ -43,6 +50,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "./ui/tooltip";
|
||||
import { useBlossomUpload } from "@/hooks/useBlossomUpload";
|
||||
|
||||
interface ChatViewerProps {
|
||||
protocol: ChatProtocol;
|
||||
@@ -333,6 +341,22 @@ export function ChatViewer({
|
||||
// Emoji search for custom emoji autocomplete
|
||||
const { searchEmojis } = useEmojiSearch();
|
||||
|
||||
// Ref to MentionEditor for programmatic submission
|
||||
const editorRef = useRef<MentionEditorHandle>(null);
|
||||
|
||||
// Blossom upload hook for file attachments
|
||||
const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({
|
||||
accept: "image/*,video/*,audio/*",
|
||||
onSuccess: (results) => {
|
||||
if (results.length > 0 && editorRef.current) {
|
||||
// Insert the first successful upload URL into the editor
|
||||
const url = results[0].blob.url;
|
||||
editorRef.current.insertText(url);
|
||||
editorRef.current.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Get the appropriate adapter for this protocol
|
||||
const adapter = useMemo(() => getAdapter(protocol), [protocol]);
|
||||
|
||||
@@ -444,9 +468,6 @@ export function ChatViewer({
|
||||
// Ref to Virtuoso for programmatic scrolling
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
// Ref to MentionEditor for programmatic submission
|
||||
const editorRef = useRef<MentionEditorHandle>(null);
|
||||
|
||||
// State for send in progress (prevents double-sends)
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
@@ -846,6 +867,25 @@ export function ChatViewer({
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="flex-shrink-0 size-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={openUpload}
|
||||
disabled={isSending}
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Attach media</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<MentionEditor
|
||||
ref={editorRef}
|
||||
placeholder="Type a message..."
|
||||
@@ -873,6 +913,7 @@ export function ChatViewer({
|
||||
{isSending ? <Loader2 className="size-3 animate-spin" /> : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
{uploadDialog}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t px-3 py-2 text-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -68,6 +68,8 @@ export interface MentionEditorHandle {
|
||||
getSerializedContent: () => SerializedContent;
|
||||
isEmpty: () => boolean;
|
||||
submit: () => void;
|
||||
/** Insert text at the current cursor position */
|
||||
insertText: (text: string) => void;
|
||||
}
|
||||
|
||||
// Create emoji extension by extending Mention with a different name and custom node view
|
||||
@@ -682,6 +684,11 @@ export const MentionEditor = forwardRef<
|
||||
handleSubmit(editor);
|
||||
}
|
||||
},
|
||||
insertText: (text: string) => {
|
||||
if (editor) {
|
||||
editor.chain().focus().insertContent(text).run();
|
||||
}
|
||||
},
|
||||
}),
|
||||
[editor, serializeContent, handleSubmit],
|
||||
);
|
||||
|
||||
93
src/hooks/useBlossomUpload.tsx
Normal file
93
src/hooks/useBlossomUpload.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { BlossomUploadDialog } from "@/components/BlossomUploadDialog";
|
||||
import type { UploadResult } from "@/services/blossom";
|
||||
|
||||
export interface UseBlossomUploadOptions {
|
||||
/** Called when upload completes successfully */
|
||||
onSuccess?: (results: UploadResult[]) => void;
|
||||
/** Called when upload is cancelled */
|
||||
onCancel?: () => void;
|
||||
/** Called when upload fails */
|
||||
onError?: (error: Error) => void;
|
||||
/** File types to accept (e.g., "image/*,video/*,audio/*") */
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
export interface UseBlossomUploadReturn {
|
||||
/** Open the upload dialog */
|
||||
open: () => void;
|
||||
/** Close the upload dialog */
|
||||
close: () => void;
|
||||
/** Whether the dialog is currently open */
|
||||
isOpen: boolean;
|
||||
/** The dialog component to render */
|
||||
dialog: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing Blossom file uploads with a dialog
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const { open, dialog } = useBlossomUpload({
|
||||
* onSuccess: (results) => {
|
||||
* const url = results[0].blob.url;
|
||||
* insertIntoEditor(url);
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <button onClick={open}>Upload</button>
|
||||
* {dialog}
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useBlossomUpload(
|
||||
options: UseBlossomUploadOptions = {},
|
||||
): UseBlossomUploadReturn {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const open = useCallback(() => setIsOpen(true), []);
|
||||
const close = useCallback(() => setIsOpen(false), []);
|
||||
|
||||
const handleSuccess = useCallback(
|
||||
(results: UploadResult[]) => {
|
||||
options.onSuccess?.(results);
|
||||
close();
|
||||
},
|
||||
[options.onSuccess, close],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
options.onCancel?.();
|
||||
close();
|
||||
}, [options.onCancel, close]);
|
||||
|
||||
const handleError = useCallback(
|
||||
(error: Error) => {
|
||||
options.onError?.(error);
|
||||
// Don't close on error - let user retry
|
||||
},
|
||||
[options.onError],
|
||||
);
|
||||
|
||||
const dialog = useMemo(
|
||||
() => (
|
||||
<BlossomUploadDialog
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={handleCancel}
|
||||
onError={handleError}
|
||||
accept={options.accept}
|
||||
/>
|
||||
),
|
||||
[isOpen, handleSuccess, handleCancel, handleError, options.accept],
|
||||
);
|
||||
|
||||
return { open, close, isOpen, dialog };
|
||||
}
|
||||
Reference in New Issue
Block a user