297 lines
8.5 KiB
TypeScript
297 lines
8.5 KiB
TypeScript
import sharp from 'sharp';
|
|
import { uploadFile, deleteFile, getPreviewUrl } from './minio';
|
|
import { getNocoDbConfiguration, Table, createTableUrl } from './nocodb';
|
|
import type { Member } from '~/utils/types';
|
|
import type { EntityResponse } from './nocodb';
|
|
|
|
interface ThumbnailSizes {
|
|
small: { width: 64, height: 64 };
|
|
medium: { width: 200, height: 200 };
|
|
}
|
|
|
|
const THUMBNAIL_SIZES: ThumbnailSizes = {
|
|
small: { width: 64, height: 64 }, // For header avatar
|
|
medium: { width: 200, height: 200 } // For profile cards and profile page
|
|
};
|
|
|
|
/**
|
|
* Upload profile image with automatic thumbnail generation
|
|
*/
|
|
export const uploadProfileImage = async (
|
|
memberId: string,
|
|
buffer: Buffer,
|
|
originalName: string
|
|
): Promise<string> => {
|
|
if (!memberId) {
|
|
throw new Error('Member ID is required for profile image upload');
|
|
}
|
|
|
|
try {
|
|
// Validate image format
|
|
const metadata = await sharp(buffer).metadata();
|
|
if (!metadata.format || !['jpeg', 'jpg', 'png', 'webp'].includes(metadata.format)) {
|
|
throw new Error('Invalid image format. Only JPEG, PNG, and WebP are supported.');
|
|
}
|
|
|
|
// Ensure minimum dimensions
|
|
if (!metadata.width || !metadata.height || metadata.width < 100 || metadata.height < 100) {
|
|
throw new Error('Image must be at least 100x100 pixels.');
|
|
}
|
|
|
|
const basePath = `profile-images/${memberId}`;
|
|
const timestamp = Date.now();
|
|
|
|
// Process original image (convert to JPEG for consistency)
|
|
const processedBuffer = await sharp(buffer)
|
|
.jpeg({ quality: 90, mozjpeg: true })
|
|
.toBuffer();
|
|
|
|
const originalPath = `${basePath}/profile.jpg`;
|
|
|
|
// Generate thumbnails
|
|
const thumbnails: Record<string, Buffer> = {};
|
|
for (const [sizeName, dimensions] of Object.entries(THUMBNAIL_SIZES)) {
|
|
thumbnails[sizeName] = await sharp(buffer)
|
|
.resize(dimensions.width, dimensions.height, {
|
|
fit: 'cover',
|
|
position: 'center'
|
|
})
|
|
.jpeg({ quality: 85 })
|
|
.toBuffer();
|
|
}
|
|
|
|
// Upload original image
|
|
await uploadFile(originalPath, processedBuffer, 'image/jpeg');
|
|
console.log(`✅ Uploaded profile image: ${originalPath}`);
|
|
|
|
// Upload thumbnails
|
|
for (const [sizeName, thumbnailBuffer] of Object.entries(thumbnails)) {
|
|
const thumbnailPath = `${basePath}/profile_${sizeName}.jpg`;
|
|
await uploadFile(thumbnailPath, thumbnailBuffer, 'image/jpeg');
|
|
console.log(`✅ Generated ${sizeName} thumbnail: ${thumbnailPath}`);
|
|
}
|
|
|
|
return originalPath;
|
|
} catch (error) {
|
|
console.error('Failed to upload profile image:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Delete profile image and all associated thumbnails
|
|
*/
|
|
export const deleteProfileImage = async (memberId: string): Promise<void> => {
|
|
if (!memberId) {
|
|
throw new Error('Member ID is required for profile image deletion');
|
|
}
|
|
|
|
try {
|
|
const basePath = `profile-images/${memberId}`;
|
|
|
|
// Delete original image
|
|
try {
|
|
await deleteFile(`${basePath}/profile.jpg`);
|
|
console.log(`🗑️ Deleted profile image: ${basePath}/profile.jpg`);
|
|
} catch (error) {
|
|
console.warn('Original profile image not found or already deleted');
|
|
}
|
|
|
|
// Delete thumbnails
|
|
for (const sizeName of Object.keys(THUMBNAIL_SIZES)) {
|
|
try {
|
|
await deleteFile(`${basePath}/profile_${sizeName}.jpg`);
|
|
console.log(`🗑️ Deleted ${sizeName} thumbnail: ${basePath}/profile_${sizeName}.jpg`);
|
|
} catch (error) {
|
|
console.warn(`${sizeName} thumbnail not found or already deleted`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to delete profile image:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get profile image URL for display
|
|
*/
|
|
export const getProfileImageUrl = async (
|
|
memberId: string,
|
|
size: 'original' | 'small' | 'medium' = 'original'
|
|
): Promise<string | null> => {
|
|
if (!memberId) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const basePath = `profile-images/${memberId}`;
|
|
let imagePath: string;
|
|
|
|
if (size === 'original') {
|
|
imagePath = `${basePath}/profile.jpg`;
|
|
} else {
|
|
imagePath = `${basePath}/profile_${size}.jpg`;
|
|
}
|
|
|
|
// Generate presigned URL
|
|
const url = await getPreviewUrl(imagePath, 'image/jpeg');
|
|
return url;
|
|
} catch (error) {
|
|
console.warn(`Profile image not found for member ${memberId}, size ${size}`);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Update member profile image URL in NocoDB
|
|
*/
|
|
export const updateMemberProfileImageUrl = async (
|
|
memberId: string,
|
|
imagePath: string,
|
|
originalName: string
|
|
): Promise<void> => {
|
|
try {
|
|
// Find member by member_id using existing pattern
|
|
const response = await $fetch<EntityResponse<Member>>(createTableUrl(Table.Members), {
|
|
headers: {
|
|
"xc-token": getNocoDbConfiguration().token,
|
|
},
|
|
params: {
|
|
where: `(member_id,eq,${memberId})`,
|
|
limit: 1,
|
|
},
|
|
});
|
|
|
|
if (!response.list || response.list.length === 0) {
|
|
throw new Error(`Member not found with ID: ${memberId}`);
|
|
}
|
|
|
|
const member = response.list[0];
|
|
|
|
// Update profile image fields using PATCH method
|
|
const updateData = {
|
|
Id: parseInt(member.Id),
|
|
profile_image_url: imagePath,
|
|
profile_image_updated_at: new Date().toISOString(),
|
|
profile_image_original_name: originalName
|
|
};
|
|
|
|
await $fetch(createTableUrl(Table.Members), {
|
|
method: "PATCH",
|
|
headers: {
|
|
"xc-token": getNocoDbConfiguration().token,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: updateData
|
|
});
|
|
|
|
console.log(`✅ Updated profile image URL for member ${memberId}`);
|
|
} catch (error) {
|
|
console.error('Failed to update member profile image URL:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Remove member profile image URL from NocoDB
|
|
*/
|
|
export const removeMemberProfileImageUrl = async (memberId: string): Promise<void> => {
|
|
try {
|
|
// Find member by member_id using existing pattern
|
|
const response = await $fetch<EntityResponse<Member>>(createTableUrl(Table.Members), {
|
|
headers: {
|
|
"xc-token": getNocoDbConfiguration().token,
|
|
},
|
|
params: {
|
|
where: `(member_id,eq,${memberId})`,
|
|
limit: 1,
|
|
},
|
|
});
|
|
|
|
if (!response.list || response.list.length === 0) {
|
|
throw new Error(`Member not found with ID: ${memberId}`);
|
|
}
|
|
|
|
const member = response.list[0];
|
|
|
|
// Clear profile image fields using PATCH method
|
|
const updateData = {
|
|
Id: parseInt(member.Id),
|
|
profile_image_url: null,
|
|
profile_image_updated_at: new Date().toISOString(),
|
|
profile_image_original_name: null
|
|
};
|
|
|
|
await $fetch(createTableUrl(Table.Members), {
|
|
method: "PATCH",
|
|
headers: {
|
|
"xc-token": getNocoDbConfiguration().token,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: updateData
|
|
});
|
|
|
|
console.log(`✅ Removed profile image URL for member ${memberId}`);
|
|
} catch (error) {
|
|
console.error('Failed to remove member profile image URL:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Validate image file before processing
|
|
*/
|
|
export const validateImageFile = (buffer: Buffer, filename: string): void => {
|
|
// Check file size (5MB max)
|
|
const maxSize = 5 * 1024 * 1024;
|
|
if (buffer.length > maxSize) {
|
|
throw new Error('Image file size must be less than 5MB');
|
|
}
|
|
|
|
// Check file extension
|
|
const allowedExtensions = ['.jpg', '.jpeg', '.png', '.webp'];
|
|
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
|
if (!allowedExtensions.includes(extension)) {
|
|
throw new Error('Only JPEG, PNG, and WebP image files are allowed');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Generate user initials from name (fallback for missing profile images)
|
|
*/
|
|
export const generateInitials = (firstName?: string, lastName?: string, fullName?: string): string => {
|
|
if (firstName && lastName) {
|
|
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
|
}
|
|
|
|
if (fullName) {
|
|
const names = fullName.trim().split(' ');
|
|
if (names.length >= 2) {
|
|
return `${names[0].charAt(0)}${names[names.length - 1].charAt(0)}`.toUpperCase();
|
|
}
|
|
return names[0].charAt(0).toUpperCase();
|
|
}
|
|
|
|
return '?';
|
|
};
|
|
|
|
/**
|
|
* Generate background color for initials based on name
|
|
*/
|
|
export const generateAvatarColor = (name: string): string => {
|
|
const colors = [
|
|
'#f44336', '#e91e63', '#9c27b0', '#673ab7',
|
|
'#3f51b5', '#2196f3', '#03a9f4', '#00bcd4',
|
|
'#009688', '#4caf50', '#8bc34a', '#cddc39',
|
|
'#ff9800', '#ff5722', '#795548', '#607d8b'
|
|
];
|
|
|
|
let hash = 0;
|
|
for (let i = 0; i < name.length; i++) {
|
|
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
|
|
const index = Math.abs(hash) % colors.length;
|
|
return colors[index];
|
|
};
|