113 lines
3.5 KiB
Vue
113 lines
3.5 KiB
Vue
<script setup lang="ts">
|
|
import type { AudioTrack } from '@/lib/AudioTrack'
|
|
import FilterNone from '@material-design-icons/svg/outlined/filter_none.svg'
|
|
import { computed, ref, shallowRef } from 'vue'
|
|
import SectionHeader from '@/components/library/SectionHeader.vue'
|
|
import SearchField from '@/components/SearchField.vue'
|
|
import { useTrackStore } from '@/store/TrackStore'
|
|
import Footer from '../Footer.vue'
|
|
import TrackCard from './TrackCard.vue'
|
|
|
|
const trackStore = useTrackStore()
|
|
|
|
const selectedTrackName = ref<string | null>(null)
|
|
|
|
const filterText = shallowRef('')
|
|
|
|
const fuzzySubsequence = (needle: string, haystack: string): boolean => {
|
|
// returns true if all chars of needle appear in haystack in order
|
|
let i = 0
|
|
for (let j = 0; j < haystack.length && i < needle.length; j++) {
|
|
if (haystack[j] === needle[i]) {
|
|
i++
|
|
}
|
|
}
|
|
return i === needle.length
|
|
}
|
|
|
|
const trackMatches = (track: AudioTrack): boolean => {
|
|
const q = filterText.value.trim().toLowerCase()
|
|
if (q === '') {
|
|
return true
|
|
}
|
|
|
|
// split into tokens so e.g. "imagine drag" matches "Imagine Dragons"
|
|
const tokens = q.split(/\s+/).filter(Boolean)
|
|
|
|
// gather candidate fields to search (lowercased)
|
|
const fields: string[] = []
|
|
const pushField = (v?: string) => {
|
|
if (typeof v === 'string' && v.length > 0) {
|
|
fields.push(v.toLowerCase())
|
|
}
|
|
}
|
|
pushField(track.Name)
|
|
pushField(track.Artist)
|
|
pushField(track.Song)
|
|
|
|
// for each token, require it to match at least one field (via includes or fuzzy match)
|
|
return tokens.every((token) => {
|
|
return fields.some(field => field.includes(token) || fuzzySubsequence(token, field))
|
|
})
|
|
}
|
|
|
|
const filteredGroupedSortedTracks = computed(() => {
|
|
if (filterText.value === '') {
|
|
return trackStore.groupedSortedTracks
|
|
}
|
|
else {
|
|
return trackStore.groupedSortedTracks
|
|
.map(([language, tracks]) => {
|
|
tracks = tracks.filter(trackMatches)
|
|
return [language, tracks] as const
|
|
})
|
|
// remove empty languages
|
|
.filter(([_language, tracks]) => tracks.length > 0)
|
|
}
|
|
})
|
|
const filteredIsEmpty = computed(() => filteredGroupedSortedTracks.value.length === 0)
|
|
</script>
|
|
|
|
<template>
|
|
<div class="tw:flex tw:flex-col tw:h-full">
|
|
<!-- TODO: static positioning does not work in flex? -->
|
|
<div
|
|
class="tw:flex-none tw:top-0 tw:pt-4 tw:px-8 tw:max-sm:px-4 tw:flex tw:justify-center"
|
|
style="position: static;"
|
|
>
|
|
<SearchField v-model="filterText" class="tw:flex-1 tw:max-w-72 tw:max-sm:max-w-full" />
|
|
</div>
|
|
<div
|
|
v-if="filteredIsEmpty" class="tw:flex-1 tw:flex tw:flex-col tw:items-center tw:justify-center tw:gap-2"
|
|
style="color: #929292;"
|
|
>
|
|
<FilterNone class="tw:w-32 tw:h-32 tw:self-center tw:fill-current" />
|
|
<p class="tw:text-2xl tw:font-bold">
|
|
No tracks found
|
|
</p>
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="tw:flex-none tw:grid tw:px-8 tw:pb-8 tw:max-sm:px-4 tw:max-sm:pb-4 tw:gap-4 tw:max-sm:columns-1" style="
|
|
grid-template-columns: repeat(auto-fit, minmax(min(var(--card-min-width), 100%), 1fr));
|
|
"
|
|
>
|
|
<template v-for="[language, tracks] in filteredGroupedSortedTracks" :key="language">
|
|
<SectionHeader class="tw:col-span-full">
|
|
{{ language }}
|
|
</SectionHeader>
|
|
<TrackCard
|
|
v-for="track in tracks"
|
|
:key="track.Name"
|
|
:track
|
|
:selected="track.Name === selectedTrackName"
|
|
@select="selectedTrackName = track.Name"
|
|
/>
|
|
</template>
|
|
</div>
|
|
<Footer />
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped></style>
|