route v1
All checks were successful
Deploy App / build (push) Successful in 2m25s
Deploy App / deploy (push) Successful in 15s

This commit is contained in:
valere
2025-10-21 00:09:26 +02:00
parent 61b0b6395f
commit f59c496c5d
18 changed files with 391 additions and 137 deletions

25
app/components/Loader.vue Normal file
View File

@@ -0,0 +1,25 @@
<template>
<transition name="fade">
<div v-if="data.isLoading" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<img src="/loader.svg" alt="Loading" class="relative h-40 w-40" />
</div>
</transition>
</template>
<script setup lang="ts">
import { useDataStore } from '~/store/data'
const data = useDataStore()
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -19,31 +19,33 @@
<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="[
<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="activate(r)">
]" @mouseenter="activeIndex = idx" @click="selectResult(resultItem)">
<div class="flex items-center gap-3">
<img v-if="coverUrlFor(r)" :src="coverUrlFor(r)" alt="" loading="lazy"
<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">{{
r.type }}</span>
<span class="text-slate-900 dark:text-slate-100">{{ r.label }}</span>
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="r.sublabel" class="text-sm text-slate-500 dark:text-slate-400">{{ r.sublabel }}</span>
<button
v-if="r.type === 'TRACK'"
class="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800"
aria-label="Toggle favorite"
@click.stop="fav.toggle(r.payload)"
>
<svg v-if="fav.isFavorite(r.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"/>
<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 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>
@@ -107,12 +109,12 @@ const results = computed<ResultItem[]>(() => {
})
}
}
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
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:${t.id}`, type: 'TRACK', label, sublabel: sub, payload: t })
out.push({ key: `track:${track.id}`, type: 'TRACK', label, sublabel: sub, payload: track })
}
}
@@ -124,34 +126,34 @@ const move = (delta: number) => {
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`
const coverUrlFor = (ResultItem: ResultItem): string | undefined => {
if (ResultItem.type === 'BOX') {
return `/${ResultItem.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 (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 (r.type === 'ARTIST') {
const tracks = data.getTracksByArtistId(r.payload.id)
if (ResultItem.type === 'ARTIST') {
const tracks = data.getTracksByArtistId(ResultItem.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`
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 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)
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)
@@ -159,19 +161,25 @@ const activate = (r: ResultItem) => {
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)
} 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 r = results.value[activeIndex.value]
if (r) activate(r)
const ResultItem = results.value[activeIndex.value]
if (ResultItem) selectResult(ResultItem)
}
watch(
@@ -199,7 +207,7 @@ watch(
.results-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(100,116,139,0.6) transparent;
scrollbar-color: rgba(100, 116, 139, 0.6) transparent;
}
.results-scroll::-webkit-scrollbar {
@@ -211,17 +219,17 @@ watch(
}
.results-scroll::-webkit-scrollbar-thumb {
background-color: rgba(100,116,139,0.6);
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;
scrollbar-color: rgba(148, 163, 184, 0.5) transparent;
}
.dark .results-scroll::-webkit-scrollbar-thumb {
background-color: rgba(148,163,184,0.5);
background-color: rgba(148, 163, 184, 0.5);
}
</style>

View File

@@ -245,7 +245,7 @@ watch(isDraggable, (enabled) => (enabled ? addListeners() : removeListeners()))
}
&-scene {
perspective: 1000px;
perspective: 2000px;
}
&.box-hidden {
@@ -360,7 +360,7 @@ watch(isDraggable, (enabled) => (enabled ? addListeners() : removeListeners()))
@apply scale-105 outline-none;
.face {
border: 4px solid rgba(0, 0, 0, 0.5);
// border: 4px solid rgba(0, 0, 0, 0.5);
}
}

View File

@@ -66,9 +66,6 @@ function KeyboardAction(e: KeyboardEvent) {
}
onMounted(async () => {
const dataStore = await useDataStore()
await dataStore.loadData()
window.addEventListener('keydown', KeyboardAction)
})
</script>

View File

@@ -1,10 +1,10 @@
<template>
<article @click="() => playerStore.playTrack(props.track).catch(err => console.error(err))"
class="card flip-card w-56 h-80" :class="isFaceUp ? 'face-up' : 'face-down'">
class="card flip-card isplaying w-56 h-80" :class="isFaceUp ? 'face-up' : 'face-down'">
<div class="flip-inner">
<!-- Face-Up -->
<main
class="flip-front backdrop-blur-sm border-2 -mt-12 z-10 card w-56 h-80 p-3 bg-opacity-40 hover:bg-opacity-80 hover:shadow-xl transition-all bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden">
class="flip-front backdrop-blur-sm border-1 -mt-12 z-10 card w-56 h-80 p-3 bg-opacity-40 hover:bg-opacity-80 hover:shadow-xl transition-all bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden">
<div class="flex items-center justify-center size-7 absolute top-7 right-7" v-if="isPlaylistTrack">
<div class="suit text-7xl absolute"
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">

View File

@@ -1,6 +1,6 @@
<template>
<div class="fixed left-0 bottom-0 opacity-1 z-50 w-full bg-white transition-all"
:class="{ '-bottom-20 opacity-0': !playerStore.currentTrack }">
<div class="fixed left-0 z-50 w-full bg-white transition-all -bottom-20 opacity-0 h-0"
:class="{ 'bottom-0 opacity-100 h-20': playerStore.currentTrack }">
<div class="flex items-center gap-3 p-2">
<img v-if="playerStore.getCurrentCoverUrl" :src="playerStore.getCurrentCoverUrl as string" alt="Current cover"
class="size-16 object-cover object-center rounded" />