Compare commits

...

32 Commits

Author SHA1 Message Date
valere
deb15b3ea1 update docker-web apps path in deploy.yml
All checks were successful
Deploy App / build (push) Successful in 1m6s
Deploy App / deploy (push) Successful in 15s
2025-10-30 15:51:46 +01:00
valere
9771c799f2 add default layout
All checks were successful
Deploy App / build (push) Successful in 2m20s
Deploy App / deploy (push) Successful in 14s
2025-10-29 19:37:37 +01:00
valere
25d56ec4ef refacto card / box / deck ajout du template default 2025-10-28 10:44:10 +01:00
valere
8ebda83a22 clean player off/on style
All checks were successful
Deploy App / build (push) Successful in 1m8s
Deploy App / deploy (push) Successful in 14s
2025-10-21 23:38:48 +02:00
valere
82de231548 clean style
All checks were successful
Deploy App / build (push) Successful in 1m11s
Deploy App / deploy (push) Successful in 14s
2025-10-21 22:50:03 +02:00
valere
f59c496c5d route v1
All checks were successful
Deploy App / build (push) Successful in 2m25s
Deploy App / deploy (push) Successful in 15s
2025-10-21 00:09:26 +02:00
valere
61b0b6395f favorite: v0.1
All checks were successful
Deploy App / build (push) Successful in 1m9s
Deploy App / deploy (push) Successful in 15s
2025-10-16 02:04:12 +02:00
valere
0aa1a57b78 search v1
All checks were successful
Deploy App / build (push) Successful in 1m5s
Deploy App / deploy (push) Successful in 14s
2025-10-16 01:45:28 +02:00
valere
3ad8cb8795 eS v1
All checks were successful
Deploy App / build (push) Successful in 2m14s
Deploy App / deploy (push) Successful in 14s
2025-10-16 00:42:38 +02:00
valere
ce73155cfa wait to navigate
All checks were successful
Deploy App / build (push) Successful in 55s
Deploy App / deploy (push) Successful in 25s
2025-10-12 03:57:42 +02:00
valere
8e4f34dd21 play/pause works
All checks were successful
Deploy App / build (push) Successful in 1m17s
Deploy App / deploy (push) Successful in 15s
2025-10-12 03:49:17 +02:00
valere
d21e731bbe fix mobile touch
All checks were successful
Deploy App / build (push) Successful in 1m11s
Deploy App / deploy (push) Successful in 15s
2025-10-11 22:20:51 +02:00
valere
9618f76a6c fix card rank ♣ is for automne
All checks were successful
Deploy App / build (push) Successful in 1m19s
Deploy App / deploy (push) Successful in 15s
2025-10-11 11:15:59 +02:00
valere
4d424eee54 ♠♣♦♥
All checks were successful
Deploy App / build (push) Successful in 2m29s
Deploy App / deploy (push) Successful in 15s
2025-10-11 09:56:14 +02:00
valere
2135b0fec6 add coverid
All checks were successful
Deploy App / build (push) Successful in 1m53s
Deploy App / deploy (push) Successful in 19s
2025-10-10 01:41:22 +02:00
valere
e2c5693948 playlists support v1
All checks were successful
Deploy App / build (push) Successful in 4m19s
Deploy App / deploy (push) Successful in 16s
2025-10-09 22:47:30 +02:00
valere
c86e345117 add folder for playlists api 3
All checks were successful
Deploy App / build (push) Successful in 1m7s
Deploy App / deploy (push) Successful in 16s
2025-10-04 01:17:24 +02:00
valere
83459227aa add folder for playlists api 2
All checks were successful
Deploy App / build (push) Successful in 1m9s
Deploy App / deploy (push) Successful in 16s
2025-10-04 01:13:38 +02:00
valere
22358b3ebb add folder for playlists api
All checks were successful
Deploy App / build (push) Successful in 1m8s
Deploy App / deploy (push) Successful in 16s
2025-10-04 01:08:14 +02:00
valere
96ffb4b10a add working player
All checks were successful
Deploy App / build (push) Successful in 1m20s
Deploy App / deploy (push) Successful in 16s
2025-10-04 00:49:12 +02:00
valere
fef1a8c234 flippable cards 2025-10-03 09:44:30 +02:00
valere
43b1a11027 add cards & tracks
All checks were successful
Deploy App / build (push) Successful in 1m13s
Deploy App / deploy (push) Successful in 15s
2025-10-02 00:38:54 +02:00
valere
8c1290beae CI drop debug
All checks were successful
Deploy App / build (push) Successful in 19s
Deploy App / deploy (push) Successful in 14s
2025-10-01 09:39:12 +02:00
valere
aba081cf05 CI debug
All checks were successful
Deploy App / build (push) Successful in 18s
Deploy App / deploy (push) Successful in 15s
2025-10-01 09:09:21 +02:00
valere
ae0cfc69c8 drop threejs
Some checks failed
Deploy App / build (push) Failing after 16s
Deploy App / deploy (push) Has been skipped
2025-10-01 09:00:49 +02:00
valere
377fb5935b CI drop ls debug
Some checks failed
Deploy App / build (push) Failing after 1m0s
Deploy App / deploy (push) Has been skipped
2025-10-01 08:55:37 +02:00
valere
9acc43a384 drop .env from git ignore
Some checks failed
Deploy App / build (push) Failing after 1m8s
Deploy App / deploy (push) Has been skipped
2025-10-01 08:54:45 +02:00
valere
ab3057ac7f ls DEBUG
Some checks failed
Deploy App / build (push) Failing after 12s
Deploy App / deploy (push) Has been skipped
2025-10-01 08:52:26 +02:00
valere
deedc333e3 add .env to cp
Some checks failed
Deploy App / build (push) Failing after 12s
Deploy App / deploy (push) Has been skipped
2025-10-01 08:48:35 +02:00
valere
454700c428 config.sh -> .env
Some checks failed
Deploy App / build (push) Failing after 1m28s
Deploy App / deploy (push) Has been skipped
2025-10-01 08:29:25 +02:00
valere
bd5ed09d5e add card verso
Some checks failed
Deploy App / build (push) Failing after 1m18s
Deploy App / deploy (push) Has been skipped
2025-09-30 12:37:24 +02:00
valere
c028fda489 drop position in studio 2025-09-30 12:35:59 +02:00
61 changed files with 2928 additions and 1059 deletions

3
.env Executable file
View File

@@ -0,0 +1,3 @@
DOMAIN="evilspins.com"
PORT="7901"
PORT_EXPOSED="3000"

View File

@@ -12,7 +12,7 @@ jobs:
- name: Prepare and build app
run: |
REPO_NAME="${GITHUB_REPOSITORY##*/}"
APP_DIR="/var/docker-web/apps/${REPO_NAME}"
APP_DIR="/var/docker-web/store/apps/${REPO_NAME}"
bash /var/docker-web/src/cli.sh down "${REPO_NAME}"
rm -rf "$APP_DIR"
mkdir "$APP_DIR"

6
.gitignore vendored
View File

@@ -17,8 +17,4 @@ logs
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
mnt

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"printWidth": 100
}

View File

@@ -1,4 +1,6 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}

View File

@@ -1,20 +1,80 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtPage />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>
<script setup>
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'
class: 'bg-slate-100'
}
})
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>
button,
input {
@apply px-4 py-2 m-4 rounded-md text-center font-bold
}
button {
@apply px-4 py-2 m-4 bg-esyellow text-slate-700 rounded-md;
@apply bg-esyellow text-slate-700;
}
input[type="email"] {
@apply bg-slate-900 text-esyellow;
}
</style>

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

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

371
app/components/box.vue Normal file
View File

@@ -0,0 +1,371 @@
<template>
<article class="box box-scene z-10" ref="scene">
<div ref="domBox" class="box-object" :class="{ 'is-draggable': isDraggable }">
<div class="face front relative" ref="frontFace">
<img v-if="box.duration" class="cover absolute" :src="`/${box.id}/cover.jpg`" alt="">
<div class="size-full flex 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.getTracksByboxId(box.id).slice(0, -1)" :key="track.id" :track="track">
<span class="" v-if="isNotManifesto">
{{ track.order }}.
</span>
<p class="text-left text-slate-700">
<i class="text-slate-950">
{{ track.title }}
</i> <br /> {{ track.artist.name }}
</p>
</li>
</div>
<div class="face right" ref="rightFace" />
<div class="face left" ref="leftFace" />
<div class="face top" ref="topFace">
<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="`/${box.id}/title.svg`" alt="">
</template>
<template v-else>
<span class="absolute block h-1/2 right-6">
playlist
</span>
<img class="logo h-full p-1" src="/favicon.svg" alt="">
<span class="absolute block h-1/2" style="left:5%;">
{{ box.name }}
</span>
</template>
</div>
<div class="face bottom" ref="bottomFace" />
</div>
<slot></slot>
</article>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
import type { Box, BoxState } from '~~/types/types'
import { useDataStore } from '~/store/data'
const props = defineProps<{
box: Box
}>();
const { $isMobile } = useNuxtApp()
const dataStore = useDataStore()
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 domBox = ref<HTMLElement>()
const frontFace = ref<HTMLElement>()
const backFace = ref<HTMLElement>()
const rightFace = ref<HTMLElement>()
const leftFace = ref<HTMLElement>()
const topFace = ref<HTMLElement>()
const bottomFace = ref<HTMLElement>()
// --- Angles ---
const rotateX = ref(0)
const rotateY = ref(0)
const rotateZ = ref(0)
// --- Drag + inertie ---
let dragging = false
let lastPointer = { x: 0, y: 0, time: 0 }
let velocity = { x: 0, y: 0 }
let raf: number | null = null
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 (!domBox.value) return
rotateX.value = Math.round(rotateX.value)
rotateY.value = Math.round(rotateY.value)
rotateZ.value = Math.round(rotateZ.value)
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.box.state) {
case 'box-list':
rotateX.value = 76
rotateY.value = 0
rotateZ.value = 150
break
case 'box-selected':
rotateX.value = -20
rotateY.value = 20
rotateZ.value = 0
break
case 'box-hidden':
rotateX.value = 76
rotateY.value = 0
rotateZ.value = 150
break
}
applyTransform(0.8) // transition fluide
}
// --- Couleurs ---
function applyColor() {
if (!frontFace.value || !backFace.value || !leftFace.value || !topFace.value || !bottomFace.value) return
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 ---
function tickInertia() {
if (!enableInertia) return
velocity.x *= friction
velocity.y *= friction
rotateX.value += velocity.y
rotateY.value += velocity.x
rotateX.value = Math.max(-80, Math.min(80, rotateX.value))
applyTransform(0.05) // court duration pour inertie fluide
if (Math.abs(velocity.x) > minVelocity || Math.abs(velocity.y) > minVelocity) {
raf = requestAnimationFrame(tickInertia)
} else {
raf = null
}
}
// --- Pointer events ---
let listenersAttached = false
const down = (ev: PointerEvent) => {
ev.preventDefault()
dragging = true
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 }
}
const move = (ev: PointerEvent) => {
if (!dragging) return
ev.preventDefault()
const now = performance.now()
const dx = ev.clientX - lastPointer.x
const dy = ev.clientY - lastPointer.y
const dt = Math.max(1, now - lastPointer.time)
rotateY.value += dx * sensitivity
rotateX.value -= dy * sensitivity
rotateX.value = Math.max(-80, Math.min(80, rotateX.value))
velocity.x = (dx / dt) * 16 * sensitivity
velocity.y = (-dy / dt) * 16 * sensitivity
lastPointer = { x: ev.clientX, y: ev.clientY, time: now }
applyTransform(0) // immédiat pendant drag
}
const end = (ev: PointerEvent) => {
if (!dragging) return
dragging = false
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 (!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 (!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.value) addListeners()
})
onBeforeUnmount(() => {
cancelAnimationFrame(raf!)
removeListeners()
})
// --- Watchers ---
watch(() => props.box.state, () => applyBoxState())
watch(() => props.box, () => applyColor(), { deep: true })
watch(isDraggable, (enabled) => (enabled ? addListeners() : removeListeners()))
</script>
<style lang="scss" scoped>
.box {
--size: 6px;
--height: calc(var(--size) * (100 / 3));
--width: calc(var(--size) * 50);
--depth: calc(var(--size) * 10);
transition: height .5s ease, opacity .5s ease;
&.box-list {
height: calc(var(--size) * 20);
@apply hover:scale-105;
transition: all .5s ease;
will-change: transform;
}
&.box-selected {
height: calc(var(--size) * 34);
}
&-scene {
perspective: 2000px;
}
&.box-hidden {
height: 0;
opacity: 0;
z-index: 0;
}
&-object {
width: var(--width);
height: var(--height);
position: relative;
transform-style: preserve-3d;
margin: auto;
user-select: none;
.box-list & {
cursor: pointer;
}
.box-selected & {
cursor: grab;
}
&:active {
cursor: grabbing;
}
}
.face {
position: absolute;
color: white;
font-weight: 600;
backface-visibility: hidden;
box-sizing: border-box;
border: 1px solid black;
}
.front,
.back {
width: 100%;
height: 100%;
}
.face.top,
.face.bottom {
width: var(--width);
height: var(--depth);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.face.left,
.face.right {
width: var(--depth);
height: var(--height);
}
.face.front {
transform: translateX(0) translateY(0) translateZ(var(--depth));
}
.face.back {
transform: rotateY(180deg) translateX(0) translateY(0) translateZ(0);
}
.face.right {
transform: rotateY(90deg) translateX(calc(var(--depth)*-1)) translateY(0px) translateZ(var(--width));
transform-origin: top left;
}
.face.left {
transform: rotateY(-90deg) translateX(calc(var(--depth)/2)) translateY(0) translateZ(calc(var(--depth)/2));
}
.face.top {
transform: rotateX(90deg) translateX(0px) translateY(calc(var(--depth)/2)) translateZ(calc(var(--depth)/2));
}
.face.top>* {
@apply rotate-180;
}
.face.bottom {
transform: rotateX(-90deg) translateX(0px) translateY(calc(var(--depth)* -0.5)) translateZ(calc(var(--height) - var(--depth)/2));
}
.cover {
width: 100%;
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>

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

@@ -0,0 +1,48 @@
<template>
<div class="boxes">
<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 text-esyellow backdrop-blur-sm bg-black/25">
{{ !playerStore.isPaused && playerStore.currentTrack?.boxId === box.id ? 'I I' : '▶' }}
</button>
<deck :box="box" class="box-page" v-if="box.state === 'box-selected'" @click.stop />
</box>
</div>
</template>
<script setup lang="ts">
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)
}
</script>
<style lang="scss">
.boxes {
@apply flex flex-col-reverse;
}
</style>

118
app/components/card.vue Normal file
View File

@@ -0,0 +1,118 @@
<template>
<article class="card isplaying w-56 h-80" :class="isFaceUp ? 'face-up' : 'face-down'">
<div class="flip-inner">
<!-- Face-Up -->
<main
class="face-up backdrop-blur-sm border-1 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]">
<img :src="`/${props.track.card?.suit}.svg`" />
</div>
<div class="rank text-white font-bold absolute -mt-1">
{{ props.track.card?.rank }}
</div>
</div>
<!-- Cover -->
<figure class="flex-1 overflow-hidden rounded-t-xl cursor-pointer">
<img :src="coverUrl" alt="Pochette de l'album" class="w-full h-full object-cover object-center" />
</figure>
<!-- Body -->
<div class="p-3 text-center bg-white rounded-b-xl">
<div class="label" v-if="isOrder">
{{ props.track.order }}
</div>
<h2 class="text-base text-neutral-800 font-bold truncate">
{{ props.track.title }}
</h2>
<p class="text-sm text-neutral-500 truncate">
<template v-if="isPlaylistTrack">
{{ props.track.artist.name }}
</template>
</p>
</div>
</main>
<!-- Face-Down -->
<footer
class="face-down backdrop-blur-sm z-10 card w-56 h-80 p-3 bg-opacity-10 bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden">
<div class="h-full flex p-16 text-center bg-slate-800 rounded-xl">
<img src="/favicon.svg" />
<div class="label label--id" v-if="isOrder">
{{ props.track.order }}
</div>
</div>
</footer>
</div>
</article>
</template>
<script setup lang="ts">
import type { Track } from '~~/types/types'
const props = withDefaults(defineProps<{ track: Track; isFaceUp?: boolean }>(), {
isFaceUp: false
})
const isManifesto = computed(() => props.track.boxId.startsWith('ES00'))
const isOrder = computed(() => props.track.order && !isManifesto)
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
: `https://f4.bcbits.com/img/${props.track.coverId}_4.jpg`;
</script>
<style lang="scss">
.label {
@apply rounded-full size-7 p-2 bg-esyellow leading-3 -mt-6;
font-weight: bold;
text-align: center;
}
/* Flip effect */
.card {
perspective: 1000px;
.flip-inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.6s;
transform-style: preserve-3d;
.face-down & {
transform: rotateY(180deg);
}
.face-up & {
transform: rotateY(0deg);
}
}
.face-down,
.face-up {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
will-change: transform;
}
.face-up {
transform: rotateY(0deg);
}
.face-down {
transform: rotateY(180deg);
}
.,
.,
.,
. {
@apply text-5xl size-14;
}
}
</style>

55
app/components/deck.vue Normal file
View File

@@ -0,0 +1,55 @@
<template>
<div>
<div class="deck-order">
<button @click="orderDeck('pile')">pile</button>
<button @click="orderDeck('plateau')">plateau</button>
<button @click="orderDeck('holdem')">holdem</button>
</div>
<div ref="deck" class="deck flex flex-wrap justify-center gap-4">
<card v-for="(track, i) in tracks" :key="track.id" :track="track" tabindex="i"
@click="() => playerStore.playTrack(track).catch(err => console.error(err))" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useDataStore } from '~/store/data'
import type { Box } from '~~/types/types'
import { usePlayerStore } from '~/store/player'
const props = defineProps<{
box: Box
}>()
const dataStore = useDataStore()
const deck = ref()
const tracks = computed(() => dataStore.getTracksByboxId(props.box.id))
const playerStore = usePlayerStore()
function orderDeck(order: string) {
deck.value.classList.remove('pile', 'plateau', 'holdem')
deck.value.classList.add(order)
}
</script>
<style lang="scss">
.deck {
@apply transition-all;
&.pile {
@apply relative;
.card {
@apply absolute top-0;
}
}
&.plateau {
@apply mt-8 p-8 w-full flex flex-wrap justify-around;
}
&.holdem {
/* style holdem */
}
}
</style>

8
app/components/logo.vue Normal file
View File

@@ -0,0 +1,8 @@
<template>
<header class="py-4">
<img class="logo w-80" src="/logo.svg" alt="">
<h1 class="text-center">
mix-tapes
</h1>
</header>
</template>

View File

@@ -1,126 +0,0 @@
<template>
<div class="w-96 m-6">
<atropos-component class="atropos game-box atropos-rotate-touch-scroll-y" rotate-touch="scroll-y" rotate-x-max="24"
rotate-y-max="24">
<div class="atropos-inner relative">
<div class="game-box-bg bg-gradient-to-t from-slate-800 to-zinc-900 h-96 relative" data-atropos-offset="-8" />
<img :src="id + '/object.png'" data-atropos-offset="-3" class="absolute bottom-0 inset-0 h-96 object-cover">
<img :src="id + '/name.png'" data-atropos-offset="0" class="absolute inset-0 self-end justify-self-end p-4">
<img src="/logo.svg" data-atropos-offset="0" width="70%"
class="logo absolute inset-0 self-center justify-self-center">
<!-- <img src="/play.svg" width="20%" class="absolute play"> -->
</div>
<span class="game-box-t" />
<span class="game-box-r" />
<span class="game-box-b" />
<span class="game-box-l" />
</atropos-component>
</div>
</template>
<script setup>
const id = 'ES01A'
</script>
<style>
.game-box .logo {
filter: drop-shadow(4px 4px 0 rgb(0 0 0 / 0.8));
}
.game-box {
--side-color: #004297;
--side-size: 32px;
aspect-ratio: 526 / 656;
}
.atropos-rotate {
position: relative;
}
.game-box .atropos-rotate:before {
content: "";
position: absolute;
left: 0;
width: calc(100% + 8px);
height: calc(100% + 16px);
top: -8px;
background: #086ef4;
z-index: 1;
}
.atropos-inner {
width: 100%;
height: 100%;
overflow: hidden;
transform-style: preserve-3d;
transform: translateZ(0);
display: block;
z-index: 1;
position: relative;
}
.game-box-t,
.game-box-r,
.game-box-b,
.game-box-l {
transform-style: preserve-3d;
backface-visibility: hidden;
position: absolute;
/* display: none; */
}
.game-box-t {
width: calc(100% + 8px);
height: var(--side-size);
background: var(--side-color);
left: 0;
top: -8px;
transform: translate3d(0, 0, -32px) rotateX(90deg);
transform-origin: center top;
top: -8px;
transform: translateZ(-32px) rotateX(90deg);
transform-origin: center top;
}
.game-box-b {
width: calc(100% + 8px);
height: var(--side-size);
background: var(--side-color);
left: 0;
bottom: -8px;
transform: translate3d(0, 0, -32px) rotateX(-90deg);
transform-origin: center bottom;
}
.game-box-r {
width: var(--side-size);
height: calc(100% + 16px);
background: var(--side-color);
right: -8px;
top: -8px;
transform: translate3d(0, 0, -32px) rotateY(90deg);
transform-origin: right center;
}
.game-box-l {
width: var(--side-size);
height: calc(100% + 16px);
background: var(--side-color);
left: 0px;
top: -8px;
transform: translate3d(0, 0, -32px) rotateY(-90deg);
transform-origin: left center;
overflow: hidden;
&::before {
content: '';
position: absolute;
width: 100%;
height: 10.4%;
background: #a5a5a5;
left: 0;
top: 9px;
}
}
</style>

View File

@@ -1,296 +0,0 @@
<template>
<article class="box box-scene z-10" ref="scene">
<div class="box-object" ref="box">
<div class="face front relative" ref="frontFace">
<img class="cover absolute" :src="`/${compilation.id}/cover.jpg`" alt="">
</div>
<div class="face back" ref="backFace">
{{ compilation.description }}
</div>
<div class="face right" ref="rightFace" />
<div class="face left" ref="leftFace" />
<div class="face top" ref="topFace">
<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="">
</div>
<div class="face bottom" ref="bottomFace" />
</div>
<OrganismCompilationPage :compilation="compilation" class="box-page" v-if="props.BoxState === 'selected'" />
</article>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import type { Compilation, BoxState } from '~~/types/types'
const props = withDefaults(
defineProps<{
compilation: Compilation
BoxState?: BoxState
}>(),
{ BoxState: 'list' }
)
// --- Réfs ---
const scene = ref<HTMLElement>()
const box = ref<HTMLElement>()
const frontFace = ref<HTMLElement>()
const backFace = ref<HTMLElement>()
const rightFace = ref<HTMLElement>()
const leftFace = ref<HTMLElement>()
const topFace = ref<HTMLElement>()
const bottomFace = ref<HTMLElement>()
// --- Angles ---
const rotateX = ref(0)
const rotateY = ref(0)
const rotateZ = ref(0)
// --- Drag + inertie ---
let dragging = false
let lastPointer = { x: 0, y: 0, time: 0 }
let velocity = { x: 0, y: 0 }
let raf: number | null = null
const sensitivity = 0.3
const friction = 0.95
const minVelocity = 0.02
const enableInertia = true
// --- Transformations ---
function applyTransform(duration = 0.5) {
if (!box.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)`
}
// --- Gestion BoxState ---
function applyBoxState() {
switch (props.BoxState) {
case 'list':
rotateX.value = 76
rotateY.value = 0
rotateZ.value = 150
break
case 'selected':
rotateX.value = -20
rotateY.value = 20
rotateZ.value = 0
break
case 'hide':
rotateX.value = 76
rotateY.value = 0
rotateZ.value = 150
break
}
applyTransform(0.8) // transition fluide
}
// --- Couleurs ---
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
}
// --- Inertie ---
function tickInertia() {
if (!enableInertia) return
velocity.x *= friction
velocity.y *= friction
rotateX.value += velocity.y
rotateY.value += velocity.x
rotateX.value = Math.max(-80, Math.min(80, rotateX.value))
applyTransform(0.05) // court duration pour inertie fluide
if (Math.abs(velocity.x) > minVelocity || Math.abs(velocity.y) > minVelocity) {
raf = requestAnimationFrame(tickInertia)
} else {
raf = null
}
}
// --- Pointer events ---
onMounted(() => {
applyColor()
applyBoxState()
const down = (ev: PointerEvent) => {
ev.preventDefault()
dragging = true
scene.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 }
}
const move = (ev: PointerEvent) => {
if (!dragging) return
ev.preventDefault()
const now = performance.now()
const dx = ev.clientX - lastPointer.x
const dy = ev.clientY - lastPointer.y
const dt = Math.max(1, now - lastPointer.time)
rotateY.value += dx * sensitivity
rotateX.value -= dy * sensitivity
rotateX.value = Math.max(-80, Math.min(80, rotateX.value))
velocity.x = (dx / dt) * 16 * sensitivity
velocity.y = (-dy / dt) * 16 * sensitivity
lastPointer = { x: ev.clientX, y: ev.clientY, time: now }
applyTransform(0) // immédiat pendant drag
}
const end = (ev: PointerEvent) => {
if (!dragging) return
dragging = false
try { scene.value?.releasePointerCapture(ev.pointerId) } catch { }
if (enableInertia && (Math.abs(velocity.x) > minVelocity || Math.abs(velocity.y) > minVelocity)) {
if (!raf) raf = requestAnimationFrame(tickInertia)
}
}
scene.value?.addEventListener('pointerdown', down)
scene.value?.addEventListener('pointermove', move)
scene.value?.addEventListener('pointerup', end)
scene.value?.addEventListener('pointercancel', end)
scene.value?.addEventListener('pointerleave', end)
onBeforeUnmount(() => {
cancelAnimationFrame(raf!)
})
})
// --- Watchers ---
watch(() => props.BoxState, () => applyBoxState())
watch(() => props.compilation, () => applyColor(), { deep: true })
</script>
<style lang="scss" scoped>
.box {
--size: 6px;
--height: calc(var(--size) * (100 / 3));
--width: calc(var(--size) * 50);
--depth: calc(var(--size) * 10);
transition: all .5s;
&.hide {
height: 0;
opacity: 0;
z-index: 0;
}
&.list {
@apply hover:scale-105;
}
&-scene {
height: calc(var(--size) * 20);
width: var(--width);
perspective: 1000px;
touch-action: none;
}
&-object {
width: var(--width);
height: var(--height);
position: relative;
transform-style: preserve-3d;
margin: auto;
user-select: none;
.list & {
cursor: pointer;
}
.selected & {
cursor: grab;
}
&:active {
cursor: grabbing;
}
}
.face {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
font-weight: 600;
backface-visibility: hidden;
box-sizing: border-box;
border: 1px solid black;
}
.front,
.back {
width: 100%;
height: 100%;
}
.face.top,
.face.bottom {
width: var(--width);
height: var(--depth);
}
.face.left,
.face.right {
width: var(--depth);
height: var(--height);
}
.face.front {
transform: translateX(0) translateY(0) translateZ(var(--depth));
}
.face.back {
transform: rotateY(180deg) translateX(0) translateY(0) translateZ(0);
}
.face.right {
transform: rotateY(90deg) translateX(calc(var(--depth)*-1)) translateY(0px) translateZ(var(--width));
transform-origin: top left;
}
.face.left {
transform: rotateY(-90deg) translateX(calc(var(--depth)/2)) translateY(0) translateZ(calc(var(--depth)/2));
}
.face.top {
transform: rotateX(90deg) translateX(0px) translateY(calc(var(--depth)/2)) translateZ(calc(var(--depth)/2));
}
.face.top>* {
@apply rotate-180;
}
.face.bottom {
transform: rotateX(-90deg) translateX(0px) translateY(calc(var(--depth)* -0.5)) translateZ(calc(var(--height) - var(--depth)/2));
}
.cover {
width: 100%;
height: 100%;
object-fit: cover;
}
}
</style>

View File

@@ -1,25 +0,0 @@
<template>
<article
class="backdrop-blur-sm -mt-12 z-10 card w-56 h-80 p-3 bg-opacity-10 bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden">
<!-- Cover -->
<figure class="flex-1 overflow-hidden rounded-t-xl">
<img :src="coverUrl" alt="Pochette de l'album" class="w-full h-full object-cover object-center" />
</figure>
<!-- Body -->
<div class="p-3 text-center bg-white rounded-b-xl">
<h2 class="text-base text-neutral-800 font-bold truncate">{{ props.track.title }}</h2>
<p class="text-sm text-neutral-500 truncate">
{{ props.track.artist.name }}
</p>
</div>
</article>
</template>
<script setup lang="ts">
import type { Track } from '~~/types/types'
const props = defineProps<{ track: Track }>()
const coverUrl = `https://f4.bcbits.com/img/${props.track.artist.coverId}_4.jpg`
</script>

View File

@@ -1,103 +0,0 @@
<template>
<h1 class="flex items-center justify-center min-h-screen">
<atropos-component class="my-atropos">
<span class="game-box-t" />
<span class="game-box-r" />
<span class="game-box-b" />
<span class="game-box-l" />
<div class="game-box-bg bg-gradient-to-t from-slate-800 to-zinc-900 h-60" data-atropos-offset="-8" />
<img :src="id + '/object.png'" data-atropos-offset="-3" class="absolute inset-0 object-cover">
<img :src="id + '/name.png'" data-atropos-offset="0" class="absolute inset-0 object-cover">
<img src="/logo.svg" data-atropos-offset="0" width="70%" class="logo absolute inset-0">
<img src="/play.svg" width="20%" class="absolute play">
</atropos-component>
</h1>
</template>
<script setup>
const id = 'ES01A'
</script>
<style>
/* .atropos-inner,
.game-box-bg {
width: 300px;
height: 300px;
} */
.game-box-t,
.game-box-r,
.game-box-b,
.game-box-l {
transform-style: preserve-3d;
backface-visibility: hidden;
position: absolute;
/* display: none; */
}
.game-box-t {
width: calc(100% + 8px);
height: var(--side-size);
background: var(--side-color);
left: 0;
top: -8px;
transform: translate3d(0, 0, -32px) rotateX(90deg);
transform-origin: center top;
}
.game-box-b {
width: calc(100% + 8px);
height: var(--side-size);
background: var(--side-color);
left: 0;
bottom: -8px;
transform: translate3d(0, 0, -32px) rotateX(-90deg);
transform-origin: center bottom;
}
.game-box-r {
width: var(--side-size);
height: calc(100% + 16px);
background: var(--side-color);
right: -8px;
top: -8px;
transform: translate3d(0, 0, -32px) rotateY(90deg);
transform-origin: right center;
}
.game-box-l {
width: var(--side-size);
height: calc(100% + 16px);
background: var(--side-color);
left: 0px;
top: -8px;
transform: translate3d(0, 0, -32px) rotateY(-90deg);
transform-origin: left center;
overflow: hidden;
&::before {
content: '';
position: absolute;
width: 100%;
height: 10.4%;
background: #a5a5a5;
left: 0;
top: 9px;
}
}
.atropos-rotate {
position: relative;
}
.atropos-rotate:before {
content: "";
position: absolute;
left: 0;
width: calc(100% + 8px);
height: calc(100% + 16px);
top: -8px;
background: #086ef4;
z-index: 1;
}
</style>

View File

@@ -0,0 +1,11 @@
<template>
<form class="h-screen flex justify-center items-center flex-col absolute top-0 left-1/2 -translate-x-1/2">
<label for="email" class="block text-xl">
be notify when's evilSpins open :
</label>
<div>
<input type="email" name="" id="email" placeholder="email">
<button>ok</button>
</div>
</form>
</template>

View File

@@ -1,53 +0,0 @@
<template>
<div class="flex flex-col-reverse mt-16">
<molecule-box v-for="compilation in dataStore.getAllCompilations.slice().reverse()" :key="compilation.id"
:compilation="compilation" :BoxState="boxStates[compilation.id]" @click="() => openCompilation(compilation.id)"
:class="boxStates[compilation.id]" />
</div>
</template>
<script setup lang="ts">
import { useDataStore } from '~/store/data'
import type { BoxState } from '~~/types/types'
import { useRouter } from 'vue-router'
const dataStore = useDataStore()
const router = useRouter()
const boxStates = ref<Record<string, BoxState>>({})
function openCompilation(id: string) {
if (boxStates.value[id] === 'list') {
for (const key in boxStates.value) {
boxStates.value[key] = (key === id) ? 'selected' : 'hide'
}
window.history.pushState({}, '', '/compilation/' + id)
}
}
function closeCompilation(e: KeyboardEvent) {
if (e.key === 'Escape') {
for (const key in boxStates.value) {
boxStates.value[key] = 'list'
}
}
window.history.pushState({}, '', '/')
}
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

@@ -1,17 +0,0 @@
<template>
<div class="mt-8 p-8 w-96">
<MoleculeCard v-for="track in dataStore.getTracksByCompilationId(compilation.id)" :key="track.id" :track="track" />
</div>
</template>
<script setup lang="ts">
import { useDataStore } from '~/store/data'
import type { Compilation } from '~~/types/types'
const props = defineProps<{
compilation: Compilation
}>()
const dataStore = useDataStore()
</script>

37
app/components/player.vue Normal file
View File

@@ -0,0 +1,37 @@
<template>
<div class="player-container fixed left-0 z-50 w-full h-20 bg-white"
:class="playerStore.currentTrack ? '-bottom-0 opacity-100' : '-bottom-32 opacity-0'">
<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>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { usePlayerStore } from '~/store/player'
const playerStore = usePlayerStore()
const audioRef = ref<HTMLAudioElement | null>(null)
onMounted(() => {
if (audioRef.value) {
playerStore.attachAudio(audioRef.value)
audioRef.value.addEventListener("timeupdate", playerStore.updateTime)
}
})
onUnmounted(() => {
if (audioRef.value) {
audioRef.value.removeEventListener("timeupdate", playerStore.updateTime)
}
})
</script>
<style>
.player-container {
transition: all 1s ease-in-out;
}
</style>

32
app/layouts/default.vue Normal file
View File

@@ -0,0 +1,32 @@
<template>
<div class="w-full min-h-screen flex flex-col items-center bg-gray-50">
<!-- Header avec logo -->
<header class="w-full py-4 px-6 bg-white shadow-sm">
<div class="max-w-7xl mx-auto w-full flex justify-center">
<div @click="navigateToHome" class="cursor-pointer inline-block">
<logo />
</div>
</div>
</header>
<!-- Contenu principal -->
<main class="w-full max-w-7xl flex-1 p-6">
<slot />
</main>
<!-- Player de musique fixe en bas -->
<SearchModal />
<Loader />
<Player class="w-full border-t border-gray-200" />
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const navigateToHome = () => {
router.push('/')
}
</script>

35
app/pages/box/[id].vue Normal file
View File

@@ -0,0 +1,35 @@
<template>
<boxes />
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useUiStore } from '~/store/ui'
import { useDataStore } from '~/store/data'
import { usePlayerStore } from '~/store/player'
// Configuration du layout
definePageMeta({
layout: 'default'
})
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)
// Lire automatiquement la box si on est sur la page d'une box
const box = dataStore.boxes.find(b => b.id === idParam)
if (box) {
const player = usePlayerStore()
player.playBox(box).catch(console.error)
}
}
})
</script>

View File

@@ -1,20 +1,27 @@
<template>
<div class="w-full flex flex-col items-center">
<header class="py-4">
<img class="logo p-1 w-80" src="/logo.svg" alt="">
<h1 class="dark:text-white text-center">
compilations
indépendantes
</h1>
</header>
<main>
<OrganismCompilationList />
</main>
</div>
<boxes />
</template>
<script setup>
import { useUiStore } from '~/store/ui'
import { useDataStore } from '~/store/data'
// Configuration du layout
definePageMeta({
layout: 'default'
})
const uiStore = useUiStore()
onMounted(async () => {
const dataStore = useDataStore()
await dataStore.loadData()
uiStore.listBoxes()
})
</script>
<style>
.logo {
filter: drop-shadow(2px 2px 0 rgb(0 0 0 / 0.8));
filter: drop-shadow(3px 3px 0 rgb(0 0 0 / 0.7));
}
</style>

14
app/pages/newsletter.vue Normal file
View File

@@ -0,0 +1,14 @@
<template>
<div class="w-full flex flex-col items-center">
<logo />
<main>
<newsletter />
</main>
</div>
</template>
<style>
.logo {
filter: drop-shadow(2px 2px 0 rgb(0 0 0 / 0.8));
}
</style>

View File

@@ -1,26 +1,26 @@
<template>
<div class="flex flex-wrap justify-center">
<div class="flex flex-wrap justify-center items-center h-screen">
<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">
<molecule-box :compilation="compilation" :position="currentPosition" :size="size" />
<!-- <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.side">side</button>
<button @click="currentPosition = boxPositions.front">front</button>
<button @click="currentPosition = boxPositions.back">back</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>
<!-- <div>
<label class="block">
X: {{ currentPosition.x }}
<input v-model.number="currentPosition.x" type="range" step="1" min="-180" max="180">
@@ -33,20 +33,19 @@
Z: {{ currentPosition.z }}
<input v-model.number="currentPosition.z" type="range" step="1" min="-180" max="180">
</label>
</div>
</div> -->
</div>
</div>
</div>
<molecule-card :track="track" />
<card :track="track" />
</div>
</div>
</template>
<script setup lang="ts">
import type { BoxPosition, Compilation, Track } from '~~/types/types'
import { boxPositions } from '~/store/position'
import type { Box, Track } from '~~/types/types'
const compilation = ref<Compilation>({
const box = ref<Box>({
id: 'ES00A',
name: 'zero',
duration: 2794,
@@ -54,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,
@@ -69,11 +69,8 @@ const track = ref<Track>({
start: 0,
url: 'https://arakirecords.bandcamp.com/track/the-grinding-wheel',
coverId: 'a3236746052',
type: 'compilation',
})
const size = ref(6)
const currentPosition: Ref<BoxPosition> = ref(boxPositions.side)
//from-slate-800 to-zinc-900
</script>

33
app/pages/track/[id].vue Normal file
View File

@@ -0,0 +1,33 @@
<template>
<boxes />
</template>
<script setup>
import { useUiStore } from '~/store/ui'
import { useDataStore } from '~/store/data'
import { usePlayerStore } from '~/store/player'
// Configuration du layout
definePageMeta({
layout: 'default'
})
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>

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

@@ -0,0 +1,6 @@
import { useFavoritesStore } from '~/store/favorites'
export default defineNuxtPlugin(() => {
const fav = useFavoritesStore()
fav.load()
})

View File

@@ -0,0 +1,106 @@
import { useUiStore } from '~/store/ui'
import { usePlayerStore } from '~/store/player'
import { onBeforeUnmount, onUnmounted, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useDataStore } from '~/store/data'
export default defineNuxtPlugin((nuxtApp) => {
// Ne s'exécuter que côté client
if (process.server) return
const ui = useUiStore()
const player = usePlayerStore()
const route = useRoute()
const dataStore = useDataStore()
function isInputElement(target: EventTarget | null): boolean {
return (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable)
)
}
function handleKeyDown(e: KeyboardEvent) {
console.log('Key pressed:', e.code, 'Key:', e.key, 'Target:', e.target)
// Ne pas interférer avec les champs de formulaire
if (isInputElement(e.target as HTMLElement)) {
console.log('Input element, ignoring key')
return
}
// Gestion du raccourci de recherche (Ctrl+F / Cmd+F)
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') {
e.preventDefault()
if (!ui.showSearch) {
ui.openSearch()
}
return
}
// Gestion des autres touches uniquement si pas de touche de contrôle enfoncée
if (e.ctrlKey || e.altKey || e.metaKey) {
return
}
switch (e.code) {
// Gestion de la barre d'espace pour play/pause
case 'Space':
console.log('Space pressed, toggling play/pause')
e.preventDefault()
e.stopPropagation()
if (player.currentTrack) {
console.log('Toggling play state')
player.togglePlay()
} else {
console.log('No current track to play/pause')
}
return false
// Gestion de la touche Échap pour fermer la boîte
case 'Escape':
e.preventDefault()
ui.closeBox()
break
// Gestion de la touche Entrée pour ouvrir une boîte
case 'Enter':
if (document.activeElement?.id) {
e.preventDefault()
ui.selectBox(document.activeElement.id)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
break
// Gestion des touches fléchées (à implémenter si nécessaire)
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowLeft':
case 'ArrowRight':
// Implémentation future de la navigation au clavier
break
}
}
// Ajout de l'écouteur d'événements avec capture pour intercepter l'événement plus tôt
window.addEventListener('keydown', handleKeyDown, { capture: true, passive: false })
console.log('Keyboard event listener added')
// Nettoyage lors de la destruction
const stop = () => {
window.removeEventListener('keydown', handleKeyDown)
}
// Nettoyage quand le composant est démonté
onUnmounted(stop)
// Nettoyage quand la page est déchargée
if (process.client) {
window.addEventListener('unload', stop)
onBeforeUnmount(() => {
window.removeEventListener('unload', stop)
})
}
})

16
app/router.options.ts Normal file
View 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
}

View File

@@ -1,58 +1,146 @@
import type { Compilation, Artist, Track } from '~/../types/types'
import type { Box, Artist, Track } from '~/../types/types'
import { FAVORITES_BOX_ID, useFavoritesStore } from '~/store/favorites'
// 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
artists: [] as Artist[], // Store artist data here
tracks: [] as Track[], // Store track data here
isLoaded: false, // Remember if data is already loaded
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
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')
const playlistTracks = await $fetch<Track[]>('/api/tracks/playlist')
const { data: compilations } = await useFetch<Compilation[]>('/api/compilations')
const { data: artists } = await useFetch<Artist[]>('/api/artists')
const { data: rawTracks } = await useFetch<{ id: number, compilationId: string, title: string, artist: number, start: number, url: string, coverId: string }[]>('/api/tracks')
// Mapper les tracks pour remplacer l'artist avec un objet Artist cohérent
const artistMap = new Map(this.artists.map((a) => [a.id, a]))
const allTracks = [...(compilationTracks ?? []), ...(playlistTracks ?? [])]
// Stocker les données de base
this.compilations = compilations.value ?? []
this.artists = artists.value ?? []
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: '' }
}
// Mapper les tracks pour remplacer l'artistId par l'objet Artist
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: '' }
}))
this.isLoaded = true
return {
...track,
artist: artistObj
}
})
const favBox: Box = {
id: FAVORITES_BOX_ID,
type: 'playlist',
name: 'Favoris',
duration: 0,
tracks: [],
description: '',
color1: '#0f172a',
color2: '#1e293b',
color3: '#334155',
state: 'box-list'
}
if (!this.boxes.find((b) => b.id === FAVORITES_BOX_ID)) {
this.boxes = [favBox, ...this.boxes]
}
this.isLoaded = true
} finally {
this.isLoading = false
}
}
},
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) => (compilationId: string) => {
return state.tracks.filter(track => track.compilationId === compilationId)
// Obtenir toutes les pistes d'une box donnée
getTracksByboxId: (state) => (id: string) => {
if (id === FAVORITES_BOX_ID) {
const fav = useFavoritesStore()
return fav.trackIds
.map((tid) => state.tracks.find((t) => t.id === tid))
.filter((t): t is Track => !!t)
}
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),
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
)
},
},
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.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)
return index >= 0 && index < tracksInPlaylist.length - 1
? tracksInPlaylist[index + 1]
: null
}
},
getNextTrack: (state) => {
return (track: Track) => {
// 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 = tracksInBox.findIndex((t) => t.id === track.id)
// Retourner la track suivante ou null si cest la dernière
return index >= 0 && index < tracksInBox.length - 1 ? tracksInBox[index + 1] : null
}
},
getPrevTrack: (state) => {
return (track: Track) => {
const tracksInBox = state.tracks
.filter((t) => t.boxId === track.boxId)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
const index = tracksInBox.findIndex((t) => t.id === track.id)
return index > 0 ? tracksInBox[index - 1] : null
}
}
}
})

52
app/store/favorites.ts Normal file
View File

@@ -0,0 +1,52 @@
import { defineStore } from 'pinia'
import type { Track } from '~/../types/types'
export const FAVORITES_BOX_ID = 'FAV'
const STORAGE_KEY = 'evilspins:favorites:v1'
export const useFavoritesStore = defineStore('favorites', {
state: () => ({
trackIds: [] as number[]
}),
actions: {
load() {
if (!process.client) return
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const arr = JSON.parse(raw)
if (Array.isArray(arr)) this.trackIds = arr.filter((x) => typeof x === 'number')
}
} catch {}
},
save() {
if (!process.client) return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.trackIds))
} catch {}
},
toggle(track: Track) {
const id = track.id
const idx = this.trackIds.indexOf(id)
if (idx >= 0) this.trackIds.splice(idx, 1)
else this.trackIds.unshift(id)
this.save()
},
add(track: Track) {
if (!this.trackIds.includes(track.id)) {
this.trackIds.unshift(track.id)
this.save()
}
},
remove(trackId: number) {
const idx = this.trackIds.indexOf(trackId)
if (idx >= 0) {
this.trackIds.splice(idx, 1)
this.save()
}
},
isFavorite(trackId: number) {
return this.trackIds.includes(trackId)
}
}
})

200
app/store/player.ts Normal file
View File

@@ -0,0 +1,200 @@
// ~/store/player.ts
import { defineStore } from 'pinia'
import type { Track, Box } from '~/../types/types'
import { useDataStore } from '~/store/data'
export const usePlayerStore = defineStore('player', {
state: () => ({
currentTrack: null as Track | null,
position: 0,
audio: null as HTMLAudioElement | null,
isPaused: true,
progressionLast: 0
}),
actions: {
attachAudio(el: HTMLAudioElement) {
this.audio = el
// attach listeners if not already attached (idempotent enough for our use)
this.audio.addEventListener('play', () => {
this.isPaused = false
})
this.audio.addEventListener('playing', () => {
this.isPaused = false
})
this.audio.addEventListener('pause', () => {
this.isPaused = true
})
this.audio.addEventListener('ended', () => {
this.isPaused = true
const track = this.currentTrack
if (!track) return
const dataStore = useDataStore()
if (track.type === 'playlist') {
const next = dataStore.getNextPlaylistTrack(track)
if (next && next.boxId === track.boxId) {
this.playTrack(next)
}
} else {
console.log('ended')
this.currentTrack = null
}
})
},
async playBox(box: Box) {
if (this.currentTrack?.boxId === box.id) {
this.togglePlay()
} else {
const dataStore = useDataStore()
const first = dataStore.getFirstTrackOfBox(box)
if (first) {
await this.playTrack(first)
}
}
},
async playTrack(track: Track) {
// mettre à jour la piste courante uniquement après avoir géré le toggle
if (this.currentTrack && this.currentTrack?.id === track.id) {
this.togglePlay()
} else {
this.currentTrack = track
if (!this.audio) {
// fallback: create an audio element and attach listeners
this.attachAudio(new Audio())
}
// Interrompre toute lecture en cours avant de charger une nouvelle source
const audio = this.audio as HTMLAudioElement
try {
audio.pause()
} catch (_) {}
// on entre en phase de chargement
this.isPaused = true
audio.src = track.url
audio.load()
// lancer la lecture (seek si nécessaire une fois les metadata chargées)
try {
const wantedStart = track.start ?? 0
// Attendre que les metadata soient prêtes pour pouvoir positionner currentTime
await new Promise<void>((resolve) => {
if (audio.readyState >= 1) return resolve()
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) {
// Ignorer les AbortError (arrivent lorsqu'une nouvelle source est chargée rapidement)
if (err && err.name === 'AbortError') return
this.isPaused = true
console.error('Impossible de lire la piste :', err)
}
}
},
togglePlay() {
if (!this.audio) return
if (this.audio.paused) {
this.isPaused = false
this.audio
.play()
.then(() => {
this.isPaused = false
})
.catch((err) => console.error(err))
} else {
this.audio.pause()
this.isPaused = true
}
},
updateTime() {
const audio = this.audio
if (!audio) return
// update current position
this.position = audio.currentTime
// compute and cache a stable progression value
const duration = audio.duration
const progression = (this.position / duration) * 100
if (!isNaN(progression)) {
this.progressionLast = progression
}
// update current track when changing time in compilation
const cur = this.currentTrack
if (cur && cur.type === 'compilation') {
const dataStore = useDataStore()
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
}
}
}
}
},
getters: {
isCurrentBox: (state) => {
return (boxId: string) => boxId === state.currentTrack?.boxId
},
isPlaylistTrack: () => {
return (track: Track) => {
return track.type === 'playlist'
}
},
getCurrentTrack: (state) => state.currentTrack,
getCurrentBox: (state) => {
return state.currentTrack ? state.currentTrack.url : null
},
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`
}
}
})

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

@@ -0,0 +1,70 @@
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
showSearch: false,
searchQuery: ''
}),
actions: {
openSearch() {
this.showSearch = true
// reset query on open to avoid stale state
this.searchQuery = ''
},
closeSearch() {
this.showSearch = false
this.searchQuery = ''
},
setSearchQuery(q: string) {
this.searchQuery = q
},
listBoxes() {
const dataStore = useDataStore()
dataStore.boxes.forEach((box) => {
box.state = 'box-list'
})
},
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
}
}
})

11
app/store/user.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { Compilation, Artist, Track } from '~/../types/types'
// stores/user.ts
import { defineStore } from 'pinia'
export const useDataStore = defineStore('data', {
state: () => ({
badge: [] // un badge par user achievement pour enrichir le déchifrage de l'app (afichage des nom des titres/artiste, collection de carte déjà joué (et du coups possibilité de les rejouer dans son deck))
// evilSpins est un jeux mais pas vraiment pokemon (un morceau = un pokemon) mais aussi un jeux d'aventure / exploration ou plus on progresse plus on peu voir de chose
})
})

View File

@@ -1,5 +0,0 @@
export REPO_NAME="evilspins"
export DOMAIN="evilspins.com"
export PORT="7901"
export PORT_EXPOSED="3000"
export REDIRECTIONS="" # example.$MAIN_DOMAIN->/route $MAIN_DOMAIN->url /route->/another-route /route->url

View File

@@ -10,7 +10,7 @@ services:
ports:
- "${PORT}:${PORT_EXPOSED}"
volumes:
- "${MEDIA_DIR}:/mnt/media"
- $MEDIA_DIR:/app/mnt/media
environment:
VIRTUAL_HOST: "${DOMAIN}"
LETSENCRYPT_HOST: "${DOMAIN}"

View File

@@ -1,6 +1,8 @@
// @ts-check
import withNuxt from "./.nuxt/eslint.config.mjs";
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
export default withNuxt([eslintPluginPrettierRecommended])
export default withNuxt({
rules: {
// Garde l'ordre correct : class avant @click

View File

@@ -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: [
{
src: 'https://umami.erudi.fr/script.js',
defer: true,
'data-website-id': '615690ea-0306-48cc-8feb-e9093fe6a1b7'
}
],
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' }
]

View File

@@ -7,7 +7,11 @@
"dev": "nuxt dev --host",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
"postinstall": "nuxt prepare",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --check .",
"format:fix": "prettier --write ."
},
"dependencies": {
"@nuxt/eslint": "1.9.0",

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 20 KiB

53
public/loader.svg Normal file
View 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

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

@@ -36,7 +36,7 @@ export default eventHandler(() => {
id: 5,
name: "New candys",
url: "https://newcandys.bandcamp.com",
coverId: "0033518637",
coverId: "0039963261",
},
{
id: 6,
@@ -67,6 +67,84 @@ export default eventHandler(() => {
name: "I love UFO",
url: "https://bruitblanc.bandcamp.com",
coverId: "a2203158939",
}
},
{
id: 11,
name: "Kid Congo & The Pink Monkey Birds",
url: "https://kidcongothepinkmonkeybirds.bandcamp.com/",
coverId: "0017196290",
},
{
id: 12,
name: "Firefriend",
url: "https://firefriend.bandcamp.com/",
coverId: "0031072203",
},
{
id: 13,
name: "Squid",
url: "https://squiduk.bandcamp.com/",
coverId: "0037649385",
},
{
id: 14,
name: "Lysistrata",
url: "https://lysistrata.bandcamp.com/",
coverId: "0033900158",
},
{
id: 15,
name: "Pablo X Broadcasting Services",
url: "https://pabloxbroadcastingservices.bandcamp.com/",
coverId: "0036956486",
},
{
id: 16,
name: "Night Beats",
url: "https://nightbeats.bandcamp.com/",
coverId: "0036987720",
},
{
id: 17,
name: "Deltron 3030",
url: "https://delthefunkyhomosapien.bandcamp.com/",
coverId: "0005254781",
},
{
id: 18,
name: "The Amorphous Androgynous",
url: "https://theaa.bandcamp.com/",
coverId: "0022226700",
},
{
id: 19,
name: "Wooden Shjips",
url: "https://woodenshjips.bandcamp.com/",
coverId: "0012406678",
},
{
id: 20,
name: "Silas J. Dirge",
url: "https://silasjdirge.bandcamp.com/",
coverId: "0035751570",
},
{
id: 21,
name: "Secret Colours",
url: "https://secretcolours.bandcamp.com/",
coverId: "0010661379",
},
{
id: 22,
name: "Larry McNeil And The Blue Knights",
url: "https://www.discogs.com/artist/6528940-Larry-McNeil-And-The-Blue-Knights",
coverId: "https://i.discogs.com/Yr05_neEXwzPwKlDeV7dimmTG34atkAMgpxbMBhHBkI/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTEyMTEw/ODE1LTE1Mjg1NjU1/NzQtMjcyOC5qcGVn.jpeg",
},
{
id: 23,
name: "Hugo Blanco",
url: "https://elpalmasmusic.bandcamp.com/album/color-de-tr-pico-compiled-by-el-dr-gon-criollo-el-palmas",
coverId: "0016886708",
},
]
})

177
server/api/boxes.ts Normal file
View File

@@ -0,0 +1,177 @@
import { eventHandler } from 'h3'
export default eventHandler(() => {
const boxes = [
{
id: 'ES2012',
type: 'playlist',
name: '2012',
duration: 0,
description: '🐉<i class="indice">💧</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2013',
type: 'playlist',
name: '2013',
duration: 0,
description: '🐍<i class="indice">💧</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2015',
type: 'playlist',
name: '2015',
duration: 0,
description: '🐐<i class="indice">🌳</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2016',
type: 'playlist',
name: '2016',
duration: 0,
description: '🐒<i class="indice">🔥</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2017',
type: 'playlist',
name: '2017',
duration: 0,
description: '🐓<i class="indice">🔥</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2018',
type: 'playlist',
name: '2018',
duration: 0,
description: '🐕<i class="indice">🌱</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2019',
type: 'playlist',
name: '2019',
duration: 0,
description: '🐖<i class="indice">🌱</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2020',
type: 'playlist',
name: '2020',
duration: 0,
description: '🐀<i class="indice">🪙</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2021',
type: 'playlist',
name: '2021',
duration: 0,
description: '🐃<i class="indice">🪙</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2022',
type: 'playlist',
name: '2022',
duration: 0,
description: '🐅<i class="indice">💧</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2023',
type: 'playlist',
name: '2023',
duration: 0,
description: '🐇<i class="indice">💧</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2024',
type: 'playlist',
name: '2024',
duration: 0,
description: '🐉<i class="indice">🌳</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES2025',
type: 'playlist',
name: '2025',
duration: 0,
description: '🐍<i class="indice">🌳</i>',
color1: '#ffffff',
color2: '#32021F',
color3: '#00ff00'
},
{
id: 'ES00A',
type: 'compilation',
name: 'manifeste',
duration: 2794,
description: 'Zero is for manifesto',
color1: '#ffffff',
color2: '#48959d',
color3: '#00ff00'
},
{
id: 'ES00B',
type: 'compilation',
name: 'manifeste B',
duration: 2470,
description: 'Even Zero has a b-side',
color1: '#0d01b9',
color2: '#3b7589',
color3: '#00ff00'
},
{
id: 'ES01A',
type: 'compilation',
name: '...',
duration: 3487,
description: '...',
color1: '#c7b3aa',
color2: '#000100',
color3: '#00ff00'
},
{
id: 'ES01B',
type: 'compilation',
name: '... B',
duration: 3773,
description: '...',
color1: '#f7dd01',
color2: '#010103',
color3: '#00ff00'
}
]
return boxes.map((b) => ({ ...b, state: 'box-hidden' })) // boxes are first hidden to allow the animation to work (hidden -> list -> selected)
})

View File

@@ -1,42 +0,0 @@
import { eventHandler } from 'h3'
export default eventHandler(() => {
return [
{
id: 'ES00A',
name: 'zero',
duration: 2794,
description: 'Zero is for manifesto',
color1: '#ffffff',
color2: '#48959d',
color3: '#00ff00',
},
{
id: 'ES00B',
name: 'zero b-side',
duration: 2470,
description: 'Even Zero has a b-side',
color1: '#0d01b9',
color2: '#3b7589',
color3: '#00ff00',
},
{
id: 'ES01A',
name: 'first',
duration: 3487,
description: '...',
color1: '#c7b3aa',
color2: '#000100',
color3: '#00ff00',
},
{
id: 'ES01B',
name: 'first b-side',
duration: 3773,
description: '...',
color1: '#f7dd01',
color2: '#010103',
color3: '#00ff00',
}
]
})

View File

@@ -1,24 +0,0 @@
import fs from 'fs'
import path from 'path'
import { eventHandler } from 'h3'
export default eventHandler(async (event) => {
const id = event.context.params?.id || ''
const directoryPath = path.join(process.cwd(), 'media/files/music/' + id) // replace 'your-folder' with the folder you want to list
try {
// Read the directory contents
const files = await fs.promises.readdir(directoryPath)
return {
success: true,
files: files.filter(file => !file.startsWith('.')) // optional: exclude unwanted files
}
} catch (error) {
return {
success: false,
error: error.message
}
}
})

View File

@@ -1,22 +0,0 @@
import fs from 'fs'
import path from 'path'
import { eventHandler } from 'h3'
export default eventHandler(async (event) => {
const directoryPath = path.join(process.cwd(), 'media/files/music')
try {
// Read the directory contents
const files = await fs.promises.readdir(directoryPath)
return {
success: true,
files: files.filter(file => !file.startsWith('.')).reverse() // exclude unwanted files
}
} catch (error) {
return {
success: false,
error: error.message
}
}
})

View File

@@ -1,204 +0,0 @@
import { eventHandler } from 'h3'
export default eventHandler(() => {
return [
{
id: 1,
compilationId: 'ES00A',
title: 'The grinding wheel',
artist: 0,
start: 0,
url: 'https://arakirecords.bandcamp.com/track/the-grinding-wheel',
coverId: 'a3236746052',
},
{
id: 2,
compilationId: 'ES00A',
title: 'Bleach',
artist: 1,
start: 393,
url: 'https://the-kundalini-genie.bandcamp.com/track/bleach-2',
coverId: 'a1714786533',
},
{
id: 3,
compilationId: 'ES00A',
title: 'Televised mind',
artist: 2,
start: 892,
url: 'https://fontainesdc.bandcamp.com/track/televised-mind',
coverId: 'a3772806156'
},
{
id: 4,
compilationId: 'ES00A',
title: 'In it',
artist: 3,
start: 1138,
url: 'https://howlinbananarecords.bandcamp.com/track/in-it',
coverId: 'a1720372066',
},
{
id: 5,
compilationId: 'ES00A',
title: 'Bad michel',
artist: 4,
start: 1245,
url: 'https://johnnymafia.bandcamp.com/track/bad-michel-3',
coverId: 'a0984622869',
},
{
id: 6,
compilationId: 'ES00A',
title: 'Overall',
artist: 5,
start: 1394,
url: 'https://newcandys.bandcamp.com/track/overall',
coverId: 'a0559661270',
},
{
id: 7,
compilationId: 'ES00A',
title: 'Blowup',
artist: 6,
start: 1674,
url: 'https://magicshoppe.bandcamp.com/track/blowup',
coverId: 'a1444895293',
},
{
id: 8,
compilationId: 'ES00A',
title: 'Guitar jet',
artist: 7,
start: 1880,
url: 'https://radiomartiko.bandcamp.com/track/guitare-jet',
coverId: 'a1494681687',
},
{
id: 9,
compilationId: 'ES00A',
title: 'Intercontinental radio waves',
artist: 8,
start: 2024,
url: 'https://traams.bandcamp.com/track/intercontinental-radio-waves',
coverId: 'a0046738552',
},
{
id: 10,
compilationId: 'ES00A',
title: 'Here comes the sun',
artist: 9,
start: 2211,
url: 'https://blue-orchid.bandcamp.com/track/here-come-the-sun',
coverId: 'a4102567047',
},
{
id: 11,
compilationId: 'ES00A',
title: 'Like in the movies',
artist: 10,
start: 2559,
url: 'https://bruitblanc.bandcamp.com/track/like-in-the-movies-2',
coverId: 'a2203158939',
},
{
id: 21,
compilationId: 'ES00B',
title: 'Ce que révèle l\'éclipse',
artist: 0,
start: 0,
url: 'https://arakirecords.bandcamp.com/track/ce-que-r-v-le-l-clipse',
coverId: 'a3236746052',
},
{
id: 22,
compilationId: 'ES00B',
title: 'Bleedin\' Gums Mushrool',
artist: 1,
start: 263,
url: 'https://the-kundalini-genie.bandcamp.com/track/bleedin-gums-mushroom',
coverId: 'a1714786533',
},
{
id: 23,
compilationId: 'ES00B',
title: 'A lucid dream',
artist: 2,
start: 554,
url: 'https://fontainesdc.bandcamp.com/track/a-lucid-dream',
coverId: 'a3772806156',
},
{
id: 24,
compilationId: 'ES00B',
title: 'Lights off',
artist: 3,
start: 781,
url: 'https://howlinbananarecords.bandcamp.com/track/lights-off',
coverId: 'a1720372066',
},
{
id: 25,
compilationId: 'ES00B',
title: 'I\'m sentimental',
artist: 4,
start: 969,
url: 'https://johnnymafia.bandcamp.com/track/im-sentimental-2',
coverId: 'a2333676849',
},
{
id: 26,
compilationId: 'ES00B',
title: 'Thrill or trip',
artist: 5,
start: 1128,
url: 'https://newcandys.bandcamp.com/track/thrill-or-trip',
coverId: 'a0559661270',
},
{
id: 27,
compilationId: 'ES00B',
title: 'Redhead',
artist: 6,
start: 1303,
url: 'https://magicshoppe.bandcamp.com/track/redhead',
coverId: 'a0594426943',
},
{
id: 28,
compilationId: 'ES00B',
title: 'Supersonic twist',
artist: 7,
start: 1584,
url: 'https://open.spotify.com/track/66voQIZAJ3zD3Eju2qtNjF',
coverId: 'a1494681687',
},
{
id: 29,
compilationId: 'ES00B',
title: 'Flowers',
artist: 8,
start: 1749,
url: 'https://traams.bandcamp.com/track/flowers',
coverId: 'a3644668199',
},
{
id: 30,
compilationId: 'ES00B',
title: 'The shade',
artist: 9,
start: 1924,
url: 'https://blue-orchid.bandcamp.com/track/the-shade',
coverId: 'a0804204790',
},
{
id: 31,
compilationId: 'ES00B',
title: 'Like in the movies',
artist: 10,
start: 2185,
url: 'https://bruitblanc.bandcamp.com/track/like-in-the-movies',
coverId: 'a3647322740',
},
]
})

View File

@@ -0,0 +1,411 @@
import { eventHandler } from 'h3'
export default eventHandler(() => {
const tracks = [
{
order: 1,
boxId: 'ES00A',
title: 'The grinding wheel',
artist: 0,
start: 0,
link: 'https://arakirecords.bandcamp.com/track/the-grinding-wheel',
coverId: 'a3236746052'
},
{
order: 2,
boxId: 'ES00A',
title: 'Bleach',
artist: 1,
start: 392,
link: 'https://the-kundalini-genie.bandcamp.com/track/bleach-2',
coverId: 'a1714786533'
},
{
order: 3,
boxId: 'ES00A',
title: 'Televised mind',
artist: 2,
start: 896,
link: 'https://fontainesdc.bandcamp.com/track/televised-mind',
coverId: 'a3772806156'
},
{
order: 4,
boxId: 'ES00A',
title: 'In it',
artist: 3,
start: 1139,
link: 'https://howlinbananarecords.bandcamp.com/track/in-it',
coverId: 'a1720372066'
},
{
order: 5,
boxId: 'ES00A',
title: 'Bad michel',
artist: 4,
start: 1245,
link: 'https://johnnymafia.bandcamp.com/track/bad-michel-3',
coverId: 'a0984622869'
},
{
order: 6,
boxId: 'ES00A',
title: 'Overall',
artist: 5,
start: 1394,
link: 'https://newcandys.bandcamp.com/track/overall',
coverId: 'a0559661270'
},
{
order: 7,
boxId: 'ES00A',
title: 'Blowup',
artist: 6,
start: 1674,
link: 'https://magicshoppe.bandcamp.com/track/blowup',
coverId: 'a1444895293'
},
{
order: 8,
boxId: 'ES00A',
title: 'Guitar jet',
artist: 7,
start: 1880,
link: 'https://radiomartiko.bandcamp.com/track/guitare-jet',
coverId: 'a1494681687'
},
{
order: 9,
boxId: 'ES00A',
title: 'Intercontinental radio waves',
artist: 8,
start: 2024,
link: 'https://traams.bandcamp.com/track/intercontinental-radio-waves',
coverId: 'a0046738552'
},
{
order: 10,
boxId: 'ES00A',
title: 'Here comes the sun',
artist: 9,
start: 2211,
link: 'https://blue-orchid.bandcamp.com/track/here-come-the-sun',
coverId: 'a4102567047'
},
{
order: 11,
boxId: 'ES00A',
title: 'Like in the movies',
artist: 10,
start: 2560,
link: 'https://bruitblanc.bandcamp.com/track/like-in-the-movies-2',
coverId: 'a2203158939'
},
{
order: 1,
boxId: 'ES00B',
title: "Ce que révèle l'éclipse",
artist: 0,
start: 0,
link: 'https://arakirecords.bandcamp.com/track/ce-que-r-v-le-l-clipse',
coverId: 'a3236746052'
},
{
order: 2,
boxId: 'ES00B',
title: "Bleedin' Gums Mushrool",
artist: 1,
start: 263,
link: 'https://the-kundalini-genie.bandcamp.com/track/bleedin-gums-mushroom',
coverId: 'a1714786533'
},
{
order: 3,
boxId: 'ES00B',
title: 'A lucid dream',
artist: 2,
start: 554,
link: 'https://fontainesdc.bandcamp.com/track/a-lucid-dream',
coverId: 'a3772806156'
},
{
order: 4,
boxId: 'ES00B',
title: 'Lights off',
artist: 3,
start: 781,
link: 'https://howlinbananarecords.bandcamp.com/track/lights-off',
coverId: 'a1720372066'
},
{
order: 5,
boxId: 'ES00B',
title: "I'm sentimental",
artist: 4,
start: 969,
link: 'https://johnnymafia.bandcamp.com/track/im-sentimental-2',
coverId: 'a2333676849'
},
{
order: 6,
boxId: 'ES00B',
title: 'Thrill or trip',
artist: 5,
start: 1128,
link: 'https://newcandys.bandcamp.com/track/thrill-or-trip',
coverId: 'a0559661270'
},
{
order: 7,
boxId: 'ES00B',
title: 'Redhead',
artist: 6,
start: 1303,
link: 'https://magicshoppe.bandcamp.com/track/redhead',
coverId: 'a0594426943'
},
{
order: 8,
boxId: 'ES00B',
title: 'Supersonic twist',
artist: 7,
start: 1584,
link: 'https://open.spotify.com/track/66voQIZAJ3zD3Eju2qtNjF',
coverId: 'a1494681687'
},
{
order: 9,
boxId: 'ES00B',
title: 'Flowers',
artist: 8,
start: 1749,
link: 'https://traams.bandcamp.com/track/flowers',
coverId: 'a3644668199'
},
{
order: 10,
boxId: 'ES00B',
title: 'The shade',
artist: 9,
start: 1924,
link: 'https://blue-orchid.bandcamp.com/track/the-shade',
coverId: 'a0804204790'
},
{
order: 11,
boxId: 'ES00B',
title: 'Like in the movies',
artist: 10,
start: 2186,
link: 'https://bruitblanc.bandcamp.com/track/like-in-the-movies',
coverId: 'a3647322740'
},
{
order: 1,
boxId: 'ES01A',
title: 'He Walked In',
artist: 11,
start: 0,
link: 'https://kidcongothepinkmonkeybirds.bandcamp.com/track/he-walked-in',
coverId: 'a0336300523'
},
{
order: 2,
boxId: 'ES01A',
title: 'The Third Wave',
artist: 12,
start: 841,
link: 'https://firefriend.bandcamp.com/track/the-third-wave',
coverId: 'a2803689859'
},
{
order: 3,
boxId: 'ES01A',
title: 'Broadcaster',
artist: 13,
start: 1104.5,
link: 'https://squiduk.bandcamp.com/track/broadcaster',
coverId: 'a3391719769'
},
{
order: 4,
boxId: 'ES01A',
title: 'Mourn',
artist: 14,
start: 1441,
link: 'https://lysistrata.bandcamp.com/track/mourn-2',
coverId: 'a0872900041'
},
{
order: 5,
boxId: 'ES01A',
title: 'Let it Blow',
artist: 15,
start: 1844.8,
link: 'https://pabloxbroadcastingservices.bandcamp.com/track/let-it-blow',
coverId: 'a4000148031'
},
{
order: 6,
boxId: 'ES01A',
title: 'Sunday Mourning',
artist: 16,
start: 2091.7,
link: 'https://nightbeats.bandcamp.com/track/sunday-mourning',
coverId: 'a0031987121'
},
{
order: 7,
boxId: 'ES01A',
title: '3030 Instrumental',
artist: 17,
start: 2339.3,
link: 'https://delthefunkyhomosapien.bandcamp.com/track/3030',
coverId: 'a1948146136'
},
{
order: 8,
boxId: 'ES01A',
title: 'Immortality Break',
artist: 18,
start: 2530.5,
link: 'https://theaa.bandcamp.com/track/immortality-break',
coverId: 'a2749250329'
},
{
order: 9,
boxId: 'ES01A',
title: 'Lazy Bones',
artist: 19,
start: 2718,
link: 'https://woodenshjips.bandcamp.com/track/lazy-bones',
coverId: 'a1884221104'
},
{
order: 10,
boxId: 'ES01A',
title: 'On the Train of Aches',
artist: 20,
start: 2948,
link: 'https://silasjdirge.bandcamp.com/track/on-the-train-of-aches',
coverId: 'a1124177379'
},
{
order: 11,
boxId: 'ES01A',
title: 'Me',
artist: 21,
start: 3265,
link: 'https://secretcolours.bandcamp.com/track/me',
coverId: 'a1497022499'
},
{
order: 1,
boxId: 'ES01B',
title: 'Lady Hawke Blues',
artist: 11,
start: 0,
link: 'https://kidcongothepinkmonkeybirds.bandcamp.com/track/lady-hawke-blues',
coverId: 'a2532623230'
},
{
order: 2,
boxId: 'ES01B',
title: 'Dreamscapes',
artist: 12,
start: 235,
link: 'https://littlecloudrecords.bandcamp.com/track/dreamscapes',
coverId: 'a3498981203'
},
{
order: 3,
boxId: 'ES01B',
title: 'Crispy Skin',
artist: 13,
start: 644.2,
link: 'https://squiduk.bandcamp.com/track/crispy-skin-2',
coverId: 'a2516727021'
},
{
order: 4,
boxId: 'ES01B',
title: 'The Boy Who Stood Above The Earth',
artist: 14,
start: 1018,
link: 'https://lysistrata.bandcamp.com/track/the-boy-who-stood-above-the-earth-2',
coverId: 'a0350933426'
},
{
order: 5,
boxId: 'ES01B',
title: 'Better Off Alone',
artist: 15,
start: 1698,
link: 'https://pabloxbroadcastingservices.bandcamp.com/track/better-off-alone',
coverId: 'a4000148031'
},
{
order: 6,
boxId: 'ES01B',
title: 'Celebration #1',
artist: 16,
start: 2235,
link: 'https://nightbeats.bandcamp.com/track/celebration-1',
coverId: 'a0031987121'
},
{
order: 7,
boxId: 'ES01B',
title: '3030 Instrumental',
artist: 17,
start: 2458.3,
link: 'https://delthefunkyhomosapien.bandcamp.com/track/3030',
coverId: 'a1948146136'
},
{
order: 8,
boxId: 'ES01B',
title: 'The Emptiness Of Nothingness',
artist: 18,
start: 2864.5,
link: 'https://theaa.bandcamp.com/track/the-emptiness-of-nothingness',
coverId: 'a1053923875'
},
{
order: 9,
boxId: 'ES01B',
title: 'Rising',
artist: 19,
start: 3145,
link: 'https://woodenshjips.bandcamp.com/track/rising',
coverId: 'a1884221104'
},
{
order: 10,
boxId: 'ES01B',
title: 'The Last Time',
artist: 22,
start: 3447,
link: 'https://www.discogs.com/release/12110815-Larry-McNeil-And-The-Blue-Knights-Jealous-Woman',
coverId:
'https://i.discogs.com/Yr05_neEXwzPwKlDeV7dimmTG34atkAMgpxbMBhHBkI/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTEyMTEw/ODE1LTE1Mjg1NjU1/NzQtMjcyOC5qcGVn.jpeg'
},
{
order: 11,
boxId: 'ES01B',
title: 'Guajira Con Arpa',
artist: 23,
start: 3586,
link: 'https://elpalmasmusic.bandcamp.com/track/guajira-con-arpa',
coverId: 'a3463036407'
}
]
return tracks.map((track, index) => ({
id: index + 1,
...track,
url: `https://files.erudi.fr/evilspins/${track.boxId}.mp3`,
coverId: `https://f4.bcbits.com/img/${track.coverId}_4.jpg`,
type: 'compilation',
}))
})

View File

@@ -0,0 +1,74 @@
import fs from 'fs'
import path from 'path'
import { eventHandler } from 'h3'
import { getCardFromDate } from '../../../utils/cards'
export default eventHandler(async (event) => {
const basePath = path.join(process.cwd(), '/mnt/media/files/music')
try {
let allTracks: any[] = []
const dirPath = basePath
const urlPrefix = `https://files.erudi.fr/music`
let files = await fs.promises.readdir(dirPath)
files = files.filter((f) => !f.startsWith('.') && !f.endsWith('.jpg'))
const tracks = files.map((file, index) => {
const EXT_RE = /\.(mp3|flac|wav|opus)$/i
const nameWithoutExt = file.replace(EXT_RE, '')
// On split sur __
const parts = nameWithoutExt.split('__')
let stamp = parts[0] || ''
let artist = parts[1] || ''
let title = parts[2] || ''
title = title.replaceAll('_', ' ')
artist = artist.replaceAll('_', ' ')
// Parser la date depuis le stamp
let year = 2020,
month = 1,
day = 1,
hour = 0
if (stamp.length === 10) {
year = Number(stamp.slice(0, 4))
month = Number(stamp.slice(4, 6))
day = Number(stamp.slice(6, 8))
hour = Number(stamp.slice(8, 10))
}
const date = new Date(year, month - 1, day, hour)
const card = getCardFromDate(date)
const url = `${urlPrefix}/${encodeURIComponent(file)}`
const coverId = `${urlPrefix}/${encodeURIComponent(file).replace(EXT_RE, '.jpg')}`
return {
id: Number(`${year}${index + 1}`),
boxId: `ES${year}`,
date,
title: title.trim(),
artist: artist.trim(),
url,
coverId,
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
} catch (error) {
return {
success: false,
error: (error as Error).message
}
}
})

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,22 +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
},
screens: {
"2sm": "320px",
},
},
'2sm': '320px'
}
}
},
plugins: [],
};
plugins: []
}

Submodule three.js deleted from 1abd71ef8a

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 {
@@ -16,28 +18,29 @@ export interface Artist {
url: string
coverId: string
}
export interface Track {
id: number
compilationId: string
order?: number
boxId: string
title: string
artist: Artist
start: number
artist?: Artist | number | string
start?: number
url: string
coverId: string
coverId?: string
date?: Date
card?: { suit: CardSuit; rank: CardRank }
link?: string
type: 'playlist' | 'compilation'
}
// pour une v2
export type BoxState = 'hide' | 'list' | 'selected'
export interface BoxPosition {
x: number
y: number
z: number
export interface Playlist {
id: number
date: Date
title: string
url: string
filename: string
}
export interface BoxSize {
h: number
w: number
d: number
}
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'

21
utils/cards.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { CardSuit, CardRank } from '~/types/cards'
export function getCardFromDate(date: Date): { suit: CardSuit; rank: CardRank } {
const month = date.getMonth() + 1
const day = date.getDate()
const hour = date.getHours()
const suit: CardSuit =
month >= 12 || month <= 2
? '♠'
: month >= 3 && month <= 5
? '♥'
: month >= 6 && month <= 8
? '♦'
: '♣'
const ranks: CardRank[] = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
const rank = ranks[(day + hour) % ranks.length]
return { suit, rank }
}