Add profile image system with MinIO storage
Some checks failed
Build And Push Image / docker (push) Failing after 1m5s
Some checks failed
Build And Push Image / docker (push) Failing after 1m5s
- 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:
310
components/ProfileAvatar.vue
Normal file
310
components/ProfileAvatar.vue
Normal file
@@ -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>
|
||||
Reference in New Issue
Block a user