From 9e3788e71df381cf6d9ac7ed2e61ad864f2ee295 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Jan 2026 12:42:47 +0000 Subject: [PATCH] Enhance Blossom viewer with server selection and blob details - Add server selection checkboxes to upload view for choosing target servers - Add BlobDetailView with media preview (image/video/audio) and metadata display - Add 'blob' subcommand to view individual blob details - Remove unused 'check' subcommand --- src/components/BlossomViewer.tsx | 555 +++++++++++++++++++++++-------- src/lib/blossom-parser.ts | 44 ++- src/types/man.ts | 11 +- 3 files changed, 448 insertions(+), 162 deletions(-) diff --git a/src/components/BlossomViewer.tsx b/src/components/BlossomViewer.tsx index 6e540a5..a27de4f 100644 --- a/src/components/BlossomViewer.tsx +++ b/src/components/BlossomViewer.tsx @@ -20,8 +20,11 @@ import { Music, FileText, Archive, + ArrowLeft, + Eye, } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { useGrimoire } from "@/core/state"; import { useEventStore } from "applesauce-react/hooks"; import { addressLoader } from "@/services/loaders"; @@ -64,12 +67,12 @@ export function BlossomViewer({ switch (subcommand) { case "servers": return ; - case "check": - return ; case "upload": return ; case "list": return ; + case "blob": + return ; case "mirror": return ; case "delete": @@ -304,67 +307,20 @@ function ServerRow({ } /** - * CheckServerView - Check a specific server's health - */ -function CheckServerView({ serverUrl }: { serverUrl: string }) { - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const check = async () => { - setLoading(true); - const result = await checkServer(serverUrl); - setStatus(result); - setLoading(false); - }; - check(); - }, [serverUrl]); - - return ( -
- {loading ? ( - <> - -

Checking {serverUrl}...

- - ) : status ? ( - <> - {status.online ? ( - - ) : ( - - )} -

- {status.online ? "Server Online" : "Server Offline"} -

- - {serverUrl} - - {status.online && status.responseTime && ( -

- Response time: {status.responseTime}ms -

- )} - {!status.online && status.error && ( -

{status.error}

- )} - - ) : null} -
- ); -} - -/** - * UploadView - File upload interface + * UploadView - File upload interface with server selection */ function UploadView() { const { state } = useGrimoire(); const eventStore = useEventStore(); const pubkey = state.activeAccount?.pubkey; const [servers, setServers] = useState([]); + const [selectedServers, setSelectedServers] = useState>( + new Set(), + ); const [uploading, setUploading] = useState(false); const [results, setResults] = useState([]); const [errors, setErrors] = useState<{ server: string; error: string }[]>([]); + const [selectedFile, setSelectedFile] = useState(null); const fileInputRef = useRef(null); const { copy, copied } = useCopy(); @@ -374,7 +330,10 @@ function UploadView() { const event = eventStore.getReplaceable(USER_SERVER_LIST_KIND, pubkey, ""); if (event) { - setServers(getServersFromEvent(event)); + const s = getServersFromEvent(event); + setServers(s); + // Select all by default + setSelectedServers(new Set(s)); } const subscription = addressLoader({ @@ -385,7 +344,10 @@ function UploadView() { next: () => { const e = eventStore.getReplaceable(USER_SERVER_LIST_KIND, pubkey, ""); if (e) { - setServers(getServersFromEvent(e)); + const s = getServersFromEvent(e); + setServers(s); + // Select all by default if not already set + setSelectedServers((prev) => (prev.size === 0 ? new Set(s) : prev)); } }, }); @@ -393,14 +355,38 @@ function UploadView() { return () => subscription.unsubscribe(); }, [pubkey, eventStore]); - const handleFileSelect = async (e: React.ChangeEvent) => { + const handleFileChange = (e: React.ChangeEvent) => { const files = e.target.files; - if (!files || files.length === 0) return; + if (files && files.length > 0) { + setSelectedFile(files[0]); + setResults([]); + setErrors([]); + } + }; - const file = files[0]; + const toggleServer = (server: string) => { + setSelectedServers((prev) => { + const newSet = new Set(prev); + if (newSet.has(server)) { + newSet.delete(server); + } else { + newSet.add(server); + } + return newSet; + }); + }; - if (servers.length === 0) { - toast.error("No Blossom servers configured"); + const selectAll = () => setSelectedServers(new Set(servers)); + const selectNone = () => 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; } @@ -410,14 +396,14 @@ function UploadView() { try { const { results: uploadResults, errors: uploadErrors } = - await uploadBlobToServers(file, servers); + await uploadBlobToServers(selectedFile, Array.from(selectedServers)); setResults(uploadResults); setErrors(uploadErrors); if (uploadResults.length > 0) { toast.success( - `Uploaded to ${uploadResults.length}/${servers.length} servers`, + `Uploaded to ${uploadResults.length}/${selectedServers.size} servers`, ); } else { toast.error("Upload failed on all servers"); @@ -426,9 +412,6 @@ function UploadView() { toast.error(error instanceof Error ? error.message : "Upload failed"); } finally { setUploading(false); - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } } }; @@ -452,39 +435,110 @@ function UploadView() { Upload to Blossom -
- {/* Upload Area */} -
+
+ {/* File Selection */} +
- {uploading ? ( + {selectedFile ? (
- -

Uploading...

+ {getFileIcon(selectedFile.type, "size-8")} +

+ {selectedFile.name} +

+

+ {formatSize(selectedFile.size)} -{" "} + {selectedFile.type || "Unknown type"} +

+
) : (
-

- {servers.length === 0 - ? "No servers configured" - : `Upload to ${servers.length} server${servers.length !== 1 ? "s" : ""}`} -

-
)}
+ {/* Server Selection */} + {servers.length > 0 && ( +
+
+

+ Upload to ({selectedServers.size}/{servers.length} selected) +

+
+ + +
+
+
+ {servers.map((server) => ( + + ))} +
+
+ )} + + {servers.length === 0 && ( +
+ +

No Blossom servers configured

+
+ )} + + {/* Upload Button */} + + {/* Results */} {results.length > 0 && (
@@ -521,7 +575,7 @@ function UploadView() { {/* Errors */} {errors.length > 0 && ( -
+

Failed ({errors.length})

@@ -544,15 +598,15 @@ function UploadView() { /** * Get icon for file type */ -function getFileIcon(mimeType?: string) { - if (!mimeType) return ; - if (mimeType.startsWith("image/")) return ; - if (mimeType.startsWith("video/")) return ; - if (mimeType.startsWith("audio/")) return ; - if (mimeType.startsWith("text/")) return ; +function getFileIcon(mimeType?: string, className = "size-4") { + if (!mimeType) return ; + if (mimeType.startsWith("image/")) return ; + if (mimeType.startsWith("video/")) return ; + if (mimeType.startsWith("audio/")) return ; + if (mimeType.startsWith("text/")) return ; if (mimeType.includes("zip") || mimeType.includes("archive")) - return ; - return ; + return ; + return ; } /** @@ -579,7 +633,7 @@ function ListBlobsView({ pubkey }: { pubkey?: string }) { const [blobs, setBlobs] = useState([]); const [loading, setLoading] = useState(true); const [selectedServer, setSelectedServer] = useState(null); - const { copy, copied } = useCopy(); + const [selectedBlob, setSelectedBlob] = useState(null); // Fetch servers for the target pubkey useEffect(() => { @@ -652,6 +706,17 @@ function ListBlobsView({ pubkey }: { pubkey?: string }) { fetchBlobs(); }, [selectedServer, targetPubkey]); + // Show blob detail view if a blob is selected + if (selectedBlob) { + return ( + setSelectedBlob(null)} + /> + ); + } + if (!targetPubkey) { return (
@@ -712,51 +777,11 @@ function ListBlobsView({ pubkey }: { pubkey?: string }) { ) : (
{blobs.map((blob) => ( -
-
- {getFileIcon(blob.type)} -
-
- {blob.sha256.slice(0, 16)}... -
-
- {formatSize(blob.size)} - {blob.type && {blob.type}} - {blob.uploaded && ( - - - {formatDistanceToNow(blob.uploaded * 1000, { - addSuffix: true, - })} - - )} -
-
-
-
- - -
-
+ blob={blob} + onClick={() => setSelectedBlob(blob)} + /> ))}
)} @@ -766,7 +791,259 @@ function ListBlobsView({ pubkey }: { pubkey?: string }) { } /** - * MirrorView - Mirror a blob to another server (placeholder) + * BlobRow - Single blob in list view + */ +function BlobRow({ + blob, + onClick, +}: { + blob: BlobDescriptor; + onClick: () => void; +}) { + const { copy, copied } = useCopy(); + + return ( +
+
+ {getFileIcon(blob.type)} +
+
+ {blob.sha256.slice(0, 16)}... +
+
+ {formatSize(blob.size)} + {blob.type && {blob.type}} + {blob.uploaded && ( + + + {formatDistanceToNow(blob.uploaded * 1000, { + addSuffix: true, + })} + + )} +
+
+
+
+ + +
+
+ ); +} + +/** + * BlobDetailView - Detailed view of a single blob + */ +function BlobDetailView({ + sha256, + serverUrl, + blob: initialBlob, + onBack, +}: { + sha256?: string; + serverUrl?: string; + blob?: BlobDescriptor; + onBack?: () => void; +}) { + const { copy, copied } = useCopy(); + const blob = initialBlob; + + // If we have a blob descriptor, use that data + const blobUrl = + blob?.url || (serverUrl && sha256 ? `${serverUrl}/${sha256}` : null); + const blobSha256 = blob?.sha256 || sha256; + const mimeType = blob?.type; + + const isImage = mimeType?.startsWith("image/"); + const isVideo = mimeType?.startsWith("video/"); + const isAudio = mimeType?.startsWith("audio/"); + + if (!blobSha256) { + return ( +
+ +

No Blob Selected

+
+ ); + } + + return ( +
+ {/* Header */} +
+ {onBack && ( + + )} + + Blob Details +
+ +
+ {/* Preview */} + {blobUrl && ( +
+ {isImage && ( + Blob preview + )} + {isVideo && ( +
+ )} + + {/* Info */} +
+
+
+ SHA256 +
+
+ {blobSha256} + +
+
+ + {blobUrl && ( +
+
+ URL +
+
+ {blobUrl} + + +
+
+ )} + + {serverUrl && ( +
+
+ Server +
+
+ + {serverUrl} +
+
+ )} + + {blob && ( + <> +
+
+ Size +
+
{formatSize(blob.size)}
+
+ + {blob.type && ( +
+
+ Type +
+
{blob.type}
+
+ )} + + {blob.uploaded && ( +
+
+ Uploaded +
+
+ {new Date(blob.uploaded * 1000).toLocaleString()} + + ( + {formatDistanceToNow(blob.uploaded * 1000, { + addSuffix: true, + })} + ) + +
+
+ )} + + )} +
+
+
+ ); +} + +/** + * MirrorView - Mirror a blob to another server */ function MirrorView({ sourceUrl, @@ -797,7 +1074,7 @@ function MirrorView({ } /** - * DeleteView - Delete a blob from a server (placeholder) + * DeleteView - Delete a blob from a server */ function DeleteView({ sha256, diff --git a/src/lib/blossom-parser.ts b/src/lib/blossom-parser.ts index afa8816..7a20c58 100644 --- a/src/lib/blossom-parser.ts +++ b/src/lib/blossom-parser.ts @@ -3,33 +3,33 @@ * * Parses arguments for the blossom command with subcommands: * - servers: Show/manage user's Blossom server list - * - check : Check if a server is online - * - upload : Upload a file (handled by UI file picker) + * - upload: Upload a file (handled by UI file picker) * - list [pubkey]: List blobs for a user + * - blob [server]: View a specific blob * - mirror : Mirror a blob to another server + * - delete : Delete a blob from a server */ import { nip19 } from "nostr-tools"; export type BlossomSubcommand = | "servers" - | "check" | "upload" | "list" + | "blob" | "mirror" | "delete"; export interface BlossomCommandResult { subcommand: BlossomSubcommand; - // For 'check' subcommand + // For 'blob' and 'delete' subcommands + sha256?: string; serverUrl?: string; // For 'list' subcommand pubkey?: string; // For 'mirror' subcommand sourceUrl?: string; targetServer?: string; - // For 'delete' subcommand - sha256?: string; } /** @@ -91,9 +91,9 @@ function resolvePubkey( * * Usage: * blossom servers - Show your Blossom servers - * blossom check - Check server health * blossom upload - Open upload dialog * blossom list [pubkey] - List blobs (defaults to $me) + * blossom blob [server] - View blob details * blossom mirror - Mirror blob to server * blossom delete - Delete blob from server */ @@ -113,16 +113,6 @@ export function parseBlossomCommand( case "server": return { subcommand: "servers" }; - case "check": { - if (args.length < 2) { - throw new Error("Server URL required. Usage: blossom check "); - } - return { - subcommand: "check", - serverUrl: normalizeServerUrl(args[1]), - }; - } - case "upload": return { subcommand: "upload" }; @@ -149,6 +139,24 @@ export function parseBlossomCommand( }; } + case "blob": + case "view": { + if (args.length < 2) { + throw new Error( + "SHA256 hash required. Usage: blossom blob [server]", + ); + } + const sha256 = args[1].toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(sha256)) { + throw new Error("Invalid SHA256 hash. Must be 64 hex characters."); + } + return { + subcommand: "blob", + sha256, + serverUrl: args[2] ? normalizeServerUrl(args[2]) : undefined, + }; + } + case "mirror": { if (args.length < 3) { throw new Error( @@ -186,9 +194,9 @@ export function parseBlossomCommand( Available subcommands: servers Show your configured Blossom servers - check Check if a server is online upload Open file upload dialog list [pubkey] List blobs (defaults to your account) + blob [server] View blob details mirror Mirror a blob to another server delete Delete a blob from a server`, ); diff --git a/src/types/man.ts b/src/types/man.ts index 0fb814e..1f5fbec 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -528,10 +528,6 @@ export const manPages: Record = { description: "Show your configured Blossom servers from kind 10063 event", }, - { - flag: "check ", - description: "Check if a Blossom server is online and responsive", - }, { flag: "upload", description: @@ -542,6 +538,11 @@ export const manPages: Record = { description: "List blobs uploaded by a user (defaults to your account). Supports npub, hex, or $me", }, + { + flag: "blob [server]", + description: + "View details and preview of a specific blob by its SHA256 hash", + }, { flag: "mirror ", description: "Mirror a blob from a URL to another Blossom server", @@ -554,11 +555,11 @@ export const manPages: Record = { examples: [ "blossom Show your Blossom servers", "blossom servers Show your Blossom servers", - "blossom check cdn.satellite.earth Check if server is online", "blossom upload Open file upload dialog", "blossom list List your uploaded blobs", "blossom list $me List your uploaded blobs", "blossom list npub1... List blobs for another user", + "blossom blob abc123... View blob details", "blossom mirror https://... cdn.example.com Mirror blob to server", ], seeAlso: ["profile"],