Add avatar placeholer when avatar is empty

This commit is contained in:
Eli Ribble 2026-04-01 14:48:31 +00:00
parent 0ecf9c1be1
commit c253e655b1
No known key found for this signature in database
3 changed files with 142 additions and 78 deletions

1
svg/avatar.svg Normal file
View file

@ -0,0 +1 @@
<svg height="512" viewBox="0 0 64 64" width="512" xmlns="http://www.w3.org/2000/svg"><g id="User"><circle cx="32" cy="32" fill="#e6ecff" r="31"/><g fill="#4294ff"><path d="m56.877 50.4748a31.0647 31.0647 0 0 0 -49.7651-.0156 30.9669 30.9669 0 0 0 49.7651.0156z"/><circle cx="32" cy="22" r="12"/></g></g></svg>

After

Width:  |  Height:  |  Size: 309 B

View file

@ -1,110 +1,168 @@
<template>
<div class="user-selector">
<label v-if="label" :for="selectId" class="form-label">
{{ label }}
</label>
<div class="user-autocomplete position-relative">
<input
type="text"
class="form-control"
v-model="searchQuery"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
:placeholder="placeholder"
autocomplete="off"
/>
<select
:id="selectId"
v-model="selectedUserId"
class="form-select"
:class="{ 'is-invalid': error }"
:disabled="loading || disabled"
@change="handleChange"
<div
v-if="showDropdown && filteredUsers.length > 0"
class="dropdown-menu show w-100"
style="max-height: 300px; overflow-y: auto"
>
<option :value="null">{{ placeholder }}</option>
<option v-for="user in usersStore.all" :key="user.id" :value="user.id">
{{ user.display_name || user.username || `User ${user.id}` }}
</option>
</select>
<a
v-for="user in filteredUsers"
:key="user.id"
href="#"
class="dropdown-item d-flex align-items-center"
@mousedown.prevent="selectUser(user)"
>
<img
v-if="user.avatar"
:src="user.avatar"
:alt="user.display_name"
class="rounded-circle me-2"
width="32"
height="32"
/>
<span v-else class="badge bg-secondary me-2">{{ user.initials }}</span>
<div v-if="loading" class="form-text">
<span
class="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
Loading users...
<div class="flex-grow-1">
<div v-html="highlightMatch(user.display_name)"></div>
<small
class="text-muted"
v-html="highlightMatch(user.username)"
></small>
</div>
</a>
</div>
<div v-if="error" class="invalid-feedback d-block">
Failed to load users. Please try again.
<div
v-if="
showDropdown &&
searchQuery.length >= minChars &&
filteredUsers.length === 0
"
class="dropdown-menu show w-100"
>
<div class="dropdown-item text-muted">No users found</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from "vue";
import { ref, computed, onMounted } from "vue";
import { useUserStore } from "@/store/user";
import type { User } from "@/types";
interface Props {
modelValue?: number | null;
label?: string;
placeholder?: string;
disabled?: boolean;
}
interface Emits {
(e: "update:modelValue", value: number | null): void;
(e: "change", user: User | null): void;
minChars?: number;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: null,
label: "",
placeholder: "Select a user...",
disabled: false,
placeholder: "Search users...",
minChars: 3,
});
const emit = defineEmits<Emits>();
const emit = defineEmits<{
select: [user: User];
input: [query: string];
}>();
const usersStore = useUserStore();
const selectedUserId = ref<number | null>(props.modelValue);
const loading = ref(false);
const error = ref(false);
const selectId = computed(
() => `user-select-${Math.random().toString(36).substr(2, 9)}`,
);
// Watch for external changes to modelValue
watch(
() => props.modelValue,
(newValue) => {
selectedUserId.value = newValue;
},
);
const handleChange = () => {
emit("update:modelValue", selectedUserId.value);
const selectedUser = selectedUserId.value
? usersStore.byID(selectedUserId.value)
: null;
emit("change", selectedUser || null);
};
const searchQuery = ref("");
const showDropdown = ref(false);
onMounted(async () => {
// Only fetch if users haven't been loaded yet
// Fetch all users if not already loaded
if (!usersStore.all) {
loading.value = true;
error.value = false;
try {
await usersStore.fetchAll();
} catch (err) {
console.error("Failed to fetch users:", err);
error.value = true;
} finally {
loading.value = false;
}
}
});
const filteredUsers = computed(() => {
if (searchQuery.value.length < props.minChars || !usersStore.all) {
return [];
}
const query = searchQuery.value.toLowerCase();
return usersStore.all
.filter((user: User) => {
const displayName = user.display_name.toLowerCase();
const username = user.username.toLowerCase();
return displayName.includes(query) || username.includes(query);
})
.slice(0, 10); // Limit to 10 results
});
function onInput() {
showDropdown.value = searchQuery.value.length >= props.minChars;
emit("input", searchQuery.value);
}
function onFocus() {
if (searchQuery.value.length >= props.minChars) {
showDropdown.value = true;
}
}
function onBlur() {
// Delay to allow click event on dropdown items
setTimeout(() => {
showDropdown.value = false;
}, 200);
}
function selectUser(user: User) {
searchQuery.value = user.display_name;
showDropdown.value = false;
emit("select", user);
}
function highlightMatch(text: string): string {
if (!searchQuery.value || searchQuery.value.length < props.minChars) {
return escapeHtml(text);
}
const query = escapeHtml(searchQuery.value);
const escapedText = escapeHtml(text);
const regex = new RegExp(`(${query})`, "gi");
return escapedText.replace(regex, "<strong>$1</strong>");
}
function escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
</script>
<style scoped>
.user-selector {
margin-bottom: 1rem;
.user-autocomplete {
position: relative;
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
}
.dropdown-item {
cursor: pointer;
}
.dropdown-item:hover {
background-color: #f8f9fa;
}
</style>

View file

@ -5,7 +5,10 @@
object-fit: cover;
border: 3px solid #dee2e6;
}
.bi-avatar {
height: 128px;
width: 128px;
}
.btn-close-white {
opacity: 0.8;
}
@ -47,7 +50,9 @@ pre {
<label class="form-label fw-bold">Avatar</label>
<div class="d-flex align-items-center">
<div class="position-relative">
<i v-if="avatar == ''" class="bi bi-avatar"></i>
<img
v-else
:src="avatar || defaultAvatar"
alt="User Avatar"
class="rounded-circle avatar-preview"