mirror of
https://github.com/ThatGuySam/doesitarm.git
synced 2026-05-15 06:35:20 -07:00
Resolve the Pagefind browser loader in Vite dev and cap filter-only result hydration so broad filters render promptly instead of stalling behind thousands of fragment fetches.
598 lines
22 KiB
Vue
598 lines
22 KiB
Vue
<template>
|
|
<div
|
|
ref="search-container"
|
|
class="search-container w-full space-y-4"
|
|
>
|
|
<slot name="before-search">
|
|
<div class="list-summary-wrapper flex justify-center text-center text-sm">
|
|
<ListSummary
|
|
v-if="summary !== null"
|
|
:custom-numbers="summary"
|
|
class="max-w-4xl"
|
|
/>
|
|
</div>
|
|
</slot>
|
|
|
|
<div class="search-input relative space-y-4">
|
|
<div>
|
|
<input
|
|
id="search"
|
|
ref="search"
|
|
v-model="userTextQuery"
|
|
:autofocus="autofocus"
|
|
aria-label="Type here to Search"
|
|
class="appearance-none w-full text-white font-hairline sm:text-5xl outline-none bg-transparent p-3"
|
|
type="search"
|
|
placeholder="Type to Search"
|
|
autocomplete="off"
|
|
@keyup="handleSearchInput"
|
|
>
|
|
<div class="search-input-separator border-white border-t-2" />
|
|
</div>
|
|
<div class="quick-buttons overflow-x-auto whitespace-no-wrap space-x-2">
|
|
<button
|
|
v-for="button in quickButtons"
|
|
:key="button.query"
|
|
:class="[
|
|
'inline-block text-xs rounded-lg py-1 px-2',
|
|
'border-2 border-white focus:outline-none',
|
|
hasActiveFilter( button.query ) ? 'border-opacity-50 bg-darkest' : 'border-opacity-0 neumorphic-shadow-inner'
|
|
]"
|
|
:aria-label="`Filter list by ${button.label}`"
|
|
@click="toggleFilter(button.query); queryResults()"
|
|
>{{ button.label }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="slot-wrapper">
|
|
<slot>
|
|
<AdInline
|
|
v-once
|
|
:name="isSSR ? 'placeholder' : 'default'"
|
|
:page="kindPage"
|
|
/>
|
|
</slot>
|
|
</div>
|
|
|
|
<div
|
|
ref="search-container"
|
|
class="search-container relative divide-y divide-gray-700 w-full rounded-lg border border-gray-700 bg-gradient-to-br from-darker to-dark my-8 px-5"
|
|
>
|
|
<svg style="display: none;">
|
|
<defs>
|
|
<path
|
|
id="chevron-right"
|
|
fill-rule="evenodd"
|
|
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</defs>
|
|
</svg>
|
|
|
|
<!-- hasStartedAnyQuery: {{ hasStartedAnyQuery }} -->
|
|
|
|
<template v-if="chunkedListings.length === 0">
|
|
<div
|
|
class="text-center py-4"
|
|
>
|
|
<span>No apps found for</span>
|
|
<span
|
|
v-for="term in userTerms"
|
|
:key="term"
|
|
class="font-bold border rounded px-1 pb-1 mx-1"
|
|
>{{ term }}</span>
|
|
|
|
<template v-if="isFilteredList">
|
|
<span>within</span>
|
|
|
|
<span
|
|
v-for="term in baseFilters"
|
|
:key="term"
|
|
class="font-bold border rounded px-1 pb-1 mx-1"
|
|
>{{ term }}</span>
|
|
</template>
|
|
</div>
|
|
|
|
<div
|
|
v-if="baseFilters.length > 0"
|
|
class="w-full flex justify-center p-6"
|
|
>
|
|
<LinkButton
|
|
href="/"
|
|
:class="[
|
|
'px-3 py-2 rounded-md text-sm pointer-events-auto focus:outline-none focus:text-white focus:bg-gray-700 transition duration-150 ease-in-out',
|
|
'text-gray-300 hover:bg-darker hover:neumorphic-shadow'
|
|
]"
|
|
:class-groups="{
|
|
shadow: 'hover:neumorphic-shadow',
|
|
bg: 'hover:bg-darker',
|
|
}"
|
|
label="🌎 Search Everything"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<ul
|
|
v-for="(listingsChunk, chunkIndex) in chunkedListings"
|
|
:key="`listings-chunk-${ chunkIndex }`"
|
|
class="listings-container divide-y divide-gray-700"
|
|
>
|
|
<!-- <pre>
|
|
{{ listingsChunk }}
|
|
</pre> -->
|
|
<li
|
|
v-for="(listing, listingIndex) in listingsChunk"
|
|
:key="`${ listing.slug }-${ listingIndex }`"
|
|
:ref="`${ listing.slug }-row`"
|
|
:data-app-slug="listing.slug"
|
|
class="relative"
|
|
>
|
|
<!-- app.endpoint: {{ app.endpoint }} -->
|
|
<a
|
|
:href="listing.endpoint"
|
|
:class="[
|
|
'flex flex-col justify-center inset-x-0 hover:bg-darkest border-2 border-white border-opacity-0 hover:border-opacity-50 focus:outline-none focus:bg-gray-50 duration-300 ease-in-out rounded-lg space-y-2 -mx-5 pl-5 md:pl-20 pr-6 md:pr-64 py-5',
|
|
listing?.linkClass
|
|
]"
|
|
style="transition-property: border;"
|
|
>
|
|
|
|
|
|
<div class="absolute hidden left-0 h-12 w-12 rounded-full md:flex items-center justify-center bg-darker">
|
|
{{ getIconForListing( listing ) }}
|
|
</div>
|
|
|
|
<h3
|
|
v-html="listing.name"
|
|
/>
|
|
|
|
<div class="text-sm leading-5 font-bold">
|
|
{{ listing.text }}
|
|
</div>
|
|
|
|
<div
|
|
v-if="listing.resultExcerptsMarkup?.length"
|
|
class="text-xs leading-5 font-light"
|
|
>
|
|
<div
|
|
v-for="(excerptMarkup, excerptIndex) in listing.resultExcerptsMarkup"
|
|
:key="`excerpt-${ excerptIndex }`"
|
|
class="result-excerpt space-y-3"
|
|
v-html="excerptMarkup"
|
|
/>
|
|
</div>
|
|
<!-- listing.lastUpdated: {{ listing.lastUpdated }} -->
|
|
<template v-if="listing.lastUpdated">
|
|
<small
|
|
class="text-xs opacity-50"
|
|
>
|
|
<RelativeTime
|
|
:timestamp="listing.lastUpdated.timestamp"
|
|
class="text-xs opacity-50"
|
|
/>
|
|
</small>
|
|
</template>
|
|
|
|
<!-- listing.endpoint: {{ listing.endpoint }} -->
|
|
|
|
</a>
|
|
|
|
|
|
<div
|
|
class="search-item-options relative md:absolute md:inset-0 w-full pointer-events-none"
|
|
>
|
|
<div class="search-item-options-container h-full flex justify-center md:justify-end items-center pb-4 md:py-4 md:px-4">
|
|
<LinkButton
|
|
v-for="link in getSearchLinks( listing )"
|
|
:key="`${ listing.slug }-${ link.label.toLowerCase() }`"
|
|
:href="link.href"
|
|
:class="[
|
|
'px-3 py-2 rounded-md text-sm pointer-events-auto focus:outline-none focus:text-white focus:bg-gray-700 transition duration-150 ease-in-out',
|
|
'text-gray-300 hover:bg-darker hover:neumorphic-shadow'
|
|
]"
|
|
:class-groups="{
|
|
// general: 'relative inline-flex items-center rounded-md px-4 py-2',
|
|
// font: 'leading-5 font-bold',
|
|
// text: 'text-white',
|
|
// border: 'border border-transparent focus:outline-none focus:border-indigo-600',
|
|
shadow: 'hover:neumorphic-shadow',
|
|
bg: 'hover:bg-darker',
|
|
// transition: 'transition duration-150 ease-in-out'
|
|
}"
|
|
:label="link.label"
|
|
/>
|
|
|
|
<LinkButton
|
|
v-if="listing.endpoint.length"
|
|
:href="listing.endpoint"
|
|
:class="[
|
|
'px-3 py-2 rounded-md text-sm pointer-events-auto focus:outline-none focus:text-white focus:bg-gray-700 transition duration-150 ease-in-out',
|
|
'text-gray-300 hover:bg-darker hover:neumorphic-shadow'
|
|
]"
|
|
:class-groups="{
|
|
// general: 'relative inline-flex items-center rounded-md px-4 py-2',
|
|
// font: 'leading-5 font-bold',
|
|
// text: 'text-white',
|
|
// border: 'border border-transparent focus:outline-none focus:border-indigo-600',
|
|
shadow: 'hover:neumorphic-shadow',
|
|
bg: 'hover:bg-darker',
|
|
// transition: 'transition duration-150 ease-in-out'
|
|
}"
|
|
label="Details »"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<li
|
|
v-if="showingInitialList"
|
|
class="list-navigation"
|
|
>
|
|
<nav
|
|
class="pagination w-full flex gap-6 justify-center py-4"
|
|
>
|
|
<LinkButton
|
|
v-if="previousPageUrl"
|
|
:href="previousPageUrl"
|
|
|
|
:class="[
|
|
'w-32 justify-end',
|
|
'rounded-md text-sm pointer-events-auto focus:outline-none focus:text-white focus:bg-gray-700 transition duration-150 ease-in-out px-3 py-2',
|
|
'text-gray-300 hover:bg-darker hover:neumorphic-shadow'
|
|
]"
|
|
:class-groups="{
|
|
shadow: 'hover:neumorphic-shadow',
|
|
bg: 'hover:bg-darker',
|
|
}"
|
|
label="← Previous"
|
|
/>
|
|
|
|
<LinkButton
|
|
v-if="nextPageUrl"
|
|
:href="nextPageUrl"
|
|
|
|
:class="[
|
|
'w-32',
|
|
'px-3 py-2 rounded-md text-sm pointer-events-auto focus:outline-none focus:text-white focus:bg-gray-700 transition duration-150 ease-in-out',
|
|
'text-gray-300 hover:bg-darker hover:neumorphic-shadow'
|
|
]"
|
|
:class-groups="{
|
|
shadow: 'hover:neumorphic-shadow',
|
|
bg: 'hover:bg-darker',
|
|
}"
|
|
label="Next →"
|
|
/>
|
|
</nav>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="list-end-area flex justify-center py-6">
|
|
<ListEndButtons
|
|
:query="userTextQuery"
|
|
/>
|
|
</div>
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import scrollIntoView from 'scroll-into-view-if-needed'
|
|
|
|
import {
|
|
defaultStatusFilters,
|
|
} from '~/helpers/statuses.js'
|
|
import {
|
|
getIconForListing
|
|
} from '~/helpers/app-derived.js'
|
|
import {
|
|
PagefindClient,
|
|
mapPagefindDataToListing
|
|
} from '~/helpers/pagefind/browser.js'
|
|
import {
|
|
getSearchProvider
|
|
} from '~/helpers/search/config.js'
|
|
import {
|
|
SearchFilters
|
|
} from '~/helpers/search/filters.js'
|
|
import {
|
|
StorkClient,
|
|
makeHighlightedMarkup,
|
|
makeHighlightedResultTitle
|
|
} from '~/helpers/stork/browser.js'
|
|
|
|
import AdInline from '~/components/ad-inline.vue'
|
|
import LinkButton from '~/components/link-button.vue'
|
|
import RelativeTime from '~/components/relative-time.vue'
|
|
import ListSummary from '~/components/list-summary.vue'
|
|
import ListEndButtons from '~/components/list-end-buttons.vue'
|
|
|
|
const searchProvider = getSearchProvider( import.meta.env.PUBLIC_SEARCH_PROVIDER )
|
|
|
|
export default {
|
|
components: {
|
|
AdInline,
|
|
ListSummary,
|
|
RelativeTime,
|
|
LinkButton,
|
|
ListEndButtons
|
|
},
|
|
props: {
|
|
kindPage: {
|
|
type: Object,
|
|
required: true
|
|
},
|
|
initialLimit: {
|
|
type: Number,
|
|
default: null
|
|
},
|
|
autofocus: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
quickButtons: {
|
|
type: Array,
|
|
default: () => defaultStatusFilters
|
|
},
|
|
baseFilters: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
listSummary: {
|
|
type: Object,
|
|
default: () => null
|
|
},
|
|
},
|
|
data: function () {
|
|
return {
|
|
userTextQuery: '',
|
|
filterQueryList: [],
|
|
hasStartedAnyQuery: false,
|
|
listingsResults: [],
|
|
waitingForQuery: false,
|
|
isSSR: import.meta.env.SSR,
|
|
searchClient: null,
|
|
searchFilters: null,
|
|
lastQueryId: 0
|
|
}
|
|
},
|
|
computed: {
|
|
activeSearchProvider () {
|
|
return searchProvider
|
|
},
|
|
activeQuery () {
|
|
return [
|
|
this.userTextQuery.trim(),
|
|
...this.filterQueryList
|
|
].filter( Boolean ).join(' ')
|
|
},
|
|
pagefindResultLimit () {
|
|
return Math.max( this.initialList.length, 25 )
|
|
},
|
|
pagefindFilters () {
|
|
const filters = new SearchFilters()
|
|
filters.setFromStringArray( this.filterQueryList )
|
|
|
|
return filters.asPagefindFilters
|
|
},
|
|
appList () {
|
|
return this.kindPage.items
|
|
},
|
|
initialList () {
|
|
return this.initialLimit !== null ? this.appList.slice(0, this.initialLimit) : this.appList
|
|
},
|
|
listings () {
|
|
// Build filler listings to use while loading results
|
|
if ( this.waitingForQuery ) return Array( 10 ).fill({ name: 'Loading', slug: 'loading', endpoint: '', linkClass: 'shimmer pointer-events-none' })
|
|
|
|
if ( this.showingInitialList ) return this.initialList
|
|
|
|
return this.listingsResults
|
|
},
|
|
// Chunk results to avoid having a parent node with more than 60 child nodes.
|
|
chunkedListings () {
|
|
|
|
const listings = [
|
|
...this.listings
|
|
]
|
|
|
|
const size = 25
|
|
const chunks = []
|
|
|
|
while (listings.length > 0)
|
|
chunks.push(listings.splice(0, size))
|
|
|
|
return chunks
|
|
},
|
|
isFilteredList () {
|
|
return this.baseFilters.length > 0
|
|
},
|
|
hasSearchInputText () {
|
|
return this.userTextQuery.trim().length > 0
|
|
},
|
|
hasAnyUserFilters () {
|
|
return this.userFilters.length > 0
|
|
},
|
|
hasAnyUserTerms () {
|
|
return this.userTerms.length > 0
|
|
},
|
|
showingInitialList () {
|
|
return !this.hasAnyUserTerms
|
|
},
|
|
inputTerms () {
|
|
return this.userTextQuery.trim().split(' ').filter( Boolean )
|
|
},
|
|
userFilters () {
|
|
// console.log('filterQueryList', )
|
|
return this.filterQueryList.filter( filterTerm => {
|
|
return !this.baseFilters.includes( filterTerm )
|
|
})
|
|
},
|
|
userTerms () {
|
|
// If out input is empty, return just the user filters
|
|
if ( !this.hasSearchInputText ) return this.userFilters
|
|
|
|
return [
|
|
...this.inputTerms,
|
|
...this.userFilters
|
|
]
|
|
},
|
|
summary () {
|
|
if ( this.listSummary !== null ) {
|
|
return this.listSummary
|
|
}
|
|
|
|
if ( !!this.kindPage.summary ) {
|
|
return this.kindPage.summary
|
|
}
|
|
|
|
return null
|
|
},
|
|
previousPageUrl () {
|
|
if ( this.kindPage.previousPage.length === 0 ) return null
|
|
|
|
return this.kindPage.previousPage
|
|
},
|
|
nextPageUrl () {
|
|
if ( this.kindPage.nextPage.length === 0 ) return null
|
|
|
|
return this.kindPage.nextPage
|
|
}
|
|
},
|
|
mounted () {
|
|
this.searchClient = this.makeSearchClient()
|
|
|
|
this.searchFilters = new SearchFilters()
|
|
|
|
this.searchFilters.setFromStringArray( this.baseFilters )
|
|
this.filterQueryList = this.searchFilters.list
|
|
},
|
|
methods: {
|
|
getIconForListing,
|
|
|
|
makeSearchClient () {
|
|
if ( this.activeSearchProvider === 'stork' ) {
|
|
return new StorkClient()
|
|
}
|
|
|
|
return new PagefindClient()
|
|
},
|
|
getSearchLinks (app) {
|
|
return app?.searchLinks || []
|
|
},
|
|
// Search tools
|
|
hasActiveFilter ( filter ) {
|
|
return this.filterQueryList.includes( filter )
|
|
},
|
|
toggleFilter ( newFilterQuery ) {
|
|
this.searchFilters.toggleFilter( newFilterQuery )
|
|
this.filterQueryList = this.searchFilters.list
|
|
},
|
|
scrollInputToTop () {
|
|
scrollIntoView(this.$refs['search-container'], {
|
|
block: 'start',
|
|
behavior: 'smooth'
|
|
})
|
|
},
|
|
mapStorkResultToListing ( result ) {
|
|
return {
|
|
name: makeHighlightedResultTitle( result ),
|
|
text: '',
|
|
endpoint: result.entry.url,
|
|
slug: result.entry.url,
|
|
category: {
|
|
slug: 'uncategorized'
|
|
},
|
|
lastUpdated: null,
|
|
resultExcerptsMarkup: ( result.excerpts || [] ).flatMap( excerpt => {
|
|
return makeHighlightedMarkup( excerpt )
|
|
} )
|
|
}
|
|
},
|
|
async runPagefindQuery () {
|
|
const pagefindQuery = await this.searchClient.lazyQuery( this.userTextQuery, {
|
|
filters: this.pagefindFilters,
|
|
sort: this.hasSearchInputText ? {} : {
|
|
updated: 'desc'
|
|
}
|
|
} )
|
|
|
|
if ( pagefindQuery === null ) {
|
|
return null
|
|
}
|
|
|
|
const limitedResults = ( pagefindQuery.results || [] ).slice( 0, this.pagefindResultLimit )
|
|
const settledResultData = await Promise.allSettled( limitedResults.map( async result => {
|
|
return await result.data()
|
|
} ) )
|
|
|
|
const resultData = settledResultData.flatMap( settledResult => {
|
|
if ( settledResult.status === 'fulfilled' ) {
|
|
return [ settledResult.value ]
|
|
}
|
|
|
|
console.warn('Failed to load Pagefind result data', settledResult.reason)
|
|
return []
|
|
} )
|
|
|
|
return resultData.map( data => {
|
|
return mapPagefindDataToListing( data, {
|
|
highlightTerms: this.inputTerms
|
|
} )
|
|
} )
|
|
},
|
|
async runStorkQuery () {
|
|
const storkQuery = await this.searchClient.lazyQuery( this.activeQuery, this.activeQuery.split(' ') )
|
|
|
|
if ( storkQuery === null ) {
|
|
return null
|
|
}
|
|
|
|
return ( storkQuery.results || [] ).map( result => {
|
|
return this.mapStorkResultToListing( result )
|
|
} )
|
|
},
|
|
|
|
// Called on input and when a filter is toggled
|
|
async queryResults ( rawQuery = this.userTextQuery ) {
|
|
const queryId = ++this.lastQueryId
|
|
|
|
if ( this.activeQuery.trim().length === 0 ) {
|
|
this.waitingForQuery = false
|
|
return
|
|
}
|
|
|
|
this.waitingForQuery = true
|
|
|
|
this.$emit('update:query', rawQuery)
|
|
|
|
// Declare that at least one query has been made
|
|
this.hasStartedAnyQuery = true
|
|
|
|
const results = this.activeSearchProvider === 'stork'
|
|
? await this.runStorkQuery()
|
|
: await this.runPagefindQuery()
|
|
|
|
if ( queryId !== this.lastQueryId ) {
|
|
return
|
|
}
|
|
|
|
if ( results === null ) {
|
|
this.waitingForQuery = false
|
|
return
|
|
}
|
|
|
|
this.listingsResults = results
|
|
|
|
this.waitingForQuery = false
|
|
},
|
|
|
|
handleSearchInput ( event ) {
|
|
const inputValue = event.target.value
|
|
|
|
this.scrollInputToTop()
|
|
this.queryResults( inputValue )
|
|
},
|
|
}
|
|
}
|
|
</script>
|