feat: Implement S3 presigned URL fetching for user avatars and add an image proxy utility.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { usersService, User, getErrorMessage } from '@/services';
|
||||
import { usersService, User, getErrorMessage, uploadService } from '@/services';
|
||||
|
||||
type RoleFilter = 'ALL' | 'ADMIN' | 'USER' | 'AGENT';
|
||||
|
||||
@@ -21,8 +21,40 @@ export default function UsersPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalUsers, setTotalUsers] = useState(0);
|
||||
const [activeFilter, setActiveFilter] = useState<RoleFilter>('ALL');
|
||||
const [avatarUrls, setAvatarUrls] = useState<Record<string, string>>({});
|
||||
const limit = 10;
|
||||
|
||||
// Helper function to check if avatar is an S3 key
|
||||
const isS3Key = (avatar: string | null | undefined): boolean => {
|
||||
if (!avatar) return false;
|
||||
return !avatar.startsWith('http') && !avatar.startsWith('/');
|
||||
};
|
||||
|
||||
// Fetch presigned URLs for user avatars
|
||||
const fetchAvatarUrls = async (userList: User[]) => {
|
||||
const urls: Record<string, string> = {};
|
||||
|
||||
await Promise.all(
|
||||
userList.map(async (user) => {
|
||||
const avatar = user.profile?.avatar;
|
||||
if (avatar) {
|
||||
if (isS3Key(avatar)) {
|
||||
try {
|
||||
const presignedUrl = await uploadService.getPresignedDownloadUrl(avatar);
|
||||
urls[user.id] = presignedUrl;
|
||||
} catch (err) {
|
||||
console.error(`Failed to get avatar URL for user ${user.id}:`, err);
|
||||
}
|
||||
} else {
|
||||
urls[user.id] = avatar;
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setAvatarUrls(urls);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [currentPage, activeFilter]);
|
||||
@@ -36,8 +68,12 @@ export default function UsersPage() {
|
||||
filters.role = activeFilter;
|
||||
}
|
||||
const response = await usersService.getUsers(filters);
|
||||
setUsers(response.users || []);
|
||||
const userList = response.users || [];
|
||||
setUsers(userList);
|
||||
setTotalUsers(response.total || 0);
|
||||
|
||||
// Fetch presigned URLs for avatars
|
||||
await fetchAvatarUrls(userList);
|
||||
} catch (err) {
|
||||
const errorMessage = getErrorMessage(err);
|
||||
setError(errorMessage);
|
||||
@@ -167,10 +203,10 @@ export default function UsersPage() {
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10">
|
||||
{user.profile?.avatar ? (
|
||||
{avatarUrls[user.id] ? (
|
||||
<img
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
src={user.profile.avatar}
|
||||
src={avatarUrls[user.id]}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
|
||||
35
src/lib/imageProxy.ts
Normal file
35
src/lib/imageProxy.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Convert avatar URL to proper backend URL
|
||||
* Handles both S3 URLs and relative backend paths
|
||||
*/
|
||||
export function getProxyImageUrl(url: string | null | undefined): string {
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// If it's a data URL, return as-is
|
||||
if (url.startsWith('data:')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
|
||||
const apiBaseUrl = apiUrl.replace('/api/v1', '');
|
||||
|
||||
// If it's a relative path from backend (like /development/avatars/...)
|
||||
// Prefix with backend base URL
|
||||
if (url.startsWith('/development/') || url.startsWith('/uploads/') || url.startsWith('/avatars/')) {
|
||||
return `${apiBaseUrl}${url}`;
|
||||
}
|
||||
|
||||
// If it's an S3/external URL, proxy through backend
|
||||
if (url.startsWith('http')) {
|
||||
return `${apiUrl}/upload/image?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
// For other relative paths, assume they're backend paths
|
||||
if (url.startsWith('/')) {
|
||||
return `${apiBaseUrl}${url}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
@@ -53,3 +53,6 @@ export type {
|
||||
CreateFieldDto,
|
||||
UpdateFieldDto,
|
||||
} from './profile-fields.service';
|
||||
|
||||
// Upload Service
|
||||
export { uploadService } from './upload.service';
|
||||
|
||||
27
src/services/upload.service.ts
Normal file
27
src/services/upload.service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import api from './api';
|
||||
|
||||
class UploadService {
|
||||
private basePath = '/upload';
|
||||
|
||||
/**
|
||||
* Get a presigned download URL for an S3 key
|
||||
*/
|
||||
async getPresignedDownloadUrl(key: string): Promise<string> {
|
||||
const response = await api.get<{ data: { url: string } }>(
|
||||
`${this.basePath}/presigned-download-url`,
|
||||
{ params: { key } }
|
||||
);
|
||||
return response.data.data.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is an S3 key (not a full URL or local path)
|
||||
*/
|
||||
isS3Key(url: string | null | undefined): boolean {
|
||||
if (!url) return false;
|
||||
// S3 keys don't start with http or /
|
||||
return !url.startsWith('http') && !url.startsWith('/');
|
||||
}
|
||||
}
|
||||
|
||||
export const uploadService = new UploadService();
|
||||
Reference in New Issue
Block a user