236 lines
		
	
	
		
			8.4 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			236 lines
		
	
	
		
			8.4 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <template>
 | |
|   <transition name="fade">
 | |
|     <div v-if="ui.showSearch" class="fixed inset-0 z-50 flex items-center justify-center transition-all">
 | |
|       <div class="absolute inset-0 bg-black/60 backdrop-blur-md" @click="close"></div>
 | |
|       <div
 | |
|         class="relative w-full max-w-2xl rounded-xl bg-white shadow-xl ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-slate-700"
 | |
|         role="dialog" aria-modal="true" @keydown.esc.prevent.stop="close">
 | |
|         <div class="flex items-center gap-2 dark:border-slate-700">
 | |
|           <svg class="ml-4 h-7 w-7 text-slate-500" viewBox="0 0 24 24" fill="none" stroke="currentColor"
 | |
|             stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
 | |
|             <circle cx="11" cy="11" r="8" />
 | |
|             <path d="m21 21-4.3-4.3" />
 | |
|           </svg>
 | |
|           <input ref="inputRef" v-model="ui.searchQuery" type="text" placeholder="Rechercher boxes, artistes, tracks..."
 | |
|             class="flex-1 bg-transparent px-2 py-2 text-slate-900 text-3xl placeholder-slate-400 outline-none dark:text-slate-100"
 | |
|             @keydown.down.prevent="move(1)" @keydown.up.prevent="move(-1)" @keydown.enter.prevent="confirm" />
 | |
|         </div>
 | |
| 
 | |
|         <div class="max-h-72 overflow-auto results-scroll">
 | |
|           <template v-if="results.length">
 | |
|             <ul class="divide-y divide-slate-100 dark:divide-slate-800">
 | |
|               <li v-for="(resultItem, idx) in results" :key="resultItem.key" :class="[
 | |
|                 'flex cursor-pointer items-center justify-between gap-3 px-2 py-3 hover:bg-slate-50 dark:hover:bg-slate-800',
 | |
|                 idx === activeIndex ? 'bg-slate-100 dark:bg-slate-800' : ''
 | |
|               ]" @mouseenter="activeIndex = idx" @click="selectResult(resultItem)">
 | |
|                 <div class="flex items-center gap-3">
 | |
|                   <img v-if="coverUrlFor(resultItem)" :src="coverUrlFor(resultItem)" alt="" loading="lazy"
 | |
|                     class="h-10 w-10 rounded object-cover ring-1 ring-slate-200 dark:ring-slate-700" />
 | |
|                   <span
 | |
|                     class="inline-flex min-w-[68px] items-center justify-center rounded-md border px-2 py-0.5 text-xs font-semibold uppercase text-slate-600 dark:text-slate-300 dark:border-slate-600">{{
 | |
|                       resultItem.type }}</span>
 | |
|                   <span class="text-slate-900 dark:text-slate-100">{{ resultItem.label }}</span>
 | |
|                 </div>
 | |
|                 <div class="flex items-center gap-2">
 | |
|                   <span v-if="resultItem.sublabel" class="text-sm text-slate-500 dark:text-slate-400">{{
 | |
|                     resultItem.sublabel }}</span>
 | |
|                   <button v-if="resultItem.type === 'TRACK'"
 | |
|                     class="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800" aria-label="Toggle favorite"
 | |
|                     @click.stop="fav.toggle(resultItem.payload)">
 | |
|                     <svg v-if="fav.isFavorite(resultItem.payload.id)" class="h-5 w-5 text-rose-500" viewBox="0 0 24 24"
 | |
|                       fill="currentColor">
 | |
|                       <path
 | |
|                         d="M12 21s-6.716-4.35-9.428-7.062C.86 12.226.5 10.64.5 9.5.5 6.462 2.962 4 6 4c1.657 0 3.157.806 4 2.09C10.843 4.806 12.343 4 14 4c3.038 0 5.5 2.462 5.5 5.5 0 1.14-.36 2.726-2.072 4.438C18.716 16.65 12 21 12 21z" />
 | |
|                     </svg>
 | |
|                     <svg v-else class="h-5 w-5 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor"
 | |
|                       stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
 | |
|                       <path
 | |
|                         d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8z" />
 | |
|                     </svg>
 | |
|                   </button>
 | |
|                 </div>
 | |
|               </li>
 | |
|             </ul>
 | |
|           </template>
 | |
|           <div v-else-if="ui.searchQuery" class="px-2 py-6 text-center text-slate-500 dark:text-slate-400">
 | |
|             Aucun résultat
 | |
|           </div>
 | |
|         </div>
 | |
|       </div>
 | |
|     </div>
 | |
|   </transition>
 | |
| </template>
 | |
| 
 | |
| <script setup lang="ts">
 | |
| import { computed, nextTick, onMounted, ref, watch } from 'vue'
 | |
| import { useUiStore } from '~/store/ui'
 | |
| import { useDataStore } from '~/store/data'
 | |
| import { usePlayerStore } from '~/store/player'
 | |
| import { useFavoritesStore } from '~/store/favorites'
 | |
| 
 | |
| const ui = useUiStore()
 | |
| const data = useDataStore()
 | |
| const player = usePlayerStore()
 | |
| const fav = useFavoritesStore()
 | |
| const inputRef = ref<HTMLInputElement | null>(null)
 | |
| const activeIndex = ref(0)
 | |
| 
 | |
| const close = () => ui.closeSearch()
 | |
| 
 | |
| const normalized = (s: string) => s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase()
 | |
| 
 | |
| type ResultItem = {
 | |
|   key: string
 | |
|   type: 'BOX' | 'ARTIST' | 'TRACK'
 | |
|   label: string
 | |
|   sublabel?: string
 | |
|   payload: any
 | |
| }
 | |
| 
 | |
| const results = computed<ResultItem[]>(() => {
 | |
|   const q = normalized(ui.searchQuery || '')
 | |
|   if (!q) return []
 | |
| 
 | |
|   const out: ResultItem[] = []
 | |
| 
 | |
|   for (const b of data.boxes) {
 | |
|     const label = `${b.id}`
 | |
|     if (normalized(label).includes(q)) {
 | |
|       out.push({ key: `box:${b.id}`, type: 'BOX', label, payload: b })
 | |
|     }
 | |
|   }
 | |
|   for (const a of data.artists) {
 | |
|     if (normalized(a.name).includes(q)) {
 | |
|       out.push({
 | |
|         key: `artist:${a.id}`,
 | |
|         type: 'ARTIST',
 | |
|         label: a.name,
 | |
|         payload: a
 | |
|       })
 | |
|     }
 | |
|   }
 | |
|   for (const track of data.tracks) {
 | |
|     const artistName = typeof track.artist === 'object' && track.artist ? (track.artist as any).name ?? '' : String(track.artist)
 | |
|     const label = track.title
 | |
|     const sub = artistName
 | |
|     if (normalized(label).includes(q) || normalized(sub).includes(q)) {
 | |
|       out.push({ key: `track:${track.id}`, type: 'TRACK', label, sublabel: sub, payload: track })
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   return out.slice(0, 100)
 | |
| })
 | |
| 
 | |
| const move = (delta: number) => {
 | |
|   if (!results.value.length) return
 | |
|   activeIndex.value = (activeIndex.value + delta + results.value.length) % results.value.length
 | |
| }
 | |
| 
 | |
| const coverUrlFor = (ResultItem: ResultItem): string | undefined => {
 | |
|   if (ResultItem.type === 'BOX') {
 | |
|     return `/${ResultItem.payload.id}/cover.jpg`
 | |
|   }
 | |
|   if (ResultItem.type === 'TRACK') {
 | |
|     const track = ResultItem.payload
 | |
|     if (track && track.type === 'playlist' && track.coverId) return track.coverId as string
 | |
|     if (track && track.coverId) return track.coverId as string
 | |
|     return `/${track.boxId}/cover.jpg`
 | |
|   }
 | |
|   if (ResultItem.type === 'ARTIST') {
 | |
|     const tracks = data.getTracksByArtistId(ResultItem.payload.id)
 | |
|     if (tracks && tracks.length) {
 | |
|       const track = tracks[0]!
 | |
|       if (track.type === 'playlist' && track.coverId) return track.coverId as string
 | |
|       if (track.coverId) return track.coverId as string
 | |
|       return `/${track.boxId}/cover.jpg`
 | |
|     }
 | |
|   }
 | |
|   return undefined
 | |
| }
 | |
| 
 | |
| const selectResult = (ResultItem: ResultItem) => {
 | |
|   if (ResultItem.type === 'BOX') {
 | |
|     ui.selectBox(ResultItem.payload.id)
 | |
|     nextTick(() => ui.scrollToBox(ResultItem.payload))
 | |
|   } else if (ResultItem.type === 'ARTIST') {
 | |
|     const tracks = data.getTracksByArtistId(ResultItem.payload.id)
 | |
|     if (tracks && tracks.length) {
 | |
|       const track = tracks[0]!
 | |
|       const box = data.getBoxById(track.boxId)
 | |
|       if (box) {
 | |
|         ui.selectBox(box.id)
 | |
|       }
 | |
|     }
 | |
|   } else if (ResultItem.type === 'TRACK') {
 | |
|     const track = ResultItem.payload
 | |
|     // If the selected track is a favorite, just play it without navigating/selecting its box
 | |
|     if (fav.isFavorite(track.id)) {
 | |
|       player.playTrack(track)
 | |
|     } else {
 | |
|       const box = data.getBoxById(track.boxId)
 | |
|       if (box) {
 | |
|         ui.selectBox(box.id)
 | |
|         player.playTrack(track)
 | |
|       }
 | |
|     }
 | |
|   }
 | |
|   close()
 | |
| }
 | |
| 
 | |
| const confirm = () => {
 | |
|   const ResultItem = results.value[activeIndex.value]
 | |
|   if (ResultItem) selectResult(ResultItem)
 | |
| }
 | |
| 
 | |
| watch(
 | |
|   () => ui.showSearch,
 | |
|   async (open) => {
 | |
|     if (open) {
 | |
|       activeIndex.value = 0
 | |
|       await nextTick()
 | |
|       inputRef.value?.focus()
 | |
|     }
 | |
|   }
 | |
| )
 | |
| </script>
 | |
| 
 | |
| <style scoped>
 | |
| .fade-enter-active,
 | |
| .fade-leave-active {
 | |
|   transition: opacity 0.15s ease;
 | |
| }
 | |
| 
 | |
| .fade-enter-from,
 | |
| .fade-leave-to {
 | |
|   opacity: 0;
 | |
| }
 | |
| 
 | |
| .results-scroll {
 | |
|   scrollbar-width: thin;
 | |
|   scrollbar-color: rgba(100, 116, 139, 0.6) transparent;
 | |
| }
 | |
| 
 | |
| .results-scroll::-webkit-scrollbar {
 | |
|   width: 8px;
 | |
| }
 | |
| 
 | |
| .results-scroll::-webkit-scrollbar-track {
 | |
|   background: transparent;
 | |
| }
 | |
| 
 | |
| .results-scroll::-webkit-scrollbar-thumb {
 | |
|   background-color: rgba(100, 116, 139, 0.6);
 | |
|   border-radius: 9999px;
 | |
|   border: 2px solid transparent;
 | |
|   background-clip: content-box;
 | |
| }
 | |
| 
 | |
| .dark .results-scroll {
 | |
|   scrollbar-color: rgba(148, 163, 184, 0.5) transparent;
 | |
| }
 | |
| 
 | |
| .dark .results-scroll::-webkit-scrollbar-thumb {
 | |
|   background-color: rgba(148, 163, 184, 0.5);
 | |
| }
 | |
| </style>
 |