Add file uploads with Blossom

This commit is contained in:
Alex Gleason 2025-05-14 17:08:07 -05:00
parent f77a6a4af3
commit 601b3ce370
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
5 changed files with 185 additions and 71 deletions

View File

@ -292,6 +292,30 @@ function EditProfilePage() {
The `EditProfileForm` component displays just the form. It requires no props, and will "just work" automatically.
## Uploading Files
Use the `useUploadFile` hook to upload files.
```tsx
import { useUploadFile } from "@/hooks/useUploadFile";
function MyComponent() {
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const handleUpload = async (file: File) => {
try {
// The first tuple in the array contains the URL
const [[_, url]] = await uploadFile(file);
// ...use the url
} catch (error) {
// ...handle errors
}
};
// ...rest of component
}
```
## Development Practices
- Uses React Query for data fetching and caching

57
package-lock.json generated
View File

@ -9,8 +9,8 @@
"version": "0.0.0",
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.45.2",
"@nostrify/react": "npm:@jsr/nostrify__react@^0.2.3",
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.1",
"@nostrify/react": "npm:@jsr/nostrify__react@^0.2.5",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-aspect-ratio": "^1.1.0",
@ -821,13 +821,12 @@
}
},
"node_modules/@jsr/nostrify__nostrify": {
"version": "0.45.2",
"resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.45.2.tgz",
"integrity": "sha512-0eN+ceK79FcLDN6gJQd8GhM2UG09RVZBUDlveQqQMmNjuakvqLSE9Emg2i12EyEPOBa/yzCSiwkFFK2XzknH9Q==",
"version": "0.46.1",
"resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.46.1.tgz",
"integrity": "sha512-7XSP4+kjcPgw937jQPUxf+qo1mWx7rbKUuA5ma0CofRQZa2v01IA4f+OfVGJbABxbJ8SC+8VdPkLqqUOJxeVPg==",
"dependencies": {
"@jsr/nostrify__types": "^0.36.0",
"@jsr/scure__base": "^1.2.4",
"@jsr/std__crypto": "^0.224.0",
"@jsr/std__encoding": "^0.224.1",
"lru-cache": "^10.2.0",
"nostr-tools": "^2.10.4",
@ -845,42 +844,11 @@
"resolved": "https://npm.jsr.io/~/11/@jsr/scure__base/1.2.5.tgz",
"integrity": "sha512-ino21k5s2kamz+uhAL/N3iI5vJ7x3zTtrzhM52iXBth41KxkFSIDiOlQNbMj4/jTih27jmS6Mi4Xke5vKTdNIA=="
},
"node_modules/@jsr/std__assert": {
"version": "0.224.0",
"resolved": "https://npm.jsr.io/~/11/@jsr/std__assert/0.224.0.tgz",
"integrity": "sha512-RB0p0ydybgKSfTba6kHWytfpEJ0CBPi+byxZikLYa51L9uLINW52/j6n4KuiLFoh2cdFfpNZSNMY/dzQPW90DQ==",
"dependencies": {
"@jsr/std__fmt": "^0.224.0",
"@jsr/std__internal": "^0.224.0"
}
},
"node_modules/@jsr/std__crypto": {
"version": "0.224.0",
"resolved": "https://npm.jsr.io/~/11/@jsr/std__crypto/0.224.0.tgz",
"integrity": "sha512-qzZWI8VnH215FS7hmQsAeNafjLMkmSl1OOvexorVUEf1Zl9omHSN87MwIjtmyVXGLtpGRLzIhKXbeup1xO69Zw==",
"dependencies": {
"@jsr/std__assert": "^0.224.0",
"@jsr/std__encoding": "^0.224.0"
}
},
"node_modules/@jsr/std__encoding": {
"version": "0.224.3",
"resolved": "https://npm.jsr.io/~/11/@jsr/std__encoding/0.224.3.tgz",
"integrity": "sha512-zAuX2QV1zwJ5RSmrnDGVerAtN3pBXpYYNlGzhERW9AiQ1UJd2/xruyB3i5NdTWy2OK2pjETswOj+0+prYTPlxQ=="
},
"node_modules/@jsr/std__fmt": {
"version": "0.224.0",
"resolved": "https://npm.jsr.io/~/11/@jsr/std__fmt/0.224.0.tgz",
"integrity": "sha512-lyrH5LesMB897QW0NIbZlGp72Ucopj2hMZW2wqB0NyZhuXfLH2sPBIUpCSf87kRKTGnx90JV905w4iTp0TD+Sg=="
},
"node_modules/@jsr/std__internal": {
"version": "0.224.0",
"resolved": "https://npm.jsr.io/~/11/@jsr/std__internal/0.224.0.tgz",
"integrity": "sha512-inYzKOGAFK2tyy1D4NfwlbPiqEcSaXfOms3Tm4Y+1LmKSYOeB9wjqWHF4y/BJuYj8XUv61F7eaHaIw6NIlhBWg==",
"dependencies": {
"@jsr/std__fmt": "^0.224.0"
}
},
"node_modules/@noble/ciphers": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
@ -963,13 +931,12 @@
},
"node_modules/@nostrify/nostrify": {
"name": "@jsr/nostrify__nostrify",
"version": "0.45.2",
"resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.45.2.tgz",
"integrity": "sha512-0eN+ceK79FcLDN6gJQd8GhM2UG09RVZBUDlveQqQMmNjuakvqLSE9Emg2i12EyEPOBa/yzCSiwkFFK2XzknH9Q==",
"version": "0.46.1",
"resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.46.1.tgz",
"integrity": "sha512-7XSP4+kjcPgw937jQPUxf+qo1mWx7rbKUuA5ma0CofRQZa2v01IA4f+OfVGJbABxbJ8SC+8VdPkLqqUOJxeVPg==",
"dependencies": {
"@jsr/nostrify__types": "^0.36.0",
"@jsr/scure__base": "^1.2.4",
"@jsr/std__crypto": "^0.224.0",
"@jsr/std__encoding": "^0.224.1",
"lru-cache": "^10.2.0",
"nostr-tools": "^2.10.4",
@ -979,11 +946,11 @@
},
"node_modules/@nostrify/react": {
"name": "@jsr/nostrify__react",
"version": "0.2.3",
"resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__react/0.2.3.tgz",
"integrity": "sha512-XIa9aBhaSOe6AQWvKHYG3rq3+hTheWLd8kQM9SeyUQRpHM5RMlrmdrBKAXHCwt+/vZ9eOxM6nUq38C0hp4X6Bw==",
"version": "0.2.5",
"resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__react/0.2.5.tgz",
"integrity": "sha512-Hyi1N4hXa89gYsuk7fqGrWEzIzTp7md8hKog+D2DgpSqVwFQM1vw267Uv3rOieE/hPereD3gen9X88XkeAXP+Q==",
"dependencies": {
"@jsr/nostrify__nostrify": "^0.45.2",
"@jsr/nostrify__nostrify": "^0.46.1",
"nostr-tools": "^2.10.4",
"react": "^18.0.0"
}

View File

@ -14,8 +14,8 @@
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.45.2",
"@nostrify/react": "npm:@jsr/nostrify__react@^0.2.3",
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.1",
"@nostrify/react": "npm:@jsr/nostrify__react@^0.2.5",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-aspect-ratio": "^1.1.0",

View File

@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -17,15 +17,17 @@ import {
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Loader2 } from 'lucide-react';
import { Loader2, Upload } from 'lucide-react';
import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
import { useQueryClient } from '@tanstack/react-query';
import { useUploadFile } from '@/hooks/useUploadFile';
export const EditProfileForm: React.FC = () => {
const queryClient = useQueryClient();
const { user, metadata } = useCurrentUser();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
// Initialize the form with default values
@ -57,6 +59,26 @@ export const EditProfileForm: React.FC = () => {
}
}, [metadata, form]);
// Handle file uploads for profile picture and banner
const uploadPicture = async (file: File, field: 'picture' | 'banner') => {
try {
// The first tuple in the array contains the URL
const [[_, url]] = await uploadFile(file);
form.setValue(field, url);
toast({
title: 'Success',
description: `${field === 'picture' ? 'Profile picture' : 'Banner'} uploaded successfully`,
});
} catch (error) {
console.error(`Failed to upload ${field}:`, error);
toast({
title: 'Error',
description: `Failed to upload ${field === 'picture' ? 'profile picture' : 'banner'}. Please try again.`,
variant: 'destructive',
});
}
};
const onSubmit = async (values: NostrMetadata) => {
if (!user) {
toast({
@ -148,16 +170,14 @@ export const EditProfileForm: React.FC = () => {
control={form.control}
name="picture"
render={({ field }) => (
<FormItem>
<FormLabel>Profile Picture URL</FormLabel>
<FormControl>
<Input placeholder="https://example.com/profile.jpg" {...field} />
</FormControl>
<FormDescription>
URL to your profile picture.
</FormDescription>
<FormMessage />
</FormItem>
<ImageUploadField
field={field}
label="Profile Picture"
placeholder="https://example.com/profile.jpg"
description="URL to your profile picture. You can upload an image or provide a URL."
previewType="square"
onUpload={(file) => uploadPicture(file, 'picture')}
/>
)}
/>
@ -165,16 +185,14 @@ export const EditProfileForm: React.FC = () => {
control={form.control}
name="banner"
render={({ field }) => (
<FormItem>
<FormLabel>Banner Image URL</FormLabel>
<FormControl>
<Input placeholder="https://example.com/banner.jpg" {...field} />
</FormControl>
<FormDescription>
URL to a wide banner image for your profile.
</FormDescription>
<FormMessage />
</FormItem>
<ImageUploadField
field={field}
label="Banner Image"
placeholder="https://example.com/banner.jpg"
description="URL to a wide banner image for your profile. You can upload an image or provide a URL."
previewType="wide"
onUpload={(file) => uploadPicture(file, 'banner')}
/>
)}
/>
</div>
@ -239,9 +257,9 @@ export const EditProfileForm: React.FC = () => {
<Button
type="submit"
className="w-full md:w-auto"
disabled={isPending}
disabled={isPending || isUploading}
>
{isPending && (
{(isPending || isUploading) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save Profile
@ -250,3 +268,82 @@ export const EditProfileForm: React.FC = () => {
</Form>
);
};
// Reusable component for image upload fields
interface ImageUploadFieldProps {
field: {
value: string | undefined;
onChange: (value: string) => void;
name: string;
onBlur: () => void;
};
label: string;
placeholder: string;
description: string;
previewType: 'square' | 'wide';
onUpload: (file: File) => void;
}
const ImageUploadField: React.FC<ImageUploadFieldProps> = ({
field,
label,
placeholder,
description,
previewType,
onUpload,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<FormItem>
<FormLabel>{label}</FormLabel>
<div className="flex flex-col gap-2">
<FormControl>
<Input
placeholder={placeholder}
name={field.name}
value={field.value ?? ''}
onChange={e => field.onChange(e.target.value)}
onBlur={field.onBlur}
/>
</FormControl>
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
onUpload(file);
}
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-4 w-4 mr-2" />
Upload Image
</Button>
{field.value && (
<div className={`h-10 ${previewType === 'square' ? 'w-10' : 'w-24'} rounded overflow-hidden`}>
<img
src={field.value}
alt={`${label} preview`}
className="h-full w-full object-cover"
/>
</div>
)}
</div>
</div>
<FormDescription>
{description}
</FormDescription>
<FormMessage />
</FormItem>
);
};

View File

@ -0,0 +1,26 @@
import { useMutation } from "@tanstack/react-query";
import { BlossomUploader } from '@nostrify/nostrify/uploaders';
import { useCurrentUser } from "./useCurrentUser";
export function useUploadFile() {
const { user } = useCurrentUser();
return useMutation({
mutationFn: async (file: File) => {
if (!user) {
throw new Error('Must be logged in to upload files');
}
const uploader = new BlossomUploader({
servers: [
'https://blossom.primal.net/',
],
signer: user.signer,
});
const tags = await uploader.upload(file);
return tags;
},
});
}