Skip to content

Commit c3004b6

Browse files
pixelsamaclaudelyzno1
authored
feat(admin): add content image upload service and hook (#283)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: lyzno1 <92089059+lyzno1@users.noreply.github.com>
1 parent 673848e commit c3004b6

File tree

2 files changed

+367
-0
lines changed

2 files changed

+367
-0
lines changed
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
'use client';
2+
3+
import {
4+
type ContentImageUploadResult,
5+
type ValidationResult,
6+
deleteContentImage,
7+
uploadContentImage,
8+
validateImageFile,
9+
} from '@lib/services/content-image-upload-service';
10+
11+
import { useCallback, useState } from 'react';
12+
13+
/**
14+
* State type for content image upload
15+
*/
16+
export interface ContentImageUploadState {
17+
isUploading: boolean;
18+
isDeleting: boolean;
19+
progress: number;
20+
error: string | null;
21+
status: 'idle' | 'uploading' | 'success' | 'error' | 'deleting';
22+
}
23+
24+
/**
25+
* Hook for content image upload, delete, and validation
26+
*
27+
* Provides state management and operations for uploading images
28+
* to the content-images Supabase Storage bucket
29+
*
30+
* @returns Object with state and operation functions
31+
*/
32+
export function useContentImageUpload() {
33+
const [state, setState] = useState<ContentImageUploadState>({
34+
isUploading: false,
35+
isDeleting: false,
36+
progress: 0,
37+
error: null,
38+
status: 'idle',
39+
});
40+
41+
/**
42+
* Validate image file before upload
43+
*
44+
* @param file - File to validate
45+
* @returns Validation result
46+
*/
47+
const validateFile = useCallback((file: File): ValidationResult => {
48+
return validateImageFile(file);
49+
}, []);
50+
51+
/**
52+
* Upload content image to Supabase Storage
53+
*
54+
* @param file - Image file to upload
55+
* @param userId - User ID for file path generation
56+
* @returns Upload result with public URL and file path
57+
* @throws Error if upload fails
58+
*/
59+
const uploadImage = useCallback(
60+
async (file: File, userId: string): Promise<ContentImageUploadResult> => {
61+
setState(prev => ({
62+
...prev,
63+
isUploading: true,
64+
status: 'uploading',
65+
progress: 0,
66+
error: null,
67+
}));
68+
69+
try {
70+
// Upload image using service layer (validation happens there)
71+
const result = await uploadContentImage(file, userId);
72+
73+
setState(prev => ({
74+
...prev,
75+
isUploading: false,
76+
status: 'success',
77+
progress: 100,
78+
}));
79+
80+
return result;
81+
} catch (error) {
82+
const errorMessage =
83+
error instanceof Error ? error.message : 'Upload failed';
84+
85+
setState(prev => ({
86+
...prev,
87+
isUploading: false,
88+
status: 'error',
89+
error: errorMessage,
90+
}));
91+
92+
throw error;
93+
}
94+
},
95+
[]
96+
);
97+
98+
/**
99+
* Delete content image from Supabase Storage
100+
*
101+
* @param filePath - File path in storage (e.g., user-{userId}/filename.jpg)
102+
* @throws Error if deletion fails
103+
*/
104+
const deleteImage = useCallback(async (filePath: string): Promise<void> => {
105+
setState(prev => ({
106+
...prev,
107+
isDeleting: true,
108+
status: 'deleting',
109+
error: null,
110+
}));
111+
112+
try {
113+
await deleteContentImage(filePath);
114+
115+
setState(prev => ({
116+
...prev,
117+
isDeleting: false,
118+
status: 'success',
119+
}));
120+
} catch (error) {
121+
const errorMessage =
122+
error instanceof Error ? error.message : 'Delete failed';
123+
124+
setState(prev => ({
125+
...prev,
126+
isDeleting: false,
127+
status: 'error',
128+
error: errorMessage,
129+
}));
130+
131+
throw error;
132+
}
133+
}, []);
134+
135+
/**
136+
* Reset upload/delete state to idle
137+
*/
138+
const resetState = useCallback(() => {
139+
setState({
140+
isUploading: false,
141+
isDeleting: false,
142+
progress: 0,
143+
error: null,
144+
status: 'idle',
145+
});
146+
}, []);
147+
148+
return {
149+
state,
150+
uploadImage,
151+
deleteImage,
152+
validateFile,
153+
resetState,
154+
};
155+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { createClient } from '@lib/supabase/client';
2+
import { v4 as uuidv4 } from 'uuid';
3+
4+
/**
5+
* Result type for content image upload
6+
*/
7+
export interface ContentImageUploadResult {
8+
url: string;
9+
path: string;
10+
}
11+
12+
/**
13+
* Validation result type
14+
*/
15+
export interface ValidationResult {
16+
valid: boolean;
17+
error?: string;
18+
}
19+
20+
/**
21+
* Allowed image MIME types for content images
22+
*/
23+
const ALLOWED_IMAGE_TYPES = [
24+
'image/jpeg',
25+
'image/jpg',
26+
'image/png',
27+
'image/webp',
28+
'image/gif',
29+
] as const;
30+
31+
/**
32+
* MIME type to file extension mapping
33+
*/
34+
const MIME_TO_EXTENSION: Record<string, string> = {
35+
'image/jpeg': 'jpg',
36+
'image/jpg': 'jpg',
37+
'image/png': 'png',
38+
'image/webp': 'webp',
39+
'image/gif': 'gif',
40+
};
41+
42+
/**
43+
* Maximum file size in bytes (10MB)
44+
*/
45+
const MAX_FILE_SIZE = 10 * 1024 * 1024;
46+
47+
/**
48+
* Storage bucket name for content images
49+
*/
50+
const BUCKET_NAME = 'content-images';
51+
52+
/**
53+
* Validate image file type and size
54+
*
55+
* @param file - File to validate
56+
* @returns Validation result with error message if invalid
57+
*/
58+
export function validateImageFile(file: File): ValidationResult {
59+
// Check file type
60+
if (
61+
!ALLOWED_IMAGE_TYPES.includes(
62+
file.type as (typeof ALLOWED_IMAGE_TYPES)[number]
63+
)
64+
) {
65+
return {
66+
valid: false,
67+
error: `Unsupported file type. Supported formats: ${ALLOWED_IMAGE_TYPES.join(', ')}`,
68+
};
69+
}
70+
71+
// Check file size
72+
if (file.size > MAX_FILE_SIZE) {
73+
return {
74+
valid: false,
75+
error: `File too large. Maximum size: ${Math.round(MAX_FILE_SIZE / 1024 / 1024)}MB`,
76+
};
77+
}
78+
79+
return { valid: true };
80+
}
81+
82+
/**
83+
* Generate a unique file path for content image
84+
*
85+
* Format: user-{userId}/{timestamp}-{uuid}.{ext}
86+
*
87+
* @param userId - User ID
88+
* @param fileName - Original file name (used for fallback only)
89+
* @param fileType - MIME type of the file
90+
* @returns Generated file path
91+
*/
92+
export function generateContentImagePath(
93+
userId: string,
94+
fileName: string,
95+
fileType: string
96+
): string {
97+
const uuid = uuidv4();
98+
const timestamp = Date.now();
99+
const extension =
100+
MIME_TO_EXTENSION[fileType] || fileName.split('.').pop() || 'jpg';
101+
const safeFileName = `${timestamp}-${uuid}.${extension}`;
102+
return `user-${userId}/${safeFileName}`;
103+
}
104+
105+
/**
106+
* Extract file path from Supabase Storage URL
107+
*
108+
* @param url - Full Supabase Storage URL
109+
* @returns File path or null if invalid
110+
*/
111+
export function extractFilePathFromUrl(url: string): string | null {
112+
try {
113+
const urlObj = new URL(url);
114+
const pathParts = urlObj.pathname.split('/');
115+
const bucketIndex = pathParts.indexOf(BUCKET_NAME);
116+
if (bucketIndex !== -1 && bucketIndex < pathParts.length - 1) {
117+
return pathParts.slice(bucketIndex + 1).join('/');
118+
}
119+
return null;
120+
} catch {
121+
return null;
122+
}
123+
}
124+
125+
/**
126+
* Upload content image to Supabase Storage
127+
*
128+
* @param file - Image file to upload
129+
* @param userId - User ID for file path generation
130+
* @returns Upload result with public URL and file path
131+
* @throws Error if upload fails or validation fails
132+
*/
133+
export async function uploadContentImage(
134+
file: File,
135+
userId: string
136+
): Promise<ContentImageUploadResult> {
137+
// Validate file
138+
const validation = validateImageFile(file);
139+
if (!validation.valid) {
140+
throw new Error(validation.error);
141+
}
142+
143+
const supabase = createClient();
144+
145+
// Generate file path
146+
const filePath = generateContentImagePath(userId, file.name, file.type);
147+
148+
// Upload to Supabase Storage
149+
const { error: uploadError } = await supabase.storage
150+
.from(BUCKET_NAME)
151+
.upload(filePath, file, {
152+
cacheControl: '3600',
153+
upsert: false,
154+
});
155+
156+
if (uploadError) {
157+
throw new Error(`Upload failed: ${uploadError.message}`);
158+
}
159+
160+
// Get public URL
161+
const { data: urlData } = supabase.storage
162+
.from(BUCKET_NAME)
163+
.getPublicUrl(filePath);
164+
165+
if (!urlData?.publicUrl) {
166+
throw new Error('Failed to get public URL');
167+
}
168+
169+
return {
170+
url: urlData.publicUrl,
171+
path: filePath,
172+
};
173+
}
174+
175+
/**
176+
* Delete content image from Supabase Storage
177+
*
178+
* @param filePath - File path in storage (e.g., user-{userId}/filename.jpg)
179+
* @throws Error if deletion fails
180+
*/
181+
export async function deleteContentImage(filePath: string): Promise<void> {
182+
const supabase = createClient();
183+
184+
const { error } = await supabase.storage.from(BUCKET_NAME).remove([filePath]);
185+
186+
if (error) {
187+
throw new Error(`Delete failed: ${error.message}`);
188+
}
189+
}
190+
191+
/**
192+
* List all content images for a specific user
193+
*
194+
* @param userId - User ID
195+
* @returns Array of file paths
196+
* @throws Error if listing fails
197+
*/
198+
export async function listUserContentImages(userId: string): Promise<string[]> {
199+
const supabase = createClient();
200+
201+
const { data, error } = await supabase.storage
202+
.from(BUCKET_NAME)
203+
.list(`user-${userId}`, {
204+
sortBy: { column: 'created_at', order: 'desc' },
205+
});
206+
207+
if (error) {
208+
throw new Error(`Failed to list images: ${error.message}`);
209+
}
210+
211+
return data.map(file => `user-${userId}/${file.name}`);
212+
}

0 commit comments

Comments
 (0)