mirror of
https://github.com/elk-zone/elk.git
synced 2024-11-27 03:39:43 +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">
|
<script setup lang="ts">
|
||||||
import type { SearchResult } from '~/composables/masto/search'
|
import type { SearchResult } from '~/composables/masto/search'
|
||||||
|
|
||||||
defineProps<{
|
const { showActions = false } = defineProps<{
|
||||||
result: SearchResult
|
result: SearchResult
|
||||||
active: boolean
|
active: boolean
|
||||||
|
showActions?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function onActivate() {
|
function onActivate() {
|
||||||
|
@ -23,7 +24,7 @@ function onActivate() {
|
||||||
>
|
>
|
||||||
<SearchHashtagInfo v-if="result.type === 'hashtag'" :hashtag="result.data" />
|
<SearchHashtagInfo v-if="result.type === 'hashtag'" :hashtag="result.data" />
|
||||||
<SearchAccountInfo v-else-if="result.type === 'account'" :account="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>
|
<!-- <div v-else-if="result.type === 'action'" text-center>
|
||||||
{{ result.action!.label }}
|
{{ result.action!.label }}
|
||||||
</div> -->
|
</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 index = ref(0)
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const el = ref<HTMLElement>()
|
const searchWidgetElem = ref<HTMLElement>()
|
||||||
const input = ref<HTMLInputElement>()
|
const input = ref<HTMLInputElement>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { focused } = useFocusWithin(el)
|
const { focused } = useFocusWithin(searchWidgetElem)
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
input,
|
input,
|
||||||
|
@ -21,15 +21,6 @@ const results = computed(() => {
|
||||||
...hashtags.value.slice(0, 3),
|
...hashtags.value.slice(0, 3),
|
||||||
...accounts.value,
|
...accounts.value,
|
||||||
...statuses.value,
|
...statuses.value,
|
||||||
|
|
||||||
// Disable until search page is implemented
|
|
||||||
// {
|
|
||||||
// type: 'action',
|
|
||||||
// to: `/search?q=${query.value}`,
|
|
||||||
// action: {
|
|
||||||
// label: `Search for ${query.value}`,
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
@ -48,10 +39,8 @@ function activate() {
|
||||||
if (query.value.length === 0)
|
if (query.value.length === 0)
|
||||||
return
|
return
|
||||||
|
|
||||||
// Disable redirection until search page is implemented
|
|
||||||
if (currentIndex === -1) {
|
if (currentIndex === -1) {
|
||||||
index.value = 0
|
router.push(`/search?q=${query.value}`)
|
||||||
// router.push(`/search?q=${query.value}`)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -63,9 +52,10 @@ function activate() {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 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
|
<input
|
||||||
ref="input"
|
ref="input"
|
||||||
v-model="query"
|
v-model="query"
|
||||||
|
|
|
@ -42,7 +42,7 @@ function reply() {
|
||||||
color="text-blue" hover="text-blue" elk-group-hover="bg-blue/10"
|
color="text-blue" hover="text-blue" elk-group-hover="bg-blue/10"
|
||||||
icon="i-ri:chat-1-line"
|
icon="i-ri:chat-1-line"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="reply"
|
@click.prevent="reply"
|
||||||
>
|
>
|
||||||
<template v-if="status.repliesCount && !getPreferences(userSettings, 'hideReplyCount')" #text>
|
<template v-if="status.repliesCount && !getPreferences(userSettings, 'hideReplyCount')" #text>
|
||||||
<CommonLocalizedNumber
|
<CommonLocalizedNumber
|
||||||
|
@ -64,7 +64,7 @@ function reply() {
|
||||||
:active="!!status.reblogged"
|
:active="!!status.reblogged"
|
||||||
:disabled="isLoading.reblogged || !canReblog"
|
:disabled="isLoading.reblogged || !canReblog"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleReblog()"
|
@click.prevent="toggleReblog()"
|
||||||
>
|
>
|
||||||
<template v-if="status.reblogsCount && !getPreferences(userSettings, 'hideBoostCount')" #text>
|
<template v-if="status.reblogsCount && !getPreferences(userSettings, 'hideBoostCount')" #text>
|
||||||
<CommonLocalizedNumber
|
<CommonLocalizedNumber
|
||||||
|
@ -87,7 +87,7 @@ function reply() {
|
||||||
:active="!!status.favourited"
|
:active="!!status.favourited"
|
||||||
:disabled="isLoading.favourited"
|
:disabled="isLoading.favourited"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleFavourite()"
|
@click.prevent="toggleFavourite()"
|
||||||
>
|
>
|
||||||
<template v-if="status.favouritesCount && !getPreferences(userSettings, 'hideFavoriteCount')" #text>
|
<template v-if="status.favouritesCount && !getPreferences(userSettings, 'hideFavoriteCount')" #text>
|
||||||
<CommonLocalizedNumber
|
<CommonLocalizedNumber
|
||||||
|
@ -109,7 +109,7 @@ function reply() {
|
||||||
:active="!!status.bookmarked"
|
:active="!!status.bookmarked"
|
||||||
:disabled="isLoading.bookmarked"
|
:disabled="isLoading.bookmarked"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleBookmark()"
|
@click.prevent="toggleBookmark()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -132,7 +132,7 @@ function showFavoritedAndBoostedBy() {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CommonDropdown flex-none ms3 placement="bottom" :eager-mount="command">
|
<CommonDropdown flex-none ms3 placement="bottom" :eager-mount="command" @click.prevent>
|
||||||
<StatusActionButton
|
<StatusActionButton
|
||||||
:content="$t('action.more')"
|
:content="$t('action.more')"
|
||||||
color="text-primary"
|
color="text-primary"
|
||||||
|
|
|
@ -3,5 +3,6 @@ import { breakpointsTailwind } from '@vueuse/core'
|
||||||
export const breakpoints = useBreakpoints(breakpointsTailwind)
|
export const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||||
|
|
||||||
export const isSmallScreen = breakpoints.smallerOrEqual('sm')
|
export const isSmallScreen = breakpoints.smallerOrEqual('sm')
|
||||||
|
export const isTabletOrLarger = breakpoints.isGreater('sm')
|
||||||
export const isMediumOrLargeScreen = breakpoints.between('sm', 'xl')
|
export const isMediumOrLargeScreen = breakpoints.between('sm', 'xl')
|
||||||
export const isExtraLargeScreen = breakpoints.smallerOrEqual('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">
|
<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>
|
<div sticky top-0 h-100dvh flex="~ col" gap-2 py3 ms-2>
|
||||||
<slot name="right">
|
<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 />
|
<div flex-auto />
|
||||||
|
|
||||||
<PwaPrompt />
|
<PwaPrompt />
|
||||||
|
|
|
@ -1,24 +1,52 @@
|
||||||
<script setup lang="ts">
|
<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 { t } = useI18n()
|
||||||
|
const query = ref('')
|
||||||
|
const { accounts, hashtags, statuses, loading } = useSearch(query)
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const userSettings = useUserSettings()
|
||||||
|
|
||||||
useHydratedHead({
|
const searchInput = ref<HTMLInputElement | null>(null)
|
||||||
title: () => t('nav.search'),
|
|
||||||
|
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 }>()
|
watch(route, () => {
|
||||||
|
updateQueryFromRoute()
|
||||||
watchEffect(() => {
|
|
||||||
if (search.value?.input)
|
|
||||||
search.value?.input?.focus()
|
|
||||||
})
|
})
|
||||||
onActivated(() => search.value?.input?.focus())
|
|
||||||
onDeactivated(() => search.value?.input?.blur())
|
|
||||||
|
|
||||||
watch(keys['/'], (v) => {
|
watch(query, (newQuery: string) => {
|
||||||
// focus on input when '/' is up to avoid '/' being typed
|
router.replace({ query: { q: newQuery } })
|
||||||
if (!v)
|
|
||||||
search.value?.input?.focus()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -27,12 +55,102 @@ watch(keys['/'], (v) => {
|
||||||
<template #title>
|
<template #title>
|
||||||
<NuxtLink to="/search" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
|
<NuxtLink to="/search" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
|
||||||
<div i-ri:search-line class="rtl-flip" />
|
<div i-ri:search-line class="rtl-flip" />
|
||||||
<span>{{ $t('nav.search') }}</span>
|
<span>{{ t('nav.search') }}</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</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>
|
bg="[rgba(var(--rgb-bg-base),0.7)]"
|
||||||
<SearchWidget v-if="isHydrated" ref="search" m-1 />
|
: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>
|
</div>
|
||||||
</MainContent>
|
</MainContent>
|
||||||
</template>
|
</template>
|
||||||
|
|
Loading…
Reference in a new issue