2026-03-31 15:10:32 +00:00
|
|
|
<template>
|
2026-04-01 14:48:31 +00:00
|
|
|
<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"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<div
|
2026-04-02 19:36:49 +00:00
|
|
|
v-if="showDropdown && filteredUsers && filteredUsers.length > 0"
|
2026-04-01 14:48:31 +00:00
|
|
|
class="dropdown-menu show w-100"
|
|
|
|
|
style="max-height: 300px; overflow-y: auto"
|
2026-03-31 15:10:32 +00:00
|
|
|
>
|
2026-04-01 14:48:31 +00:00
|
|
|
<a
|
|
|
|
|
v-for="user in filteredUsers"
|
|
|
|
|
:key="user.id"
|
|
|
|
|
href="#"
|
|
|
|
|
class="dropdown-item d-flex align-items-center"
|
|
|
|
|
@mousedown.prevent="selectUser(user)"
|
|
|
|
|
>
|
2026-04-02 15:39:52 +00:00
|
|
|
<Avatar :user="user" />
|
2026-04-01 14:48:31 +00:00
|
|
|
|
|
|
|
|
<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>
|
2026-03-31 15:10:32 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-04-01 14:48:31 +00:00
|
|
|
<div
|
|
|
|
|
v-if="
|
|
|
|
|
showDropdown &&
|
|
|
|
|
searchQuery.length >= minChars &&
|
2026-04-02 19:36:49 +00:00
|
|
|
filteredUsers &&
|
2026-04-01 14:48:31 +00:00
|
|
|
filteredUsers.length === 0
|
|
|
|
|
"
|
|
|
|
|
class="dropdown-menu show w-100"
|
|
|
|
|
>
|
|
|
|
|
<div class="dropdown-item text-muted">No users found</div>
|
2026-03-31 15:10:32 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-04-02 19:36:49 +00:00
|
|
|
import { ref, onMounted, watch } from "vue";
|
|
|
|
|
import { computedAsync } from "@vueuse/core";
|
2026-04-02 15:39:52 +00:00
|
|
|
import Avatar from "@/components/Avatar.vue";
|
2026-03-31 15:10:32 +00:00
|
|
|
import { useUserStore } from "@/store/user";
|
2026-04-09 00:25:21 +00:00
|
|
|
import type { User } from "@/type/api";
|
2026-03-31 15:10:32 +00:00
|
|
|
|
|
|
|
|
interface Props {
|
2026-04-02 17:39:16 +00:00
|
|
|
modelValue?: User | null;
|
2026-03-31 15:10:32 +00:00
|
|
|
placeholder?: string;
|
2026-04-01 14:48:31 +00:00
|
|
|
minChars?: number;
|
2026-03-31 15:10:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
2026-04-02 17:39:16 +00:00
|
|
|
modelValue: null,
|
2026-04-01 14:48:31 +00:00
|
|
|
placeholder: "Search users...",
|
|
|
|
|
minChars: 3,
|
2026-03-31 15:10:32 +00:00
|
|
|
});
|
|
|
|
|
|
2026-04-01 14:48:31 +00:00
|
|
|
const emit = defineEmits<{
|
2026-04-02 17:39:16 +00:00
|
|
|
"update:modelValue": [user: User | null];
|
2026-04-01 14:48:31 +00:00
|
|
|
}>();
|
2026-03-31 15:10:32 +00:00
|
|
|
|
|
|
|
|
const usersStore = useUserStore();
|
2026-04-01 14:48:31 +00:00
|
|
|
const searchQuery = ref("");
|
|
|
|
|
const showDropdown = ref(false);
|
2026-03-31 15:10:32 +00:00
|
|
|
|
|
|
|
|
onMounted(async () => {
|
2026-04-01 14:48:31 +00:00
|
|
|
// Fetch all users if not already loaded
|
2026-04-02 19:36:49 +00:00
|
|
|
usersStore.withAll().then((all: User[]) => {
|
|
|
|
|
// Initialize search query with selected user's name if provided
|
|
|
|
|
if (props.modelValue) {
|
|
|
|
|
searchQuery.value = props.modelValue.display_name;
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-04-01 14:48:31 +00:00
|
|
|
});
|
|
|
|
|
|
2026-04-02 17:39:16 +00:00
|
|
|
// Watch for external changes to modelValue
|
|
|
|
|
watch(
|
|
|
|
|
() => props.modelValue,
|
|
|
|
|
(newValue) => {
|
|
|
|
|
if (newValue) {
|
|
|
|
|
searchQuery.value = newValue.display_name;
|
|
|
|
|
} else {
|
|
|
|
|
searchQuery.value = "";
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-02 19:36:49 +00:00
|
|
|
const filteredUsers = computedAsync(async (): Promise<User[]> => {
|
|
|
|
|
if (searchQuery.value.length < props.minChars) {
|
2026-04-01 14:48:31 +00:00
|
|
|
return [];
|
2026-03-31 15:10:32 +00:00
|
|
|
}
|
2026-04-01 14:48:31 +00:00
|
|
|
|
|
|
|
|
const query = searchQuery.value.toLowerCase();
|
|
|
|
|
|
2026-04-02 19:36:49 +00:00
|
|
|
const users = await usersStore.withAll();
|
|
|
|
|
return users
|
2026-04-01 14:48:31 +00:00
|
|
|
.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
|
2026-03-31 15:10:32 +00:00
|
|
|
});
|
2026-04-01 14:48:31 +00:00
|
|
|
|
|
|
|
|
function onInput() {
|
|
|
|
|
showDropdown.value = searchQuery.value.length >= props.minChars;
|
2026-04-02 17:39:16 +00:00
|
|
|
|
|
|
|
|
// Clear selection if user is typing
|
|
|
|
|
if (props.modelValue && searchQuery.value !== props.modelValue.display_name) {
|
|
|
|
|
emit("update:modelValue", null);
|
|
|
|
|
}
|
2026-04-01 14:48:31 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-04-02 17:39:16 +00:00
|
|
|
emit("update:modelValue", user);
|
2026-04-01 14:48:31 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-03-31 15:10:32 +00:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-04-01 14:48:31 +00:00
|
|
|
.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;
|
2026-03-31 15:10:32 +00:00
|
|
|
}
|
|
|
|
|
</style>
|