Skip to content

Commit bc7e5ff

Browse files
authored
feat(admin): add image upload dialog component (#286)
1 parent c3004b6 commit bc7e5ff

File tree

4 files changed

+402
-1
lines changed

4 files changed

+402
-1
lines changed
Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
'use client';
2+
3+
import { useContentImageUpload } from '@lib/hooks/use-content-image-upload';
4+
import { ALLOWED_IMAGE_TYPES } from '@lib/services/content-image-upload-service';
5+
import { cn, formatBytes } from '@lib/utils';
6+
import { CheckCircle2, Image as ImageIcon, Upload, X } from 'lucide-react';
7+
8+
import React, { useCallback, useRef, useState } from 'react';
9+
10+
import { useTranslations } from 'next-intl';
11+
12+
interface ImageUploadDialogProps {
13+
isOpen: boolean;
14+
onClose: () => void;
15+
onUploadSuccess: (url: string, path: string) => void;
16+
userId: string;
17+
}
18+
19+
/**
20+
* Image upload dialog for content editor
21+
*
22+
* Features:
23+
* - File picker with drag-and-drop support
24+
* - Upload progress indicator
25+
* - Image preview
26+
* - Validation and error handling
27+
*/
28+
export function ImageUploadDialog({
29+
isOpen,
30+
onClose,
31+
onUploadSuccess,
32+
userId,
33+
}: ImageUploadDialogProps) {
34+
const t = useTranslations('pages.admin.content.imageUpload');
35+
const { state, uploadImage, validateFile, resetState } =
36+
useContentImageUpload();
37+
38+
const fileInputRef = useRef<HTMLInputElement>(null);
39+
const [selectedFile, setSelectedFile] = useState<File | null>(null);
40+
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
41+
const [isDragging, setIsDragging] = useState(false);
42+
const [validationError, setValidationError] = useState<string | null>(null);
43+
44+
/**
45+
* Handle file selection
46+
*/
47+
const handleFileSelect = useCallback(
48+
(file: File) => {
49+
// Clear previous validation error
50+
setValidationError(null);
51+
52+
// Validate file
53+
const validation = validateFile(file);
54+
if (!validation.valid) {
55+
setValidationError(validation.error || 'Invalid file');
56+
return;
57+
}
58+
59+
setSelectedFile(file);
60+
61+
// Generate preview URL
62+
const reader = new FileReader();
63+
reader.onloadend = () => {
64+
setPreviewUrl(reader.result as string);
65+
};
66+
reader.readAsDataURL(file);
67+
},
68+
[validateFile]
69+
);
70+
71+
/**
72+
* Handle file input change
73+
*/
74+
const handleFileInputChange = useCallback(
75+
(e: React.ChangeEvent<HTMLInputElement>) => {
76+
const file = e.target.files?.[0];
77+
if (file) {
78+
handleFileSelect(file);
79+
}
80+
},
81+
[handleFileSelect]
82+
);
83+
84+
/**
85+
* Handle drag and drop events
86+
*/
87+
const handleDragOver = useCallback((e: React.DragEvent) => {
88+
e.preventDefault();
89+
setIsDragging(true);
90+
}, []);
91+
92+
const handleDragLeave = useCallback((e: React.DragEvent) => {
93+
e.preventDefault();
94+
setIsDragging(false);
95+
}, []);
96+
97+
const handleDrop = useCallback(
98+
(e: React.DragEvent) => {
99+
e.preventDefault();
100+
setIsDragging(false);
101+
102+
const file = e.dataTransfer.files?.[0];
103+
if (file) {
104+
handleFileSelect(file);
105+
}
106+
},
107+
[handleFileSelect]
108+
);
109+
110+
/**
111+
* Handle dialog close
112+
*/
113+
const handleClose = useCallback(() => {
114+
setSelectedFile(null);
115+
setPreviewUrl(null);
116+
resetState();
117+
onClose();
118+
}, [onClose, resetState]);
119+
120+
/**
121+
* Handle upload button click
122+
*/
123+
const handleUpload = useCallback(async () => {
124+
if (!selectedFile) return;
125+
126+
try {
127+
const result = await uploadImage(selectedFile, userId);
128+
onUploadSuccess(result.url, result.path);
129+
handleClose();
130+
} catch (error) {
131+
// Error is already in state
132+
console.error('Upload failed:', error);
133+
}
134+
}, [selectedFile, uploadImage, userId, onUploadSuccess, handleClose]);
135+
136+
/**
137+
* Handle click on file picker area
138+
*/
139+
const handlePickerClick = useCallback(() => {
140+
fileInputRef.current?.click();
141+
}, []);
142+
143+
/**
144+
* Handle keyboard interaction on file picker area
145+
*/
146+
const handlePickerKeyDown = useCallback(
147+
(e: React.KeyboardEvent<HTMLDivElement>) => {
148+
if (e.key === 'Enter' || e.key === ' ') {
149+
e.preventDefault();
150+
handlePickerClick();
151+
}
152+
},
153+
[handlePickerClick]
154+
);
155+
156+
/**
157+
* Handle remove selected file
158+
*/
159+
const handleRemoveFile = useCallback(() => {
160+
setSelectedFile(null);
161+
setPreviewUrl(null);
162+
}, []);
163+
164+
if (!isOpen) return null;
165+
166+
return (
167+
<div
168+
className={cn(
169+
'fixed inset-0 z-[100] flex items-center justify-center p-4',
170+
'bg-black/60 backdrop-blur-sm',
171+
'animate-in fade-in duration-200'
172+
)}
173+
onClick={e => {
174+
if (e.target === e.currentTarget && !state.isUploading) {
175+
handleClose();
176+
}
177+
}}
178+
>
179+
<div
180+
role="dialog"
181+
aria-modal="true"
182+
aria-labelledby="image-upload-dialog-title"
183+
className={cn(
184+
'mx-auto w-full max-w-lg rounded-xl bg-white shadow-2xl',
185+
'border border-stone-200 dark:border-stone-700 dark:bg-stone-900',
186+
'animate-in zoom-in-95 duration-200'
187+
)}
188+
>
189+
{/* Header */}
190+
<div className="flex items-center justify-between border-b border-stone-200 px-6 py-4 dark:border-stone-700">
191+
<div className="flex items-center gap-3">
192+
<div
193+
className={cn(
194+
'flex h-10 w-10 items-center justify-center rounded-lg',
195+
'bg-stone-100 text-stone-600 dark:bg-stone-800 dark:text-stone-400'
196+
)}
197+
>
198+
<Upload className="h-5 w-5" />
199+
</div>
200+
<h2
201+
id="image-upload-dialog-title"
202+
className="font-serif text-lg font-semibold text-stone-900 dark:text-stone-100"
203+
>
204+
{t('title')}
205+
</h2>
206+
</div>
207+
<button
208+
type="button"
209+
onClick={handleClose}
210+
disabled={state.isUploading}
211+
className={cn(
212+
'rounded-md p-1.5 transition-colors',
213+
'text-stone-400 hover:bg-stone-100 hover:text-stone-600',
214+
'dark:text-stone-500 dark:hover:bg-stone-800 dark:hover:text-stone-300',
215+
'disabled:cursor-not-allowed disabled:opacity-50'
216+
)}
217+
>
218+
<X className="h-4 w-4" />
219+
</button>
220+
</div>
221+
222+
{/* Content */}
223+
<div className="p-6">
224+
{/* File picker / Drop zone */}
225+
{!selectedFile && (
226+
<>
227+
<div
228+
onClick={handlePickerClick}
229+
onKeyDown={handlePickerKeyDown}
230+
onDragOver={handleDragOver}
231+
onDragLeave={handleDragLeave}
232+
onDrop={handleDrop}
233+
role="button"
234+
tabIndex={0}
235+
aria-label={t('dropzone.title')}
236+
className={cn(
237+
'cursor-pointer rounded-lg border-2 border-dashed p-12 text-center transition-all',
238+
isDragging
239+
? 'border-stone-400 bg-stone-50 dark:border-stone-500 dark:bg-stone-800'
240+
: 'border-stone-300 bg-stone-50/50 hover:border-stone-400 hover:bg-stone-100 dark:border-stone-600 dark:bg-stone-800/50 dark:hover:border-stone-500 dark:hover:bg-stone-800'
241+
)}
242+
>
243+
<ImageIcon className="mx-auto mb-4 h-12 w-12 text-stone-400 dark:text-stone-500" />
244+
<p className="mb-2 font-serif text-sm font-medium text-stone-700 dark:text-stone-300">
245+
{t('dropzone.title')}
246+
</p>
247+
<p className="mb-4 font-serif text-xs text-stone-500 dark:text-stone-400">
248+
{t('dropzone.subtitle')}
249+
</p>
250+
<p className="font-serif text-xs text-stone-400 dark:text-stone-500">
251+
{t('dropzone.formats')}
252+
</p>
253+
<input
254+
ref={fileInputRef}
255+
type="file"
256+
accept={ALLOWED_IMAGE_TYPES.join(',') as string}
257+
onChange={handleFileInputChange}
258+
className="hidden"
259+
/>
260+
</div>
261+
262+
{/* Validation error message */}
263+
{validationError && (
264+
<div className="mt-4 rounded-lg bg-red-50 px-4 py-3 dark:bg-red-900/20">
265+
<p className="font-serif text-sm text-red-700 dark:text-red-300">
266+
{validationError}
267+
</p>
268+
</div>
269+
)}
270+
</>
271+
)}
272+
273+
{/* Preview */}
274+
{selectedFile && previewUrl && (
275+
<div className="space-y-4">
276+
<div className="relative overflow-hidden rounded-lg border border-stone-200 bg-stone-50 dark:border-stone-700 dark:bg-stone-800">
277+
<img
278+
src={previewUrl}
279+
alt={`Preview of ${selectedFile.name}`}
280+
className="h-64 w-full object-contain"
281+
/>
282+
</div>
283+
284+
<div className="flex items-center justify-between rounded-lg bg-stone-100 px-4 py-3 dark:bg-stone-800">
285+
<div className="flex items-center gap-3">
286+
<ImageIcon className="h-5 w-5 text-stone-500 dark:text-stone-400" />
287+
<div>
288+
<p className="font-serif text-sm font-medium text-stone-900 dark:text-stone-100">
289+
{selectedFile.name}
290+
</p>
291+
<p className="font-serif text-xs text-stone-500 dark:text-stone-400">
292+
{formatBytes(selectedFile.size)}
293+
</p>
294+
</div>
295+
</div>
296+
{!state.isUploading && (
297+
<button
298+
onClick={handleRemoveFile}
299+
className="text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-300"
300+
>
301+
<X className="h-4 w-4" />
302+
</button>
303+
)}
304+
</div>
305+
306+
{/* Upload progress */}
307+
{state.isUploading && (
308+
<div className="space-y-2">
309+
<div className="h-2 overflow-hidden rounded-full bg-stone-200 dark:bg-stone-700">
310+
<div
311+
className="h-full bg-stone-600 transition-all duration-300 dark:bg-stone-400"
312+
style={{ width: `${state.progress}%` }}
313+
/>
314+
</div>
315+
<p className="text-center font-serif text-sm text-stone-600 dark:text-stone-400">
316+
{t('uploading')} {state.progress}%
317+
</p>
318+
</div>
319+
)}
320+
321+
{/* Success message */}
322+
{state.status === 'success' && (
323+
<div className="flex items-center gap-2 rounded-lg bg-green-50 px-4 py-3 dark:bg-green-900/20">
324+
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
325+
<p className="font-serif text-sm text-green-700 dark:text-green-300">
326+
{t('success')}
327+
</p>
328+
</div>
329+
)}
330+
331+
{/* Error message */}
332+
{state.status === 'error' && state.error && (
333+
<div className="rounded-lg bg-red-50 px-4 py-3 dark:bg-red-900/20">
334+
<p className="font-serif text-sm text-red-700 dark:text-red-300">
335+
{state.error}
336+
</p>
337+
</div>
338+
)}
339+
</div>
340+
)}
341+
</div>
342+
343+
{/* Footer */}
344+
<div className="flex justify-end gap-3 border-t border-stone-200 px-6 py-4 dark:border-stone-700">
345+
<button
346+
type="button"
347+
onClick={handleClose}
348+
disabled={state.isUploading}
349+
className={cn(
350+
'rounded-lg border px-4 py-2 font-serif text-sm transition-colors',
351+
'border-stone-300 text-stone-700 hover:bg-stone-100',
352+
'dark:border-stone-600 dark:text-stone-300 dark:hover:bg-stone-800',
353+
'disabled:cursor-not-allowed disabled:opacity-50'
354+
)}
355+
>
356+
{t('cancel')}
357+
</button>
358+
<button
359+
type="button"
360+
onClick={handleUpload}
361+
disabled={!selectedFile || state.isUploading}
362+
className={cn(
363+
'rounded-lg px-4 py-2 font-serif text-sm font-medium transition-colors',
364+
'bg-stone-700 text-white hover:bg-stone-800',
365+
'dark:bg-stone-600 dark:hover:bg-stone-700',
366+
'disabled:cursor-not-allowed disabled:opacity-50'
367+
)}
368+
>
369+
{state.isUploading ? t('uploading') : t('upload')}
370+
</button>
371+
</div>
372+
</div>
373+
</div>
374+
);
375+
}

lib/services/content-image-upload-service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export interface ValidationResult {
2020
/**
2121
* Allowed image MIME types for content images
2222
*/
23-
const ALLOWED_IMAGE_TYPES = [
23+
export const ALLOWED_IMAGE_TYPES = [
2424
'image/jpeg',
2525
'image/jpg',
2626
'image/png',

0 commit comments

Comments
 (0)