2025-08-14 10:28:40 +02:00
|
|
|
<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">
|
2025-08-14 10:56:55 +02:00
|
|
|
import { generateInitials, generateAvatarColor } from '~/utils/client-utils';
|
2025-08-14 10:28:40 +02:00
|
|
|
|
|
|
|
|
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>
|