monacousa-portal/components/ProfileAvatar.vue

311 lines
6.7 KiB
Vue

<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>