eS v1
All checks were successful
Deploy App / build (push) Successful in 2m14s
Deploy App / deploy (push) Successful in 14s

This commit is contained in:
valere
2025-10-16 00:42:38 +02:00
parent ce73155cfa
commit 3ad8cb8795
21 changed files with 752 additions and 332 deletions

View File

@@ -1,16 +1,13 @@
<template>
<article class="box box-scene z-10" ref="scene">
<div class="box-object" ref="box">
<div ref="domBox" class="box-object" :class="{ 'is-draggable': isDraggable }">
<div class="face front relative" ref="frontFace">
<img v-if="compilation.duration" class="cover absolute" :src="`/${compilation.id}/cover.jpg`" alt="">
<div class="size-full flex justify-center items-center text-7xl" v-else>
{{ compilation.description }}
</div>
<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>
<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"
v-for="track in dataStore.getTracksByCompilationId(compilation.id).slice(0, -1)" :key="track.id"
:track="track">
v-for="track in dataStore.getTracksByboxId(box.id).slice(0, -1)" :key="track.id" :track="track">
<span class="" v-if="isNotManifesto">
{{ track.order }}.
</span>
@@ -24,9 +21,9 @@
<div class="face right" ref="rightFace" />
<div class="face left" ref="leftFace" />
<div class="face top" ref="topFace">
<template v-if="compilation.duration !== 0">
<template v-if="box.duration !== 0">
<img class="logo h-full p-1" src="/logo.svg" alt="">
<img class="absolute block h-1/2" style="left:5%;" :src="`/${compilation.id}/title.svg`" alt="">
<img class="absolute block h-1/2" style="left:5%;" :src="`/${box.id}/title.svg`" alt="">
</template>
<template v-else>
<span class="absolute block h-1/2 right-6">
@@ -34,7 +31,7 @@
</span>
<img class="logo h-full p-1" src="/favicon.svg" alt="">
<span class="absolute block h-1/2" style="left:5%;">
{{ compilation.name }}
{{ box.name }}
</span>
</template>
</div>
@@ -45,24 +42,23 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import type { Compilation, BoxState } from '~~/types/types'
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
import type { Box, BoxState } from '~~/types/types'
import { useDataStore } from '~/store/data'
const props = withDefaults(
defineProps<{
compilation: Compilation
BoxState?: BoxState
}>(),
{ BoxState: 'list' }
)
const props = defineProps<{
box: Box
}>();
const { $isMobile } = useNuxtApp()
const dataStore = useDataStore()
const isDraggable = computed(() => !['list', 'hidden'].includes(BoxState.value()))
const isNotManifesto = computed(() => !props.compilation.id.startsWith('ES00'))
const isDraggable = computed(() => !['box-list', 'box-hidden'].includes(props.box.state))
const isNotManifesto = computed(() => !props.box.id.startsWith('ES00'))
// --- Réfs ---
const scene = ref<HTMLElement>()
const box = ref<HTMLElement>()
const domBox = ref<HTMLElement>()
const frontFace = ref<HTMLElement>()
const backFace = ref<HTMLElement>()
const rightFace = ref<HTMLElement>()
@@ -81,36 +77,36 @@ let lastPointer = { x: 0, y: 0, time: 0 }
let velocity = { x: 0, y: 0 }
let raf: number | null = null
const sensitivity = 0.3
const sensitivity = $isMobile ? 0.5 : 0.15
const friction = 0.95
const minVelocity = 0.02
const enableInertia = true
// --- Transformations ---
function applyTransform(duration = 0.5) {
if (!box.value) return
if (!domBox.value) return
rotateX.value = Math.round(rotateX.value)
rotateY.value = Math.round(rotateY.value)
rotateZ.value = Math.round(rotateZ.value)
box.value.style.transition = `transform ${duration}s ease`
box.value.style.transform = `rotateX(${rotateX.value}deg) rotateY(${rotateY.value}deg) rotateZ(${rotateZ.value}deg)`
domBox.value.style.transition = `transform ${duration}s ease`
domBox.value.style.transform = `rotateX(${rotateX.value}deg) rotateY(${rotateY.value}deg) rotateZ(${rotateZ.value}deg)`
}
// --- Gestion BoxState ---
function applyBoxState() {
switch (props.BoxState) {
case 'list':
switch (props.box.state) {
case 'box-list':
rotateX.value = 76
rotateY.value = 0
rotateZ.value = 150
break
case 'selected':
case 'box-selected':
rotateX.value = -20
rotateY.value = 20
rotateZ.value = 0
break
case 'hide':
case 'box-hidden':
rotateX.value = 76
rotateY.value = 0
rotateZ.value = 150
@@ -123,12 +119,12 @@ function applyBoxState() {
function applyColor() {
if (!frontFace.value || !backFace.value || !leftFace.value || !topFace.value || !bottomFace.value) return
frontFace.value.style.background = props.compilation.color2
backFace.value.style.background = `linear-gradient(to top, ${props.compilation.color1}, ${props.compilation.color2})`
leftFace.value.style.background = `linear-gradient(to top, ${props.compilation.color1}, ${props.compilation.color2})`
rightFace.value.style.background = `linear-gradient(to top, ${props.compilation.color1}, ${props.compilation.color2})`
topFace.value.style.background = `linear-gradient(to top, ${props.compilation.color2}, ${props.compilation.color2})`
bottomFace.value.style.background = props.compilation.color1
frontFace.value.style.background = props.box.color2
backFace.value.style.background = `linear-gradient(to top, ${props.box.color1}, ${props.box.color2})`
leftFace.value.style.background = `linear-gradient(to top, ${props.box.color1}, ${props.box.color2})`
rightFace.value.style.background = `linear-gradient(to top, ${props.box.color1}, ${props.box.color2})`
topFace.value.style.background = `linear-gradient(to top, ${props.box.color2}, ${props.box.color2})`
bottomFace.value.style.background = props.box.color1
}
// --- Inertie ---
@@ -157,7 +153,7 @@ let listenersAttached = false
const down = (ev: PointerEvent) => {
ev.preventDefault()
dragging = true
box.value?.setPointerCapture(ev.pointerId)
domBox.value?.setPointerCapture(ev.pointerId)
lastPointer = { x: ev.clientX, y: ev.clientY, time: performance.now() }
velocity = { x: 0, y: 0 }
if (raf) { cancelAnimationFrame(raf); raf = null }
@@ -185,36 +181,36 @@ const move = (ev: PointerEvent) => {
const end = (ev: PointerEvent) => {
if (!dragging) return
dragging = false
try { box.value?.releasePointerCapture(ev.pointerId) } catch { }
try { domBox.value?.releasePointerCapture(ev.pointerId) } catch { }
if (enableInertia && (Math.abs(velocity.x) > minVelocity || Math.abs(velocity.y) > minVelocity)) {
if (!raf) raf = requestAnimationFrame(tickInertia)
}
}
function addListeners() {
if (!box.value || listenersAttached) return
box.value.addEventListener('pointerdown', down)
box.value.addEventListener('pointermove', move)
box.value.addEventListener('pointerup', end)
box.value.addEventListener('pointercancel', end)
box.value.addEventListener('pointerleave', end)
if (!domBox.value || listenersAttached) return
domBox.value.addEventListener('pointerdown', down)
domBox.value.addEventListener('pointermove', move)
domBox.value.addEventListener('pointerup', end)
domBox.value.addEventListener('pointercancel', end)
domBox.value.addEventListener('pointerleave', end)
listenersAttached = true
}
function removeListeners() {
if (!box.value || !listenersAttached) return
box.value.removeEventListener('pointerdown', down)
box.value.removeEventListener('pointermove', move)
box.value.removeEventListener('pointerup', end)
box.value.removeEventListener('pointercancel', end)
box.value.removeEventListener('pointerleave', end)
if (!domBox.value || !listenersAttached) return
domBox.value.removeEventListener('pointerdown', down)
domBox.value.removeEventListener('pointermove', move)
domBox.value.removeEventListener('pointerup', end)
domBox.value.removeEventListener('pointercancel', end)
domBox.value.removeEventListener('pointerleave', end)
listenersAttached = false
}
onMounted(() => {
applyColor()
applyBoxState()
if (isDraggable) addListeners()
if (isDraggable.value) addListeners()
})
onBeforeUnmount(() => {
@@ -223,9 +219,9 @@ onBeforeUnmount(() => {
})
// --- Watchers ---
watch(() => props.BoxState, () => applyBoxState())
watch(() => props.compilation, () => applyColor(), { deep: true })
watch(() => isDraggable, (enabled) => enabled ? addListeners() : removeListeners())
watch(() => props.box.state, () => applyBoxState())
watch(() => props.box, () => applyColor(), { deep: true })
watch(isDraggable, (enabled) => (enabled ? addListeners() : removeListeners()))
</script>
@@ -235,23 +231,29 @@ watch(() => isDraggable, (enabled) => enabled ? addListeners() : removeListeners
--height: calc(var(--size) * (100 / 3));
--width: calc(var(--size) * 50);
--depth: calc(var(--size) * 10);
transition: all .5s;
transition: height .5s ease, opacity .5s ease;
&.hide {
height: 0;
opacity: 0;
z-index: 0;
&.box-list {
height: calc(var(--size) * 20);
@apply hover:scale-105;
transition: all .5s ease;
will-change: transform;
}
&.list {
@apply hover:scale-105;
&.box-selected {
height: calc(var(--size) * 34);
}
&-scene {
height: calc(var(--size) * 20);
perspective: 1000px;
}
&.box-hidden {
height: 0;
opacity: 0;
z-index: 0;
}
&-object {
width: var(--width);
height: var(--height);
@@ -260,11 +262,11 @@ watch(() => isDraggable, (enabled) => enabled ? addListeners() : removeListeners
margin: auto;
user-select: none;
.list & {
.box-list & {
cursor: pointer;
}
.selected & {
.box-selected & {
cursor: grab;
}
@@ -339,5 +341,31 @@ watch(() => isDraggable, (enabled) => enabled ? addListeners() : removeListeners
height: 100%;
object-fit: cover;
}
/* Deck fade in/out purely in CSS */
.box-page {
opacity: 0;
transition: opacity .25s ease;
pointer-events: none;
}
&.box-selected .box-page {
opacity: 1;
pointer-events: auto;
}
/* for tabindex */
&:focus-visible {
outline: 0;
@apply scale-105 outline-none;
.face {
border: 4px solid rgba(0, 0, 0, 0.5);
}
}
:deep(.indice) {
@apply text-xl p-2 relative bg-black/50 rounded-full backdrop-blur-md;
}
}
</style>

74
app/components/boxes.vue Normal file
View File

@@ -0,0 +1,74 @@
<template>
<div class="flex flex-col-reverse mt-16">
<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">
{{ !playerStore.isPaused && playerStore.currentTrack?.boxId === box.id ? 'I I' : '▶' }}
</button>
<deck :box="box" class="box-page" v-if="box.state === 'box-selected'" @click.stop />
</box>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import type { Box } from '~~/types/types'
import { useDataStore } from '~/store/data'
import { usePlayerStore } from '~/store/player'
import { useUiStore } from '~/store/ui'
const dataStore = useDataStore()
const playerStore = usePlayerStore()
const uiStore = useUiStore()
function openBox(id: string) {
uiStore.selectBox(id)
// Scroll to the top smoothly
window.scrollTo({
top: 0,
behavior: 'smooth'
});
}
function onBoxClick(b: Box) {
if (b.state !== 'box-selected') {
openBox(b.id)
}
}
function playSelectedBox(b: Box) {
playerStore.playBox(b)
}
function KeyboardAction(e: KeyboardEvent) {
switch (e.key) {
case 'Escape':
uiStore.closeBox()
break;
case 'ArrowUp':
break;
case 'Enter':
if (document.activeElement?.id) {
openBox(document.activeElement.id)
}
break;
case 'ArrowDown':
break;
case 'ArrowLeft':
break;
case 'ArrowRight':
break;
default:
break;
}
}
onMounted(async () => {
const dataStore = await useDataStore()
await dataStore.loadData()
window.addEventListener('keydown', KeyboardAction)
})
</script>

View File

@@ -8,7 +8,7 @@
<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]">
{{ props.track.card?.suit }}
<img :src="`/${props.track.card?.suit}.svg`" />
</div>
<div class="rank text-white font-bold absolute -mt-1">
{{ props.track.card?.rank }}
@@ -58,9 +58,9 @@ const props = withDefaults(defineProps<{ track: Track; isFaceUp?: boolean }>(),
isFaceUp: false
})
const playerStore = usePlayerStore()
const isManifesto = computed(() => props.track.compilationId.startsWith('ES00'))
const isManifesto = computed(() => props.track.boxId.startsWith('ES00'))
const isOrder = computed(() => props.track.order && !isManifesto)
const isPlaylistTrack = computed(() => props.track.compilationId.length === 6)
const isPlaylistTrack = computed(() => props.track.type === 'playlist')
const isRedCard = computed(() => props.track.card?.suit === '♥' || props.track.card?.suit === '♦')
const coverUrl = props.track.coverId.startsWith('http')
? props.track.coverId
@@ -111,20 +111,11 @@ const coverUrl = props.track.coverId.startsWith('http')
transform: rotateY(180deg);
}
. {
@apply -mt-2;
}
. {
@apply text-7xl -mt-2;
}
. {
@apply -mt-3;
}
.,
.,
.,
. {
@apply text-5xl
@apply text-5xl size-14;
}
}
</style>

View File

@@ -1,63 +0,0 @@
<template>
<div class="flex flex-col-reverse mt-16">
<box v-for="compilation in dataStore.getAllCompilations.slice()" :key="compilation.id" :compilation="compilation"
:BoxState="boxStates[compilation.id]" @click="() => openCompilation(compilation.id)"
:class="boxStates[compilation.id]" class="text-center">
<button @click="playerStore.playCompilation(compilation.id)" v-if="boxStates[compilation.id] === 'selected'"
class="relative z-40 rounded-full size-24 bottom-1/2 text-2xl">
{{ !playerStore.isPaused && playerStore.currentTrack?.compilationId === compilation.id ? 'II' : '▶' }}
</button>
<deck :compilation="compilation" class="box-page" v-if="boxStates[compilation.id] === 'selected'" />
</box>
</div>
</template>
<script setup lang="ts">
import { useDataStore } from '~/store/data'
import type { BoxState } from '~~/types/types'
import { usePlayerStore } from '~/store/player'
const dataStore = useDataStore()
const boxStates = ref<Record<string, BoxState>>({})
const playerStore = usePlayerStore()
function openCompilation(id: string) {
if (boxStates.value[id] === 'list') {
for (const key in boxStates.value) {
boxStates.value[key] = (key === id) ? 'selected' : 'hide'
}
// Scroll to the top smoothly
window.scrollTo({
top: 0,
behavior: 'smooth'
});
// navigateTo(`/box/${id}`)
}
}
function closeCompilation(e: KeyboardEvent) {
if (e.key === 'Escape') {
for (const key in boxStates.value) {
boxStates.value[key] = 'list'
}
}
}
onMounted(async () => {
const dataStore = await useDataStore()
await dataStore.loadData()
dataStore.getAllCompilations.forEach(c => {
if (!(c.id in boxStates.value)) boxStates.value[c.id] = 'hide'
})
window.addEventListener('keydown', closeCompilation)
setTimeout(() => {
dataStore.getAllCompilations.forEach(c => {
boxStates.value[c.id] = 'list'
})
}, 333)
})
</script>

View File

@@ -6,21 +6,22 @@
<button @click="setDisplay('holdem')">holdem</button>
</div>
<div ref="deck" class="deck flex flex-wrap justify-center gap-4">
<card v-for="track in tracks" :key="track.id" :track="track" />
<card v-for="(track, i) in tracks" :key="track.id" :track="track" tabindex="i" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useDataStore } from '~/store/data'
import type { Compilation } from '~~/types/types'
import type { Box } from '~~/types/types'
const props = defineProps<{
compilation: Compilation
box: Box
}>()
const dataStore = useDataStore()
const deck = ref()
const tracks = props.compilation.duration ? dataStore.getTracksByCompilationId(props.compilation.id) : dataStore.getPlaylistTracksByCompilationId(props.compilation.id)
const tracks = computed(() => dataStore.getTracksByboxId(props.box.id))
function setDisplay(displayMode) {
deck.value.classList.remove('pile', 'plateau', 'holdem')

View File

@@ -1,7 +1,11 @@
<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 }">
<audio ref="audioRef" class="w-full" :src="playerStore.currentTrack?.url || ''" controls />
<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" />
<audio ref="audioRef" class="flex-1" controls />
</div>
</div>
</template>
@@ -14,7 +18,7 @@ const audioRef = ref<HTMLAudioElement | null>(null)
onMounted(() => {
if (audioRef.value) {
playerStore.audio = audioRef.value
playerStore.attachAudio(audioRef.value)
audioRef.value.addEventListener("timeupdate", playerStore.updateTime)
}
})

View File

@@ -1,12 +1,20 @@
<template>
<div class="w-full flex flex-col items-center">
<logo />
<div @click="uiStore.closeBox()" class="cursor-pointer">
<logo />
</div>
<main>
<compilations />
<boxes />
<player />
</main>
</div>
</template>
<script setup>
import { useUiStore } from '~/store/ui'
const uiStore = useUiStore()
</script>
<style>
.logo {
filter: drop-shadow(2px 2px 0 rgb(0 0 0 / 0.8));

View File

@@ -3,22 +3,22 @@
<div class="bg-page-dark-bg text-white">
<div class="flex flex-col-reverse bg-gradient-to-r from-primary to-primary-dark">
<div class="mt-8 flex flex-wrap justify-center">
<!-- <box :compilation="compilation" /> -->
<!-- <box :box="box" /> -->
<div class="devtool absolute right-4 text-white bg-black rounded-2xl px-4 py-2">
<!-- <button @click="currentPosition = boxPositions.side">side</button>
<button @click="currentPosition = boxPositions.front">front</button>
<button @click="currentPosition = boxPositions.back">back</button> -->
<div class="w-full block">
<input class="w-1/2" type="color" name="color1" id="color1" v-model="compilation.color1">
<input class="w-1/2" type="color" name="color1" id="color1" v-model="compilation.color2">
<input class="w-1/2" type="color" name="color1" id="color1" v-model="box.color1">
<input class="w-1/2" type="color" name="color1" id="color1" v-model="box.color2">
<div class="block w-full h-32" :style="{
background: `linear-gradient(to top, ${compilation.color1}, ${compilation.color2})`
background: `linear-gradient(to top, ${box.color1}, ${box.color2})`
}">
</div>
<label class="block">
<!-- <label class="block">
size: {{ size }}
<input v-model.number="size" type="range" step="1" min="1" max="14">
</label>
</label> -->
</div>
<!-- <div>
<label class="block">
@@ -43,9 +43,9 @@
</template>
<script setup lang="ts">
import type { BoxPosition, Compilation, Track } from '~~/types/types'
import type { Box, Track } from '~~/types/types'
const compilation = ref<Compilation>({
const box = ref<Box>({
id: 'ES00A',
name: 'zero',
duration: 2794,
@@ -53,11 +53,12 @@ const compilation = ref<Compilation>({
color1: '#ffffff',
color2: '#48959d',
color3: '#00ff00',
type: 'compilation'
})
const track = ref<Track>({
id: 1,
compilationId: 'ES00A',
boxId: 'ES00A',
title: 'The grinding wheel',
artist: {
id: 0,
@@ -68,6 +69,7 @@ const track = ref<Track>({
start: 0,
url: 'https://arakirecords.bandcamp.com/track/the-grinding-wheel',
coverId: 'a3236746052',
type: 'compilation',
})
//from-slate-800 to-zinc-900

View File

@@ -0,0 +1,6 @@
export default defineNuxtPlugin((nuxtApp) => {
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
)
nuxtApp.provide('isMobile', isMobile)
})

View File

@@ -1,14 +1,14 @@
import type { Compilation, Artist, Track } from '~/../types/types'
import type { Box, Artist, Track } from '~/../types/types'
// stores/data.ts
import { defineStore } from 'pinia'
import { useUiStore } from '~/store/ui'
export const useDataStore = defineStore('data', {
state: () => ({
compilations: [] as Compilation[], // Store your compilation data here
boxes: [] as Box[], // Store your box data here
artists: [] as Artist[], // Store artist data here
tracks: [] as Track[], // Store track data here
playlistTracks: [] as Track[], // store playslit tracks
isLoaded: false // Remember if data is already loaded
}),
@@ -16,75 +16,76 @@ export const useDataStore = defineStore('data', {
async loadData() {
if (this.isLoaded) return
const { data: compilations } = await useFetch<Compilation[]>('/api/compilations')
const { data: artists } = await useFetch<Artist[]>('/api/artists')
const { data: rawTracks } = await useFetch<Track[]>('/api/tracks')
const { data: playlistTracks } = await useFetch<Track[]>('/api/playlists')
// Stocker les données de base
this.compilations = compilations.value ?? []
this.artists = artists.value ?? []
this.boxes = await $fetch<Box[]>('/api/boxes')
this.artists = await $fetch<Artist[]>('/api/artists')
const compilationTracks = await $fetch<Track[]>('/api/tracks/compilation')
const playlistTracks = await $fetch<Track[]>('/api/tracks/playlist')
// Mapper les tracks pour remplacer l'artistId par l'objet Artist
// Mapper les tracks pour remplacer l'artist avec un objet Artist cohérent
const artistMap = new Map(this.artists.map((a) => [a.id, a]))
this.tracks = (rawTracks.value ?? []).map((track) => ({
...track,
artist: artistMap.get(track.artist) ?? {
id: track.artist,
name: 'Unknown',
url: '',
coverId: ''
}
}))
const allTracks = [...(compilationTracks ?? []), ...(playlistTracks ?? [])]
this.playlistTracks = (playlistTracks.value ?? []).map((track) => ({
...track,
artist: artistMap.get(track.artist) ?? {
id: track.artist,
name: track.artist,
url: '',
coverId: ''
this.tracks = allTracks.map((track) => {
const a = track.artist as unknown
let artistObj: Artist
if (typeof a === 'number') {
artistObj = artistMap.get(a) ?? { id: a, name: String(a), url: '', coverId: '' }
} else if (typeof a === 'string') {
artistObj = { id: 0, name: a, url: '', coverId: '' }
} else if (a && typeof a === 'object' && 'id' in (a as any)) {
const idVal = (a as any).id as number | undefined
artistObj = idVal != null ? artistMap.get(idVal) ?? (a as Artist) : (a as Artist)
} else {
artistObj = { id: 0, name: '', url: '', coverId: '' }
}
}))
return {
...track,
artist: artistObj
}
})
this.isLoaded = true
const uiStore = useUiStore()
uiStore.closeBox()
}
},
getters: {
// Obtenir tous les compilations
getAllCompilations: (state) => state.compilations,
getCompilationById: (state) => {
// Obtenir toutes les boxes
getBoxById: (state) => {
return (id: string) => {
return state.compilations.find((compilation) => compilation.id === id)
return state.boxes.find((box) => box.id === id)
}
},
// Obtenir toutes les pistes d'une compilation donnée
getTracksByCompilationId: (state) => (id: string) => {
return state.tracks.filter((track) => track.compilationId === id)
// Obtenir toutes les pistes d'une box donnée
getTracksByboxId: (state) => (id: string) => {
return state.tracks.filter((track) => track.boxId === id)
},
// Filtrer les artistes selon certains critères
getArtistById: (state) => (id: number) => state.artists.find((artist) => artist.id === id),
// Obtenir toutes les pistes d'un artiste donné
getTracksByArtistId: (state) => (artistId: number) => {
return state.tracks.filter((track) => track.artist.id === artistId)
return state.tracks.filter(
(track) =>
typeof track.artist === 'object' &&
!!track.artist &&
'id' in track.artist &&
(track.artist as Artist).id === artistId
)
},
getFirstTrackOfCompilation() {
return (compilationId: string) => {
const tracks = this.getTracksByCompilationId(compilationId)
return tracks.length > 0 ? tracks[0] : null
}
},
getFirstTrackOfPlaylist() {
return (compilationId: string) => {
const tracks = this.getPlaylistTracksByCompilationId(compilationId)
getFirstTrackOfBox() {
return (box: Box) => {
const tracks = this.getTracksByboxId(box.id)
.slice()
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
return tracks.length > 0 ? tracks[0] : null
}
},
getNextPlaylistTrack: (state) => {
return (track: Track) => {
const tracksInPlaylist = state.playlistTracks
.filter((t) => t.compilationId === track.compilationId)
const tracksInPlaylist = state.tracks
.filter((t) => t.boxId === track.boxId)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
const index = tracksInPlaylist.findIndex((t) => t.id === track.id)
@@ -95,31 +96,26 @@ export const useDataStore = defineStore('data', {
},
getNextTrack: (state) => {
return (track: Track) => {
// Récupérer toutes les tracks de la même compilation et les trier par ordre
const tracksInCompilation = state.tracks
.filter((t) => t.compilationId === track.compilationId)
// Récupérer toutes les tracks de la même box et les trier par ordre
const tracksInBox = state.tracks
.filter((t) => t.boxId === track.boxId)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
// Trouver lindex de la track courante
const index = tracksInCompilation.findIndex((t) => t.id === track.id)
const index = tracksInBox.findIndex((t) => t.id === track.id)
// Retourner la track suivante ou null si cest la dernière
return index >= 0 && index < tracksInCompilation.length - 1
? tracksInCompilation[index + 1]
: null
return index >= 0 && index < tracksInBox.length - 1 ? tracksInBox[index + 1] : null
}
},
getPrevTrack: (state) => {
return (track: Track) => {
const tracksInCompilation = state.tracks
.filter((t) => t.compilationId === track.compilationId)
const tracksInBox = state.tracks
.filter((t) => t.boxId === track.boxId)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
const index = tracksInCompilation.findIndex((t) => t.id === track.id)
return index > 0 ? tracksInCompilation[index - 1] : null
const index = tracksInBox.findIndex((t) => t.id === track.id)
return index > 0 ? tracksInBox[index - 1] : null
}
},
getPlaylistTracksByCompilationId: (state) => (id: string) => {
return state.playlistTracks.filter((track) => track.compilationId === id)
}
}
})

View File

@@ -1,6 +1,6 @@
// ~/store/player.ts
import { defineStore } from 'pinia'
import type { Track } from '~/../types/types'
import type { Track, Box } from '~/../types/types'
import { useDataStore } from '~/store/data'
export const usePlayerStore = defineStore('player', {
@@ -30,22 +30,23 @@ export const usePlayerStore = defineStore('player', {
const track = this.currentTrack
if (!track) return
const dataStore = useDataStore()
if (track.compilationId.length === 6) {
if (track.type === 'playlist') {
const next = dataStore.getNextPlaylistTrack(track)
if (next && next.compilationId === track.compilationId) {
if (next && next.boxId === track.boxId) {
this.playTrack(next)
}
} else {
console.log('ended')
this.currentTrack = null
}
})
},
async playCompilation(compilationId: string) {
if (this.currentTrack?.compilationId === compilationId) {
async playBox(box: Box) {
if (this.currentTrack?.boxId === box.id) {
this.togglePlay()
} else {
const dataStore = useDataStore()
const first = (compilationId.length === 6) ? dataStore.getFirstTrackOfPlaylist(compilationId) : dataStore.getFirstTrackOfCompilation(compilationId)
const first = dataStore.getFirstTrackOfBox(box)
if (first) {
await this.playTrack(first)
}
@@ -53,7 +54,7 @@ export const usePlayerStore = defineStore('player', {
},
async playTrack(track: Track) {
// mettre à jour la piste courante uniquement après avoir géré le toggle
if (this.currentTrack?.id === track.id) {
if (this.currentTrack && this.currentTrack?.id === track.id) {
this.togglePlay()
} else {
this.currentTrack = track
@@ -83,10 +84,18 @@ export const usePlayerStore = defineStore('player', {
const onLoaded = () => resolve()
audio.addEventListener('loadedmetadata', onLoaded, { once: true })
})
// Appliquer le temps de départ
audio.currentTime = wantedStart > 0 ? wantedStart : 0
await new Promise<void>((resolve) => {
const onCanPlay = () => {
if (wantedStart > 0 && audio.currentTime < wantedStart - 0.05) {
audio.currentTime = wantedStart
}
resolve()
}
if (audio.readyState >= 3) return resolve()
audio.addEventListener('canplay', onCanPlay, { once: true })
})
this.isPaused = false
await audio.play()
} catch (err: any) {
@@ -133,61 +142,37 @@ export const usePlayerStore = defineStore('player', {
if (!track) return
const dataStore = useDataStore()
const t = audio.currentTime
// For playlists (id length 6), do NOT forward auto-advance here; rely on 'ended'
if (track.compilationId.length === 6) {
const from = track.start ?? 0
const prevTrack = dataStore.getPrevTrack(track)
if (t < from + 0.05) {
if (prevTrack && prevTrack.compilationId === track.compilationId) {
this.currentTrack = prevTrack
}
}
return
}
// For compilations, forward auto-advance at segment boundary
const nextTrack = dataStore.getNextTrack(track)
const to = (track.order === 0) ? Math.round(this.audio?.duration ?? 0) : nextTrack?.start
if (!to || isNaN(to)) return
if (t >= to - 0.05) {
if (nextTrack && nextTrack.compilationId === track.compilationId) {
this.currentTrack = nextTrack
}
} else {
const from = track.start ?? 0
const prevTrack = dataStore.getPrevTrack(track)
if (t < from + 0.05) {
if (prevTrack && prevTrack.compilationId === track.compilationId) {
this.currentTrack = prevTrack
}
}
}
}
},
getters: {
isCurrentCompilation: (state) => {
return (compilationId: string) => compilationId === state.currentTrack?.compilationId
isCurrentBox: (state) => {
return (boxId: string) => boxId === state.currentTrack?.boxId
},
isPlaylistTrack: () => {
return (track: Track) => {
return track.compilationId.length === 6
return track.type === 'playlist'
}
},
getCurrentTrack: (state) => state.currentTrack,
getCurrentCompilation: (state) => {
getCurrentBox: (state) => {
return state.currentTrack ? state.currentTrack.url : null
},
currentProgression(state) {
getCurrentProgression(state) {
if (!state.audio) return 0
const duration = state.audio.duration
const progression = (state.position / duration) * 100
return isNaN(progression) ? state.progressionLast : progression
},
getCurrentCoverUrl(state) {
const id = state.currentTrack?.coverId
if (!id) return null
return id.startsWith('http') ? id : `https://f4.bcbits.com/img/${id}_4.jpg`
}
}
})

46
app/store/ui.ts Normal file
View File

@@ -0,0 +1,46 @@
import { defineStore } from 'pinia'
import { useDataStore } from '~/store/data'
import type { Box } from '~/../types/types'
export const useUiStore = defineStore('ui', {
state: () => ({
// UI-only state can live here later
}),
actions: {
selectBox(id: string) {
const dataStore = useDataStore()
dataStore.boxes.forEach((box) => {
box.state = box.id === id ? 'box-selected' : 'box-hidden'
})
},
closeBox() {
const selectedBox = this.getSelectedBox
const dataStore = useDataStore()
dataStore.boxes.forEach((box) => {
box.state = 'box-list'
})
// Scroll back to the unselected box in the list
if (selectedBox) this.scrollToBox(selectedBox)
},
scrollToBox(box: Box) {
if (box) {
const boxElement = document.getElementById(box.id)
if (boxElement) {
setTimeout(() => {
boxElement.scrollIntoView({ behavior: 'smooth' })
}, 333)
}
}
}
},
getters: {
getSelectedBox: () => {
const dataStore = useDataStore()
return (dataStore.boxes as Box[]).find((box) => box.state === 'box-selected') || null
}
}
})

59
public/♠.svg Normal file
View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="180mm"
height="180mm"
viewBox="0 0 180 180"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="spades.svg"
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">
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="0.1984375"
inkscape:cx="2980.7874"
inkscape:cy="798.74016"
inkscape:window-width="1920"
inkscape:window-height="1132"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:page
x="0"
y="0"
width="180"
height="180"
id="page1"
margin="0 0.625 0 0"
bleed="0" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
fill="#000000"
d="m 83.298209,123.60739 a 0.11377083,0.11377083 0 0 0 -0.17463,-0.12171 c -4.318,3.08239 -8.95085,5.57477 -14.05202,7.07495 -2.81252,0.82815 -6.18596,0.79111 -9.03817,0.69057 -14.65791,-0.52388 -27.23356,-10.09121 -30.90598,-24.43427 -4.73075,-18.473218 3.94494,-34.184176 16.9889,-46.704259 7.05644,-6.773334 14.67644,-12.961938 22.20383,-19.198167 5.41073,-4.484688 10.49338,-9.339794 17.15559,-16.123714 0.87048,-0.88635 2.00289,-1.86796 3.27818,-2.12725 q 3.07711,-0.62971 5.503343,1.79123 c 4.92654,4.91067 9.165158,9.24983 15.062728,14.353651 9.05404,7.836958 16.97302,13.95677 25.05604,21.666729 q 6.31825,6.027208 11.00137,13.350875 c 5.88698,9.207499 8.01688,19.698228 6.13834,30.487945 -2.36273,13.55989 -12.94871,23.97919 -26.33927,26.3816 q -4.953,0.88636 -10.35315,0.48684 c -6.477,-0.4789 -12.38779,-4.10369 -17.692688,-7.54857 q -0.13494,-0.0873 -0.20373,-0.1217 a 0.1031875,0.1031875 0 0 0 -0.14287,0.1217 c 3.780888,12.18142 7.791978,22.91821 14.030848,33.53594 q 0.15346,0.26194 -0.15081,0.26987 -0.56885,0.0132 -1.28323,-0.0873 c -4.2836,-0.61383 -8.636,-1.11389 -12.964578,-1.27529 q -8.702153,-0.32543 -17.399003,0.2249 -1.79917,0.11377 -8.79475,1.15094 -0.27781,0.0397 -0.84667,-0.037 -0.14552,-0.0185 -0.0926,-0.15611 0.254,-0.67469 0.61648,-1.27 3.5216,-5.78908 6.19918,-12.01473 4.29155,-9.98273 7.19932,-20.36762 z"
id="path1"
style="stroke-width:0.264583" />
<g
id="g5"
transform="matrix(0.26458333,0,0,0.26458333,527.61409,-106.44214)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

58
public/♣.svg Normal file
View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="180mm"
height="180mm"
viewBox="0 0 180 180"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="clubs.svg"
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">
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="0.1984375"
inkscape:cx="2980.7874"
inkscape:cy="798.74016"
inkscape:window-width="1920"
inkscape:window-height="1132"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:page
x="0"
y="0"
width="180"
height="180"
id="page1"
margin="0 0.625 0 0"
bleed="0" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
id="path3"
style="fill:#000000;stroke-width:0.264583"
d="m 90.000022,16.86819 a 32.819258,32.819258 0 0 0 -32.819176,32.819182 32.819258,32.819258 0 0 0 12.24214,25.567427 c 0.45986,0.67849 0.93396,1.34539 1.41956,1.99937 a 32.819258,32.819258 0 0 0 -16.14785,-4.24729 32.819258,32.819258 0 0 0 -32.81919,32.819191 32.819258,32.819258 0 0 0 32.81919,32.81919 32.819258,32.819258 0 0 0 31.463186,-23.48229 c 0.13863,-0.0901 0.27759,-0.17961 0.41703,-0.26871 a 0.06614583,0.06614583 0 0 1 0.10025,0.0713 c -1.82915,8.07332 -4.03647,16.0141 -6.62233,23.82283 -2.8284,8.54604 -6.180456,16.69282 -10.834476,24.02178 a 0.11641667,0.11641667 0 0 0 0.0713,0.17468 c 0.61736,0.14288 1.10161,0.18153 1.45262,0.11626 4.75192,-0.86077 9.865556,-1.33004 15.340666,-1.40766 8.12006,-0.11377 15.345448,0.0267 23.145368,1.4025 0.34043,0.06 0.79935,0.0246 1.37614,-0.10594 0.0952,-0.0212 0.11812,-0.074 0.0687,-0.15864 -4.21217,-7.23019 -7.6376,-14.81736 -10.27638,-22.76192 -2.416528,-7.27075 -4.641798,-15.08437 -6.675568,-23.43991 -0.11994,-0.49389 -0.17573,-0.9406 -0.16691,-1.341 a 0.12435417,0.12170833 16 0 1 0.1881,-0.10336 c 0.0396,0.0238 0.0789,0.0479 0.11834,0.0718 4.05187,13.52893 16.596578,23.38824 31.444088,23.38824 18.12557,0 32.81967,-14.69362 32.81967,-32.81919 0,-18.125581 -14.6941,-32.819191 -32.81967,-32.819191 -5.84735,0 -11.33713,1.52926 -16.09205,4.20956 0.51661,-0.6831 1.01243,-1.36973 1.48622,-2.06034 A 32.819258,32.819258 0 0 0 122.81917,49.687332 32.819258,32.819258 0 0 0 89.999982,16.86815 Z" />
<g
id="g5"
transform="matrix(0.26458333,0,0,0.26458333,527.61409,-106.44214)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

59
public/♥.svg Normal file
View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="180mm"
height="180mm"
viewBox="0 0 180 180"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="hearts.svg"
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">
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="0.1984375"
inkscape:cx="2980.7874"
inkscape:cy="798.74016"
inkscape:window-width="1920"
inkscape:window-height="1132"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:page
x="0"
y="0"
width="180"
height="180"
id="page1"
margin="0 0.625 0 0"
bleed="0" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
fill="#ff0000"
d="m 90.078668,46.355621 q 1.66952,-1.568979 3.34698,-3.127375 7.781392,-7.228416 17.774712,-10.871729 c 1.62983,-0.595311 3.87879,-0.997478 5.77849,-1.113894 q 5.4954,-0.336021 10.70504,0.502708 c 13.57313,2.185457 24.52159,12.00679 28.21781,25.225373 4.10634,14.681729 -0.11377,29.458708 -9.41916,41.301458 q -4.50586,5.733518 -8.43756,9.477368 c -13.77686,13.10746 -29.337,24.08501 -42.410062,37.95446 -1.97909,2.09814 -4.07988,3.78619 -7.10936,2.96598 -1.91293,-0.51859 -3.4872,-2.17488 -4.89479,-3.67771 -8.852957,-9.45091 -19.282827,-17.72707 -29.262907,-26.16462 -12.95136,-10.94846 -25.90536,-22.314958 -30.46148,-39.441436 -2.35744,-8.863542 -1.70656,-19.187583 2.05581,-27.585458 4.45558,-9.956271 13.47258,-17.118541 24.05856,-19.645312 2.14577,-0.513291 4.91596,-0.928686 7.34748,-1.00277 4.51379,-0.132291 8.46138,0.06085 12.63121,1.664228 q 9.239247,3.550708 16.494117,10.371667 1.68275,1.582208 3.36815,3.167062 0.10848,0.100542 0.21696,0 z"
id="path2"
style="stroke-width:0.264583" />
<g
id="g5"
transform="matrix(0.26458333,0,0,0.26458333,527.61409,-106.44214)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

59
public/♦.svg Normal file
View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="180mm"
height="180mm"
viewBox="0 0 180 180"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="diamonds.svg"
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">
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="0.1984375"
inkscape:cx="2980.7874"
inkscape:cy="798.74016"
inkscape:window-width="1920"
inkscape:window-height="1132"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:page
x="0"
y="0"
width="180"
height="180"
id="page1"
margin="0 0.625 0 0"
bleed="0" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
fill="#0000ff"
d="m 92.443692,23.012778 c 0.48419,0.17727 0.91546,0.37836 1.29646,0.762 q 1.95527,1.97909 2.92364,3.15648 22.412858,27.278537 46.466128,57.380187 c 1.05304,1.31762 2.37066,2.667 2.75166,4.18835 0.76465,3.03477 -0.59795,4.47411 -2.65641,7.05115 q -20.58988,25.791585 -41.48402,51.339755 -3.653898,4.46881 -7.482418,8.7921 c -2.02406,2.28336 -6.21242,2.42359 -8.28146,0.20902 q -2.45004,-2.61937 -3.86821,-4.34445 -23.88393,-29.09094 -47.3366,-58.531135 -0.11906,-0.15346 -0.27252,-0.43656 c -1.10596,-2.05581 -0.57944,-4.68048 0.7964,-6.45583 q 1.57162,-2.02936 3.17764,-4.01903 c 15.55221,-19.26165 31.14146,-38.502157 46.92385,-57.575967 1.7489,-2.11138 4.52703,-2.44475 7.04586,-1.51607 z"
id="path4"
style="fill:#ff0000;stroke-width:0.264583" />
<g
id="g5"
transform="matrix(0.26458333,0,0,0.26458333,527.61409,-106.44214)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,126 +1,140 @@
import { eventHandler } from 'h3'
export default eventHandler(() => {
return [
const boxes = [
{
id: 'ES2012',
type: 'playlist',
name: '2012',
duration: 0,
description: '🐉💧',
description: '🐉<i class="indice">💧</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2013',
type: 'playlist',
name: '2013',
duration: 0,
description: '🐍💧',
description: '🐍<i class="indice">💧</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2015',
type: 'playlist',
name: '2015',
duration: 0,
description: '🐐🌳',
description: '🐐<i class="indice">🌳</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2016',
type: 'playlist',
name: '2016',
duration: 0,
description: '🐒🔥',
description: '🐒<i class="indice">🔥</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2017',
type: 'playlist',
name: '2017',
duration: 0,
description: '🐓🔥',
description: '🐓<i class="indice">🔥</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2018',
type: 'playlist',
name: '2018',
duration: 0,
description: '🐕🌱',
description: '🐕<i class="indice">🌱</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2019',
type: 'playlist',
name: '2019',
duration: 0,
description: '🐖🌱',
description: '🐖<i class="indice">🌱</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2020',
type: 'playlist',
name: '2020',
duration: 0,
description: '🐀🪙',
description: '🐀<i class="indice">🪙</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2021',
type: 'playlist',
name: '2021',
duration: 0,
description: '🐃🪙',
description: '🐃<i class="indice">🪙</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2022',
type: 'playlist',
name: '2022',
duration: 0,
description: '🐅💧',
description: '🐅<i class="indice">💧</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2023',
type: 'playlist',
name: '2023',
duration: 0,
description: '🐇💧',
description: '🐇<i class="indice">💧</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2024',
type: 'playlist',
name: '2024',
duration: 0,
description: '🐉🌳',
description: '🐉<i class="indice">🌳</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2025',
type: 'playlist',
name: '2025',
duration: 0,
description: '🐍🌳',
description: '🐍<i class="indice">🌳</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES00A',
type: 'compilation',
name: 'manifeste',
duration: 2794,
description: 'Zero is for manifesto',
@@ -130,6 +144,7 @@ export default eventHandler(() => {
},
{
id: 'ES00B',
type: 'compilation',
name: 'manifeste B',
duration: 2470,
description: 'Even Zero has a b-side',
@@ -139,6 +154,7 @@ export default eventHandler(() => {
},
{
id: 'ES01A',
type: 'compilation',
name: '...',
duration: 3487,
description: '...',
@@ -148,6 +164,7 @@ export default eventHandler(() => {
},
{
id: 'ES01B',
type: 'compilation',
name: '... B',
duration: 3773,
description: '...',
@@ -156,4 +173,5 @@ export default eventHandler(() => {
color3: '#00ff00'
}
]
return boxes.map((b) => ({ ...b, state: 'box-hidden' }))
})

View File

@@ -4,7 +4,7 @@ export default eventHandler(() => {
const tracks = [
{
order: 1,
compilationId: 'ES00A',
boxId: 'ES00A',
title: 'The grinding wheel',
artist: 0,
start: 0,
@@ -13,7 +13,7 @@ export default eventHandler(() => {
},
{
order: 2,
compilationId: 'ES00A',
boxId: 'ES00A',
title: 'Bleach',
artist: 1,
start: 392,
@@ -22,7 +22,7 @@ export default eventHandler(() => {
},
{
order: 3,
compilationId: 'ES00A',
boxId: 'ES00A',
title: 'Televised mind',
artist: 2,
start: 896,
@@ -31,7 +31,7 @@ export default eventHandler(() => {
},
{
order: 4,
compilationId: 'ES00A',
boxId: 'ES00A',
title: 'In it',
artist: 3,
start: 1139,
@@ -40,7 +40,7 @@ export default eventHandler(() => {
},
{
order: 5,
compilationId: 'ES00A',
boxId: 'ES00A',
title: 'Bad michel',
artist: 4,
start: 1245,
@@ -49,7 +49,7 @@ export default eventHandler(() => {
},
{
order: 6,
compilationId: 'ES00A',
boxId: 'ES00A',
title: 'Overall',
artist: 5,
start: 1394,
@@ -58,7 +58,7 @@ export default eventHandler(() => {
},
{
order: 7,
compilationId: 'ES00A',
boxId: 'ES00A',
title: 'Blowup',
artist: 6,
start: 1674,
@@ -67,7 +67,7 @@ export default eventHandler(() => {
},
{
order: 8,
compilationId: 'ES00A',
boxId: 'ES00A',
title: 'Guitar jet',
artist: 7,
start: 1880,
@@ -76,7 +76,7 @@ export default eventHandler(() => {
},
{
order: 9,
compilationId: 'ES00A',
boxId: 'ES00A',
title: 'Intercontinental radio waves',
artist: 8,
start: 2024,
@@ -85,7 +85,7 @@ export default eventHandler(() => {
},
{
order: 10,
compilationId: 'ES00A',
boxId: 'ES00A',
title: 'Here comes the sun',
artist: 9,
start: 2211,
@@ -94,7 +94,7 @@ export default eventHandler(() => {
},
{
order: 11,
compilationId: 'ES00A',
boxId: 'ES00A',
title: 'Like in the movies',
artist: 10,
start: 2560,
@@ -103,7 +103,7 @@ export default eventHandler(() => {
},
{
order: 1,
compilationId: 'ES00B',
boxId: 'ES00B',
title: "Ce que révèle l'éclipse",
artist: 0,
start: 0,
@@ -112,7 +112,7 @@ export default eventHandler(() => {
},
{
order: 2,
compilationId: 'ES00B',
boxId: 'ES00B',
title: "Bleedin' Gums Mushrool",
artist: 1,
start: 263,
@@ -121,7 +121,7 @@ export default eventHandler(() => {
},
{
order: 3,
compilationId: 'ES00B',
boxId: 'ES00B',
title: 'A lucid dream',
artist: 2,
start: 554,
@@ -130,7 +130,7 @@ export default eventHandler(() => {
},
{
order: 4,
compilationId: 'ES00B',
boxId: 'ES00B',
title: 'Lights off',
artist: 3,
start: 781,
@@ -139,7 +139,7 @@ export default eventHandler(() => {
},
{
order: 5,
compilationId: 'ES00B',
boxId: 'ES00B',
title: "I'm sentimental",
artist: 4,
start: 969,
@@ -148,7 +148,7 @@ export default eventHandler(() => {
},
{
order: 6,
compilationId: 'ES00B',
boxId: 'ES00B',
title: 'Thrill or trip',
artist: 5,
start: 1128,
@@ -157,7 +157,7 @@ export default eventHandler(() => {
},
{
order: 7,
compilationId: 'ES00B',
boxId: 'ES00B',
title: 'Redhead',
artist: 6,
start: 1303,
@@ -166,7 +166,7 @@ export default eventHandler(() => {
},
{
order: 8,
compilationId: 'ES00B',
boxId: 'ES00B',
title: 'Supersonic twist',
artist: 7,
start: 1584,
@@ -175,7 +175,7 @@ export default eventHandler(() => {
},
{
order: 9,
compilationId: 'ES00B',
boxId: 'ES00B',
title: 'Flowers',
artist: 8,
start: 1749,
@@ -184,7 +184,7 @@ export default eventHandler(() => {
},
{
order: 10,
compilationId: 'ES00B',
boxId: 'ES00B',
title: 'The shade',
artist: 9,
start: 1924,
@@ -193,7 +193,7 @@ export default eventHandler(() => {
},
{
order: 11,
compilationId: 'ES00B',
boxId: 'ES00B',
title: 'Like in the movies',
artist: 10,
start: 2186,
@@ -202,7 +202,7 @@ export default eventHandler(() => {
},
{
order: 1,
compilationId: 'ES01A',
boxId: 'ES01A',
title: 'He Walked In',
artist: 11,
start: 0,
@@ -211,7 +211,7 @@ export default eventHandler(() => {
},
{
order: 2,
compilationId: 'ES01A',
boxId: 'ES01A',
title: 'The Third Wave',
artist: 12,
start: 841,
@@ -220,7 +220,7 @@ export default eventHandler(() => {
},
{
order: 3,
compilationId: 'ES01A',
boxId: 'ES01A',
title: 'Broadcaster',
artist: 13,
start: 1104.5,
@@ -229,7 +229,7 @@ export default eventHandler(() => {
},
{
order: 4,
compilationId: 'ES01A',
boxId: 'ES01A',
title: 'Mourn',
artist: 14,
start: 1441,
@@ -238,7 +238,7 @@ export default eventHandler(() => {
},
{
order: 5,
compilationId: 'ES01A',
boxId: 'ES01A',
title: 'Let it Blow',
artist: 15,
start: 1844.8,
@@ -247,7 +247,7 @@ export default eventHandler(() => {
},
{
order: 6,
compilationId: 'ES01A',
boxId: 'ES01A',
title: 'Sunday Mourning',
artist: 16,
start: 2091.7,
@@ -256,7 +256,7 @@ export default eventHandler(() => {
},
{
order: 7,
compilationId: 'ES01A',
boxId: 'ES01A',
title: '3030 Instrumental',
artist: 17,
start: 2339.3,
@@ -265,7 +265,7 @@ export default eventHandler(() => {
},
{
order: 8,
compilationId: 'ES01A',
boxId: 'ES01A',
title: 'Immortality Break',
artist: 18,
start: 2530.5,
@@ -274,7 +274,7 @@ export default eventHandler(() => {
},
{
order: 9,
compilationId: 'ES01A',
boxId: 'ES01A',
title: 'Lazy Bones',
artist: 19,
start: 2718,
@@ -283,7 +283,7 @@ export default eventHandler(() => {
},
{
order: 10,
compilationId: 'ES01A',
boxId: 'ES01A',
title: 'On the Train of Aches',
artist: 20,
start: 2948,
@@ -292,7 +292,7 @@ export default eventHandler(() => {
},
{
order: 11,
compilationId: 'ES01A',
boxId: 'ES01A',
title: 'Me',
artist: 21,
start: 3265,
@@ -301,7 +301,7 @@ export default eventHandler(() => {
},
{
order: 1,
compilationId: 'ES01B',
boxId: 'ES01B',
title: 'Lady Hawke Blues',
artist: 11,
start: 0,
@@ -310,7 +310,7 @@ export default eventHandler(() => {
},
{
order: 2,
compilationId: 'ES01B',
boxId: 'ES01B',
title: 'Dreamscapes',
artist: 12,
start: 235,
@@ -319,7 +319,7 @@ export default eventHandler(() => {
},
{
order: 3,
compilationId: 'ES01B',
boxId: 'ES01B',
title: 'Crispy Skin',
artist: 13,
start: 644.2,
@@ -328,7 +328,7 @@ export default eventHandler(() => {
},
{
order: 4,
compilationId: 'ES01B',
boxId: 'ES01B',
title: 'The Boy Who Stood Above The Earth',
artist: 14,
start: 1018,
@@ -337,7 +337,7 @@ export default eventHandler(() => {
},
{
order: 5,
compilationId: 'ES01B',
boxId: 'ES01B',
title: 'Better Off Alone',
artist: 15,
start: 1698,
@@ -346,7 +346,7 @@ export default eventHandler(() => {
},
{
order: 6,
compilationId: 'ES01B',
boxId: 'ES01B',
title: 'Celebration #1',
artist: 16,
start: 2235,
@@ -355,7 +355,7 @@ export default eventHandler(() => {
},
{
order: 7,
compilationId: 'ES01B',
boxId: 'ES01B',
title: '3030 Instrumental',
artist: 17,
start: 2458.3,
@@ -364,7 +364,7 @@ export default eventHandler(() => {
},
{
order: 8,
compilationId: 'ES01B',
boxId: 'ES01B',
title: 'The Emptiness Of Nothingness',
artist: 18,
start: 2864.5,
@@ -373,7 +373,7 @@ export default eventHandler(() => {
},
{
order: 9,
compilationId: 'ES01B',
boxId: 'ES01B',
title: 'Rising',
artist: 19,
start: 3145,
@@ -382,7 +382,7 @@ export default eventHandler(() => {
},
{
order: 10,
compilationId: 'ES01B',
boxId: 'ES01B',
title: 'The Last Time',
artist: 22,
start: 3447,
@@ -392,7 +392,7 @@ export default eventHandler(() => {
},
{
order: 11,
compilationId: 'ES01B',
boxId: 'ES01B',
title: 'Guajira Con Arpa',
artist: 23,
start: 3586,
@@ -404,6 +404,8 @@ export default eventHandler(() => {
return tracks.map((track, index) => ({
id: index + 1,
...track,
url: `https://files.erudi.fr/evilspins/${track.compilationId}.mp3`
url: `https://files.erudi.fr/evilspins/${track.boxId}.mp3`,
coverId: `https://f4.bcbits.com/img/${track.coverId}_4.jpg`,
type: 'compilation',
}))
})

View File

@@ -1,7 +1,7 @@
import fs from 'fs'
import path from 'path'
import { eventHandler } from 'h3'
import { getCardFromDate } from '../../utils/cards'
import { getCardFromDate } from '../../../utils/cards'
export default eventHandler(async (event) => {
const basePath = path.join(process.cwd(), '/mnt/media/files/music')
@@ -47,17 +47,21 @@ export default eventHandler(async (event) => {
return {
id: Number(`${year}${index + 1}`),
compilationId: `ES${year}`,
boxId: `ES${year}`,
date,
title: title.trim(),
artist: artist.trim(),
url,
coverId,
card
card,
order: 0,
type: 'playlist',
}
})
tracks.sort((a, b) => b.date.getTime() - a.date.getTime())
// assign a stable order after sort (1..N)
tracks.forEach((t, i) => (t.order = i + 1))
allTracks.push(...tracks)
return allTracks

80
spade.svg Normal file
View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="180mm"
height="180mm"
viewBox="0 0 180 180"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="spade.svg"
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">
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="0.396875"
inkscape:cx="797.48031"
inkscape:cy="452.28346"
inkscape:window-width="1920"
inkscape:window-height="1132"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:page
x="0"
y="0"
width="180"
height="180"
id="page1"
margin="0 0.625 0 0"
bleed="0" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
fill="#ff0000"
d="m 90.078671,46.355618 q 1.66952,-1.568979 3.34698,-3.127375 7.781389,-7.228416 17.774709,-10.871729 c 1.62983,-0.595311 3.87879,-0.997478 5.77849,-1.113894 q 5.4954,-0.336021 10.70504,0.502708 c 13.57313,2.185457 24.52159,12.00679 28.21781,25.225373 4.10634,14.681729 -0.11377,29.458708 -9.41916,41.301458 q -4.50586,5.733521 -8.43756,9.477371 c -13.77686,13.10746 -29.337,24.08501 -42.410059,37.95447 -1.97909,2.09814 -4.07988,3.78619 -7.10936,2.96598 -1.91293,-0.51859 -3.487205,-2.17488 -4.894788,-3.67771 C 74.777814,135.54135 64.347939,127.26519 54.367856,118.82764 41.416502,107.87918 28.462502,96.512679 23.906377,79.386201 21.54894,70.522659 22.199815,60.198618 25.96219,51.800743 30.417773,41.844472 39.434773,34.682202 50.020752,32.155431 c 2.145771,-0.513291 4.915958,-0.928686 7.347479,-1.00277 4.513792,-0.132291 8.461375,0.06085 12.631208,1.664228 q 9.23925,3.550708 16.494122,10.371667 1.68275,1.582208 3.36815,3.167062 0.10848,0.100542 0.21696,0 z"
id="path2"
style="stroke-width:0.264583" />
<path
fill="#000000"
d="m 470.83582,48.369648 a 0.11377083,0.11377083 0 0 0 -0.17463,-0.121709 c -4.318,3.082396 -8.95085,5.574771 -14.05202,7.074959 -2.81252,0.828146 -6.18596,0.791104 -9.03817,0.690562 -14.65791,-0.523875 -27.23356,-10.091208 -30.90598,-24.43427 -4.73075,-18.473208 3.94494,-34.1841663 16.9889,-46.704249 7.05644,-6.773334 14.67644,-12.961938 22.20383,-19.198167 5.41073,-4.484688 10.49338,-9.339792 17.15559,-16.123708 0.87048,-0.886354 2.00289,-1.867959 3.27818,-2.12725 q 3.07711,-0.629709 5.50334,1.791229 c 4.92654,4.910666 9.16516,9.249833 15.06273,14.353646 9.05404,7.836958 16.97302,13.95677 25.05604,21.666729 q 6.31825,6.027208 11.00137,13.3508746 c 5.88698,9.2074997 8.01688,19.6982284 6.13834,30.4879364 -2.36273,13.559896 -12.94871,23.979188 -26.33927,26.381604 q -4.953,0.886354 -10.35315,0.486834 c -6.477,-0.478896 -12.38779,-4.103688 -17.69269,-7.548563 q -0.13494,-0.08731 -0.20373,-0.121708 a 0.1031875,0.1031875 0 0 0 -0.14287,0.121708 c 3.78089,12.181417 7.79198,22.918208 14.03085,33.535937 q 0.15346,0.261938 -0.15081,0.269875 -0.56885,0.01323 -1.28323,-0.08731 c -4.2836,-0.613834 -8.636,-1.113896 -12.96458,-1.275292 q -8.70215,-0.325437 -17.399,0.224896 -1.79917,0.113771 -8.79475,1.150937 -0.27781,0.03969 -0.84667,-0.03704 -0.14552,-0.01852 -0.0926,-0.156104 0.254,-0.674688 0.61648,-1.27 3.5216,-5.789084 6.19918,-12.014729 4.29155,-9.98273 7.19932,-20.367625 z"
id="path1"
style="stroke-width:0.264583" />
<path
id="path3"
style="fill:#000000;stroke-width:0.264583"
d="m 689.88977,93.581748 a 32.819258,32.819258 0 0 0 -32.81918,32.819182 32.819258,32.819258 0 0 0 12.24214,25.56743 c 0.45986,0.67849 0.93396,1.34539 1.41956,1.99937 a 32.819258,32.819258 0 0 0 -16.14785,-4.24729 32.819258,32.819258 0 0 0 -32.81919,32.81918 32.819258,32.819258 0 0 0 32.81919,32.81919 32.819258,32.819258 0 0 0 31.46319,-23.48229 c 0.13863,-0.0901 0.27759,-0.17961 0.41703,-0.26871 a 0.06614583,0.06614583 0 0 1 0.10025,0.0713 c -1.82915,8.07332 -4.03647,16.0141 -6.62233,23.82283 -2.8284,8.54604 -6.18046,16.69282 -10.83448,24.02178 a 0.11641667,0.11641667 0 0 0 0.0713,0.17468 c 0.61736,0.14288 1.10161,0.18153 1.45262,0.11626 4.75192,-0.86077 9.86556,-1.33004 15.34067,-1.40766 8.12006,-0.11377 15.34544,0.0267 23.14536,1.4025 0.34043,0.06 0.79935,0.0246 1.37614,-0.10594 0.0952,-0.0212 0.11812,-0.074 0.0687,-0.15864 -4.21217,-7.23019 -7.6376,-14.81736 -10.27638,-22.76192 -2.41652,-7.27075 -4.64179,-15.08437 -6.67556,-23.43991 -0.11994,-0.49389 -0.17573,-0.9406 -0.16691,-1.341 a 0.12435417,0.12170833 16 0 1 0.1881,-0.10336 c 0.0396,0.0238 0.0789,0.0479 0.11834,0.0718 4.05187,13.52893 16.59657,23.38824 31.44408,23.38824 18.12557,0 32.81968,-14.69362 32.81968,-32.81919 0,-18.12557 -14.69411,-32.81918 -32.81968,-32.81918 -5.84735,0 -11.33713,1.52926 -16.09205,4.20956 0.51661,-0.6831 1.01243,-1.36973 1.48622,-2.06034 a 32.819258,32.819258 0 0 0 12.12018,-25.46873 32.819258,32.819258 0 0 0 -32.81918,-32.819182 z" />
<path
fill="#0000ff"
d="m 349.32373,119.55231 c 0.48419,0.17727 0.91546,0.37836 1.29646,0.762 q 1.95527,1.97909 2.92364,3.15648 22.41286,27.27854 46.46613,57.38019 c 1.05304,1.31762 2.37066,2.667 2.75166,4.18835 0.76465,3.03477 -0.59795,4.47411 -2.65641,7.05115 q -20.58988,25.79158 -41.48402,51.33975 -3.6539,4.46881 -7.48242,8.7921 c -2.02406,2.28336 -6.21242,2.42359 -8.28146,0.20902 q -2.45004,-2.61937 -3.86821,-4.34445 -23.88393,-29.09094 -47.3366,-58.53113 -0.11906,-0.15346 -0.27252,-0.43656 c -1.10596,-2.05581 -0.57944,-4.68048 0.7964,-6.45583 q 1.57162,-2.02936 3.17764,-4.01903 c 15.55221,-19.26166 31.14146,-38.50216 46.92385,-57.57597 1.7489,-2.11138 4.52703,-2.44475 7.04586,-1.51607 z"
id="path4"
style="fill:#ff0000;stroke-width:0.264583" />
<g
id="g5"
transform="matrix(0.26458333,0,0,0.26458333,273.92232,-63.930064)" />
<rect
style="fill:#808080;stroke-width:0.7;stroke-linecap:square;stroke-miterlimit:2.3;paint-order:markers stroke fill"
id="rect1"
width="179.375"
height="180"
x="0"
y="0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@@ -1,6 +1,7 @@
// types.ts
export interface Compilation {
export interface Box {
id: string
type: 'playlist' | 'compilation'
name: string
duration: number
tracks?: Track[]
@@ -8,6 +9,7 @@ export interface Compilation {
color2: string
color1: string
color3: string
state: BoxState
}
export interface Artist {
@@ -19,15 +21,16 @@ export interface Artist {
export interface Track {
id: number
order?: number
compilationId: string
boxId: string
title: string
artist?: Artist | { id?: Artist }
artist?: Artist | number | string
start?: number
url: string
coverId?: string
date?: Date
card?: { suit: CardSuit; value: CardRank }
card?: { suit: CardSuit; rank: CardRank }
link?: string
type: 'playlist' | 'compilation'
}
export interface Playlist {
@@ -38,6 +41,6 @@ export interface Playlist {
filename: string
}
export type BoxState = 'hide' | 'list' | 'selected'
export type BoxState = 'box-hidden' | 'box-list' | 'box-selected'
export type CardSuit = '♠' | '♣' | '♦' | '♥'
export type CardRank = 'A' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | 'J' | 'Q' | 'K'