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 => { 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 = {}; 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 => { 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 => { 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 => { try { // Find member by member_id using existing pattern const response = await $fetch>(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 => { try { // Find member by member_id using existing pattern const response = await $fetch>(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]; };