route v1
This commit is contained in:
63
app/app.vue
63
app/app.vue
@@ -3,15 +3,17 @@
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtPage />
|
||||
<SearchModal />
|
||||
<Loader />
|
||||
|
||||
<!-- Persistent audio player across routes -->
|
||||
<Player />
|
||||
|
||||
<!-- 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">
|
||||
<button v-if="$isMobile" @click="ui.openSearch()"
|
||||
class="fixed bottom-4 right-4 z-50 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>
|
||||
@@ -21,15 +23,62 @@
|
||||
|
||||
<script setup>
|
||||
import SearchModal from '~/components/SearchModal.vue'
|
||||
import Player from '~/components/player.vue'
|
||||
import Loader from '~/components/Loader.vue'
|
||||
import { useUiStore } from '~/store/ui'
|
||||
import { usePlayerStore } from '~/store/player'
|
||||
import { watch, computed } from 'vue'
|
||||
|
||||
const ui = useUiStore()
|
||||
const player = usePlayerStore()
|
||||
const { $isMobile } = useNuxtApp()
|
||||
useHead({
|
||||
bodyAttrs: {
|
||||
class: 'bg-slate-100 dark:bg-slate-900'
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
watch(
|
||||
() => player.currentTrack?.id,
|
||||
(id) => {
|
||||
if (!id) {
|
||||
if (route.name === 'track-id') router.replace({ path: '/' })
|
||||
return
|
||||
}
|
||||
const currentParam = Number(
|
||||
Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
)
|
||||
if (route.name === 'track-id' && currentParam === id) return
|
||||
router.replace({ name: 'track-id', params: { id } })
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
// Keep URL in sync with selected box: /box/:id when a box is selected, back to / when none
|
||||
const selectedBoxId = computed(() => ui.getSelectedBox?.id)
|
||||
watch(
|
||||
() => selectedBoxId.value,
|
||||
(id) => {
|
||||
if (process.client) {
|
||||
if (!id) {
|
||||
// Back to root path without navigation to preserve UI state/animations
|
||||
if (location.pathname.startsWith('/box/')) {
|
||||
history.replaceState(null, '', '/')
|
||||
}
|
||||
return
|
||||
}
|
||||
const currentId = location.pathname.startsWith('/box/') ? location.pathname.split('/').pop() : null
|
||||
if (currentId === id) return
|
||||
requestAnimationFrame(() => {
|
||||
history.replaceState(null, '', `/box/${id}`)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
25
app/components/Loader.vue
Normal file
25
app/components/Loader.vue
Normal 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>
|
||||
@@ -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)
|
||||
} 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(r.payload)
|
||||
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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,9 +66,6 @@ function KeyboardAction(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const dataStore = await useDataStore()
|
||||
await dataStore.loadData()
|
||||
|
||||
window.addEventListener('keydown', KeyboardAction)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
29
app/pages/box/[id].vue
Normal file
29
app/pages/box/[id].vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="w-full flex flex-col items-center">
|
||||
<div @click="uiStore.closeBox()" class="cursor-pointer">
|
||||
<logo />
|
||||
</div>
|
||||
<main>
|
||||
<boxes />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useUiStore } from '~/store/ui'
|
||||
import { useDataStore } from '~/store/data'
|
||||
|
||||
const uiStore = useUiStore()
|
||||
const dataStore = useDataStore()
|
||||
const route = useRoute()
|
||||
|
||||
onMounted(async () => {
|
||||
await dataStore.loadData()
|
||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
if (typeof idParam === 'string' && idParam.length > 0) {
|
||||
uiStore.selectBox(idParam)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,18 +1,24 @@
|
||||
<template>
|
||||
<div class="w-full flex flex-col items-center">
|
||||
<div class="w-full flex flex-col items-center mb-96">
|
||||
<div @click="uiStore.closeBox()" class="cursor-pointer">
|
||||
<logo />
|
||||
</div>
|
||||
<main>
|
||||
<boxes />
|
||||
<player />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useUiStore } from '~/store/ui'
|
||||
import { useDataStore } from '~/store/data'
|
||||
const uiStore = useUiStore()
|
||||
|
||||
onMounted(async () => {
|
||||
const dataStore = useDataStore()
|
||||
await dataStore.loadData()
|
||||
uiStore.listBoxes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
35
app/pages/track/[id].vue
Normal file
35
app/pages/track/[id].vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="w-full flex flex-col items-center">
|
||||
<div @click="uiStore.closeBox()" class="cursor-pointer">
|
||||
<logo />
|
||||
</div>
|
||||
<main>
|
||||
<boxes />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useUiStore } from '~/store/ui'
|
||||
import { useDataStore } from '~/store/data'
|
||||
import { usePlayerStore } from '~/store/player'
|
||||
|
||||
const uiStore = useUiStore()
|
||||
const dataStore = useDataStore()
|
||||
const playerStore = usePlayerStore()
|
||||
const route = useRoute()
|
||||
|
||||
onMounted(async () => {
|
||||
await dataStore.loadData()
|
||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
const id = Number(idParam)
|
||||
if (!Number.isNaN(id)) {
|
||||
const track = dataStore.tracks.find((t) => t.id === id)
|
||||
if (track) {
|
||||
// Open the box containing this track without changing global UI flow/animations
|
||||
uiStore.selectBox(track.boxId)
|
||||
playerStore.playTrack(track)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
16
app/router.options.ts
Normal file
16
app/router.options.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { RouterScrollBehavior } from 'vue-router'
|
||||
|
||||
const scrollBehavior: RouterScrollBehavior = (to, from, savedPosition) => {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
}
|
||||
if (to.hash) {
|
||||
return { el: to.hash }
|
||||
}
|
||||
// Preserve current scroll position on navigation (no scroll-to-top)
|
||||
return false
|
||||
}
|
||||
|
||||
export default {
|
||||
scrollBehavior
|
||||
}
|
||||
@@ -10,13 +10,15 @@ export const useDataStore = defineStore('data', {
|
||||
boxes: [] as Box[], // Store your box data here
|
||||
artists: [] as Artist[], // Store artist data here
|
||||
tracks: [] as Track[], // Store track data here
|
||||
isLoaded: false // Remember if data is already loaded
|
||||
isLoaded: false, // Remember if data is already loaded
|
||||
isLoading: true
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async loadData() {
|
||||
if (this.isLoaded) return
|
||||
|
||||
this.isLoading = true
|
||||
try {
|
||||
this.boxes = await $fetch<Box[]>('/api/boxes')
|
||||
this.artists = await $fetch<Artist[]>('/api/artists')
|
||||
const compilationTracks = await $fetch<Track[]>('/api/tracks/compilation')
|
||||
@@ -61,8 +63,9 @@ export const useDataStore = defineStore('data', {
|
||||
this.boxes = [favBox, ...this.boxes]
|
||||
}
|
||||
this.isLoaded = true
|
||||
const uiStore = useUiStore()
|
||||
uiStore.closeBox()
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -136,12 +136,34 @@ export const usePlayerStore = defineStore('player', {
|
||||
if (!isNaN(progression)) {
|
||||
this.progressionLast = progression
|
||||
}
|
||||
|
||||
// auto advance behavior: playlists use 'ended', compilations use time boundary
|
||||
const track = this.currentTrack
|
||||
if (!track) return
|
||||
// update current track when changing time in compilation
|
||||
const cur = this.currentTrack
|
||||
if (cur && cur.type === 'compilation') {
|
||||
const dataStore = useDataStore()
|
||||
const t = audio.currentTime
|
||||
const tracks = dataStore
|
||||
.getTracksByboxId(cur.boxId)
|
||||
.slice()
|
||||
.filter((t) => t.type === 'compilation')
|
||||
.sort((a, b) => (a.start ?? 0) - (b.start ?? 0))
|
||||
|
||||
if (tracks.length > 0) {
|
||||
const now = audio.currentTime
|
||||
// find the last track whose start <= now (fallback to first track)
|
||||
let found = tracks[0]
|
||||
for (const t of tracks) {
|
||||
const s = t.start ?? 0
|
||||
if (s <= now) {
|
||||
found = t
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (found && found.id !== cur.id) {
|
||||
// only update metadata reference; do not reload audio
|
||||
this.currentTrack = found
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -25,6 +25,13 @@ export const useUiStore = defineStore('ui', {
|
||||
this.searchQuery = q
|
||||
},
|
||||
|
||||
listBoxes() {
|
||||
const dataStore = useDataStore()
|
||||
dataStore.boxes.forEach((box) => {
|
||||
box.state = 'box-list'
|
||||
})
|
||||
},
|
||||
|
||||
selectBox(id: string) {
|
||||
const dataStore = useDataStore()
|
||||
dataStore.boxes.forEach((box) => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
@@ -12,13 +14,15 @@ export default defineNuxtConfig({
|
||||
{ rel: 'apple-touch-icon', sizes: '180x180', href: '/favicon/apple-touch-icon.png' },
|
||||
{ rel: 'manifest', href: '/favicon/site.webmanifest' }
|
||||
],
|
||||
script: [
|
||||
script: isProd
|
||||
? [
|
||||
{
|
||||
src: 'https://umami.erudi.fr/script.js',
|
||||
defer: true,
|
||||
'data-website-id': '615690ea-0306-48cc-8feb-e9093fe6a1b7'
|
||||
}
|
||||
],
|
||||
]
|
||||
: [],
|
||||
meta: [
|
||||
{ name: 'apple-mobile-web-app-title', content: 'evilSpins' }
|
||||
]
|
||||
|
||||
53
public/loader.svg
Normal file
53
public/loader.svg
Normal file
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="-20 -10 45.311569 71.596806"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="favicon.svg"
|
||||
width="25.311569"
|
||||
height="51.596806"
|
||||
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1">
|
||||
<filter id="blur" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="1 5" />
|
||||
</filter>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#505050"
|
||||
inkscape:zoom="12.1"
|
||||
inkscape:cx="61.77686"
|
||||
inkscape:cy="29.958678"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1132"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1" />
|
||||
<path
|
||||
d="m 13.81157,0.8 -3.3,1.7 -0.5999997,0.3 v 0.3 h -0.4 v 0.3 l -2.2,1.4 -0.6,0.5 h -0.4 v 0.3 h -0.3 l -0.8,1 -1.6,1 c -0.3,0.3 -1,1.4 -1.4,1.7 l -0.3,-0.2 v 0.4 l -1.19999992,1 c -0.4,0.4 -1.80000008,2 -2.10000008,2.7 l -0.1,0.3 -0.7,0.6 -0.4,1.6 -0.9,2.2 c 0,0.333333 -0.1,0.666667 -0.3,1 l -0.2,1 0.2,0.4 -0.2,0.5 0.6,0.7 0.2,0.5 c 1.103691,0.814425 2.36312908,1.393085 3.70000008,1.7 l 0.89999992,-0.2 0.8,0.2 h 1.4 l 1.8,1.9 1.1,0.8 c 1,0 0.7,1.1 1.1,1.7 0.3,0.2 0.5,-0.2 0.6,0.4 l -0.2,0.3 c 0,0.6 0.1,1.2 0.3,1.8 l 0.2,0.5 v 0.3 l -1,3 -1.6,2 -0.2,0.7 -0.2,0.4 -0.2,-0.1 -0.1,0.3 -1.3,1.6 -0.7,0.7 -0.2,0.3 -0.2,-0.2 -0.1,0.3 -1.5,0.7 c -3.6,2.8 0.3,0.2 -1.79999992,1.9 l -1.40000008,0.8 -1.6,1.1 -1,0.5 -0.1,0.3 h -0.4 c -0.2,0.06667 -0.4,0.233333 -0.6,0.5 l -2,1.4 -1.2,0.9 -0.5,0.2 v 0.2 h -0.3 V 49 h -0.2 c -0.3000003,0.1 0.1,0.2 0.1,0.2 v 0.3 c 0.06667,0.466667 0.366667,0.8 0.9,1 l 0.1,0.6 0.6,0.2 c 2,0.6 2.7,0.2 4.7,-0.4 l 1.4,-0.3 h 0.4 l 0.90000008,-0.4 c 5.49999992,-2.7 -0.2,0.2 4.69999992,-2.6 l 1.5,-0.8 v -0.3 h 0.4 v -0.2 c 3.5,-2.5 1,0 4.9999997,-3.7 l 0.8,-1 0.5,-0.3 V 41 l 0.5,-0.1 0.5,-1.5 1,-1.4 0.6,-2 v -2.8 l -0.2,-1.9 0.2,-0.2 c 0,-0.7 -0.5,-1.2 -0.6,-1.9 V 28 l -0.3,-0.5 0.1,-0.3 -0.2,-0.2 -0.6,-0.9 c -1.7,-2.7 0,0 -1.4,-1.8 l -0.3,-0.5 h -0.4 l 0.1,-0.4 -0.3,-0.1 -0.5,-0.7 -0.9999997,-0.7 -0.7,-0.6 -0.2,-0.5 -0.6,-0.2 c -1,-0.5 -1.4,-1.3 -2.2,-1.9 l -0.4,-0.2 v -1 l -0.5,-0.9 0.4,-1 0.8,-2.6 0.9,-1.8 0.3,-0.6 0.4,-0.2 0.9,-1 1.9999997,-2.7 0.8,-0.9 0.5,-0.3 0.2,-0.6 1.6,-1.6 0.7,-1 0.2,-0.3 h 0.3 l 0.6,-2"
|
||||
fill="#fdec50"
|
||||
id="path1"
|
||||
filter="url(#blur)"
|
||||
sodipodi:nodetypes="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
attributeType="XML"
|
||||
type="rotate"
|
||||
from="0 2.6557845 25.798403"
|
||||
to="-360 2.6557845 25.798403"
|
||||
dur="0.4s"
|
||||
repeatCount="indefinite" />
|
||||
</path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -173,5 +173,5 @@ export default eventHandler(() => {
|
||||
color3: '#00ff00'
|
||||
}
|
||||
]
|
||||
return boxes.map((b) => ({ ...b, state: 'box-hidden' }))
|
||||
return boxes.map((b) => ({ ...b, state: 'box-hidden' })) // boxes are first hidden to allow the animation to work (hidden -> list -> selected)
|
||||
})
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./components/**/*.{js,vue,ts}",
|
||||
"./layouts/**/*.vue",
|
||||
"./pages/**/*.vue",
|
||||
"./plugins/**/*.{js,ts}",
|
||||
"./app.vue",
|
||||
"./error.vue",
|
||||
'./components/**/*.{js,vue,ts}',
|
||||
'./layouts/**/*.vue',
|
||||
'./pages/**/*.vue',
|
||||
'./plugins/**/*.{js,ts}',
|
||||
'./app.vue',
|
||||
'./error.vue'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
esyellow: "#fdec50ff",
|
||||
esyellow: '#fdec50ff'
|
||||
},
|
||||
fontSize: {
|
||||
xxs: "0.625rem", // 10px par exemple
|
||||
xxs: '0.625rem' // 10px par exemple
|
||||
},
|
||||
screens: {
|
||||
"2sm": "320px",
|
||||
'2sm': '320px'
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
plugins: []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user