mirror of
https://gitlab.com/soapbox-pub/mkstack.git
synced 2025-08-27 04:59:22 +00:00
Add file uploads with Blossom
This commit is contained in:
parent
f77a6a4af3
commit
601b3ce370
24
CONTEXT.md
24
CONTEXT.md
@ -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
57
package-lock.json
generated
@ -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"
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
26
src/hooks/useUploadFile.ts
Normal file
26
src/hooks/useUploadFile.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user