Skip to content

Commit

Permalink
Feedback support image uploader
Browse files Browse the repository at this point in the history
  • Loading branch information
ahaapple committed Dec 22, 2024
1 parent 086f1ca commit f9180d0
Show file tree
Hide file tree
Showing 6 changed files with 154 additions and 64 deletions.
86 changes: 86 additions & 0 deletions frontend/components/image/image-uploader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
'use client';

import { useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
import { uploadSingleFile } from '@/lib/uploader';

interface ImageUploaderProps {
value: string;
onChange: (url: string) => void;
showGeneratedImage?: boolean;
}

export function ImageUploader({ value, onChange }: ImageUploaderProps) {
const [isUploading, setIsUploading] = useState(false);
const [image, setImage] = useState<string | null>(value);

const onDrop = async (acceptedFiles: File[]) => {
const file = acceptedFiles[0];
if (!file) return;

try {
setIsUploading(true);
const response = await uploadSingleFile(file);
onChange(response.url);
setImage(response.url);
toast.success('Image uploaded successfully');
} catch (error) {
console.error('Upload error:', error);
toast.error('Failed to upload image');
} finally {
setIsUploading(false);
}
};

const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'image/*': ['.png', '.jpg', '.jpeg', '.gif'],
},
maxFiles: 1,
});
return (
<div className="space-y-4">
<div
{...getRootProps()}
className={`
relative group cursor-pointer
border-2 border-dashed rounded-lg overflow-hidden
transition-colors duration-200
${isDragActive ? 'border-primary bg-primary/5' : 'border-gray-200'}
${isUploading ? 'pointer-events-none' : ''}
`}
>
<input {...getInputProps()} />

{isUploading && (
<div className="absolute inset-0 bg-background/50 backdrop-blur-sm flex items-center justify-center z-10">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
)}

{image ? (
<div className="relative group">
<img src={image} alt="Uploaded content" className="w-full h-48 object-cover" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<p className="text-white text-sm">Click or drag to replace</p>
</div>
</div>
) : (
<div className="w-full h-48 flex flex-col items-center justify-center gap-2 text-muted-foreground">
{isDragActive ? (
<p className="text-primary">Drop the image here...</p>
) : (
<>
<p>Click or drag image to upload</p>
<p className="text-xs">PNG, JPG, GIF up to 10MB</p>
</>
)}
</div>
)}
</div>
</div>
);
}
3 changes: 2 additions & 1 deletion frontend/components/index/file-uploader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ import { useUserStore } from '@/lib/store/local-store';
import { useUpgradeModal } from '@/hooks/use-upgrade-modal';
import { useIndexModal } from '@/hooks/use-index-modal';
import { useTranslations } from 'next-intl';
import { UploadedFile } from '@/lib/uploader';

interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
value?: File[];
onValueChange?: (files: File[]) => void;
onUpload?: (files: File[]) => Promise<void>;
onUpload?: (files: File[]) => Promise<UploadedFile[]>;
accept?: DropzoneProps['accept'];
disabled?: boolean;
}
Expand Down
5 changes: 3 additions & 2 deletions frontend/components/layout/feedback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { SendHorizonal } from 'lucide-react';
import { toast } from 'sonner';
import { Icons } from '@/components/shared/icons';
import { ImageUploader } from '@/components/image/image-uploader';

export default function FeedbackForm() {
const [name, setName] = useState('');
Expand Down Expand Up @@ -108,12 +109,12 @@ export default function FeedbackForm() {
</div>
</div>

{/* <div>
<div>
<label htmlFor="content" className="block text-sm font-medium mb-2">
Screenshot
</label>
<ImageUploader value={''} onChange={(e) => setFile(e)} showGeneratedImage={false} />
</div> */}
</div>

<Button type="submit" className="w-full md:w-auto rounded-full">
{loading ? (
Expand Down
6 changes: 3 additions & 3 deletions frontend/components/search/model-selection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ export const modelMap: Record<string, Model> = {
};

const ModelItem: React.FC<{ model: Model }> = ({ model }) => (
<RowSelectItem key={model.value} value={model.value} className="w-full p-2">
<div className="flex justify-between">
<span className="text-md">{model.name}</span>
<RowSelectItem key={model.value} value={model.value} className="w-full p-2 block">
<div className="flex w-full justify-between">
<span className="text-md mr-2">{model.name}</span>
<span
className={`text-xs flex items-center justify-center ${model.flag === 'Pro' || model.flag === 'Premium' ? ' text-primary bg-purple-300 rounded-xl px-2' : ''}`}
>
Expand Down
60 changes: 2 additions & 58 deletions frontend/hooks/use-upload-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,64 +3,7 @@ import { toast } from 'sonner';
import { getAuthToken } from '@/actions/token';
import { NEXT_PUBLIC_VECTOR_HOST } from '@/lib/client_env';
import { useImageCompression } from '@/hooks/use-image-compression';

export interface UploadedFile {
name: string;
url: string;
type: string;
}

interface PresignedResponse {
url: string;
file: string;
}

class UploadError extends Error {
constructor(
message: string,
public fileName: string,
) {
super(message);
this.name = 'UploadError';
}
}

async function getPresignedUrl(filename: string): Promise<PresignedResponse> {
const response = await fetch(`/api/pre-signed`, {
method: 'POST',
body: JSON.stringify({ filename }),
});

if (!response.ok) {
throw new UploadError(`Failed to get presigned URL`, filename);
}

return response.json();
}

async function uploadFileToR2(presignedUrl: string, file: File): Promise<void> {
const response = await fetch(presignedUrl, {
method: 'PUT',
headers: {
'Content-Type': file.type,
},
body: file,
});

if (!response.ok) {
throw new UploadError('Upload failed', file.name);
}
}

async function uploadSingleFile(file: File): Promise<UploadedFile> {
const presignedData = await getPresignedUrl(file.name);
await uploadFileToR2(presignedData.url, file);
return {
name: file.name,
url: `https://image.memfree.me/${presignedData.file}`,
type: file.type,
};
}
import { UploadedFile, uploadSingleFile } from '@/lib/uploader';

export function useUploadFile() {
const [uploadedFiles, setUploadedFiles] = React.useState<UploadedFile[]>([]);
Expand Down Expand Up @@ -114,6 +57,7 @@ export function useUploadFile() {
if (successfulUploads.length > 0) {
setUploadedFiles((prev) => (prev ? [...prev, ...successfulUploads] : successfulUploads));
}
return successfulUploads;
} else {
const res = await indexLocalFile(files);
setUploadedFiles((prev) => (prev ? [...prev, ...res] : res));
Expand Down
58 changes: 58 additions & 0 deletions frontend/lib/uploader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@

interface PresignedResponse {
url: string;
file: string;
}

class UploadError extends Error {
constructor(
message: string,
public fileName: string,
) {
super(message);
this.name = 'UploadError';
}
}

export interface UploadedFile {
name: string;
url: string;
type: string;
}

export async function getPresignedUrl(filename: string): Promise<PresignedResponse> {
const response = await fetch(`/api/pre-signed`, {
method: 'POST',
body: JSON.stringify({ filename }),
});

if (!response.ok) {
throw new UploadError(`Failed to get presigned URL`, filename);
}

return response.json();
}

export async function uploadFileToR2(presignedUrl: string, file: File): Promise<void> {
const response = await fetch(presignedUrl, {
method: 'PUT',
headers: {
'Content-Type': file.type,
},
body: file,
});

if (!response.ok) {
throw new UploadError('Upload failed', file.name);
}
}

export async function uploadSingleFile(file: File): Promise<UploadedFile> {
const presignedData = await getPresignedUrl(file.name);
await uploadFileToR2(presignedData.url, file);
return {
name: file.name,
url: `https://image.memfree.me/${presignedData.file}`,
type: file.type,
};
}

0 comments on commit f9180d0

Please sign in to comment.