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 { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { usersService, User, getErrorMessage } from '@/services';
|
import { usersService, User, getErrorMessage, uploadService } from '@/services';
|
||||||
|
|
||||||
type RoleFilter = 'ALL' | 'ADMIN' | 'USER' | 'AGENT';
|
type RoleFilter = 'ALL' | 'ADMIN' | 'USER' | 'AGENT';
|
||||||
|
|
||||||
@@ -21,8 +21,40 @@ export default function UsersPage() {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [totalUsers, setTotalUsers] = useState(0);
|
const [totalUsers, setTotalUsers] = useState(0);
|
||||||
const [activeFilter, setActiveFilter] = useState<RoleFilter>('ALL');
|
const [activeFilter, setActiveFilter] = useState<RoleFilter>('ALL');
|
||||||
|
const [avatarUrls, setAvatarUrls] = useState<Record<string, string>>({});
|
||||||
const limit = 10;
|
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(() => {
|
useEffect(() => {
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
}, [currentPage, activeFilter]);
|
}, [currentPage, activeFilter]);
|
||||||
@@ -36,8 +68,12 @@ export default function UsersPage() {
|
|||||||
filters.role = activeFilter;
|
filters.role = activeFilter;
|
||||||
}
|
}
|
||||||
const response = await usersService.getUsers(filters);
|
const response = await usersService.getUsers(filters);
|
||||||
setUsers(response.users || []);
|
const userList = response.users || [];
|
||||||
|
setUsers(userList);
|
||||||
setTotalUsers(response.total || 0);
|
setTotalUsers(response.total || 0);
|
||||||
|
|
||||||
|
// Fetch presigned URLs for avatars
|
||||||
|
await fetchAvatarUrls(userList);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = getErrorMessage(err);
|
const errorMessage = getErrorMessage(err);
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
@@ -167,10 +203,10 @@ export default function UsersPage() {
|
|||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0 h-10 w-10">
|
<div className="flex-shrink-0 h-10 w-10">
|
||||||
{user.profile?.avatar ? (
|
{avatarUrls[user.id] ? (
|
||||||
<img
|
<img
|
||||||
className="h-10 w-10 rounded-full object-cover"
|
className="h-10 w-10 rounded-full object-cover"
|
||||||
src={user.profile.avatar}
|
src={avatarUrls[user.id]}
|
||||||
alt=""
|
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,
|
CreateFieldDto,
|
||||||
UpdateFieldDto,
|
UpdateFieldDto,
|
||||||
} from './profile-fields.service';
|
} 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