mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2026-01-11 09:07:12 -05:00
237 lines
8.5 KiB
Vue
237 lines
8.5 KiB
Vue
<template>
|
|
<slot name="activator">
|
|
<v-btn @click="dialog = true" variant="plain" density="default" :icon="mobile">
|
|
<v-icon icon="fa-solid fa-search" class="mr-1 fa-fw"></v-icon>
|
|
<span class="d-none d-sm-block">{{ $t('Search') }}</span>
|
|
<v-chip size="x-small" variant="tonal" class="d-none d-md-flex ml-1" label>{{ $t('Ctrl+K') }}</v-chip>
|
|
</v-btn>
|
|
</slot>
|
|
|
|
<v-dialog v-model="dialog" location="id_dialog_anchor"
|
|
location-strategy="connected"
|
|
:max-width="(mobile) ? '100vw': '800px'"
|
|
:fullscreen="mobile"
|
|
>
|
|
|
|
<v-card>
|
|
<v-closable-card-title :title="$t('Search')" v-model="dialog"></v-closable-card-title>
|
|
<!-- search input -->
|
|
<v-card-text class="pt-0 pt-md-2">
|
|
<v-text-field
|
|
id="id_global_search_input"
|
|
v-model="searchQuery"
|
|
@update:modelValue="debouncedAsyncSearch"
|
|
autocomplete="off"
|
|
clearable
|
|
placeholder="Search"
|
|
prepend-inner-icon="fas fa-search"
|
|
variant="solo"
|
|
></v-text-field>
|
|
|
|
<v-card :variant="cardVariant(index)" v-for="(item, index) in searchResults" hover class="mt-1" @click="selectedResult = index" :key="index">
|
|
<v-card-title @click="goToSelectedRecipe(index)">
|
|
<v-avatar v-if="item.image" :image="item.image"></v-avatar>
|
|
<v-avatar v-else-if="item.recipeId !== undefined" color="tandoor">{{ item.name.charAt(0) }}</v-avatar>
|
|
<v-icon :icon="item.icon" v-if="item.icon"></v-icon>
|
|
{{ item.name }}
|
|
</v-card-title>
|
|
</v-card>
|
|
</v-card-text>
|
|
|
|
<v-divider class="d-none d-sm-block"></v-divider>
|
|
<!-- keybind info shown on screens at least sm -->
|
|
<v-card-text class="d-none d-sm-block pt-2">
|
|
<v-chip size="x-small" class="mr-1" label><i class="fas fa-arrow-up"></i></v-chip>
|
|
<v-chip size="x-small" class="mr-1" label><i class="fas fa-arrow-down"></i></v-chip>
|
|
<small class="mr-2">{{ $t('to_navigate') }}</small>
|
|
<v-chip size="x-small" class="mr-1" label><i class="fas fa-level-down-alt fa-rotate-90"></i></v-chip>
|
|
<small class="mr-2">{{ $t('to_select') }}</small>
|
|
<v-chip size="x-small" class="mr-1" label> esc</v-chip>
|
|
<small>{{ $t('to_close') }}</small>
|
|
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<v-btn @click="dialog=false" :to="{name: 'SearchPage'}" variant="plain" prepend-icon="$search">{{ $t('Advanced') }}</v-btn>
|
|
<v-btn @click="dialog=false" variant="plain">{{ $t('Close') }}</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
|
|
|
|
</v-dialog>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
|
|
import {computed, onMounted, ref, watch} from 'vue'
|
|
import {SearchResult} from "@/types/SearchTypes";
|
|
import {ApiApi, Recipe, RecipeFlat, RecipeOverview} from "@/openapi";
|
|
import {useRouter} from "vue-router";
|
|
import {useDisplay} from "vuetify";
|
|
import VClosableCardTitle from "@/components/dialogs/VClosableCardTitle.vue";
|
|
import {useDebounceFn} from "@vueuse/core";
|
|
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
|
|
import {useI18n} from "vue-i18n";
|
|
import SearchPage from "@/pages/SearchPage.vue";
|
|
|
|
const router = useRouter()
|
|
const {mobile} = useDisplay()
|
|
const {t} = useI18n()
|
|
|
|
const dialog = ref(false)
|
|
const recipes = ref([] as Recipe[])
|
|
const flatRecipes = ref([] as RecipeFlat[])
|
|
const searchQuery = ref(null as string | null)
|
|
const selectedResult = ref(0)
|
|
const asyncSearchResults = ref([] as RecipeOverview[])
|
|
|
|
const flatListLoading = ref(false)
|
|
const asyncLoading = ref(false)
|
|
|
|
/**
|
|
* build array of search results
|
|
* uses custom type to be able to incorporate recent items, plans, books, ... at a later stage
|
|
*/
|
|
const searchResults = computed(() => {
|
|
let searchResults = [] as Array<SearchResult>
|
|
|
|
if (searchQuery.value != '' && searchQuery.value != null) {
|
|
flatRecipes.value.filter(fr => fr.name.toLowerCase().includes(searchQuery.value.toLowerCase())).slice(0, 10).forEach(r => {
|
|
searchResults.push({name: r.name, image: r.image, recipeId: r.id, type: "recipe"} as SearchResult)
|
|
})
|
|
|
|
if (searchResults.length < 3) {
|
|
asyncSearchResults.value.slice(0, 5).forEach(r => {
|
|
if (searchResults.findIndex(x => x.recipeId == r.id) == -1) {
|
|
searchResults.push({name: r.name, image: r.image, recipeId: r.id, type: "recipe"})
|
|
}
|
|
})
|
|
}
|
|
|
|
searchResults.push({name: searchQuery.value, icon: 'fas fa-search', type: "link_advanced_search"} as SearchResult)
|
|
|
|
} else {
|
|
// show first 5 recipes by default
|
|
|
|
// TODO special "quick links" if applicable
|
|
// searchResults.push({name: 'Recent 1', icon: 'fas fa-history',} as SearchResult)
|
|
// searchResults.push({name: 'Recent 2', icon: 'fas fa-history',} as SearchResult)
|
|
// searchResults.push({name: 'Recent 3', icon: 'fas fa-history',} as SearchResult)
|
|
|
|
searchResults.push({name: t('AllRecipes'), icon: 'fas fa-search', type: "link_advanced_search"} as SearchResult)
|
|
|
|
flatRecipes.value.slice(0, 5).forEach(r => {
|
|
searchResults.push({name: r.name, image: r.image, recipeId: r.id} as SearchResult)
|
|
})
|
|
}
|
|
|
|
return searchResults
|
|
})
|
|
|
|
watch(dialog, (newValue) => {
|
|
/**
|
|
* since dialog has no opened event watch the variable and focus input after delay (nextTick/directly does not work)
|
|
*/
|
|
searchQuery.value = ""
|
|
setTimeout(() => {
|
|
if (newValue) {
|
|
let search = document.getElementById('id_global_search_input')
|
|
if (search != null) {
|
|
search.focus()
|
|
}
|
|
}
|
|
}, 20)
|
|
})
|
|
|
|
watch(searchQuery, () => {
|
|
/**
|
|
* update selected result if search result length changes due to search_query changes
|
|
*/
|
|
if (selectedResult.value >= searchResults.value.length) {
|
|
selectedResult.value = searchResults.value.length - 1
|
|
}
|
|
})
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('keydown', (e) => {
|
|
if (dialog.value) {
|
|
if (e.key == 'ArrowUp') {
|
|
selectedResult.value = Math.max(0, selectedResult.value - 1)
|
|
}
|
|
if (e.key == 'ArrowDown') {
|
|
selectedResult.value = Math.min(searchResults.value.length, selectedResult.value + 1)
|
|
}
|
|
if (e.key == 'Enter') {
|
|
goToSelectedRecipe(selectedResult.value)
|
|
}
|
|
} else {
|
|
if (e.key == 'k' && e.ctrlKey) {
|
|
e.preventDefault();
|
|
dialog.value = true
|
|
}
|
|
}
|
|
})
|
|
|
|
flatListLoading.value = true
|
|
const api = new ApiApi()
|
|
api.apiRecipeFlatList().then(r => {
|
|
flatRecipes.value = r
|
|
}).catch(err => {
|
|
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
|
|
}).finally(() => {
|
|
flatListLoading.value = false
|
|
})
|
|
})
|
|
|
|
/**
|
|
* search for query on server but debounce search so its not searched on every keypress
|
|
*/
|
|
const debouncedAsyncSearch = useDebounceFn(() => {
|
|
if (searchQuery.value != null && searchQuery.value != '') {
|
|
let api = new ApiApi()
|
|
asyncLoading.value = true
|
|
api.apiRecipeList({query: searchQuery.value}).then(r => {
|
|
asyncSearchResults.value = r.results
|
|
}).catch(err => {
|
|
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
|
|
}).finally(() => {
|
|
asyncLoading.value = false
|
|
})
|
|
}
|
|
}, 300)
|
|
|
|
/**
|
|
* determines the style for selected elements
|
|
* @param index index of card to determine style for
|
|
*/
|
|
function cardVariant(index: number) {
|
|
if (selectedResult.value == index) {
|
|
return 'tonal'
|
|
} else {
|
|
return 'elevated'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* open selected recipe
|
|
*/
|
|
function goToSelectedRecipe(index: number) {
|
|
dialog.value = false
|
|
let searchResult = searchResults.value[index]
|
|
|
|
if (searchResult.type == 'link_advanced_search') {
|
|
router.push({name: 'SearchPage', query: {'query': searchQuery.value}})
|
|
} else {
|
|
console.log('going to', searchResult.recipeId)
|
|
if (searchResult.recipeId != null) {
|
|
router.push({name: 'RecipeViewPage', params: {'id': searchResult.recipeId}})
|
|
}
|
|
}
|
|
|
|
|
|
}
|
|
</script>
|
|
|
|
|
|
<style scoped>
|
|
|
|
</style> |