mirror of
https://github.com/ThatGuySam/doesitarm.git
synced 2026-05-15 06:35:20 -07:00
Add Pagefind indexing and browser search adapters behind a provider switch. This lets prebuild generate either Stork or Pagefind search artifacts and lets the existing search UI run against Pagefind while preserving scoped filters, excerpts, and result metadata.
585 lines
21 KiB
Vue
585 lines
21 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(' ')
|
|
},
|
|
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 resultData = await Promise.all( ( pagefindQuery.results || [] ).map( async result => {
|
|
return await result.data()
|
|
} ) )
|
|
|
|
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>
|