feat(ui): implement search page for desktop

This commit is contained in:
Chris Hayes 2024-08-11 03:22:46 -04:00
parent 1d62c2640e
commit 7ee31461f8
No known key found for this signature in database
8 changed files with 190 additions and 41 deletions

View file

@ -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> -->

View 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>

View file

@ -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"

View file

@ -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>

View file

@ -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"

View file

@ -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')

View file

@ -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 />

View file

@ -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>