Add avatar placeholer when avatar is empty
This commit is contained in:
parent
0ecf9c1be1
commit
c253e655b1
3 changed files with 142 additions and 78 deletions
1
svg/avatar.svg
Normal file
1
svg/avatar.svg
Normal 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 |
|
|
@ -1,110 +1,168 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="user-selector">
|
<div class="user-autocomplete position-relative">
|
||||||
<label v-if="label" :for="selectId" class="form-label">
|
<input
|
||||||
{{ label }}
|
type="text"
|
||||||
</label>
|
class="form-control"
|
||||||
|
v-model="searchQuery"
|
||||||
|
@input="onInput"
|
||||||
|
@focus="onFocus"
|
||||||
|
@blur="onBlur"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
<select
|
<div
|
||||||
:id="selectId"
|
v-if="showDropdown && filteredUsers.length > 0"
|
||||||
v-model="selectedUserId"
|
class="dropdown-menu show w-100"
|
||||||
class="form-select"
|
style="max-height: 300px; overflow-y: auto"
|
||||||
:class="{ 'is-invalid': error }"
|
|
||||||
:disabled="loading || disabled"
|
|
||||||
@change="handleChange"
|
|
||||||
>
|
>
|
||||||
<option :value="null">{{ placeholder }}</option>
|
<a
|
||||||
<option v-for="user in usersStore.all" :key="user.id" :value="user.id">
|
v-for="user in filteredUsers"
|
||||||
{{ user.display_name || user.username || `User ${user.id}` }}
|
:key="user.id"
|
||||||
</option>
|
href="#"
|
||||||
</select>
|
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">
|
<div class="flex-grow-1">
|
||||||
<span
|
<div v-html="highlightMatch(user.display_name)"></div>
|
||||||
class="spinner-border spinner-border-sm me-2"
|
<small
|
||||||
role="status"
|
class="text-muted"
|
||||||
aria-hidden="true"
|
v-html="highlightMatch(user.username)"
|
||||||
></span>
|
></small>
|
||||||
Loading users...
|
</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="invalid-feedback d-block">
|
<div
|
||||||
Failed to load users. Please try again.
|
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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, watch } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useUserStore } from "@/store/user";
|
import { useUserStore } from "@/store/user";
|
||||||
import type { User } from "@/types";
|
import type { User } from "@/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue?: number | null;
|
|
||||||
label?: string;
|
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
minChars?: number;
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: "update:modelValue", value: number | null): void;
|
|
||||||
(e: "change", user: User | null): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
modelValue: null,
|
placeholder: "Search users...",
|
||||||
label: "",
|
minChars: 3,
|
||||||
placeholder: "Select a user...",
|
|
||||||
disabled: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<Emits>();
|
const emit = defineEmits<{
|
||||||
|
select: [user: User];
|
||||||
|
input: [query: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
const usersStore = useUserStore();
|
const usersStore = useUserStore();
|
||||||
const selectedUserId = ref<number | null>(props.modelValue);
|
const searchQuery = ref("");
|
||||||
const loading = ref(false);
|
const showDropdown = 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);
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Only fetch if users haven't been loaded yet
|
// Fetch all users if not already loaded
|
||||||
if (!usersStore.all) {
|
if (!usersStore.all) {
|
||||||
loading.value = true;
|
|
||||||
error.value = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await usersStore.fetchAll();
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.user-selector {
|
.user-autocomplete {
|
||||||
margin-bottom: 1rem;
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 3px solid #dee2e6;
|
border: 3px solid #dee2e6;
|
||||||
}
|
}
|
||||||
|
.bi-avatar {
|
||||||
|
height: 128px;
|
||||||
|
width: 128px;
|
||||||
|
}
|
||||||
.btn-close-white {
|
.btn-close-white {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +50,9 @@ pre {
|
||||||
<label class="form-label fw-bold">Avatar</label>
|
<label class="form-label fw-bold">Avatar</label>
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="position-relative">
|
<div class="position-relative">
|
||||||
|
<i v-if="avatar == ''" class="bi bi-avatar"></i>
|
||||||
<img
|
<img
|
||||||
|
v-else
|
||||||
:src="avatar || defaultAvatar"
|
:src="avatar || defaultAvatar"
|
||||||
alt="User Avatar"
|
alt="User Avatar"
|
||||||
class="rounded-circle avatar-preview"
|
class="rounded-circle avatar-preview"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue