search v1
This commit is contained in:
19
app/app.vue
19
app/app.vue
@@ -2,10 +2,29 @@
|
||||
<div>
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtPage />
|
||||
<SearchModal />
|
||||
|
||||
<!-- Mobile-only floating search button -->
|
||||
<button
|
||||
v-if="$isMobile"
|
||||
@click="ui.openSearch()"
|
||||
class="fixed bottom-4 right-4 z-40 inline-flex h-12 w-12 items-center justify-center rounded-full bg-esyellow text-slate-800 shadow-lg ring-1 ring-slate-300 hover:brightness-95 active:brightness-90 dark:ring-slate-600"
|
||||
aria-label="Rechercher"
|
||||
>
|
||||
<svg class="h-6 w-6" 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>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SearchModal from '~/components/SearchModal.vue'
|
||||
import { useUiStore } from '~/store/ui'
|
||||
|
||||
const ui = useUiStore()
|
||||
const { $isMobile } = useNuxtApp()
|
||||
useHead({
|
||||
bodyAttrs: {
|
||||
class: 'bg-slate-100 dark:bg-slate-900'
|
||||
|
||||
210
app/components/SearchModal.vue
Normal file
210
app/components/SearchModal.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<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="(r, idx) in results" :key="r.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="activate(r)">
|
||||
<div class="flex items-center gap-3">
|
||||
<img v-if="coverUrlFor(r)" :src="coverUrlFor(r)" 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">{{
|
||||
r.type }}</span>
|
||||
<span class="text-slate-900 dark:text-slate-100">{{ r.label }}</span>
|
||||
</div>
|
||||
<span v-if="r.sublabel" class="text-sm text-slate-500 dark:text-slate-400">{{ r.sublabel }}</span>
|
||||
</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'
|
||||
|
||||
const ui = useUiStore()
|
||||
const data = useDataStore()
|
||||
const player = usePlayerStore()
|
||||
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 t of data.tracks) {
|
||||
const artistName = typeof t.artist === 'object' && t.artist ? (t.artist as any).name ?? '' : String(t.artist)
|
||||
const label = t.title
|
||||
const sub = artistName
|
||||
if (normalized(label).includes(q) || normalized(sub).includes(q)) {
|
||||
out.push({ key: `track:${t.id}`, type: 'TRACK', label, sublabel: sub, payload: t })
|
||||
}
|
||||
}
|
||||
|
||||
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 = (r: ResultItem): string | undefined => {
|
||||
if (r.type === 'BOX') {
|
||||
return `/${r.payload.id}/cover.jpg`
|
||||
}
|
||||
if (r.type === 'TRACK') {
|
||||
const t = r.payload
|
||||
if (t && t.type === 'playlist' && t.coverId) return t.coverId as string
|
||||
if (t && t.coverId) return t.coverId as string
|
||||
return `/${t.boxId}/cover.jpg`
|
||||
}
|
||||
if (r.type === 'ARTIST') {
|
||||
const tracks = data.getTracksByArtistId(r.payload.id)
|
||||
if (tracks && tracks.length) {
|
||||
const t = tracks[0]!
|
||||
if (t.type === 'playlist' && t.coverId) return t.coverId as string
|
||||
if (t.coverId) return t.coverId as string
|
||||
return `/${t.boxId}/cover.jpg`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const activate = (r: ResultItem) => {
|
||||
if (r.type === 'BOX') {
|
||||
ui.selectBox(r.payload.id)
|
||||
nextTick(() => ui.scrollToBox(r.payload))
|
||||
} else if (r.type === 'ARTIST') {
|
||||
const tracks = data.getTracksByArtistId(r.payload.id)
|
||||
if (tracks && tracks.length) {
|
||||
const track = tracks[0]!
|
||||
const box = data.getBoxById(track.boxId)
|
||||
if (box) {
|
||||
ui.selectBox(box.id)
|
||||
}
|
||||
}
|
||||
} else if (r.type === 'TRACK') {
|
||||
const box = data.getBoxById(r.payload.boxId)
|
||||
if (box) {
|
||||
ui.selectBox(box.id)
|
||||
player.playTrack(r.payload)
|
||||
}
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const confirm = () => {
|
||||
const r = results.value[activeIndex.value]
|
||||
if (r) activate(r)
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -3,7 +3,7 @@
|
||||
<div ref="domBox" class="box-object" :class="{ 'is-draggable': isDraggable }">
|
||||
<div class="face front relative" ref="frontFace">
|
||||
<img v-if="box.duration" class="cover absolute" :src="`/${box.id}/cover.jpg`" alt="">
|
||||
<div class="size-full flex-col justify-center items-center text-7xl" v-html="box.description" v-else />
|
||||
<div class="size-full flex flex-col justify-center items-center text-7xl" v-html="box.description" v-else />
|
||||
</div>
|
||||
<div class="face back flex flex-col flex-wrap items-start p-4 overflow-hidden" ref="backFace">
|
||||
<li class="list-none text-xxs w-1/2 flex flex-row"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<box v-for="(box, i) in dataStore.boxes.slice()" :key="box.id" :tabindex="dataStore.boxes.length - i" :box="box"
|
||||
@click="onBoxClick(box)" class="text-center" :class="box.state" :id="box.id">
|
||||
<button @click.stop="playSelectedBox(box)" v-if="box.state === 'box-selected'"
|
||||
class="relative z-40 rounded-full size-24 bottom-1/2 text-4xl tex-bold backdrop-blur-sm bg-black/25">
|
||||
class="relative z-40 rounded-full size-24 bottom-1/2 text-4xl tex-bold text-esyellow backdrop-blur-sm bg-black/25">
|
||||
{{ !playerStore.isPaused && playerStore.currentTrack?.boxId === box.id ? 'I I' : '▶' }}
|
||||
</button>
|
||||
<deck :box="box" class="box-page" v-if="box.state === 'box-selected'" @click.stop />
|
||||
|
||||
17
app/plugins/search-shortcut.client.ts
Normal file
17
app/plugins/search-shortcut.client.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useUiStore } from '~/store/ui'
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const ui = useUiStore()
|
||||
const isMobile = nuxtApp.$isMobile as boolean | undefined
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === 'f' || e.key === 'F')) {
|
||||
if (isMobile) return
|
||||
e.preventDefault()
|
||||
if (!ui.showSearch) ui.openSearch()
|
||||
}
|
||||
}
|
||||
|
||||
if (process.client) {
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
}
|
||||
})
|
||||
@@ -5,9 +5,26 @@ import type { Box } from '~/../types/types'
|
||||
export const useUiStore = defineStore('ui', {
|
||||
state: () => ({
|
||||
// UI-only state can live here later
|
||||
showSearch: false,
|
||||
searchQuery: ''
|
||||
}),
|
||||
|
||||
actions: {
|
||||
openSearch() {
|
||||
this.showSearch = true
|
||||
// reset query on open to avoid stale state
|
||||
this.searchQuery = ''
|
||||
},
|
||||
|
||||
closeSearch() {
|
||||
this.showSearch = false
|
||||
this.searchQuery = ''
|
||||
},
|
||||
|
||||
setSearchQuery(q: string) {
|
||||
this.searchQuery = q
|
||||
},
|
||||
|
||||
selectBox(id: string) {
|
||||
const dataStore = useDataStore()
|
||||
dataStore.boxes.forEach((box) => {
|
||||
|
||||
Reference in New Issue
Block a user