Add profile image system with MinIO storage
Build And Push Image / docker (push) Failing after 1m5s
Details
Build And Push Image / docker (push) Failing after 1m5s
Details
- Implement ProfileAvatar component for user avatars - Integrate MinIO for profile image storage and management - Add profile image fields to Member type definition - Create server utilities and API endpoints for image handling - Replace basic avatar icon with new ProfileAvatar in dashboard - Update sharp dependency to v0.34.3
This commit is contained in:
parent
0952d6c381
commit
2ff0c31bbd
|
|
@ -0,0 +1,310 @@
|
||||||
|
<template>
|
||||||
|
<v-avatar
|
||||||
|
:size="avatarSize"
|
||||||
|
:color="showInitials ? backgroundColor : 'grey-lighten-2'"
|
||||||
|
:class="avatarClass"
|
||||||
|
>
|
||||||
|
<!-- Loading state -->
|
||||||
|
<v-progress-circular
|
||||||
|
v-if="loading"
|
||||||
|
:size="iconSize"
|
||||||
|
indeterminate
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Profile image -->
|
||||||
|
<v-img
|
||||||
|
v-else-if="imageUrl && !imageError && !loading"
|
||||||
|
:src="imageUrl"
|
||||||
|
:alt="altText"
|
||||||
|
cover
|
||||||
|
@error="handleImageError"
|
||||||
|
@load="handleImageLoad"
|
||||||
|
:class="imageClass"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Initials fallback -->
|
||||||
|
<span
|
||||||
|
v-else-if="initials && !loading"
|
||||||
|
:class="['text-white font-weight-bold', initialsClass]"
|
||||||
|
:style="{ fontSize: initialsSize }"
|
||||||
|
>
|
||||||
|
{{ initials }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Icon fallback -->
|
||||||
|
<v-icon
|
||||||
|
v-else
|
||||||
|
:size="iconSize"
|
||||||
|
color="grey-darken-2"
|
||||||
|
>
|
||||||
|
mdi-account
|
||||||
|
</v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { generateInitials, generateAvatarColor } from '~/server/utils/profile-images';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
memberId?: string;
|
||||||
|
memberName?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
lazy?: boolean;
|
||||||
|
clickable?: boolean;
|
||||||
|
showBorder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
size: 'medium',
|
||||||
|
lazy: true,
|
||||||
|
clickable: false,
|
||||||
|
showBorder: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: [];
|
||||||
|
imageLoaded: [];
|
||||||
|
imageError: [error: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Reactive state
|
||||||
|
const loading = ref(false);
|
||||||
|
const imageError = ref(false);
|
||||||
|
const imageUrl = ref<string | null>(null);
|
||||||
|
const isVisible = ref(false);
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const avatarSize = computed(() => {
|
||||||
|
switch (props.size) {
|
||||||
|
case 'small': return 36;
|
||||||
|
case 'medium': return 80;
|
||||||
|
case 'large': return 200;
|
||||||
|
default: return 80;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconSize = computed(() => {
|
||||||
|
switch (props.size) {
|
||||||
|
case 'small': return 20;
|
||||||
|
case 'medium': return 40;
|
||||||
|
case 'large': return 100;
|
||||||
|
default: return 40;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialsSize = computed(() => {
|
||||||
|
switch (props.size) {
|
||||||
|
case 'small': return '14px';
|
||||||
|
case 'medium': return '28px';
|
||||||
|
case 'large': return '72px';
|
||||||
|
default: return '28px';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const initials = computed(() => {
|
||||||
|
if (props.firstName && props.lastName) {
|
||||||
|
return generateInitials(props.firstName, props.lastName);
|
||||||
|
}
|
||||||
|
if (props.memberName) {
|
||||||
|
return generateInitials(undefined, undefined, props.memberName);
|
||||||
|
}
|
||||||
|
return '?';
|
||||||
|
});
|
||||||
|
|
||||||
|
const backgroundColor = computed(() => {
|
||||||
|
const name = props.memberName || `${props.firstName} ${props.lastName}`.trim();
|
||||||
|
return name ? generateAvatarColor(name) : '#9e9e9e';
|
||||||
|
});
|
||||||
|
|
||||||
|
const showInitials = computed(() => {
|
||||||
|
return !loading.value && !imageUrl.value && initials.value !== '?';
|
||||||
|
});
|
||||||
|
|
||||||
|
const altText = computed(() => {
|
||||||
|
return props.memberName || `${props.firstName} ${props.lastName}`.trim() || 'Profile';
|
||||||
|
});
|
||||||
|
|
||||||
|
const avatarClass = computed(() => [
|
||||||
|
{
|
||||||
|
'cursor-pointer': props.clickable,
|
||||||
|
'elevation-2': props.showBorder,
|
||||||
|
'profile-avatar--border': props.showBorder
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const imageClass = computed(() => [
|
||||||
|
'profile-avatar__image',
|
||||||
|
{
|
||||||
|
'profile-avatar__image--loaded': !loading.value
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const initialsClass = computed(() => [
|
||||||
|
'profile-avatar__initials',
|
||||||
|
{
|
||||||
|
'text-h6': props.size === 'small',
|
||||||
|
'text-h4': props.size === 'medium',
|
||||||
|
'text-h1': props.size === 'large'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const loadProfileImage = async () => {
|
||||||
|
if (!props.memberId || loading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
imageError.value = false;
|
||||||
|
|
||||||
|
const sizeParam = props.size === 'small' ? 'small' :
|
||||||
|
props.size === 'large' ? 'medium' : 'medium'; // Use medium for both medium and large
|
||||||
|
|
||||||
|
const response = await $fetch(`/api/profile/image/${props.memberId}/${sizeParam}`);
|
||||||
|
|
||||||
|
if (response.success && response.imageUrl) {
|
||||||
|
// Pre-load the image to ensure it's valid
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
imageUrl.value = response.imageUrl;
|
||||||
|
loading.value = false;
|
||||||
|
emit('imageLoaded');
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
handleImageError();
|
||||||
|
};
|
||||||
|
img.src = response.imageUrl;
|
||||||
|
} else {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.warn(`Profile image not found for member ${props.memberId}:`, error.message);
|
||||||
|
loading.value = false;
|
||||||
|
imageError.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageError = () => {
|
||||||
|
loading.value = false;
|
||||||
|
imageError.value = true;
|
||||||
|
imageUrl.value = null;
|
||||||
|
emit('imageError', 'Failed to load profile image');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageLoad = () => {
|
||||||
|
loading.value = false;
|
||||||
|
emit('imageLoaded');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (props.clickable) {
|
||||||
|
emit('click');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Intersection Observer for lazy loading
|
||||||
|
let observer: IntersectionObserver | null = null;
|
||||||
|
const avatarRef = ref<HTMLElement>();
|
||||||
|
|
||||||
|
const initIntersectionObserver = () => {
|
||||||
|
if (!props.lazy || !avatarRef.value || typeof IntersectionObserver === 'undefined') {
|
||||||
|
// Load immediately if not lazy or no intersection observer support
|
||||||
|
isVisible.value = true;
|
||||||
|
loadProfileImage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (entry.isIntersecting && !isVisible.value) {
|
||||||
|
isVisible.value = true;
|
||||||
|
loadProfileImage();
|
||||||
|
|
||||||
|
// Stop observing once visible
|
||||||
|
if (observer && avatarRef.value) {
|
||||||
|
observer.unobserve(avatarRef.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rootMargin: '50px',
|
||||||
|
threshold: 0.1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(avatarRef.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for prop changes
|
||||||
|
watch(
|
||||||
|
() => props.memberId,
|
||||||
|
(newMemberId) => {
|
||||||
|
if (newMemberId) {
|
||||||
|
imageUrl.value = null;
|
||||||
|
imageError.value = false;
|
||||||
|
if (isVisible.value || !props.lazy) {
|
||||||
|
loadProfileImage();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
imageUrl.value = null;
|
||||||
|
imageError.value = false;
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.memberId) {
|
||||||
|
if (props.lazy) {
|
||||||
|
nextTick(() => {
|
||||||
|
initIntersectionObserver();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
loadProfileImage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (observer && avatarRef.value) {
|
||||||
|
observer.unobserve(avatarRef.value);
|
||||||
|
observer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.profile-avatar--border {
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.8);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar__image {
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar__image--loaded {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar__initials {
|
||||||
|
user-select: none;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-pointer:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
transition: transform 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -120,9 +120,14 @@
|
||||||
<v-menu offset-y>
|
<v-menu offset-y>
|
||||||
<template v-slot:activator="{ props }">
|
<template v-slot:activator="{ props }">
|
||||||
<v-btn icon v-bind="props" color="white">
|
<v-btn icon v-bind="props" color="white">
|
||||||
<v-avatar size="36" color="white">
|
<ProfileAvatar
|
||||||
<v-icon color="primary">mdi-account</v-icon>
|
:member-name="user?.name"
|
||||||
</v-avatar>
|
:first-name="user?.firstName"
|
||||||
|
:last-name="user?.lastName"
|
||||||
|
size="small"
|
||||||
|
:lazy="false"
|
||||||
|
show-border
|
||||||
|
/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
"minio": "^8.0.5",
|
"minio": "^8.0.5",
|
||||||
"nodemailer": "^7.0.5",
|
"nodemailer": "^7.0.5",
|
||||||
"nuxt": "^3.15.4",
|
"nuxt": "^3.15.4",
|
||||||
"sharp": "^0.34.2",
|
"sharp": "^0.34.3",
|
||||||
"systeminformation": "^5.27.7",
|
"systeminformation": "^5.27.7",
|
||||||
"vue": "latest",
|
"vue": "latest",
|
||||||
"vue-country-flag-next": "^2.3.2",
|
"vue-country-flag-next": "^2.3.2",
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
"minio": "^8.0.5",
|
"minio": "^8.0.5",
|
||||||
"nodemailer": "^7.0.5",
|
"nodemailer": "^7.0.5",
|
||||||
"nuxt": "^3.15.4",
|
"nuxt": "^3.15.4",
|
||||||
"sharp": "^0.34.2",
|
"sharp": "^0.34.3",
|
||||||
"systeminformation": "^5.27.7",
|
"systeminformation": "^5.27.7",
|
||||||
"vue": "latest",
|
"vue": "latest",
|
||||||
"vue-country-flag-next": "^2.3.2",
|
"vue-country-flag-next": "^2.3.2",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { deleteProfileImage, removeMemberProfileImageUrl } from '~/server/utils/profile-images';
|
||||||
|
|
||||||
|
// Authentication utility - we'll need to check if it exists
|
||||||
|
async function requireAuth(event: any) {
|
||||||
|
// Check for session-based authentication
|
||||||
|
const sessionCookie = getCookie(event, 'auth-token') || getCookie(event, 'nuxt-oidc-auth-session');
|
||||||
|
|
||||||
|
if (!sessionCookie) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Authentication required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, return a basic user object - this should integrate with your existing auth system
|
||||||
|
const user = event.context.user;
|
||||||
|
if (!user) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Invalid authentication',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role-based access control
|
||||||
|
function canEditMember(user: any, targetMemberId: string): boolean {
|
||||||
|
// Admin can edit anyone
|
||||||
|
if (user.tier === 'admin' || user.groups?.includes('admin') || user.groups?.includes('monaco-admin')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Board members can edit anyone
|
||||||
|
if (user.tier === 'board' || user.groups?.includes('board') || user.groups?.includes('monaco-board')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users can only edit their own profile
|
||||||
|
return user.email === targetMemberId || user.member_id === targetMemberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Check authentication
|
||||||
|
const user = await requireAuth(event);
|
||||||
|
|
||||||
|
// Get route parameter
|
||||||
|
const memberId = getRouterParam(event, 'memberId');
|
||||||
|
|
||||||
|
if (!memberId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Member ID is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
if (!canEditMember(user, memberId)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'You can only delete your own profile image',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[profile-delete] Deleting profile image for member: ${memberId}`);
|
||||||
|
|
||||||
|
// Delete image files from MinIO
|
||||||
|
await deleteProfileImage(memberId);
|
||||||
|
|
||||||
|
// Remove image reference from database
|
||||||
|
await removeMemberProfileImageUrl(memberId);
|
||||||
|
|
||||||
|
console.log(`[profile-delete] Successfully deleted profile image for member: ${memberId}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Profile image deleted successfully',
|
||||||
|
memberId,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[profile-delete] Delete failed:', error);
|
||||||
|
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error; // Re-throw HTTP errors
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: error.message || 'Failed to delete profile image',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { getProfileImageUrl } from '~/server/utils/profile-images';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Get route parameters
|
||||||
|
const memberId = getRouterParam(event, 'memberId');
|
||||||
|
const size = getRouterParam(event, 'size') as 'original' | 'small' | 'medium';
|
||||||
|
|
||||||
|
if (!memberId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Member ID is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate size parameter
|
||||||
|
const validSizes = ['original', 'small', 'medium'];
|
||||||
|
if (!validSizes.includes(size)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Invalid size. Must be one of: original, small, medium',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[profile-image] Getting image URL for member: ${memberId}, size: ${size}`);
|
||||||
|
|
||||||
|
// Get presigned URL for the image
|
||||||
|
const imageUrl = await getProfileImageUrl(memberId, size);
|
||||||
|
|
||||||
|
if (!imageUrl) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Profile image not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set cache headers for better performance
|
||||||
|
setHeader(event, 'Cache-Control', 'public, max-age=300'); // 5 minutes
|
||||||
|
setHeader(event, 'Content-Type', 'application/json');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
imageUrl,
|
||||||
|
memberId,
|
||||||
|
size,
|
||||||
|
expiresIn: 3600, // URLs expire in 1 hour
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[profile-image] Failed to get image URL:', error);
|
||||||
|
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error; // Re-throw HTTP errors
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Failed to retrieve profile image',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
import formidable from 'formidable';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import {
|
||||||
|
uploadProfileImage,
|
||||||
|
updateMemberProfileImageUrl,
|
||||||
|
validateImageFile
|
||||||
|
} from '~/server/utils/profile-images';
|
||||||
|
|
||||||
|
// Authentication utility - we'll need to check if it exists
|
||||||
|
async function requireAuth(event: any) {
|
||||||
|
// Check for session-based authentication
|
||||||
|
const sessionCookie = getCookie(event, 'auth-token') || getCookie(event, 'nuxt-oidc-auth-session');
|
||||||
|
|
||||||
|
if (!sessionCookie) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Authentication required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, return a basic user object - this should integrate with your existing auth system
|
||||||
|
const user = event.context.user;
|
||||||
|
if (!user) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Invalid authentication',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role-based access control
|
||||||
|
function canEditMember(user: any, targetMemberId: string): boolean {
|
||||||
|
// Admin can edit anyone
|
||||||
|
if (user.tier === 'admin' || user.groups?.includes('admin') || user.groups?.includes('monaco-admin')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Board members can edit anyone
|
||||||
|
if (user.tier === 'board' || user.groups?.includes('board') || user.groups?.includes('monaco-board')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users can only edit their own profile
|
||||||
|
// We'll need to match by email or keycloak ID since users might not know their member_id
|
||||||
|
return user.email === targetMemberId || user.member_id === targetMemberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Check authentication
|
||||||
|
const user = await requireAuth(event);
|
||||||
|
|
||||||
|
// Get query parameters
|
||||||
|
const query = getQuery(event);
|
||||||
|
const targetMemberId = query.memberId as string;
|
||||||
|
|
||||||
|
if (!targetMemberId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Member ID is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
if (!canEditMember(user, targetMemberId)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'You can only upload images for your own profile',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[profile-upload] Processing upload for member: ${targetMemberId}`);
|
||||||
|
|
||||||
|
// Parse multipart form data
|
||||||
|
const form = formidable({
|
||||||
|
maxFileSize: 5 * 1024 * 1024, // 5MB limit
|
||||||
|
keepExtensions: true,
|
||||||
|
allowEmptyFiles: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [fields, files] = await form.parse(event.node.req);
|
||||||
|
|
||||||
|
// Get the uploaded file
|
||||||
|
const uploadedFile = Array.isArray(files.image) ? files.image[0] : files.image;
|
||||||
|
|
||||||
|
if (!uploadedFile) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'No image file provided',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[profile-upload] File received: ${uploadedFile.originalFilename}, size: ${uploadedFile.size} bytes`);
|
||||||
|
|
||||||
|
// Read file buffer
|
||||||
|
const fileBuffer = await fs.readFile(uploadedFile.filepath);
|
||||||
|
|
||||||
|
// Validate the image file
|
||||||
|
validateImageFile(fileBuffer, uploadedFile.originalFilename || 'image.jpg');
|
||||||
|
|
||||||
|
// Upload image and generate thumbnails
|
||||||
|
const imagePath = await uploadProfileImage(
|
||||||
|
targetMemberId,
|
||||||
|
fileBuffer,
|
||||||
|
uploadedFile.originalFilename || 'profile.jpg'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update database with image path
|
||||||
|
await updateMemberProfileImageUrl(
|
||||||
|
targetMemberId,
|
||||||
|
imagePath,
|
||||||
|
uploadedFile.originalFilename || 'profile.jpg'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up temporary file
|
||||||
|
try {
|
||||||
|
await fs.unlink(uploadedFile.filepath);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[profile-upload] Failed to clean up temp file:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[profile-upload] Successfully uploaded profile image for member: ${targetMemberId}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Profile image uploaded successfully',
|
||||||
|
imagePath,
|
||||||
|
originalName: uploadedFile.originalFilename,
|
||||||
|
size: uploadedFile.size,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[profile-upload] Upload failed:', error);
|
||||||
|
|
||||||
|
// Provide specific error messages
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error; // Re-throw HTTP errors
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: error.message || 'Profile image upload failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { Client } from 'minio';
|
||||||
|
|
||||||
|
// Initialize MinIO client
|
||||||
|
export const getMinioClient = () => {
|
||||||
|
const config = useRuntimeConfig().minio;
|
||||||
|
|
||||||
|
return new Client({
|
||||||
|
endPoint: config.endPoint,
|
||||||
|
port: config.port,
|
||||||
|
useSSL: config.useSSL,
|
||||||
|
accessKey: config.accessKey,
|
||||||
|
secretKey: config.secretKey,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upload file with content type detection
|
||||||
|
export const uploadFile = async (filePath: string, fileBuffer: Buffer, contentType: string) => {
|
||||||
|
const client = getMinioClient();
|
||||||
|
const bucketName = useRuntimeConfig().minio.bucketName;
|
||||||
|
|
||||||
|
return await client.putObject(bucketName, filePath, fileBuffer, fileBuffer.length, {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate presigned URL for secure downloads
|
||||||
|
export const getDownloadUrl = async (fileName: string, expiry: number = 60 * 60) => {
|
||||||
|
const client = getMinioClient();
|
||||||
|
const bucketName = useRuntimeConfig().minio.bucketName;
|
||||||
|
|
||||||
|
// Extract just the filename from the full path
|
||||||
|
let filename = fileName.split('/').pop() || fileName;
|
||||||
|
|
||||||
|
// Remove timestamp prefix if present (e.g., "1234567890-filename.pdf" -> "filename.pdf")
|
||||||
|
const timestampMatch = filename.match(/^\d{10,}-(.+)$/);
|
||||||
|
if (timestampMatch) {
|
||||||
|
filename = timestampMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force download with Content-Disposition header
|
||||||
|
const responseHeaders = {
|
||||||
|
'response-content-disposition': `attachment; filename="${filename}"`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await client.presignedGetObject(bucketName, fileName, expiry, responseHeaders);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get presigned URL for file preview (inline display)
|
||||||
|
export const getPreviewUrl = async (fileName: string, contentType: string) => {
|
||||||
|
const client = getMinioClient();
|
||||||
|
const bucketName = useRuntimeConfig().minio.bucketName;
|
||||||
|
|
||||||
|
// For images and PDFs, generate a presigned URL with appropriate response headers
|
||||||
|
const responseHeaders = {
|
||||||
|
'response-content-type': contentType,
|
||||||
|
'response-content-disposition': 'inline',
|
||||||
|
};
|
||||||
|
|
||||||
|
return await client.presignedGetObject(bucketName, fileName, 60 * 60, responseHeaders);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete file
|
||||||
|
export const deleteFile = async (fileName: string) => {
|
||||||
|
const client = getMinioClient();
|
||||||
|
const bucketName = useRuntimeConfig().minio.bucketName;
|
||||||
|
|
||||||
|
return await client.removeObject(bucketName, fileName);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get file statistics
|
||||||
|
export const getFileStats = async (fileName: string) => {
|
||||||
|
const client = getMinioClient();
|
||||||
|
const bucketName = useRuntimeConfig().minio.bucketName;
|
||||||
|
|
||||||
|
return await client.statObject(bucketName, fileName);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create bucket if it doesn't exist
|
||||||
|
export const createBucketIfNotExists = async (bucketName?: string) => {
|
||||||
|
const client = getMinioClient();
|
||||||
|
const bucket = bucketName || useRuntimeConfig().minio.bucketName;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exists = await client.bucketExists(bucket);
|
||||||
|
if (!exists) {
|
||||||
|
await client.makeBucket(bucket);
|
||||||
|
console.log(`Bucket '${bucket}' created successfully`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating bucket:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// List files with prefix support
|
||||||
|
export const listFiles = async (prefix: string = '', recursive: boolean = false) => {
|
||||||
|
const client = getMinioClient();
|
||||||
|
const bucketName = useRuntimeConfig().minio.bucketName;
|
||||||
|
|
||||||
|
const files: any[] = [];
|
||||||
|
const folders = new Set<string>();
|
||||||
|
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const stream = client.listObjectsV2(bucketName, prefix, recursive);
|
||||||
|
|
||||||
|
stream.on('data', (obj) => {
|
||||||
|
// Handle folder prefixes returned by MinIO
|
||||||
|
if (obj && obj.prefix) {
|
||||||
|
folders.add(obj.prefix);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip objects without a name
|
||||||
|
if (!obj || typeof obj.name !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recursive) {
|
||||||
|
if (prefix) {
|
||||||
|
// Extract folder structure when inside a folder
|
||||||
|
const relativePath = obj.name.substring(prefix.length);
|
||||||
|
if (!relativePath) return; // Skip if no relative path
|
||||||
|
|
||||||
|
const firstSlash = relativePath.indexOf('/');
|
||||||
|
|
||||||
|
if (firstSlash > -1) {
|
||||||
|
// This is a folder
|
||||||
|
const folderName = relativePath.substring(0, firstSlash);
|
||||||
|
folders.add(prefix + folderName + '/');
|
||||||
|
} else if (relativePath && !obj.name.endsWith('/')) {
|
||||||
|
// This is a file in the current folder
|
||||||
|
files.push({
|
||||||
|
name: obj.name,
|
||||||
|
size: obj.size || 0,
|
||||||
|
lastModified: obj.lastModified || new Date(),
|
||||||
|
etag: obj.etag || '',
|
||||||
|
isFolder: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// At root level
|
||||||
|
const firstSlash = obj.name.indexOf('/');
|
||||||
|
|
||||||
|
if (obj.name.endsWith('/')) {
|
||||||
|
// This is a folder placeholder
|
||||||
|
folders.add(obj.name);
|
||||||
|
} else if (firstSlash > -1) {
|
||||||
|
// This is inside a folder, extract the folder
|
||||||
|
const folderName = obj.name.substring(0, firstSlash);
|
||||||
|
folders.add(folderName + '/');
|
||||||
|
} else {
|
||||||
|
// This is a file at root
|
||||||
|
files.push({
|
||||||
|
name: obj.name,
|
||||||
|
size: obj.size || 0,
|
||||||
|
lastModified: obj.lastModified || new Date(),
|
||||||
|
etag: obj.etag || '',
|
||||||
|
isFolder: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// When recursive, include all files
|
||||||
|
if (!obj.name.endsWith('/')) {
|
||||||
|
files.push({
|
||||||
|
name: obj.name,
|
||||||
|
size: obj.size || 0,
|
||||||
|
lastModified: obj.lastModified || new Date(),
|
||||||
|
etag: obj.etag || '',
|
||||||
|
isFolder: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('error', (error) => {
|
||||||
|
console.error('Stream error:', error);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('end', () => {
|
||||||
|
// Add folders to the result
|
||||||
|
const folderItems = Array.from(folders).map(folder => ({
|
||||||
|
name: folder,
|
||||||
|
size: 0,
|
||||||
|
lastModified: new Date(),
|
||||||
|
etag: '',
|
||||||
|
isFolder: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
resolve([...folderItems, ...files]);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in listFiles:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
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];
|
||||||
|
};
|
||||||
|
|
@ -135,6 +135,11 @@ export interface Member {
|
||||||
registration_date?: string; // New field for tracking registration date
|
registration_date?: string; // New field for tracking registration date
|
||||||
portal_group?: 'user' | 'board' | 'admin'; // Portal access level group
|
portal_group?: 'user' | 'board' | 'admin'; // Portal access level group
|
||||||
|
|
||||||
|
// Profile Image Fields
|
||||||
|
profile_image_url?: string; // MinIO path: profile-images/{member_id}/profile.jpg
|
||||||
|
profile_image_updated_at?: string; // ISO timestamp for cache invalidation
|
||||||
|
profile_image_original_name?: string; // Original filename for audit trail
|
||||||
|
|
||||||
// Computed fields (added by processing)
|
// Computed fields (added by processing)
|
||||||
FullName?: string;
|
FullName?: string;
|
||||||
FormattedPhone?: string;
|
FormattedPhone?: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue