mirror of
https://github.com/elk-zone/elk.git
synced 2024-11-23 09:55:28 +03:00
feat(ui): implement search page for desktop
This commit is contained in:
parent
1d62c2640e
commit
7ee31461f8
8 changed files with 190 additions and 41 deletions
|
@ -1,9 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import type { SearchResult } from '~/composables/masto/search'
|
||||
|
||||
defineProps<{
|
||||
const { showActions = false } = defineProps<{
|
||||
result: SearchResult
|
||||
active: boolean
|
||||
showActions?: boolean
|
||||
}>()
|
||||
|
||||
function onActivate() {
|
||||
|
@ -23,7 +24,7 @@ function onActivate() {
|
|||
>
|
||||
<SearchHashtagInfo v-if="result.type === 'hashtag'" :hashtag="result.data" />
|
||||
<SearchAccountInfo v-else-if="result.type === 'account'" :account="result.data" />
|
||||
<StatusCard v-else-if="result.type === 'status'" :status="result.data" :actions="false" :show-reply-to="false" />
|
||||
<StatusCard v-else-if="result.type === 'status'" :status="result.data" :actions="showActions" :show-reply-to="showActions" />
|
||||
<!-- <div v-else-if="result.type === 'action'" text-center>
|
||||
{{ result.action!.label }}
|
||||
</div> -->
|
||||
|
|
39
components/search/SearchResultList.vue
Normal file
39
components/search/SearchResultList.vue
Normal file
|
@ -0,0 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { showActions = false, focusedOnResults = true, query, results, index } = defineProps<{
|
||||
results: SearchResult[]
|
||||
query: string
|
||||
focusedOnResults?: boolean
|
||||
showActions?: boolean
|
||||
index?: number
|
||||
}>()
|
||||
|
||||
const { loading } = useSearch(query)
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-if="query.trim().length === 0" block text-center text-sm text-secondary>
|
||||
{{ t('search.search_desc') }}
|
||||
</span>
|
||||
<template v-else-if="!loading">
|
||||
<template v-if="results.length > 0">
|
||||
<SearchResult
|
||||
v-for="(result, i) in results" :key="result.id"
|
||||
:active="index === parseInt(i.toString())"
|
||||
:result="result"
|
||||
:tabindex="focusedOnResults ? 0 : -1"
|
||||
:show-actions="showActions"
|
||||
/>
|
||||
</template>
|
||||
<span v-else block text-center text-sm text-secondary>
|
||||
{{ t('search.search_empty') }}
|
||||
</span>
|
||||
</template>
|
||||
<div v-else>
|
||||
<SearchResultSkeleton />
|
||||
<SearchResultSkeleton />
|
||||
<SearchResultSkeleton />
|
||||
</div>
|
||||
</template>
|
|
@ -4,10 +4,10 @@ const { accounts, hashtags, loading, statuses } = useSearch(query)
|
|||
const index = ref(0)
|
||||
|
||||
const { t } = useI18n()
|
||||
const el = ref<HTMLElement>()
|
||||
const searchWidgetElem = ref<HTMLElement>()
|
||||
const input = ref<HTMLInputElement>()
|
||||
const router = useRouter()
|
||||
const { focused } = useFocusWithin(el)
|
||||
const { focused } = useFocusWithin(searchWidgetElem)
|
||||
|
||||
defineExpose({
|
||||
input,
|
||||
|
@ -21,15 +21,6 @@ const results = computed(() => {
|
|||
...hashtags.value.slice(0, 3),
|
||||
...accounts.value,
|
||||
...statuses.value,
|
||||
|
||||
// Disable until search page is implemented
|
||||
// {
|
||||
// type: 'action',
|
||||
// to: `/search?q=${query.value}`,
|
||||
// action: {
|
||||
// label: `Search for ${query.value}`,
|
||||
// },
|
||||
// },
|
||||
]
|
||||
|
||||
return results
|
||||
|
@ -48,10 +39,8 @@ function activate() {
|
|||
if (query.value.length === 0)
|
||||
return
|
||||
|
||||
// Disable redirection until search page is implemented
|
||||
if (currentIndex === -1) {
|
||||
index.value = 0
|
||||
// router.push(`/search?q=${query.value}`)
|
||||
router.push(`/search?q=${query.value}`)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -63,9 +52,10 @@ function activate() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="el" relative group>
|
||||
<div ref="searchWidgetElem" relative group>
|
||||
<div bg-base border="~ base" h10 ps-4 pe-1 rounded-3 flex="~ row" items-center relative focus-within:box-shadow-outline>
|
||||
<div i-ri:search-2-line pointer-events-none text-secondary mt="1px" class="rtl-flip" />
|
||||
<div v-if="loading" animate-spin preserve-3d i-ri:loader-2-line pointer-events-none text-secondary mt-1 />
|
||||
<div v-else i-ri:search-2-line pointer-events-none text-secondary mt="1px" />
|
||||
<input
|
||||
ref="input"
|
||||
v-model="query"
|
||||
|
|
|
@ -42,7 +42,7 @@ function reply() {
|
|||
color="text-blue" hover="text-blue" elk-group-hover="bg-blue/10"
|
||||
icon="i-ri:chat-1-line"
|
||||
:command="command"
|
||||
@click="reply"
|
||||
@click.prevent="reply"
|
||||
>
|
||||
<template v-if="status.repliesCount && !getPreferences(userSettings, 'hideReplyCount')" #text>
|
||||
<CommonLocalizedNumber
|
||||
|
@ -64,7 +64,7 @@ function reply() {
|
|||
:active="!!status.reblogged"
|
||||
:disabled="isLoading.reblogged || !canReblog"
|
||||
:command="command"
|
||||
@click="toggleReblog()"
|
||||
@click.prevent="toggleReblog()"
|
||||
>
|
||||
<template v-if="status.reblogsCount && !getPreferences(userSettings, 'hideBoostCount')" #text>
|
||||
<CommonLocalizedNumber
|
||||
|
@ -87,7 +87,7 @@ function reply() {
|
|||
:active="!!status.favourited"
|
||||
:disabled="isLoading.favourited"
|
||||
:command="command"
|
||||
@click="toggleFavourite()"
|
||||
@click.prevent="toggleFavourite()"
|
||||
>
|
||||
<template v-if="status.favouritesCount && !getPreferences(userSettings, 'hideFavoriteCount')" #text>
|
||||
<CommonLocalizedNumber
|
||||
|
@ -109,7 +109,7 @@ function reply() {
|
|||
:active="!!status.bookmarked"
|
||||
:disabled="isLoading.bookmarked"
|
||||
:command="command"
|
||||
@click="toggleBookmark()"
|
||||
@click.prevent="toggleBookmark()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -132,7 +132,7 @@ function showFavoritedAndBoostedBy() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<CommonDropdown flex-none ms3 placement="bottom" :eager-mount="command">
|
||||
<CommonDropdown flex-none ms3 placement="bottom" :eager-mount="command" @click.prevent>
|
||||
<StatusActionButton
|
||||
:content="$t('action.more')"
|
||||
color="text-primary"
|
||||
|
|
|
@ -3,5 +3,6 @@ import { breakpointsTailwind } from '@vueuse/core'
|
|||
export const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
|
||||
export const isSmallScreen = breakpoints.smallerOrEqual('sm')
|
||||
export const isTabletOrLarger = breakpoints.isGreater('sm')
|
||||
export const isMediumOrLargeScreen = breakpoints.between('sm', 'xl')
|
||||
export const isExtraLargeScreen = breakpoints.smallerOrEqual('xl')
|
||||
|
|
|
@ -62,7 +62,7 @@ const isGrayscale = usePreferences('grayscaleMode')
|
|||
<aside v-if="isHydrated && !wideLayout" class="hidden lg:w-1/5 xl:w-1/4 sm:none xl:block native:w-full zen-hide">
|
||||
<div sticky top-0 h-100dvh flex="~ col" gap-2 py3 ms-2>
|
||||
<slot name="right">
|
||||
<SearchWidget mt-4 mx-1 hidden xl:block />
|
||||
<SearchWidget v-if="route.name !== 'server-search'" mt-4 mx-1 hidden xl:block />
|
||||
<div flex-auto />
|
||||
|
||||
<PwaPrompt />
|
||||
|
|
|
@ -1,24 +1,52 @@
|
|||
<script setup lang="ts">
|
||||
const keys = useMagicKeys()
|
||||
import { computed, onBeforeMount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const query = ref('')
|
||||
const { accounts, hashtags, statuses, loading } = useSearch(query)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userSettings = useUserSettings()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => t('nav.search'),
|
||||
const searchInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const MAX_VISIBLE_RESULTS = isTabletOrLarger ? 6 : 3
|
||||
|
||||
const visibleHashtagsCount = ref(MAX_VISIBLE_RESULTS)
|
||||
const visibleAccountsCount = ref(MAX_VISIBLE_RESULTS)
|
||||
|
||||
const limitedHashtags = computed(() => hashtags.value.slice(0, visibleHashtagsCount.value))
|
||||
const limitedAccounts = computed(() => accounts.value.slice(0, visibleAccountsCount.value))
|
||||
|
||||
const showMoreHashtags = function () {
|
||||
visibleHashtagsCount.value = visibleHashtagsCount.value + MAX_VISIBLE_RESULTS
|
||||
}
|
||||
|
||||
const showMoreAccounts = function () {
|
||||
visibleAccountsCount.value = visibleAccountsCount.value + MAX_VISIBLE_RESULTS
|
||||
}
|
||||
|
||||
const resultCount = computed(() => hashtags.value.length + accounts.value.length + statuses.value.length)
|
||||
|
||||
function updateQueryFromRoute() {
|
||||
const queryParam = route.query.q ? String(route.query.q) : ''
|
||||
query.value = queryParam
|
||||
visibleHashtagsCount.value = MAX_VISIBLE_RESULTS
|
||||
visibleAccountsCount.value = MAX_VISIBLE_RESULTS
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
updateQueryFromRoute()
|
||||
})
|
||||
|
||||
const search = ref<{ input?: HTMLInputElement }>()
|
||||
|
||||
watchEffect(() => {
|
||||
if (search.value?.input)
|
||||
search.value?.input?.focus()
|
||||
watch(route, () => {
|
||||
updateQueryFromRoute()
|
||||
})
|
||||
onActivated(() => search.value?.input?.focus())
|
||||
onDeactivated(() => search.value?.input?.blur())
|
||||
|
||||
watch(keys['/'], (v) => {
|
||||
// focus on input when '/' is up to avoid '/' being typed
|
||||
if (!v)
|
||||
search.value?.input?.focus()
|
||||
watch(query, (newQuery: string) => {
|
||||
router.replace({ query: { q: newQuery } })
|
||||
})
|
||||
</script>
|
||||
|
||||
|
@ -27,12 +55,102 @@ watch(keys['/'], (v) => {
|
|||
<template #title>
|
||||
<NuxtLink to="/search" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
|
||||
<div i-ri:search-line class="rtl-flip" />
|
||||
<span>{{ $t('nav.search') }}</span>
|
||||
<span>{{ t('nav.search') }}</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<div px-2 mt-3>
|
||||
<!-- Search input -->
|
||||
<header group sticky top-18 md:top-9 z-10>
|
||||
<div
|
||||
border rounded-3 flex h-10 ps-4 pr-1 items-center relative focus-within:box-shadow-outline
|
||||
|
||||
<div px2 mt3>
|
||||
<SearchWidget v-if="isHydrated" ref="search" m-1 />
|
||||
bg="[rgba(var(--rgb-bg-base),0.7)]"
|
||||
:class="{
|
||||
'backdrop-blur-md': !getPreferences(userSettings, 'optimizeForLowPerformanceDevice'),
|
||||
}"
|
||||
>
|
||||
<div v-if="loading" animate-spin preserve-3d i-ri:loader-2-line pointer-events-none text-secondary mt-1 />
|
||||
<div v-else i-ri:search-2-line pointer-events-none text-secondary />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="query"
|
||||
class="w-full bg-transparent px-3 ml-1 outline-none rounded-3"
|
||||
:placeholder="t('nav.search')"
|
||||
autofocus
|
||||
>
|
||||
<button v-if="query.length" btn-action-icon text-secondary @click="query = ''; searchInput?.focus()">
|
||||
<span aria-hidden="true" class="i-ri:close-line" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Results -->
|
||||
<div my-8>
|
||||
<div v-if="loading && resultCount === 0" class="search-results mt-3">
|
||||
<SearchResultSkeleton v-for="n in 3" :key="n" />
|
||||
</div>
|
||||
<div v-else-if="resultCount > 0" flex flex-col gap-4 mt-3 class="search-results transition-opacity-300" :class="loading ? 'opacity-50' : ''">
|
||||
<!-- Results: Hashtags section -->
|
||||
<section>
|
||||
<div v-if="limitedHashtags.length > 0" grid md:grid-cols-2 gap-4>
|
||||
<SearchResult
|
||||
v-for="result in limitedHashtags"
|
||||
:key="result.id"
|
||||
:result="result"
|
||||
:active="false"
|
||||
/>
|
||||
<button
|
||||
v-if="limitedHashtags.length < hashtags.length"
|
||||
md:col-span-2 p1 text-secondary text-center hover-underline focus-underline
|
||||
@click="showMoreHashtags"
|
||||
>
|
||||
{{ t('status.spoiler_show_more') }} {{ t('tab.hashtags').toLocaleLowerCase() }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Results: Accounts section -->
|
||||
<section>
|
||||
<div v-if="limitedAccounts.length > 0" grid md:grid-cols-2 gap-4>
|
||||
<SearchResult
|
||||
v-for="result in limitedAccounts"
|
||||
:key="result.id"
|
||||
:result="result"
|
||||
:active="false"
|
||||
/>
|
||||
<button
|
||||
v-if="limitedAccounts.length < accounts.length"
|
||||
md:col-span-2 p1 text-secondary text-center hover-underline focus-underline
|
||||
@click="showMoreAccounts"
|
||||
>
|
||||
{{ t('status.spoiler_show_more') }} {{ t('tab.accounts').toLocaleLowerCase() }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Results: Posts section -->
|
||||
<section>
|
||||
<div v-if="statuses.length > 0">
|
||||
<SearchResult
|
||||
v-for="result in statuses"
|
||||
:key="result.id"
|
||||
:result="result"
|
||||
:active="false"
|
||||
:show-actions="true"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
p5 text-secondary italic text-center
|
||||
>
|
||||
{{ t('common.end_of_list') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- No results -->
|
||||
<div
|
||||
v-else
|
||||
p5 mt4 text-secondary italic text-center
|
||||
>
|
||||
{{ t('search.search_empty') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainContent>
|
||||
</template>
|
||||
|
|
Loading…
Reference in a new issue