mirror of
https://gitlab.com/soapbox-pub/mkstack.git
synced 2025-08-27 13:09: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.
|
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
|
## Development Practices
|
||||||
|
|
||||||
- Uses React Query for data fetching and caching
|
- 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",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.45.2",
|
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.1",
|
||||||
"@nostrify/react": "npm:@jsr/nostrify__react@^0.2.3",
|
"@nostrify/react": "npm:@jsr/nostrify__react@^0.2.5",
|
||||||
"@radix-ui/react-accordion": "^1.2.0",
|
"@radix-ui/react-accordion": "^1.2.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
||||||
@ -821,13 +821,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jsr/nostrify__nostrify": {
|
"node_modules/@jsr/nostrify__nostrify": {
|
||||||
"version": "0.45.2",
|
"version": "0.46.1",
|
||||||
"resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.45.2.tgz",
|
"resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.46.1.tgz",
|
||||||
"integrity": "sha512-0eN+ceK79FcLDN6gJQd8GhM2UG09RVZBUDlveQqQMmNjuakvqLSE9Emg2i12EyEPOBa/yzCSiwkFFK2XzknH9Q==",
|
"integrity": "sha512-7XSP4+kjcPgw937jQPUxf+qo1mWx7rbKUuA5ma0CofRQZa2v01IA4f+OfVGJbABxbJ8SC+8VdPkLqqUOJxeVPg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsr/nostrify__types": "^0.36.0",
|
"@jsr/nostrify__types": "^0.36.0",
|
||||||
"@jsr/scure__base": "^1.2.4",
|
"@jsr/scure__base": "^1.2.4",
|
||||||
"@jsr/std__crypto": "^0.224.0",
|
|
||||||
"@jsr/std__encoding": "^0.224.1",
|
"@jsr/std__encoding": "^0.224.1",
|
||||||
"lru-cache": "^10.2.0",
|
"lru-cache": "^10.2.0",
|
||||||
"nostr-tools": "^2.10.4",
|
"nostr-tools": "^2.10.4",
|
||||||
@ -845,42 +844,11 @@
|
|||||||
"resolved": "https://npm.jsr.io/~/11/@jsr/scure__base/1.2.5.tgz",
|
"resolved": "https://npm.jsr.io/~/11/@jsr/scure__base/1.2.5.tgz",
|
||||||
"integrity": "sha512-ino21k5s2kamz+uhAL/N3iI5vJ7x3zTtrzhM52iXBth41KxkFSIDiOlQNbMj4/jTih27jmS6Mi4Xke5vKTdNIA=="
|
"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": {
|
"node_modules/@jsr/std__encoding": {
|
||||||
"version": "0.224.3",
|
"version": "0.224.3",
|
||||||
"resolved": "https://npm.jsr.io/~/11/@jsr/std__encoding/0.224.3.tgz",
|
"resolved": "https://npm.jsr.io/~/11/@jsr/std__encoding/0.224.3.tgz",
|
||||||
"integrity": "sha512-zAuX2QV1zwJ5RSmrnDGVerAtN3pBXpYYNlGzhERW9AiQ1UJd2/xruyB3i5NdTWy2OK2pjETswOj+0+prYTPlxQ=="
|
"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": {
|
"node_modules/@noble/ciphers": {
|
||||||
"version": "0.5.3",
|
"version": "0.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
|
||||||
@ -963,13 +931,12 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@nostrify/nostrify": {
|
"node_modules/@nostrify/nostrify": {
|
||||||
"name": "@jsr/nostrify__nostrify",
|
"name": "@jsr/nostrify__nostrify",
|
||||||
"version": "0.45.2",
|
"version": "0.46.1",
|
||||||
"resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.45.2.tgz",
|
"resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.46.1.tgz",
|
||||||
"integrity": "sha512-0eN+ceK79FcLDN6gJQd8GhM2UG09RVZBUDlveQqQMmNjuakvqLSE9Emg2i12EyEPOBa/yzCSiwkFFK2XzknH9Q==",
|
"integrity": "sha512-7XSP4+kjcPgw937jQPUxf+qo1mWx7rbKUuA5ma0CofRQZa2v01IA4f+OfVGJbABxbJ8SC+8VdPkLqqUOJxeVPg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsr/nostrify__types": "^0.36.0",
|
"@jsr/nostrify__types": "^0.36.0",
|
||||||
"@jsr/scure__base": "^1.2.4",
|
"@jsr/scure__base": "^1.2.4",
|
||||||
"@jsr/std__crypto": "^0.224.0",
|
|
||||||
"@jsr/std__encoding": "^0.224.1",
|
"@jsr/std__encoding": "^0.224.1",
|
||||||
"lru-cache": "^10.2.0",
|
"lru-cache": "^10.2.0",
|
||||||
"nostr-tools": "^2.10.4",
|
"nostr-tools": "^2.10.4",
|
||||||
@ -979,11 +946,11 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@nostrify/react": {
|
"node_modules/@nostrify/react": {
|
||||||
"name": "@jsr/nostrify__react",
|
"name": "@jsr/nostrify__react",
|
||||||
"version": "0.2.3",
|
"version": "0.2.5",
|
||||||
"resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__react/0.2.3.tgz",
|
"resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__react/0.2.5.tgz",
|
||||||
"integrity": "sha512-XIa9aBhaSOe6AQWvKHYG3rq3+hTheWLd8kQM9SeyUQRpHM5RMlrmdrBKAXHCwt+/vZ9eOxM6nUq38C0hp4X6Bw==",
|
"integrity": "sha512-Hyi1N4hXa89gYsuk7fqGrWEzIzTp7md8hKog+D2DgpSqVwFQM1vw267Uv3rOieE/hPereD3gen9X88XkeAXP+Q==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsr/nostrify__nostrify": "^0.45.2",
|
"@jsr/nostrify__nostrify": "^0.46.1",
|
||||||
"nostr-tools": "^2.10.4",
|
"nostr-tools": "^2.10.4",
|
||||||
"react": "^18.0.0"
|
"react": "^18.0.0"
|
||||||
}
|
}
|
||||||
|
@ -14,8 +14,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.45.2",
|
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.1",
|
||||||
"@nostrify/react": "npm:@jsr/nostrify__react@^0.2.3",
|
"@nostrify/react": "npm:@jsr/nostrify__react@^0.2.5",
|
||||||
"@radix-ui/react-accordion": "^1.2.0",
|
"@radix-ui/react-accordion": "^1.2.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
"@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 { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
@ -17,15 +17,17 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Switch } from '@/components/ui/switch';
|
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 { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||||
|
|
||||||
export const EditProfileForm: React.FC = () => {
|
export const EditProfileForm: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { user, metadata } = useCurrentUser();
|
const { user, metadata } = useCurrentUser();
|
||||||
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
|
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
|
||||||
|
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Initialize the form with default values
|
// Initialize the form with default values
|
||||||
@ -57,6 +59,26 @@ export const EditProfileForm: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [metadata, form]);
|
}, [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) => {
|
const onSubmit = async (values: NostrMetadata) => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
toast({
|
toast({
|
||||||
@ -148,16 +170,14 @@ export const EditProfileForm: React.FC = () => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="picture"
|
name="picture"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<ImageUploadField
|
||||||
<FormLabel>Profile Picture URL</FormLabel>
|
field={field}
|
||||||
<FormControl>
|
label="Profile Picture"
|
||||||
<Input placeholder="https://example.com/profile.jpg" {...field} />
|
placeholder="https://example.com/profile.jpg"
|
||||||
</FormControl>
|
description="URL to your profile picture. You can upload an image or provide a URL."
|
||||||
<FormDescription>
|
previewType="square"
|
||||||
URL to your profile picture.
|
onUpload={(file) => uploadPicture(file, 'picture')}
|
||||||
</FormDescription>
|
/>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -165,16 +185,14 @@ export const EditProfileForm: React.FC = () => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="banner"
|
name="banner"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<ImageUploadField
|
||||||
<FormLabel>Banner Image URL</FormLabel>
|
field={field}
|
||||||
<FormControl>
|
label="Banner Image"
|
||||||
<Input placeholder="https://example.com/banner.jpg" {...field} />
|
placeholder="https://example.com/banner.jpg"
|
||||||
</FormControl>
|
description="URL to a wide banner image for your profile. You can upload an image or provide a URL."
|
||||||
<FormDescription>
|
previewType="wide"
|
||||||
URL to a wide banner image for your profile.
|
onUpload={(file) => uploadPicture(file, 'banner')}
|
||||||
</FormDescription>
|
/>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -239,9 +257,9 @@ export const EditProfileForm: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full md:w-auto"
|
className="w-full md:w-auto"
|
||||||
disabled={isPending}
|
disabled={isPending || isUploading}
|
||||||
>
|
>
|
||||||
{isPending && (
|
{(isPending || isUploading) && (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
Save Profile
|
Save Profile
|
||||||
@ -250,3 +268,82 @@ export const EditProfileForm: React.FC = () => {
|
|||||||
</Form>
|
</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