Compare commits

45 Commits

Author SHA1 Message Date
valere
b8cc3d277d clean CI
All checks were successful
Deploy App / build (push) Successful in 1m57s
Deploy App / deploy (push) Successful in 31s
2026-02-03 15:08:43 +01:00
valere
19d19edb1c deploy 1
All checks were successful
Deploy App / build (push) Successful in 2m24s
Deploy App / deploy (push) Successful in 24s
2026-02-02 21:34:07 +01:00
valere
98e6213fa1 sub domain deploy for branches 22
All checks were successful
Deploy App / build (push) Successful in 18s
Deploy App / deploy (push) Successful in 19s
2026-02-02 21:21:00 +01:00
valere
8e78511738 sub domain deploy for branches 21
All checks were successful
Deploy App / build (push) Successful in 21s
Deploy App / deploy (push) Successful in 28s
2026-02-02 21:14:24 +01:00
valere
dbd992854b sub domain deploy for branches 20
All checks were successful
Deploy App / build (push) Successful in 11s
Deploy App / deploy (push) Successful in 12s
2026-02-02 21:13:21 +01:00
valere
b9a3d0184f yeah
All checks were successful
Deploy App / build (push) Successful in 10s
Deploy App / deploy (push) Successful in 11s
2026-02-02 21:00:28 +01:00
valere
e257e076c4 sub domain deploy for branches 19
All checks were successful
Deploy App / build (push) Successful in 10s
Deploy App / deploy (push) Successful in 10s
2026-02-02 20:58:48 +01:00
valere
57df3dbd5b sub domain deploy for branches 18
All checks were successful
Deploy App / build (push) Successful in 10s
Deploy App / deploy (push) Successful in 10s
2026-02-02 20:57:48 +01:00
valere
4ff3da8380 sub domain deploy for branches 17 2026-02-02 20:57:28 +01:00
valere
c5e5f1abf5 sub domain deploy for branches 16
All checks were successful
Deploy App / build (push) Successful in 1m37s
Deploy App / deploy (push) Successful in 23s
2026-02-02 20:53:25 +01:00
valere
a0fce1a8d3 sub domain deploy for branches 1(
Some checks failed
Deploy App / build (push) Failing after 10s
Deploy App / deploy (push) Has been skipped
2026-02-02 20:50:38 +01:00
valere
0cbdfdeff1 sub domain deploy for branches 14
Some checks failed
Deploy App / build (push) Failing after 26s
Deploy App / deploy (push) Has been skipped
2026-02-02 20:42:49 +01:00
valere
a48cd4049d sub domain deploy for branches 13
Some checks failed
Deploy App / build (push) Waiting to run
Deploy App / deploy (push) Has been cancelled
2026-02-02 20:39:09 +01:00
valere
0b709ff0dc sub domain deploy for branches 12
All checks were successful
Deploy App / build (push) Successful in 11s
Deploy App / deploy (push) Successful in 10s
2026-02-02 20:37:23 +01:00
valere
75507452ac sub domain deploy for branches 11
All checks were successful
Deploy App / build (push) Successful in 11s
Deploy App / deploy (push) Successful in 10s
2026-02-02 20:31:49 +01:00
valere
8337eb9e4c sub domain deploy for branches 10
Some checks failed
Deploy App / build (push) Failing after 3m14s
Deploy App / deploy (push) Has been skipped
2026-02-02 20:25:18 +01:00
valere
1310210ac9 sub domain deploy for branches 9
Some checks failed
Deploy App / build (push) Failing after 1m8s
Deploy App / deploy (push) Has been skipped
2026-02-02 20:03:11 +01:00
valere
deeff36440 sub domain deploy for branches 8
Some checks failed
Deploy App / build (push) Failing after 53s
Deploy App / deploy (push) Has been skipped
2026-02-02 20:01:26 +01:00
valere
f896a8a828 sub domain deploy for branches 7
All checks were successful
Deploy App / build (push) Successful in 16s
Deploy App / deploy (push) Successful in 13s
2026-02-02 19:58:24 +01:00
valere
b769eac9cc sub domain deploy for branches 6
All checks were successful
Deploy App / build (push) Successful in 29s
Deploy App / deploy (push) Successful in 14s
2026-02-02 19:53:39 +01:00
valere
64eb4d09b9 sub domain deploy for branches 5
Some checks failed
Deploy App / build (push) Waiting to run
Deploy App / deploy (push) Has been cancelled
2026-02-02 19:38:31 +01:00
valere
0a587b5e69 sub domain deploy for branches 4
Some checks failed
Deploy App / build (push) Failing after 13s
Deploy App / deploy (push) Has been skipped
2026-02-02 19:37:36 +01:00
valere
7a9f4d369c sub domain deploy for branches 3
All checks were successful
Deploy App / build (push) Successful in 34s
Deploy App / deploy (push) Successful in 27s
2026-02-02 15:34:59 +01:00
valere
d40ca3b1d1 sub domain deploy for branches 2
All checks were successful
Deploy App / build (push) Successful in 14s
Deploy App / deploy (push) Successful in 11s
2026-02-02 12:00:57 +01:00
valere
8573165e4b sub domain deploy for branches
All checks were successful
Deploy App / build (push) Successful in 1m46s
Deploy App / deploy (push) Successful in 35s
2026-02-02 11:41:26 +01:00
valere
c586cc3932 PLATINE blur bobine
All checks were successful
Deploy App / build (push) Successful in 1m53s
Deploy App / deploy (push) Successful in 15s
2026-01-04 23:01:26 +01:00
valere
11694d36dd CI copy server files
All checks were successful
Deploy App / build (push) Successful in 51s
Deploy App / deploy (push) Successful in 14s
2026-01-04 10:51:27 +01:00
valere
3b05938162 CI install sqlite3
All checks were successful
Deploy App / build (push) Successful in 2m36s
Deploy App / deploy (push) Successful in 17s
2026-01-04 10:34:10 +01:00
valere
f75a1481bd platine mobile size
All checks were successful
Deploy App / build (push) Successful in 20s
Deploy App / deploy (push) Successful in 14s
2026-01-02 22:34:11 +01:00
valere
bb791e35d1 platine transition
All checks were successful
Deploy App / build (push) Successful in 2m4s
Deploy App / deploy (push) Successful in 16s
2026-01-01 20:50:37 +01:00
valere
a5fe876e3f bucket cards management
All checks were successful
Deploy App / build (push) Successful in 3m57s
Deploy App / deploy (push) Successful in 17s
2025-12-31 17:23:11 +01:00
valere
9001025837 SQLITE 3 2025-12-31 16:31:53 +01:00
valere
afb20fe75f bucket + card sharer
All checks were successful
Deploy App / build (push) Successful in 1m57s
Deploy App / deploy (push) Successful in 16s
2025-12-26 19:27:33 +01:00
valere
d8fe645e5c unified gost cards (mouse + touch)
All checks were successful
Deploy App / build (push) Successful in 31s
Deploy App / deploy (push) Successful in 15s
2025-12-24 12:25:58 +01:00
valere
ad938abf79 draggable / touchable card v0.1
All checks were successful
Deploy App / build (push) Successful in 1m53s
Deploy App / deploy (push) Successful in 18s
2025-12-24 06:00:15 +01:00
valere
1f4f7868ca drop old code
All checks were successful
Deploy App / build (push) Successful in 1m0s
Deploy App / deploy (push) Successful in 14s
2025-12-23 12:56:42 +01:00
valere
2c826e29ea compose elt v0.1
Some checks failed
Deploy App / build (push) Has been cancelled
Deploy App / deploy (push) Has been cancelled
2025-12-23 12:55:37 +01:00
valere
8efafc4642 Bucket v0.1
All checks were successful
Deploy App / build (push) Successful in 2m1s
Deploy App / deploy (push) Successful in 16s
2025-12-22 09:56:27 +01:00
valere
ecc1c22475 raccourciclavier [R]eveal [H]ide 2025-12-22 09:52:16 +01:00
valere
5948b4efbd clean type 2025-12-22 09:46:38 +01:00
valere
55cae0b9c6 toogle cards 2025-12-21 20:20:20 +01:00
valere
c0d79591c3 PLATINE drag to play & random tracks
All checks were successful
Deploy App / build (push) Successful in 1m59s
Deploy App / deploy (push) Successful in 17s
2025-12-19 11:41:47 +01:00
valere
1c4cbfe21c platine as component
All checks were successful
Deploy App / build (push) Successful in 53s
Deploy App / deploy (push) Successful in 15s
2025-12-18 19:59:23 +01:00
valere
2be5724c9f platine / bobine progression
All checks were successful
Deploy App / build (push) Successful in 51s
Deploy App / deploy (push) Successful in 14s
2025-12-17 22:15:40 +01:00
valere
dc2cba500c set route /mix & /draggable
All checks were successful
Deploy App / build (push) Successful in 3m13s
Deploy App / deploy (push) Successful in 18s
2025-12-17 19:34:25 +01:00
111 changed files with 7632 additions and 22150 deletions

10
.env Executable file
View File

@@ -0,0 +1,10 @@
DOMAIN=evilspins.com
PORT=7901
PORT_EXPOSED=3000
PATH_FILES=mnt/media/files/music
PATH_DB=data/music.db
EXT_TRACK=mp3
EXT_COVER=jpg
URL_PREFIX=https://files.erudi.fr/music/
NODE_ENV=production
ENABLE_WATCHER=true

View File

@@ -1,35 +0,0 @@
name: Deploy App
on: [push]
jobs:
build:
runs-on: ubuntu-22.04
container:
volumes:
- /var/docker-web:/var/docker-web
steps:
- uses: actions/checkout@v4
- name: Prepare and build app
run: |
REPO_NAME="${GITHUB_REPOSITORY##*/}"
APP_DIR="/var/docker-web/apps/${REPO_NAME}"
bash /var/docker-web/src/cli.sh down "${REPO_NAME}"
rm -rf "$APP_DIR"
mkdir "$APP_DIR"
cp -a $(find . -mindepth 1 -maxdepth 1 ! -name '.git' ! -name 'node_modules') "$APP_DIR/"
export COMPOSE_BAKE=false
docker rmi "local/${REPO_NAME}" 2>/dev/null || true
bash /var/docker-web/src/cli.sh build "${REPO_NAME}"
deploy:
runs-on: ubuntu-22.04
needs: build
container:
volumes:
- /var/docker-web:/var/docker-web
steps:
- uses: actions/checkout@v4
- name: Deploy with docker-web
run: |
REPO_NAME="${GITHUB_REPOSITORY##*/}"
bash /var/docker-web/src/cli.sh up "${REPO_NAME}"

23
.github/workflows/merge.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Delete Merged Branches
on:
pull_request:
types: [closed]
jobs:
delete-branch:
runs-on: ubuntu-22.04
container:
volumes:
- /var/docker-web:/var/docker-web
if: github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4
- name: Delete merged branch
run: |
BRANCH_NAME="${{ github.event.pull_request.head.ref || github.event.inputs.branch_name }}"
APP_NAME="${BRANCH_NAME}_${GITHUB_REPOSITORY##*/}"
if [ "$BRANCH_NAME" != "main" ] && [ "$BRANCH_NAME" != "develop" ] && [ -n "$APP_NAME" ]; then
bash /var/docker-web/src/cli.sh rm -y "${APP_NAME}"
else
echo "Cannot delete protected branch: $BRANCH_NAME"
fi

41
.github/workflows/push.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: Deploy App
on: [push]
jobs:
build:
runs-on: ubuntu-22.04
container:
volumes:
- /var/docker-web:/var/docker-web
steps:
- uses: actions/checkout@v4
- name: Build app
run: |
bash ./.github/workflows/setup-env.sh
set -a && source .env && set +a
if [ -n "$APP_NAME" ]; then
set -a && source .env && set +a
bash /var/docker-web/src/cli.sh down "${APP_NAME}"
rm -rf "$APP_DIR"
mkdir "$APP_DIR"
cp -a $(find . -mindepth 1 -maxdepth 1 ! -name '.git' ! -name 'node_modules') "$APP_DIR/"
export COMPOSE_BAKE=false
docker rmi "local/${APP_NAME}" 2>/dev/null || true
bash /var/docker-web/src/cli.sh build "${APP_NAME}"
fi
deploy:
runs-on: ubuntu-22.04
needs: build
container:
volumes:
- /var/docker-web:/var/docker-web
steps:
- uses: actions/checkout@v4
- name: Deploy
run: |
bash ./.github/workflows/setup-env.sh
set -a && source .env && set +a
if [ -n "$APP_NAME" ]; then
bash /var/docker-web/src/cli.sh up "${APP_NAME}"
fi

34
.github/workflows/setup-env.sh vendored Normal file
View File

@@ -0,0 +1,34 @@
#!/bin/bash
set -a && source .env && set +a
changeEnvVar() {
local var_name="$1"
local new_value="$2"
local env_file=".env"
if grep -q "^${var_name}=" "$env_file"; then
sed -i "s|${var_name}=.*|${var_name}=${new_value}|" "$env_file"
else
echo "${var_name}=${new_value}" >> "$env_file"
fi
}
# Variables GitHub
APP_NAME="${GITHUB_REPOSITORY##*/}"
BRANCH_NAME=$GITHUB_REF_NAME
# Configuration pour les branches non-principales
if [ "$BRANCH_NAME" != "main" ] && [ "$BRANCH_NAME" != "master" ]; then
DOMAIN="$BRANCH_NAME.$DOMAIN"
APP_NAME="${BRANCH_NAME}_${APP_NAME}"
PORT=$(bash /var/docker-web/src/cli.sh ALLOCATE_PORT)
sed -i "s|${GITHUB_REPOSITORY##*/}|$APP_NAME|g" docker-compose.yml
fi
APP_DIR="/var/docker-web/apps/$APP_NAME"
changeEnvVar "DOMAIN" $DOMAIN
changeEnvVar "APP_NAME" $APP_NAME
changeEnvVar "PORT" $PORT
changeEnvVar "APP_DIR" $APP_DIR

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@
.nitro
.cache
dist
drizzle
# Node dependencies
node_modules

View File

@@ -1,6 +0,0 @@
---
trigger: always_on
---
j'aimerai que tu te comporte avec moi comme un prof socratique.
Tu ne me donnes pas la réponse directement mais tu me guides petit à petit vers la réponse pour que je puisse apprendre par moi même.

View File

@@ -1,5 +1,6 @@
# Builder
FROM node:20-bookworm AS builder
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install --legacy-peer-deps
@@ -8,9 +9,12 @@ COPY . .
RUN npm run build
# Runtime
FROM node:20-slim AS runner
FROM node:20 AS runner
RUN apt-get update && apt-get install -y sqlite3 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/.output ./.output
COPY package*.json ./
COPY ./data ./data
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

View File

@@ -1,86 +1,8 @@
<template>
<div>
<div class="min-h-screen bg-gray-100">
<NuxtRouteAnnouncer />
<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()
useHead({
bodyAttrs: {
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 (import.meta.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;
}
input[type='email'] {
@apply bg-slate-900 text-esyellow;
}
* {
user-select: none;
}
img {
user-drag: none;
user-select: none;
}
</style>

View File

@@ -1,63 +1,49 @@
<template>
<article class="card w-56 h-80" :class="[
isFaceUp ? 'face-up' : 'face-down',
{ 'current-track': playerStore.currentTrack?.id === track.id }
]">
<div class="flip-inner">
<article :role="props.role" :class="[
'card cursor-pointer',
isFaceUp ? 'face-up' : 'face-down'
]" :tabindex="props.tabindex" :aria-disabled="false" @click="$emit('click', $event)"
@keydown.enter="$emit('click', $event)" @keydown.space.prevent="$emit('click', $event)">
<div class="flip-inner" ref="cardElement">
<!-- Face-Up -->
<main
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 hover:shadow-xl hover:scale-110 transition-all rounded-2xl shadow-lg flex flex-col overflow-hidden">
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 right-7">
<div class="flex items-center justify-center size-7 absolute top-7 right-7">
<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`" />
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.card?.suit]">
<img :src="`/${props.card?.suit}.svg`" />
</div>
<div class="rank text-white font-bold absolute -mt-1">
{{ props.track.card?.rank }}
</div>
</div>
<div v-else class="flex items-center justify-center size-7 absolute top-6 left-6">
<div class="rank text-white font-bold absolute -mt-1">
{{ props.track.order }}
{{ props.card?.rank }}
</div>
</div>
<!-- Cover -->
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer"
@click="playerStore.playTrack(track)">
<playButton :objectToPlay="track" />
<img v-if="isFaceUp" :src="coverUrl" alt="Pochette de l'album" loading="lazy"
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer">
<playButton :objectToPlay="card" />
<img v-if="isFaceUp" :src="props.card.url_image" alt="Pochette de l'album" loading="lazy"
class="w-full h-full object-cover object-center" />
</figure>
<!-- Body -->
<div
class="p-3 text-center bg-white rounded-b-xl opacity-0 -mt-16 hover:opacity-100 hover:-mt-0 transition-all duration-300">
<div v-if="isOrder" class="label">
{{ props.track.order }}
</div>
class="card-body p-3 text-center bg-white rounded-b-xl opacity-0 -mt-16 hover:opacity-100 hover:-mt-0 transition-all duration-300">
<h2 class="select-text text-sm text-neutral-500 first-letter:uppercase truncate">
{{ props.track.title }}
{{ props.card.title || 'title' }}
</h2>
<p v-if="isPlaylistTrack" class="select-text text-base text-neutral-800 font-bold capitalize truncate">
{{ props.track.artist.name }}
<p class="select-text text-base text-neutral-800 font-bold capitalize truncate">
{{ props.card.artist || 'artist' }}
</p>
<!-- <p class="select-text">
{{ props.track.url.split('/')[4]?.split('__')[0] }}
</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">
<figure @click.stop="playerStore.playTrack(track)"
class="h-full flex text-center bg-slate-800 rounded-xl justify-center cursor-pointer">
<playButton :objectToPlay="track" />
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 select-none">
<figure class="h-full flex text-center rounded-xl justify-center items-center"
:style="{ backgroundColor: cardColor }">
<playButton :objectToPlay="card" />
<img src="/face-down.svg" />
</figure>
</footer>
@@ -66,23 +52,26 @@
</template>
<script setup lang="ts">
import type { Track } from '~~/types/types'
import { usePlayerStore } from '~/store/player'
import type { Card } from '~~/types/types'
const props = withDefaults(defineProps<{ track: Track; isFaceUp?: boolean }>(), {
isFaceUp: false
})
const playerStore = usePlayerStore()
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 = computed(() => {
if (!props.track.coverId) return ''
return props.track.coverId.startsWith('http')
? props.track.coverId
: `https://f4.bcbits.com/img/${props.track.coverId}_4.jpg`
const emit = defineEmits(['click'])
const props = withDefaults(defineProps<{
card: Card;
isFaceUp?: boolean;
role?: string;
tabindex?: string | number;
}>(), {
isFaceUp: false,
role: 'button',
tabindex: '0'
})
import { getYearColor } from '~/utils/colors'
const cardColor = computed(() => getYearColor(props.card.year || 0))
const isRedCard = computed(() => (props.card?.suit === '♥' || props.card?.suit === '♦'))
</script>
<style lang="scss">
@@ -102,7 +91,7 @@ const coverUrl = computed(() => {
/* Flip effect */
.card {
perspective: 1000px;
@apply transition-all scale-100;
@apply transition-all scale-100 w-56 h-80 min-w-56 min-h-80;
.flip-inner {
position: relative;
@@ -150,9 +139,10 @@ const coverUrl = computed(() => {
}
}
&.current-track,
&:focus {
@apply z-50;
&:focus,
&.current-card {
@apply z-50 scale-110;
outline: none;
.face-up {
@apply shadow-2xl;
@@ -160,8 +150,13 @@ const coverUrl = computed(() => {
box-shadow 0.6s,
transform 0.6s;
}
}
@apply scale-110;
&:focus,
&.current-card {
.play-button {
@apply opacity-100;
}
}
.play-button {
@@ -179,7 +174,7 @@ const coverUrl = computed(() => {
}
.play-button {
@apply absolute bottom-1/2 top-24 opacity-0 hover:opacity-100;
@apply absolute bottom-1/2 top-28 opacity-0 hover:opacity-100;
}
.pochette:active,
@@ -188,5 +183,19 @@ const coverUrl = computed(() => {
@apply scale-90;
}
}
&.is-dragging {
@apply opacity-50 scale-95 rotate-6;
cursor: grabbing !important;
.face-up {
@apply shadow-2xl;
}
.play-button,
.card-body {
display: none;
}
}
}
</style>
</style>

View File

@@ -1,11 +0,0 @@
<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 id="email" type="email" name="" placeholder="email" >
<button>ok</button>
</div>
</form>
</template>

View File

@@ -1,206 +0,0 @@
<template>
<div class="layout player fixed z-40"
:class="playerStore.currentTrack ? '-bottom-1/4 opacity-100' : '-bottom-1/2 opacity-0'">
<div class="disc bg-slate-900" id="disc">
<div class="disc__que">
</div>
<div class="disc__label rounded-full bg-cover bg-center" :style="{
backgroundImage: playerStore.currentTrack?.coverId
? `url(${playerStore.currentTrack.coverId})`
: 'none'
}">
</div>
<div class="disc__middle">
</div>
</div>
</div>
<div class="control fixed bottom-0 z-50">
<button class="control button rewind" id="rewind">&lt;&lt;</button>
<button class="control button toggle" id="playToggle">power</button>
</div>
</template>
<script setup lang="ts">
import Disc from '@/platine-tools/disc';
import Sampler from '@/platine-tools/sampler';
import Controls from '@/platine-tools/controls';
import { usePlayerStore } from '~/store/player'
const playerStore = usePlayerStore()
onMounted(async () => {
const disc = new Disc(document.querySelector('#disc')!)
const sampler = new Sampler()
const controls = new Controls({
toggleButton: document.querySelector('#playToggle') as HTMLButtonElement,
rewindButton: document.querySelector('#rewind') as HTMLButtonElement,
})
await sampler.loadTrack('/jet.mp3')
controls.isDisabled = false
disc.setDuration(sampler.duration)
disc.start()
disc.callbacks.onStop = () => sampler.pause()
disc.callbacks.onDragEnded = () => {
if (!controls.isPlaying) {
return
}
sampler.play(disc.secondsPlayed)
}
disc.callbacks.onLoop = ({ playbackSpeed, isReversed, secondsPlayed }) => {
sampler.updateSpeed(playbackSpeed, isReversed, secondsPlayed)
}
controls.callbacks.onIsplayingChanged = (isPlaying) => {
if (isPlaying) {
disc.powerOn()
sampler.play(disc.secondsPlayed)
} else {
disc.powerOff()
}
}
controls.callbacks.onRewind = () => {
disc.rewind()
}
})
</script>
<style>
.player {
transition: all 1s ease-in-out;
}
.layout {
width: 100vw;
height: 100vw;
max-width: 500px;
max-height: 500px;
display: flex;
justify-content: center;
align-items: center;
}
.disc-container {
width: 100%;
aspect-ratio: 1;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
border: 2px solid #000;
background: linear-gradient(45deg, #f0f0f0, #424242);
}
.disc {
position: relative;
aspect-ratio: 1;
width: 100%;
overflow: hidden;
border-radius: 50%;
cursor: grab;
}
.disc.is-scratching {
cursor: grabbing;
}
.disc__que {
--dim: 20px;
position: absolute;
top: 50%;
right: 30px;
width: var(--dim);
height: var(--dim);
background: var(--color-theme);
border-radius: 50%;
}
.disc__label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
justify-content: center;
align-items: center;
text-align: center;
background-size: cover;
width: 45%;
aspect-ratio: 1/1;
/* background: no-repeat url(/logo.svg) center center; */
background-size: cover;
border-radius: 50%;
pointer-events: none;
}
.disc__middle {
--dim: 10px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: var(--dim);
height: var(--dim);
background: rgb(26, 26, 26);
border-radius: 50%;
}
.button {
border-radius: 0;
border: none;
background: rgb(69, 69, 69);
font-size: 0.75rem;
padding: 0.4rem;
color: #fff;
line-height: 1.3;
cursor: pointer;
box-shadow:
1px 1px 0px 1px rgb(0 0 0),
0px 0px 0px 0px var(--color-theme);
will-change: box-shadow;
transition:
box-shadow 0.2s ease-out,
transform 0.05s ease-in;
}
.button.is-active {
transform: translate(1px, 2px);
box-shadow: 0px 0px 5px 1px var(--color-theme);
}
.button[disabled] {
opacity: 0.5;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<button
class="play-button rounded-full size-24 flex items-center justify-center text-esyellow backdrop-blur-sm bg-black/25 transition-all duration-200 ease-in-out transform active:scale-90 scale-110 text-4xl font-bold"
<button tabindex="-1"
class="play-button pointer-events-none rounded-full size-24 flex items-center justify-center text-esyellow backdrop-blur-sm bg-black/25 transition-all duration-200 ease-in-out transform active:scale-90 scale-110 text-4xl font-bold"
:class="{ loading: isLoading }" :disabled="isLoading">
<template v-if="isLoading">
<img src="/loader.svg" alt="Chargement" class="size-16" />
@@ -12,40 +12,12 @@
</template>
<script setup lang="ts">
import { usePlayerStore } from '~/store/player'
import type { Box, Track } from '~/../types/types'
const playerStore = usePlayerStore()
const props = defineProps<{ objectToPlay: Box | Track }>()
const isCurrentBox = computed(() => {
if ('activeSide' in props.objectToPlay) {
// Vérifier si la piste courante appartient à cette box
if (playerStore.currentTrack?.boxId === props.objectToPlay.id) {
// Si c'est une compilation, on vérifie le side actif
if (props.objectToPlay.type === 'compilation') {
return playerStore.currentTrack.side === props.objectToPlay.activeSide
}
return true
}
return false
}
return false
})
const isCurrentTrack = computed(() => {
if (!('activeSide' in props.objectToPlay)) {
return playerStore.currentTrack?.id === props.objectToPlay.id
}
return false
})
const isPlaying = computed(() => {
return playerStore.isPlaying && (isCurrentTrack.value || isCurrentBox.value)
})
const isLoading = computed(() => {
return playerStore.isLoading && (isCurrentTrack.value || isCurrentBox.value)
const props = withDefaults(defineProps<{
isLoading?: boolean;
isPlaying?: boolean;
}>(), {
isLoading: false,
isPlaying: false
})
</script>

View File

@@ -1,110 +0,0 @@
<template>
<div class="deck-playlist">
<div class="flex flex-col fixed right-0 top-0 z-50">
<button @click="closeDatBox" v-if="uiStore.isBoxSelected"
class="px-4 py-2 text-black hover:text-black bg-esyellow transition-colors z-50" aria-label="close the box">
close
</button>
</div>
<div class="controls flex justify-center z-50 relative">
<button class="px-4 py-2 text-black hover:text-black bg-esyellow transition-colors"
@click="cardStore.revealAllCards(tracks)">
reveal
</button>
<button class="px-4 py-2 text-black hover:text-black bg-esyellow transition-colors"
@click="cardStore.hideAllCards(tracks)">
hide
</button>
<SearchInput @search="onSearch" />
<SelectCardRank @change="onRankChange" />
<SelectCardSuit @change="onSuitChange" />
</div>
<div ref="deck" class="deck flex flex-wrap justify-center gap-4" :class="{ 'pb-36': playerStore.currentTrack }">
<card v-for="(track, i) in filteredTracks" :key="track.id" :track="track" tabindex="i"
:is-face-up="isCardRevealed(track.id)" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useDataStore } from '~/store/data'
import { useCardStore } from '~/store/card'
import { usePlayerStore } from '~/store/player'
import { useUiStore } from '~/store/ui'
import type { Box } from '~~/types/types'
import SelectCardSuit from '~/components/ui/SelectCardSuit.vue'
import SelectCardRank from '~/components/ui/SelectCardRank.vue'
import SearchInput from '~/components/ui/SearchInput.vue'
const props = defineProps<{
box: Box
}>()
const cardStore = useCardStore()
const dataStore = useDataStore()
const playerStore = usePlayerStore()
const uiStore = useUiStore()
const deck = ref()
const tracks = computed(() => dataStore.getTracksByboxId(props.box.id))
const filteredTracks = ref(tracks.value)
// Variables réactives pour les filtres
const selectedSuit = ref('')
const selectedRank = ref('')
const searchQuery = ref('')
const isCardRevealed = (trackId: number) => cardStore.isCardRevealed(trackId)
const closeDatBox = () => {
uiStore.closeBox()
}
const onSuitChange = (suit: string) => {
selectedSuit.value = suit
applyFilters()
}
const onRankChange = (rank: string) => {
selectedRank.value = rank
applyFilters()
}
const onSearch = (query: string) => {
searchQuery.value = query
applyFilters()
}
// Applique tous les filtres (couleur, rang et recherche)
const applyFilters = () => {
let result = [...tracks.value]
// Filtre par couleur
if (selectedSuit.value) {
result = result.filter(track => track.card?.suit === selectedSuit.value)
}
// Filtre par rang
if (selectedRank.value) {
result = result.filter(track => track.card?.rank === selectedRank.value)
}
// Filtre par recherche textuelle
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(track => {
// Gestion du nom d'artiste (peut être un objet ou une chaîne)
const artistName = typeof track.artist === 'object' ? track.artist?.name : String(track.artist || '')
// Recherche dans le titre, l'artiste et l'année
return (
track.title?.toLowerCase().includes(query) ||
artistName.toLowerCase().includes(query) ||
String(track.year || '').includes(query)
)
})
}
filteredTracks.value = result
}
</script>

View File

@@ -1,18 +1,5 @@
<template>
<div>
<h1
class="text-white pt-6 text-lg md:text-xl lg:text-2xl text-center font-bold tracking-widest text-shadow"
>
{{ error?.statusCode }}
</h1>
<NuxtLink to="/">Go back home</NuxtLink>
error
</div>
</template>
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps({
error: Object as () => NuxtError
})
</script>
</template>

View File

@@ -1,8 +1,3 @@
<template>
<div class="w-full flex flex-col items-center">
<slot />
<searchModal />
<loader />
<Player />
</div>
<slot />
</template>

43
app/pages/card/[slug].vue Normal file
View File

@@ -0,0 +1,43 @@
<template>
<section class="screen-centered">
<Card :card="card" :isFaceUp="isFaceUp" @click="clickOnSlugCard" />
</section>
</template>
<script setup lang="ts">
import type { Card } from '~~/types/types'
const route = useRoute()
const slug = route.params.slug as string
const isFaceUp = ref(false)
const { data: card, pending, error } = await useFetch<Card>(`/api/card/${slug}`)
useHead({
title: computed(() =>
card.value ? `${card.value.artist} - ${card.value.title}` : 'Loading...'
)
})
const clickOnSlugCard = () => {
isFaceUp.value = true
const audio = new Audio(card.value?.url_audio)
audio.play()
}
onMounted(() => {
setTimeout(() => {
clickOnSlugCard()
}, 700)
})
</script>
<style>
.screen-centered {
position: fixed;
inset: 0;
height: 100dvh;
width: 100dvw;
display: grid;
place-items: center;
}
</style>

View File

@@ -1,169 +0,0 @@
<template>
<div>
<button
class="fixed bottom-4 right-4 bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-full shadow-lg z-50 transition-colors"
title="Ranger les cartes" @click="arrangeCards">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7" />
</svg>
</button>
<div ref="deck" class="deck">
<card v-for="track in tracks" :key="track.id" :track="track"
:class="['card', 'id-' + track.id, { dragging: dragging === track }]" :style="{
top: track.y + 'px',
left: track.x + 'px',
zIndex: track.zIndex || 1,
transform: `rotate(${track.rotation || 0}deg)`
}" :is-face-up="track.isFaceUp" @mousedown="startDrag($event, track)" @click="flipCard(track)" />
</div>
</div>
</template>
<script setup>
import { useDataStore } from '~/store/data'
const tracks = ref([])
const deck = ref(null)
let zIndexCounter = 1
let dragging = null
let offset = { x: 0, y: 0 }
let velocity = { x: 0, y: 0 }
let lastTime = 0
const dataStore = useDataStore()
definePageMeta({ layout: 'default' })
onMounted(async () => {
await dataStore.loadData()
tracks.value = dataStore.getTracksByboxId('ESPLAYLIST').map((t, i) => ({
...t,
x: t.x ?? 50 + i * 20,
y: t.y ?? 50 + i * 20,
zIndex: t.zIndex ?? i + 1,
rotation: 0,
isFaceUp: t.isFaceUp ?? false
}))
window.addEventListener('mousemove', onDrag)
window.addEventListener('mouseup', stopDrag)
})
onBeforeUnmount(() => {
window.removeEventListener('mousemove', onDrag)
window.removeEventListener('mouseup', stopDrag)
})
function startDrag(e, track) {
dragging = track
const rect = deck.value.getBoundingClientRect()
offset.x = e.clientX - rect.left - track.x
offset.y = e.clientY - rect.top - track.y
lastTime = performance.now()
velocity = { x: 0, y: 0 }
zIndexCounter += 1
track.zIndex = zIndexCounter
}
function onDrag(e) {
if (!dragging) return
const rect = deck.value.getBoundingClientRect()
const newX = e.clientX - rect.left - offset.x
const newY = e.clientY - rect.top - offset.y
const now = performance.now()
const dt = now - lastTime
velocity.x = (newX - dragging.x) / dt
velocity.y = (newY - dragging.y) / dt
lastTime = now
dragging.x = newX
dragging.y = newY
// Rotation dynamique selon la position horizontale
const centerX = rect.width / 2
const dx = dragging.x - centerX
dragging.rotation = dx * 0.05
}
function stopDrag() {
if (!dragging) return
const track = dragging
dragging = null
// Inertie douce
const decay = 0.95
function animateInertia() {
if (Math.abs(velocity.x) < 0.02 && Math.abs(velocity.y) < 0.02) return
track.x += velocity.x * 16
track.y += velocity.y * 16
velocity.x *= decay
velocity.y *= decay
requestAnimationFrame(animateInertia)
}
animateInertia()
}
function flipCard(track) {
track.isFaceUp = true
}
function arrangeCards() {
const deckRect = deck.value.getBoundingClientRect()
const cardWidth = 224
const cardHeight = 320
const padding = 20
const cardsPerRow = Math.max(
1,
Math.floor((deckRect.width - padding * 2) / (cardWidth + padding))
)
tracks.value.forEach((track, index) => {
const row = Math.floor(index / cardsPerRow)
const col = index % cardsPerRow
track.x = padding + col * (cardWidth + padding)
track.y = padding + row * (cardHeight / 3)
track.zIndex = index + 1
track.rotation = 0
zIndexCounter = Math.max(zIndexCounter, track.zIndex)
})
}
</script>
<style lang="scss" scoped>
.deck {
position: relative;
width: 100%;
min-height: 80vh;
height: 100%;
overflow-y: auto;
padding: 1rem;
box-sizing: border-box;
}
.card {
position: absolute;
cursor: grab;
transition:
transform 0.1s ease,
box-shadow 0.2s ease;
}
.card:active {
cursor: grabbing;
}
.card.dragging {
z-index: 999;
cursor: grabbing;
transform: scale(1.05) rotate(0deg);
/* rotation sera remplacée dynamiquement */
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.5);
}
</style>

View File

@@ -1,118 +0,0 @@
<template>
<div class="deck">
<draggable v-model="tracks" item-key="id" class="draggable-container" @start="drag = true" @end="onDragEnd">
<template #item="{ element: track }">
<card :key="track.id" :track="track" tabindex="0" :is-face-up="track.isFaceUp" class="draggable-item"
@click="flipCard(track)" />
</template>
</draggable>
</div>
</template>
<script setup>
import { useDataStore } from '~/store/data'
import draggable from 'vuedraggable'
const drag = ref(false)
const tracks = ref([])
// Configuration du layout
definePageMeta({
layout: 'default'
})
onMounted(async () => {
const dataStore = useDataStore()
await dataStore.loadData()
tracks.value = dataStore.getTracksByboxId('ESPLAYLIST')
})
function flipCard(track) {
track.isFaceUp = !track.isFaceUp
}
function onDragEnd() {
drag.value = false
// Ici vous pouvez ajouter une logique supplémentaire après le drop si nécessaire
}
</script>
<style lang="scss" scoped>
.logo {
filter: drop-shadow(3px 3px 0 rgb(0 0 0 / 0.7));
}
.deck {
position: relative;
height: 80vh;
.draggable-container {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 1rem;
min-height: 100%;
}
.draggable-item {
cursor: grab;
transition: transform 0.2s;
&:active {
cursor: grabbing;
}
&.sortable-ghost {
opacity: 0.5;
background: #c8ebfb;
border-radius: 1rem;
}
&.sortable-drag {
transform: rotate(2deg);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
}
}
.card {
z-index: 10;
position: relative;
}
/* noise tools */
$size: 130px;
$scale: 1.05;
$border-radius: calc($size / 2);
$grad-position: 100% 0;
$grad-start: 25%;
$grad-stop: 65%;
$duration: 3.5s;
$noise: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEEAAABBCAMAAAC5KTl3AAAAgVBMVEUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABtFS1lAAAAK3RSTlMWi3QSa1uQOKBWCTwcb6V4gWInTWYOqQSGfa6XLyszmyABlFFJXySxQ0BGn2PQBgAAC4NJREFUWMMV1kWO5UAQRdFk5kwzs/33v8Cunr7ZUehKAdaRUAse99ozDjF5BqswrPKm7btzJ2tRziN3rMYXC236humIV5Our7nHWnVdFOBojW2XVnkeu1IZHNJH5OPHj9TjgVxBGBwAAmp60WoA1gBBvg3XMFhxUQ4KuLqx0CritYZPPXinsOqB7I76+OHaZlPzLEcftrqOlOwjeXvuEuH6t6emkaofgVUDIb4fEZB6CmRAeFCTq11lxbAgUyx4rXkqlH9I4bTUDRRVD1xjbqb9HyUBn7rhtr1x+x9Y0e3BdX31/loYvZaLxqnjbRuokz+pPG7WebnSNKE3yE6Tka4aDEDMVYr6Neq126c+ZR2nzzm3yyiC7PGWG/1uueqZudrVGYNdsgOMDvt1cI8CXu63QIcPvYNY8z870WwYazTS7DqpDEknZqS0AFXObWUxTaw0q5pnHlq4oQImakpLfJkmErdvAfhsc7lod0DVT4tuob25C0tQjzdiFObCz7U7eaKGP3s6yQVgQ/y+q+nY6K5dfV75iXzcNlGIP38aj22sVwtWWKMRb7B5HoHPaBvI1Ve5TSXATi66vV6utxsV+aZNFu+93VvlrG/oj8Wp67YT8l+Oq6PjwdGatFm7SEAP13kE0y9CEcf9qhtEWCMIq5AGq71moEAI9vrmFcmO8+7ZyDnmRN/VUaFkM2ce8KuBGFzDMmY6myLfQGra2ofgHhbJRXuRDZ4H+HmliWBHXQ0ysLGfv6FetbxtxzRgIZWjIsGVFl5imPXeyvVyayNek+dSWzjXd4t310YBdaF8sXeKs481PjsXbAtIru2+wHbv3GVh3sQY6Dnu6pF3pZ714VYdDi9A5GkXR/6xgaZN/tpQ8wVV3zeBuB+njoBNE4wjc+uA523ysXGd/P2sntmOb3OdHNWP5OVrxD3eJHdtH8QVkEIAqCor3hReR96yqt6PkTQfenllooQ447h6tOrnnuzwA8fMpq+jqg1oW8fTYYIncAYpVeTvkEFr/khQSbjoE8ykx9049OkE5MQEO9lC24tT7DwThQgf4Fhf8nGgAo3GYaON3crODpOr2pu5dBABz69t7F5yJBBo+r6QJdeLDWEoO7r1tceR3haA7gc7eZrCvpxSXXeKpo4P+hRixo9DeOFbqQVjKyWfBg9pnrEZKzK7R437YTTwhfoySG/YOCt3fs4aXlU3FjKortqQ6XyXaD0+Y/8VoqpyU9TRW45eN4oBxAH8Y/jLnNXfELJW+/p/MgO9Z+mBli2qqAP7dV/Arc2+YZRZwtBW8/p32y5ZsEuCS4O5AAgfR7Dde7zhiGfgvurQkfAXIrUG61rmxc2EZo18ph4vaWZI+QM0JdsbNlBJlPlwf9uguujQJy0j7TgTHdtRnjybTg55Hkk9S6l2rpYahumSewKHVosa1bh2Y6r9JGkdKvIDN/eeAwScrfjoLkCxWJuFZQ53FNP5w9XbQd1HhgHcVB/0fATG3sUUid1RTfc2+7pZVKldFSsaEK0v4k90tapQOk2HIbMhaJQtrUEL5+3sDanh8sOpbYRoQoqXWu6SQcUTQL9jzOrXNPWCJwXge4U7tlU1hkF012cAmvp8llQxf1IEMcw14pURxVOWATz4ITnYQjuF+vDXg5hgoiqXzO6mS91FQUBheURHIJxUeU1i3P0WOMpsm7vFYk0JJi/Ev+X3FwYD69cARPuP5GIc0PxoAFjcLRbNur0iMTrQmBBNYJ2ngU4x7SWfdTRl52Bqv7LmYW3C1CyTCPTHeWWIAM/Whm32COHsaj+2UQ739XB9t6NV0o9E9b7CW3XNiXzi9e0KiE+3rntukdIDBWrU2jsfQWuyFJRANxq8StHVv1JPy2C3Byco7qdNbASrnNXZ8G0L/Wp/pif4Ai9aEZ9Bb+TRx+REBdGlkF/s0dUdMSMr+6YCbuGxqPWdzcdqutvqkBzCksFcwAtjf55TeuH79M6AQa7r5PLeXxMFIlQKrXP9VJ275WGX+ptpf+tvTDBsecPnYQAlAWrVbRVJ7K2pRHwIjtSpbX96Y/lbKk6ZWXlBmh15r8yAWQsYxXgBOXYMAfHnUXF+rDqnB8bXDRtAn7bCziIqetSboK3NexMePvsCRLvmsoREA+kH8j4HWFpnNEaWgOmR7xyXHfTaz3slHc/YA6H6tl/L8d5tPcIwwD0tjvRaq3Y5BmYBSDClpv0VIX4s8D0XK3sPdpAb94HjPLkgboEz9EdZATW6ZdcmQvtKUwoWw+nAVKA7IcdY1UHnvNnIBplKci+knzewLz5/GGnzkGuuGky+0LTjtGBGR85EQICDqKChnm5pH3Z44nnWAk1YRdyu3g7QoFZ0h8jkr2ffjKmi+Qvsp+9GvNGZHmgW+YQAGUw7PPt8IPKbdy432vhKtRJjKWcSqq7helj81o3nfmaxVZ7Sqie8OOBk9WsyTD/ab7fQ5aWwQeJvnH6+ayo4IdIkOSBJjzXkgr+1TPhAx1AXDsxtCCj3TzQTLA1p782f7a8vdgPfwwrXmZxxbqo2h+6Zlo6mcMY4V7cFBOLm17VCvx9Qa2tAnkxEB+KYyQgbgAAnmNDOdOO6y2Cb+lke1MWQc9o+EMdQf7ubIG3Ek8GZ4k1PtGjbhwgOMPp5Em59JMVk/jU8/aF73Xcrd3UBNZyueQu0/xz2aGtZT8CRziOax2BWFXaeDzgZNV7oRtUzFoijoETf3xkAFFk3OMb7SgPh5wxU1+MygDIp9gZChH2qEcpgLh8pBIK90PXT1ZSU+ZExFK4Vm4GL/J7+K13lS5dQkW4HQwl6GX4yLqu8GhGWS2k75yel5IZIfFNdAL0NpKr2N5dQesBnxa42DLgJd6agS1jJsp1mO1dip7PU4P6diLLoTsZ4m3Q0QweiqeFfIGPLgF6v6mSVv6xe85VBD/1Mpe3AurRbcJ9SEo8NszNVy8rOCEexyIFcJRvYAlI/wk2I7r3p60FFLQXoH2q9xri/m41svRPbW0/EnPn2DWsmk0IiPpB60aa3+hiFfWuC8ZvWKEd9LxAk3HcOof6d77RewPaPsGw5lQAHcZN2vx1448u9pLfMLGQ3BSRRjBzRhKt7HcCw/7aqjtCDs5q76b4ZGphxN2th1WeXYlfnozX3ebKtX4Te11hf1tZP1diiGjIDAB1cR4Sb9rcFPC/nBARjlgDxd+tCBb1t91j71xJcgGjT3g/dUFnXXNiDrxkyoHANPk58ACPUa42hj8tgGrhiXOCmygxFZBiT2wyAJTDJ4wJEPmp6JIrDaSWYNqv4xH2wwdSTGYb3E0pXnS39nmLUsqoVZxzSoegqzd0o06wdbTXsaHGL+IF4JtIcXddTcD/dCd8hVf+fWPSV553kjMmMEULLS8HcgmptDO955dLGX78PjiDA6IsTHPm5IA6bc5ha0gaGkoEttXuxU11B2dOJ65/Q08tEF1+Y9cr2Nh/VECfQ33GyvR/gsdN1LuIeLpKMCAF2yRr769g9/4aJLZNRI71m2S91+Kp+Q0zubTcxoG2/6gm1Q79wkMj2XNO2ui7nWw8ULtu27CCvqTGX2PffD+xcwgh/TrOKvGZMM5jRFGDTn4NO/lwnDR/GY/waDZtkWDUPI0O8ztcFVqp6r2ZW+2bvkJ3raptYagFqu95VdIaml2CIp6CKets34x+fH2C+zH4cVFO7vj+6k2FU39PtRhWluYeZ3gDz1TLB9K2v7SD9gJU1qDxoRDrAWcrFGLyndhdtd0505+gEP79adK8fmFCWNYC+ahzVNcRH79E8dA1iqX/N0qq22xcOc20ALxLDspEj4QCFBQMgaIwoKbxr0Bd7Sbws6GiRK6tqoPfpiCle23axejRLyO1I+ahsEpWrzT5ZsCyS5RcY9jMfENFxSnhKsrfW8JHH6/rdQUMfmQPT3Uz9gY0C/pu1yuCnrPUvio0a1qMEosA/EwIzzid7cqsAAAAASUVORK5CYII=');
@mixin dithered-gradient($position, $start, $stop, $color) {
background: radial-gradient(circle at $position, transparent $start, $color $stop);
mask: $noise, radial-gradient(circle at $position, transparent $start, #000 ($stop + 10%));
}
&::before,
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
z-index: 0;
display: block;
width: 100%;
height: 100%;
}
&::before {
@include dithered-gradient(50%, 30%, 60%, #6cc8ff);
}
&::after {
mask-image:
$noise, linear-gradient(45deg, #000 0%, transparent 25%, transparent 75%, #000 100%);
background: linear-gradient(45deg, #6d6dff 10%, transparent 25%, transparent 75%, #6af789 90%);
}
}
</style>

View File

@@ -1,27 +1,5 @@
<template>
<boxes />
<div>
here is the New New front
</div>
</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(3px 3px 0 rgb(0 0 0 / 0.7));
}
</style>

View File

@@ -1,14 +0,0 @@
<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,221 +0,0 @@
<template>
<div class="min-h-screen bg-gradient-to-b from-slate-900 to-slate-800 p-8">
<h1 class="text-3xl font-bold text-center text-white mb-8">Card 3D Playground</h1>
<div class="container mx-auto flex flex-col lg:flex-row gap-8">
<!-- Controls Panel -->
<div class="w-full lg:w-1/3 bg-slate-800 p-6 rounded-xl shadow-lg">
<h2 class="text-xl font-semibold text-white mb-6">3D Controls</h2>
<div class="space-y-6">
<!-- Rotation -->
<div>
<h3 class="text-white font-medium mb-2">Rotation</h3>
<div class="space-y-4">
<div>
<label class="block text-sm text-gray-300 mb-1">X-Axis (rotateX)</label>
<input v-model="rotationX" type="range" min="-180" max="180" class="w-full" @input="updateTransform">
<span class="text-xs text-gray-400">{{ rotationX }}°</span>
</div>
<div>
<label class="block text-sm text-gray-300 mb-1">Y-Axis (rotateY)</label>
<input v-model="rotationY" type="range" min="-180" max="180" class="w-full" @input="updateTransform">
<span class="text-xs text-gray-400">{{ rotationY }}°</span>
</div>
<div>
<label class="block text-sm text-gray-300 mb-1">Z-Axis (rotateZ)</label>
<input v-model="rotationZ" type="range" min="-180" max="180" class="w-full" @input="updateTransform">
<span class="text-xs text-gray-400">{{ rotationZ }}°</span>
</div>
</div>
</div>
<!-- Translation -->
<div>
<h3 class="text-white font-medium mb-2">Position (px)</h3>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-xs text-gray-300 mb-1">X</label>
<input v-model="translateX" type="range" min="-200" max="200" class="w-full" @input="updateTransform">
<span class="text-xs text-gray-400">{{ translateX }}px</span>
</div>
<div>
<label class="block text-xs text-gray-300 mb-1">Y</label>
<input v-model="translateY" type="range" min="-200" max="200" class="w-full" @input="updateTransform">
<span class="text-xs text-gray-400">{{ translateY }}px</span>
</div>
<div>
<label class="block text-xs text-gray-300 mb-1">Z</label>
<input v-model="translateZ" type="range" min="-500" max="500" class="w-full" @input="updateTransform">
<span class="text-xs text-gray-400">{{ translateZ }}px</span>
</div>
</div>
</div>
<!-- Scale -->
<div>
<h3 class="text-white font-medium mb-2">Scale</h3>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-xs text-gray-300 mb-1">X</label>
<input v-model="scaleX" type="range" min="0.5" max="2" step="0.1" class="w-full"
@input="updateTransform">
<span class="text-xs text-gray-400">{{ scaleX }}x</span>
</div>
<div>
<label class="block text-xs text-gray-300 mb-1">Y</label>
<input v-model="scaleY" type="range" min="0.5" max="2" step="0.1" class="w-full"
@input="updateTransform">
<span class="text-xs text-gray-400">{{ scaleY }}x</span>
</div>
<div>
<label class="block text-xs text-gray-300 mb-1">Z</label>
<input v-model="scaleZ" type="range" min="0.5" max="2" step="0.1" class="w-full"
@input="updateTransform">
<span class="text-xs text-gray-400">{{ scaleZ }}x</span>
</div>
</div>
</div>
<!-- Reset Button -->
<button
class="w-full mt-6 bg-esyellow hover:bg-yellow-500 text-black font-medium py-2 px-4 rounded-md transition-colors"
@click="resetTransforms">
Reset to Default
</button>
</div>
</div>
<!-- Card Preview -->
<div class="flex-1 flex items-center justify-center min-h-[60vh] lg:min-h-auto">
<div class="relative w-64 h-96 transition-transform duration-300" :style="cardStyle">
<div class="w-full h-full" :style="{
transform: `
perspective(1000px)
rotateX(${rotationX}deg)
rotateY(${rotationY}deg)
rotateZ(${rotationZ}deg)
translate3d(${translateX}px, ${translateY}px, ${translateZ}px)
scale3d(${scaleX}, ${scaleY}, ${scaleZ})
`,
transformStyle: 'preserve-3d',
transition: 'transform 0.3s ease-out',
willChange: 'transform',
backfaceVisibility: 'visible'
}">
<div
class="w-full h-full bg-gradient-to-br from-cyan-500 to-blue-600 rounded-xl shadow-2xl flex items-center justify-center p-4">
<div class="text-center text-white">
<div class="text-6xl mb-2">🃏</div>
<h3 class="text-xl font-bold">Card Playground</h3>
<p class="text-sm opacity-80">Drag the sliders to transform me!</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
// State for 3D transformations
import { useDataStore } from '~/store/data'
const rotationX = ref(0)
const rotationY = ref(0)
const rotationZ = ref(0)
const translateX = ref(0)
const translateY = ref(0)
const translateZ = ref(0)
const scaleX = ref(1)
const scaleY = ref(1)
const scaleZ = ref(1)
const dataStore = useDataStore()
// Computed property for the card's transform style
const cardStyle = computed(() => {
return {
transform: `
perspective(1000px)
rotateX(${rotationX.value}deg)
rotateY(${rotationY.value}deg)
rotateZ(${rotationZ.value}deg)
translate3d(${translateX.value}px, ${translateY.value}px, ${translateZ.value}px)
scale3d(${scaleX.value}, ${scaleY.value}, ${scaleZ.value})
`,
transformStyle: 'preserve-3d',
transition: 'transform 0.3s ease-out',
willChange: 'transform',
backfaceVisibility: 'visible'
}
})
// Function to update transform (for immediate feedback)
const updateTransform = () => {
// The computed property will handle the update
}
// Function to reset all transforms
const resetTransforms = () => {
rotationX.value = 0
rotationY.value = 0
rotationZ.value = 0
translateX.value = 0
translateY.value = 0
translateZ.value = 0
scaleX.value = 1
scaleY.value = 1
scaleZ.value = 1
}
dataStore.isLoading.value = false
// Set page metadata
definePageMeta({
layout: 'default'
})
</script>
<style scoped>
/* Add any additional styles here */
.container {
perspective: 1000px;
}
/* Style range inputs */
input[type="range"] {
-webkit-appearance: none;
width: 100%;
height: 6px;
border-radius: 3px;
background: #475569;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #f59e0b;
cursor: pointer;
transition: all 0.2s;
}
input[type="range"]::-webkit-slider-thumb:hover {
background: #d97706;
transform: scale(1.2);
}
/* Card styling */
.card-3d {
transform-style: preserve-3d;
transition: transform 0.3s ease-out;
will-change: transform;
backface-visibility: visible;
}
</style>

View File

@@ -1,78 +0,0 @@
<template>
<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">
<!-- <box :box="box" /> -->
<div class="devtool absolute right-4 text-white bg-black rounded-2xl px-4 py-2">
<!-- <button @click="currentPosition = boxPositions.side">side</button>
<button @click="currentPosition = boxPositions.front">front</button>
<button @click="currentPosition = boxPositions.back">back</button> -->
<div class="w-full block">
<input class="w-1/2" type="color" name="color1" id="color1" v-model="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, ${box.color1}, ${box.color2})`
}"
></div>
<!-- <label class="block">
size: {{ size }}
<input v-model.number="size" type="range" step="1" min="1" max="14">
</label> -->
</div>
<!-- <div>
<label class="block">
X: {{ currentPosition.x }}
<input v-model.number="currentPosition.x" type="range" step="1" min="-180" max="180">
</label>
<label class="block">
Y: {{ currentPosition.y }}
<input v-model.number="currentPosition.y" type="range" step="1" min="-180" max="180">
</label>
<label class="block">
Z: {{ currentPosition.z }}
<input v-model.number="currentPosition.z" type="range" step="1" min="-180" max="180">
</label>
</div> -->
</div>
</div>
</div>
<card :track="track" />
</div>
</div>
</template>
<script setup lang="ts">
import type { Box, Track } from '~~/types/types'
const box = ref<Box>({
id: 'ES00A',
name: 'zero',
duration: 2794,
description: 'Zero is for manifesto',
color1: '#ffffff',
color2: '#48959d',
color3: '#00ff00',
type: 'compilation'
})
const track = ref<Track>({
id: 1,
boxId: 'ES00A',
title: 'The grinding wheel',
artist: {
id: 0,
name: "L'Efondras",
url: '',
coverId: '0024705317'
},
start: 0,
url: 'https://arakirecords.bandcamp.com/track/the-grinding-wheel',
coverId: 'a3236746052',
type: 'compilation'
})
//from-slate-800 to-zinc-900
</script>

View File

@@ -1,48 +0,0 @@
class Controls {
public toggleButton: HTMLButtonElement;
public rewindButton: HTMLButtonElement;
public isPlaying: boolean = false;
public callbacks = {
// @ts-expect-error: unused var
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onIsplayingChanged: (isPlaying: boolean) => {},
onRewind: () => {},
};
constructor({
toggleButton,
rewindButton,
}: {
toggleButton: HTMLButtonElement;
rewindButton: HTMLButtonElement;
}) {
this.toggleButton = toggleButton;
this.rewindButton = rewindButton;
this.toggleButton.addEventListener('click', () => this.toggle());
this.rewindButton.addEventListener('click', () => this.rewind());
this.isDisabled = true;
}
set isDisabled(disabled: boolean) {
this.toggleButton.disabled = disabled;
this.rewindButton.disabled = disabled;
}
toggle() {
this.isPlaying = !this.isPlaying;
this.toggleButton.classList.toggle('is-active', this.isPlaying);
this.callbacks.onIsplayingChanged(this.isPlaying);
}
rewind() {
this.callbacks.onRewind();
}
}
export default Controls;

View File

@@ -1,97 +0,0 @@
class Sampler {
public audioContext: AudioContext = new AudioContext();
public gainNode: GainNode = new GainNode(this.audioContext);
public audioBuffer: AudioBuffer | null = null;
public audioBufferReversed: AudioBuffer | null = null;
public audioSource: AudioBufferSourceNode | null = null;
public duration: number = 0;
public isReversed: boolean = false;
constructor() {
this.gainNode.connect(this.audioContext.destination);
}
async getAudioBuffer(audioUrl: string) {
const response = await fetch(audioUrl);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
return audioBuffer;
}
async loadTrack(audioUrl: string) {
this.audioBuffer = await this.getAudioBuffer(audioUrl);
this.audioBufferReversed = this.getReversedAudioBuffer(this.audioBuffer);
this.duration = this.audioBuffer.duration;
}
getReversedAudioBuffer(audioBuffer: AudioBuffer) {
const bufferArray = audioBuffer.getChannelData(0).slice().reverse();
const audioBufferReversed = this.audioContext.createBuffer(
1,
audioBuffer.length,
audioBuffer.sampleRate,
);
audioBufferReversed.getChannelData(0).set(bufferArray);
return audioBufferReversed;
}
changeDirection(isReversed: boolean, secondsPlayed: number) {
this.isReversed = isReversed;
this.play(secondsPlayed);
}
play(offset = 0) {
this.pause();
const buffer = this.isReversed
? this.audioBufferReversed
: this.audioBuffer;
const cueTime = this.isReversed ? this.duration - offset : offset;
this.audioSource = this.audioContext.createBufferSource();
this.audioSource.buffer = buffer;
this.audioSource.loop = false;
this.audioSource.connect(this.gainNode);
this.audioSource.start(0, cueTime);
}
updateSpeed(speed: number, isReversed: boolean, secondsPlayed: number) {
if (!this.audioSource) {
return;
}
if (isReversed !== this.isReversed) {
this.changeDirection(isReversed, secondsPlayed);
}
const { currentTime } = this.audioContext;
const speedAbsolute = Math.abs(speed);
this.audioSource.playbackRate.cancelScheduledValues(currentTime);
this.audioSource.playbackRate.linearRampToValueAtTime(
Math.max(0.001, speedAbsolute),
currentTime,
);
}
pause() {
if (!this.audioSource) {
return;
}
this.audioSource.stop();
}
}
export default Sampler;

View File

@@ -1,9 +0,0 @@
import { defineNuxtPlugin } from '#app'
export default defineNuxtPlugin(() => {
if (process.client) {
import('atropos/element').then(({ default: AtroposComponent }) => {
customElements.define('atropos-component', AtroposComponent)
})
}
})

View File

@@ -1,121 +0,0 @@
import { defineStore } from 'pinia'
import type { Track } from '~~/types/types'
interface CardPosition {
x: number
y: number
}
type CardPositions = Record<string, Record<number, CardPosition>>
export const useCardStore = defineStore('card', {
state: () => ({
// Stocke les IDs des cartes déjà révélées
revealedCards: new Set<number>(),
// Stocke les positions personnalisées des cartes par box
// Format: { [boxId]: { [trackId]: { x: number, y: number } } }
cardPositions: {} as CardPositions
}),
actions: {
// Marquer une carte comme révélée
revealCard(trackId: number) {
this.revealedCards.add(trackId)
this.saveToLocalStorage()
},
hideCard(trackId: number) {
this.revealedCards.delete(trackId)
this.saveToLocalStorage()
},
// Vérifier si une carte est révélée
isCardRevealed(trackId: number): boolean {
return this.revealedCards.has(trackId)
},
// Définir la position d'une carte dans une box
setCardPosition(boxId: string, trackId: number, position: { x: number; y: number }) {
if (!this.cardPositions[boxId]) {
this.cardPositions[boxId] = {}
}
this.cardPositions[boxId][trackId] = position
this.saveToLocalStorage()
},
// Obtenir la position d'une carte dans une box
getCardPosition(boxId: string, trackId: number): { x: number; y: number } | undefined {
return this.cardPositions[boxId]?.[trackId]
},
// Basculer l'état de révélation de toutes les cartes
revealAllCards(tracks: Track[]) {
tracks.forEach((track) => {
this.revealCard(track.id)
})
this.saveToLocalStorage()
},
hideAllCards(tracks: Track[]) {
tracks.forEach((track) => {
this.hideCard(track.id)
})
this.saveToLocalStorage()
},
// Sauvegarder l'état dans le localStorage
saveToLocalStorage() {
if (typeof window !== 'undefined') {
try {
localStorage.setItem(
'cardStore',
JSON.stringify({
revealedCards: Array.from(this.revealedCards),
cardPositions: this.cardPositions
})
)
} catch (e) {
console.error('Failed to save card store to localStorage', e)
}
}
},
// Charger l'état depuis le localStorage
loadFromLocalStorage() {
if (typeof window !== 'undefined') {
try {
const saved = localStorage.getItem('cardStore')
if (saved) {
const { revealedCards, cardPositions } = JSON.parse(saved)
if (Array.isArray(revealedCards)) {
this.revealedCards = new Set(
revealedCards.filter((id): id is number => typeof id === 'number')
)
}
if (cardPositions && typeof cardPositions === 'object') {
this.cardPositions = cardPositions
}
}
} catch (e) {
console.error('Failed to load card store from localStorage', e)
}
}
},
// Initialiser le store
initialize() {
this.loadFromLocalStorage()
}
},
getters: {
// Getter pour la réactivité dans les templates
isRevealed: (state) => (trackId: number) => {
return state.revealedCards.has(trackId)
},
// Obtenir toutes les positions des cartes d'une box
getBoxCardPositions: (state) => (boxId: string) => {
return state.cardPositions[boxId] || {}
}
}
})

View File

@@ -1,316 +0,0 @@
// ~/store/player.ts
import { defineStore } from 'pinia'
import type { Track, Box } from '~/../types/types'
import { useDataStore } from '~/store/data'
import { useCardStore } from '~/store/card'
export const usePlayerStore = defineStore('player', {
state: () => ({
currentTrack: null as Track | null,
position: 0,
audio: null as HTMLAudioElement | null,
progressionLast: 0,
isPlaying: false,
isLoading: false,
history: [] as string[]
}),
actions: {
attachAudio(el: HTMLAudioElement) {
this.audio = el
// attach listeners if not already attached (idempotent enough for our use)
this.audio.addEventListener('play', () => {
this.isPlaying = true
// Révéler la carte quand la lecture commence
if (this.currentTrack) {
const cardStore = useCardStore()
if (!cardStore.isCardRevealed(this.currentTrack.id)) {
requestAnimationFrame(() => {
cardStore.revealCard(this.currentTrack!.id)
})
}
}
})
this.audio.addEventListener('playing', () => {})
this.audio.addEventListener('pause', () => {
this.isPlaying = false
})
this.audio.addEventListener('ended', async () => {
const track = this.currentTrack
if (!track) return
const dataStore = useDataStore()
// Comportement par défaut pour les playlists standards
if (track.type === 'playlist') {
const next = dataStore.getNextPlaylistTrack(track)
if (next && next.boxId === track.boxId) {
await this.playTrack(next)
return
}
}
// Si on arrive ici, c'est qu'il n'y a pas de piste suivante
this.currentTrack = null
this.isPlaying = false
})
},
async playBox(box: Box) {
// Si c'est la même box, on toggle simplement la lecture
if (this.currentTrack?.boxId === box.id && this.currentTrack?.side === box.activeSide) {
this.togglePlay()
return
}
// Sinon, on charge la première piste de la box
try {
const dataStore = useDataStore()
const firstTrack = dataStore.getFirstTrackOfBox(box)
if (firstTrack) {
await this.playTrack(firstTrack)
}
} catch (error) {
console.error('Error playing box:', error)
}
},
async playTrack(track: Track) {
// Pour les autres types de pistes, on utilise la logique existante
this.isCompilationTrack(track)
? await this.playCompilationTrack(track)
: await this.playPlaylistTrack(track)
},
async playCompilationTrack(track: Track) {
// Si c'est la même piste, on toggle simplement la lecture
if (this.currentTrack?.id === track.id) {
// Si la lecture est en cours, on met en pause
if (this.isPlaying) {
this.togglePlay()
return
}
// Si c'est une compilation, on vérifie si on est dans la plage de la piste
if (track.type === 'compilation' && track.start !== undefined) {
const dataStore = useDataStore()
const nextTrack = dataStore.getNextTrack(track)
// Si on a une piste suivante et qu'on est dans la plage de la piste courante
if (nextTrack?.start && this.position >= track.start && this.position < nextTrack.start) {
this.togglePlay()
return
}
// Si c'est la dernière piste de la compilation
else if (!nextTrack && this.position >= track.start) {
this.togglePlay()
return
}
}
}
// Sinon, on charge et on lit la piste
this.currentTrack = track
await this.loadAndPlayTrack(track)
},
async playPlaylistTrack(track: Track) {
// Toggle simple si c'est la même piste
if (this.currentTrack?.id === track.id) {
this.togglePlay()
return
}
// Sinon, on charge et on lit la piste
this.currentTrack = track
await this.loadAndPlayTrack(track)
},
async loadTrack(track: Track) {
if (!this.audio) return
return new Promise<void>((resolve) => {
this.currentTrack = track
const audio = this.audio as HTMLAudioElement
// Si c'est la même source, on ne fait rien
if (audio.src === track.url) {
resolve()
return
}
// Nouvelle source
audio.src = track.url
// Une fois que suffisamment de données sont chargées
const onCanPlay = () => {
audio.removeEventListener('canplay', onCanPlay)
resolve()
}
audio.addEventListener('canplay', onCanPlay)
// Timeout de sécurité
setTimeout(resolve, 1000)
})
},
async loadAndPlayTrack(track: Track) {
if (!this.audio) {
const newAudio = new Audio()
this.attachAudio(newAudio)
}
const audio = this.audio as HTMLAudioElement
try {
this.isLoading = true
// Mettre en pause
audio.pause()
// Pour les compilations, on utilise l'URL de la piste avec le point de départ
if (track.type === 'compilation' && track.start !== undefined) {
audio.src = track.url
audio.currentTime = track.start
} else {
// Pour les playlists, on charge la piste individuelle
audio.currentTime = 0
await this.loadTrack(track)
}
// Attendre que les métadonnées soient chargées
await new Promise<void>((resolve) => {
const onCanPlay = () => {
audio.removeEventListener('canplay', onCanPlay)
resolve()
}
audio.addEventListener('canplay', onCanPlay)
// Timeout de sécurité
setTimeout(resolve, 1000)
})
// Lancer la lecture
await audio.play()
this.history.push(this.currentTrack?.id)
this.isLoading = false
} catch (error) {
console.error('Error loading/playing track:', error)
this.isLoading = false
}
},
async togglePlay() {
if (!this.audio) return
try {
if (this.audio.paused) {
await this.audio.play()
} else {
this.audio.pause()
}
} catch (error) {
console.error('Error toggling play state:', error)
}
},
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 currentTrack = this.currentTrack
if (currentTrack && currentTrack.type === 'compilation') {
const dataStore = useDataStore()
const tracks = dataStore
.getTracksByboxId(currentTrack.boxId, currentTrack.side)
.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 nextTrack = tracks[0]
for (const t of tracks) {
const s = t.start ?? 0
if (s <= now) {
nextTrack = t
} else {
break
}
}
if (nextTrack && nextTrack.id !== currentTrack.id) {
// only update metadata reference; do not reload audio
this.currentTrack = nextTrack
// Révéler la carte avec une animation fluide
const cardStore = useCardStore()
if (nextTrack.id && !cardStore.isCardRevealed(nextTrack.id)) {
// Utiliser requestAnimationFrame pour une meilleure synchronisation avec le rendu
requestAnimationFrame(() => {
cardStore.revealCard(nextTrack.id!)
})
}
}
}
}
}
},
getters: {
isCurrentBox: (state) => {
return (box: Box) => {
if (box.type === 'compilation') {
return box.id === state.currentTrack?.boxId && box.activeSide === state.currentTrack?.side
} else {
return box.id === state.currentTrack?.boxId
}
}
},
isCurrentSide: (state) => {
return (side: string) => side === state.currentTrack?.side
},
isPlaylistTrack: () => {
return (track: Track) => {
return track.type === 'playlist'
}
},
isCompilationTrack: () => {
return (track: Track) => {
return track.type === 'compilation'
}
},
isPaused: (state) => {
return state.audio?.paused ?? true
},
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`
}
}
})

39
app/utils/colors.ts Normal file
View File

@@ -0,0 +1,39 @@
export const getYearColor = (year: number): string => {
// Palette élargie avec des différences plus marquées
const colorMap: Record<number, string> = {
// Années récentes - teintes froides et claires
2025: '#3a4a6c', // bleu-gris clair
2024: '#1e3a7a', // bleu vif
2023: '#1a4d5c', // bleu-vert émeraude
2022: '#3a4a6a', // bleu-gris moyen
// Années 2020-2021 - transition
2021: '#3a2e6a', // bleu-violet
2020: '#2a467a', // bleu-gris chaud
// Années 2010-2019 - teintes moyennes
2019: '#2a2a7a', // bleu nuit profond
2018: '#1e2a8a', // bleu roi
2017: '#1a5a6a', // bleu canard vif
2016: '#1a5a4a', // vert bleuté
2015: '#1a3a7a', // bleu marine
2014: '#4a1e7a', // violet profond
2013: '#1a5a4a', // vert émeraude
2012: '#1e3a9a', // bleu ciel profond
// Années 2000-2011 - teintes chaudes et foncées
2011: '#1e293b', // slate-800 de base
2010: '#2a467a', // bleu-gris chaud
2009: '#3a4a6a', // bleu-gris moyen
2008: '#1a3a8a', // bleu nuit clair
2007: '#5a2a4a', // bordeaux
2006: '#5a1e6a', // violet profond
2005: '#3a1a7a', // bleu-violet foncé
2004: '#2a1a5a', // bleu nuit profond
2003: '#3a3a5a', // bleu-gris foncé
2002: '#1a5a4a', // vert foncé
2001: '#5a3a2a', // marron chaud
2000: '#3a3a5a' // bleu-gris foncé
}
return colorMap[year] || '#1e293b' // slate-800 par défaut
}

82
appOLDD/app.vue Normal file
View File

@@ -0,0 +1,82 @@
<template>
<div>
<NuxtRouteAnnouncer />
<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()
useHead({
bodyAttrs: {
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 (import.meta.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;
}
input[type='email'] {
@apply bg-slate-900 text-esyellow;
}
img {
user-drag: none;
user-select: none;
}
</style>

View File

@@ -9,7 +9,7 @@
</div>
<div class="face back flex flex-row flex-wrap items-start p-4 overflow-hidden"
:class="{ 'overflow-y-scroll': !isCompilation }" ref="backFace">
<li class="list-none text-xxs w-1/2 flex flex-row"
<!-- <li class="list-none text-xxs w-1/2 flex flex-row"
v-for="track in dataStore.getTracksByboxId(box.id, box.activeSide)" :key="track.id" :track="track">
<span class="text-slate-700" v-if="isNotManifesto"> {{ track.order }}. </span>
<p class="text-left text-slate-700">
@@ -19,7 +19,7 @@
<br />
{{ track.artist.name }}
</p>
</li>
</li> -->
</div>
<div class="face right" ref="rightFace" />
<div class="face left" ref="leftFace" />
@@ -43,7 +43,7 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
import type { Box } from '~~/types/types'
import type { Box } from '~~/types'
import { useDataStore } from '~/store/data'
interface Props {

View File

@@ -0,0 +1,9 @@
<template>
<div>
wait for it
</div>
</template>
<script>
console.log('wait for it')
</script>

View File

@@ -6,16 +6,16 @@
<template v-if="box.type === 'compilation'">
<playButton @click.stop="playSelectedBox(box)" :objectToPlay="box" class="relative z-40 m-auto" />
<deckCompilation :box="getBoxToDisplay(box)" class="box-page" :key="`${box.id}-${box.activeSide}`"
@click.stop="" />
@click.stop />
</template>
<deckPlaylist :box="box" class="box-page" v-if="box.type === 'playlist'" @click.stop="" />
<deckPlaylist :box="box" class="box-page" v-if="box.type === 'playlist'" @click.stop />
</template>
</box>
</div>
</template>
<script lang="ts" setup>
import type { Box } from '~~/types/types'
import type { Box } from '~~/types'
import { useDataStore } from '~/store/data'
import { usePlayerStore } from '~/store/player'
import { useUiStore } from '~/store/ui'

View File

@@ -0,0 +1,169 @@
<template>
<div class="bucket" ref="bucket" :class="{ 'drag-over': isDragOver }" @dragenter.prevent="onDragEnter"
@dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop.prevent="onDrop">
<div v-if="tracks.length === 0" class="bucket-empty">
Drop cards here
</div>
<draggable v-else v-model="tracks" item-key="id" class="bucket-cards" @start="handleDragStart" @end="handleDragEnd"
:touch-start-threshold="50" :component-data="{
tag: 'div',
type: 'transition-group',
name: 'list'
}">
<template #item="{ element: track }">
<div class="bucket-card-wrapper">
<card :track="track" tabindex="0" is-face-up class="bucket-card"
@card-click="playerStore.playPlaylistTrack(track)" />
</div>
</template>
</draggable>
</div>
</template>
<script setup lang="ts">
import { ref, defineEmits, onMounted } from 'vue'
import draggable from 'vuedraggable'
import { useCardStore } from '~/store/card'
import { usePlayerStore } from '~/store/player'
const emit = defineEmits<{
(e: 'card-dropped', track: any): void
(e: 'update:modelValue', value: any[]): void
}>()
const props = defineProps<{
modelValue?: any[]
boxId?: string
}>()
const cardStore = useCardStore()
const playerStore = usePlayerStore()
const isDragOver = ref(false)
const drag = ref(false)
const bucket = ref()
// Utilisation du bucket du store comme source de vérité
const tracks = computed({
get: () => cardStore.bucket,
set: (value) => {
// Update the store when the order changes
cardStore.updateBucketOrder(value)
}
})
// Charger les données du localStorage au montage
onMounted(() => {
cardStore.loadBucketFromLocalStorage()
})
// Gestion du drag and drop desktop
const handleDragStart = (event: { item: HTMLElement }) => {
drag.value = true
// Émettre un événement personnalisé pour indiquer qu'un glisser a commencé depuis le bucket
document.dispatchEvent(new CustomEvent('bucket-drag-start'))
}
const handleDragEnd = (event: { item: HTMLElement; newIndex: number; oldIndex: number }) => {
drag.value = false
isDragOver.value = false
// Update the store with the new order if the position changed
if (event.newIndex !== event.oldIndex) {
// The store will handle the reordering automatically through the v-model binding
}
}
const onDragEnter = (e: DragEvent) => {
e.preventDefault()
isDragOver.value = true
}
const onDragOver = (e: DragEvent) => {
e.preventDefault()
isDragOver.value = true
}
const onDragLeave = () => {
isDragOver.value = false
}
const onDrop = (e: DragEvent) => {
isDragOver.value = false
const cardData = e.dataTransfer?.getData('application/json')
if (cardData) {
try {
const track = JSON.parse(cardData)
cardStore.addToBucket(track)
} catch (e) {
console.error('Erreur lors du traitement de la carte déposée', e)
}
}
}
onMounted(() => {
// Écouter aussi les événements tactiles personnalisés
bucket.value?.addEventListener('card-dropped-touch', (e: CustomEvent) => {
emit('card-dropped', e.detail)
})
})
</script>
<style>
.bucket {
min-height: 200px;
border-radius: 8px;
padding: 1rem;
transition: all 0.3s ease;
touch-action: none;
}
.bucket.drag-over {
border-color: #4CAF50;
background-color: rgba(76, 175, 80, 0.1);
}
.bucket-empty {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
color: #666;
font-style: italic;
}
.bucket-cards {
display: flex;
justify-content: center;
width: 100%;
}
.bucket-card {
transition: transform 0.2s;
cursor: move;
touch-action: none;
/* Important pour le touch */
}
.bucket-card:hover {
transform: translateY(-4px);
}
.bucket-card:active {
opacity: 0.7;
}
.bucket-card-wrapper {
width: 70px;
transition: width 0.3s ease;
}
.bucket:hover,
.card-dragging {
border: 2px dashed #ccc;
background-color: rgba(255, 255, 255, 0.4);
.bucket-card-wrapper {
width: 280px;
}
}
</style>

519
appOLDD/components/Card.vue Normal file
View File

@@ -0,0 +1,519 @@
<template>
<article v-bind="attrs" :role="props.role" :draggable="isFaceUp" :class="[
'card cursor-pointer',
isFaceUp ? 'face-up' : 'face-down',
{ 'current-track': playerStore.currentTrack?.id === track.id },
{ 'is-dragging': isDragging }
]" :tabindex="props.tabindex" :aria-disabled="false" @click.stop="handleClick" @keydown.enter.stop="handleClick"
@keydown.space.prevent.stop="handleClick" @dragstart="handleDragStart" @dragend="handleDragEnd"
@drag="handleDragMove" @touchstart.passive="!isFaceUp" @touchmove.passive="!isFaceUp">
<div class="flip-inner" ref="cardElement">
<!-- Face-Up -->
<main
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 hover:shadow-xl hover:scale-110 transition-all rounded-2xl shadow-lg flex flex-col overflow-hidden">
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 right-7"
@click.stop="clickCardSymbol">
<div class="suit text-7xl absolute"
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
<img draggable="false" :src="`/${props.track.card?.suit}.svg`" />
</div>
<div class="rank text-white font-bold absolute -mt-1">
{{ props.track.card?.rank }}
</div>
</div>
<div v-else class="flex items-center justify-center size-7 absolute top-6 left-6">
<div class="rank text-white font-bold absolute -mt-1">
{{ props.track.order }}
</div>
</div>
<!-- Cover -->
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer">
<playButton :objectToPlay="track" />
<img draggable="false" v-if="isFaceUp" :src="coverUrl" alt="Pochette de l'album" loading="lazy"
class="w-full h-full object-cover object-center" />
</figure>
<!-- Body -->
<div
class="card-body p-3 text-center bg-white rounded-b-xl opacity-0 -mt-16 hover:opacity-100 hover:-mt-0 transition-all duration-300">
<div v-if="isOrder" class="label">
{{ props.track.order }}
</div>
<h2 class="select-text text-sm text-neutral-500 first-letter:uppercase truncate">
{{ props.track.title || 'title' }}
</h2>
<p v-if="isPlaylistTrack && track.artist && typeof track.artist === 'object'"
class="select-text text-base text-neutral-800 font-bold capitalize truncate">
{{ track.artist.name || 'artist' }}
</p>
<p v-else-if="isPlaylistTrack" class="select-text text-base text-neutral-800 font-bold capitalize truncate">
{{ typeof track.artist === 'string' ? track.artist : 'artist' }}
</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 select-none">
<figure class="h-full flex text-center rounded-xl justify-center items-center"
:style="{ backgroundColor: cardColor }">
<playButton :objectToPlay="track" />
<img draggable="false" src="/face-down.svg" />
</figure>
</footer>
</div>
</article>
<!-- Clone fantôme unifié pour drag souris ET tactile -->
<Teleport to="body">
<div v-if="isDragging && touchClone" ref="ghostElement"
class="ghost-card fixed pointer-events-none z-[9999] w-56 h-80" :style="{
left: touchClone.x + 'px',
top: touchClone.y + 'px',
transform: 'translate(-50%, -50%)'
}">
<div class="flip-inner">
<main
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 rounded-2xl shadow-2xl flex flex-col overflow-hidden bg-white bg-opacity-90">
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 left-7">
<div class="suit text-7xl absolute"
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
<img draggable="false" :src="`/${props.track.card?.suit}.svg`" />
</div>
<div class="rank text-white font-bold absolute -mt-1">
{{ props.track.card?.rank }}
</div>
</div>
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl">
<img draggable="false" :src="coverUrl" alt="Pochette de l'album"
class="w-full h-full object-cover object-center" />
</figure>
</main>
</div>
</div>
</Teleport>
<!-- Modal de partage -->
<ModalSharer v-if="showModalSharer" ref="modalSharer" />
</template>
<script setup lang="ts">
import type { Track } from '~~/types'
import { usePlayerStore } from '~/store/player'
import { useDataStore } from '~/store/data'
import { useNuxtApp } from '#app'
import ModalSharer from '~/components/ui/ModalSharer.vue'
const props = withDefaults(defineProps<{
track: Track;
isFaceUp?: boolean;
role?: string;
tabindex?: string | number;
'onUpdate:isFaceUp'?: (value: boolean) => void;
}>(), {
isFaceUp: true,
role: 'button',
tabindex: '0'
})
// Use useAttrs to get all other attributes
const attrs = useAttrs()
const modalSharer = ref<InstanceType<typeof ModalSharer> | null>(null)
const showModalSharer = ref(false)
const emit = defineEmits<{
(e: 'update:isFaceUp', value: boolean): void;
(e: 'cardClick', track: Track): void;
(e: 'clickCardSymbol', track: Track): void;
(e: 'dragstart', event: DragEvent): void;
(e: 'dragend', event: DragEvent): void;
(e: 'drag', event: DragEvent): void;
(e: 'click', event: MouseEvent): void;
}>()
// Handle click events (mouse and keyboard)
const handleClick = (event: MouseEvent | KeyboardEvent) => {
if (!isDragging.value && !hasMovedDuringPress.value) {
emit('cardClick', props.track);
emit('click', event as MouseEvent);
}
hasMovedDuringPress.value = false;
}
const clickCardSymbol = (event: MouseEvent) => {
event.stopPropagation();
// Afficher la modale
showModalSharer.value = true;
// Donner le focus à la modale après le rendu
nextTick(() => {
if (modalSharer.value) {
modalSharer.value.open(props.track);
}
});
emit('clickCardSymbol', props.track);
}
// Handle drag start with proper event emission
const handleDragStart = (event: DragEvent) => {
if (!props.isFaceUp) {
event.preventDefault();
return;
}
const { $bodyClass } = useNuxtApp()
$bodyClass.add('card-dragging')
dragStart(event);
emit('dragstart', event);
}
// Handle drag end with proper event emission
const handleDragEnd = (event: DragEvent) => {
if (!props.isFaceUp) {
event.preventDefault();
return;
}
const { $bodyClass } = useNuxtApp()
$bodyClass.remove('card-dragging')
dragEnd(event);
emit('dragend', event);
}
// Handle drag move with proper event emission
const handleDragMove = (event: DragEvent) => {
if (!props.isFaceUp) {
event.preventDefault();
return;
}
dragMove(event);
emit('drag', event);
}
const playerStore = usePlayerStore()
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 dataStore = useDataStore()
const cardColor = computed(() => dataStore.getYearColor(props.track.year || 0))
const coverUrl = computed(() => props.track.coverId || '/card-dock.svg')
const isDragging = ref(false)
const cardElement = ref<HTMLElement | null>(null)
const ghostElement = ref<HTMLElement | null>(null)
// État unifié pour souris et tactile
const touchClone = ref<{ x: number, y: number } | null>(null)
const touchStartPos = ref<{ x: number, y: number } | null>(null)
const longPressTimer = ref<number | null>(null)
const LONG_PRESS_DURATION = 200 // ms
const hasMovedDuringPress = ref(false)
// Drag desktop - utilise maintenant ghostElement
const dragStart = (event: DragEvent) => {
if (event.dataTransfer && cardElement.value) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('application/json', JSON.stringify(props.track));
// Créer une image transparente pour masquer l'image par défaut du navigateur
const img = new Image();
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0);
// Activer le clone fantôme
isDragging.value = true
touchClone.value = {
x: event.clientX,
y: event.clientY
}
}
};
// Nouveau: suivre le mouvement de la souris pendant le drag
const dragMove = (event: DragEvent) => {
if (isDragging.value && touchClone.value && event.clientX !== 0 && event.clientY !== 0) {
touchClone.value = {
x: event.clientX,
y: event.clientY
}
}
}
const instance = getCurrentInstance();
const dragEnd = (event: DragEvent) => {
isDragging.value = false
touchClone.value = null
if (event.dataTransfer?.dropEffect === 'move' && instance?.vnode?.el?.parentNode) {
instance.vnode.el.parentNode.removeChild(instance.vnode.el);
const parent = instance.parent;
if (parent?.update) {
parent.update();
}
}
}
// Touch events
const touchStart = (event: TouchEvent) => {
const touch = event.touches[0];
if (!touch) return;
touchStartPos.value = { x: touch.clientX, y: touch.clientY };
hasMovedDuringPress.value = false;
// Démarrer un timer pour le long press
longPressTimer.value = window.setTimeout(() => {
startTouchDrag(touch);
}, LONG_PRESS_DURATION);
}
const startTouchDrag = (touch: Touch) => {
if (!touch) return;
isDragging.value = true;
touchClone.value = {
x: touch.clientX,
y: touch.clientY
};
// Vibration feedback si disponible
if (navigator.vibrate) {
navigator.vibrate(50);
}
}
const touchMove = (event: TouchEvent) => {
const touch = event.touches[0];
if (!touch || !longPressTimer.value) return;
// Annuler le long press si l'utilisateur bouge trop
const dx = touch.clientX - (touchStartPos.value?.x || 0);
const dy = touch.clientY - (touchStartPos.value?.y || 0);
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 10) { // Seuil de tolérance pour un tap
clearTimeout(longPressTimer.value)
longPressTimer.value = null
hasMovedDuringPress.value = true
}
if (isDragging.value && touchClone.value) {
event.preventDefault()
const touch = event.touches[0]
touchClone.value = {
x: touch.clientX,
y: touch.clientY
}
// Déterminer l'élément sous le doigt
checkDropTarget(touch.clientX, touch.clientY)
}
}
const touchEnd = (event: TouchEvent) => {
// Annuler le timer de long press
if (longPressTimer.value) {
clearTimeout(longPressTimer.value);
longPressTimer.value = null;
}
// Vérifier si c'était un tap simple (pas de déplacement)
if (!hasMovedDuringPress.value && touchStartPos.value) {
const touch = event.changedTouches[0];
if (touch) {
const dx = touch.clientX - touchStartPos.value.x;
const dy = touch.clientY - touchStartPos.value.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 10) { // Seuil de tolérance pour un tap
handleClick(new MouseEvent('click'));
}
}
}
// Réinitialiser l'état de glisser-déposer
if (isDragging.value) {
// Vérifier si on est au-dessus d'une cible de dépôt
const touch = event.changedTouches[0];
if (touch) {
checkDropTarget(touch.clientX, touch.clientY);
}
}
// Nettoyer
isDragging.value = false;
touchClone.value = null;
touchStartPos.value = null;
hasMovedDuringPress.value = false;
}
const checkDropTarget = (x: number, y: number): HTMLElement | null => {
const element = document.elementFromPoint(x, y);
if (element) {
const dropZone = element.closest('[data-drop-zone]');
if (dropZone) {
return dropZone as HTMLElement;
}
}
return null;
}
// Cleanup
onUnmounted(() => {
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
}
})
</script>
<style lang="scss">
.label {
@apply rounded-full size-7 p-2 bg-esyellow leading-3 -mt-6;
font-weight: bold;
text-align: center;
}
.,
.,
.,
. {
@apply text-5xl size-14;
}
/* Flip effect */
.card {
perspective: 1000px;
@apply transition-all scale-100 w-56 h-80 min-w-56 min-h-80;
touch-action: none;
.flip-inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.6s;
transform-style: preserve-3d;
transform-origin: center;
}
.face-down,
.face-up {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
will-change: transform;
background-color: rgba(255, 255, 255, 0.5);
}
.face-up {
transform: rotateY(0deg);
transition: box-shadow 0.6s;
}
.face-down {
transform: rotateY(-180deg);
}
&.face-down .flip-inner {
transform: rotateY(180deg);
}
&.face-up .flip-inner {
transform: rotateY(0deg);
}
&.face-down:hover {
.play-button {
opacity: 1;
}
.flip-inner {
transform: rotateY(170deg);
}
}
&:focus,
&.current-track {
@apply z-50 scale-110;
outline: none;
.face-up {
@apply shadow-2xl;
transition:
box-shadow 0.6s,
transform 0.6s;
}
}
&:focus,
&.current-track {
.play-button {
@apply opacity-100;
}
}
.play-button {
opacity: 0;
}
.face-up:hover {
.play-button {
opacity: 1;
}
.flip-inner {
transform: rotateY(-170deg);
}
}
.play-button {
@apply absolute bottom-1/2 top-24 opacity-0 hover:opacity-100;
}
.pochette:active,
.face-down:active {
.play-button {
@apply scale-90;
}
}
&.is-dragging {
@apply opacity-50 scale-95 rotate-6;
cursor: grabbing !important;
.face-up {
@apply shadow-2xl;
}
.play-button,
.card-body {
display: none;
}
}
}
/* Ghost card styles - maintenant unifié pour souris et tactile */
.ghost-card {
transition: none;
.card {
@apply shadow-2xl scale-95 rotate-6;
.play-button,
.card-body {
display: none;
}
}
.flip-inner {
perspective: 1000px;
}
}
</style>

View File

@@ -0,0 +1,198 @@
<template>
<div class="platine pointer-events-none" :class="{ 'loading': platineStore.isLoadingTrack, 'mounted': isMounted }"
ref="platine">
<img class="cover" :src="platineStore.currentTrack?.coverId" />
<div class="disc pointer-events-auto fixed bg-transparent" ref="discRef" id="disc">
<div class="bobine"
:style="{ height: platineStore.progressPercentage + '%', width: platineStore.progressPercentage + '%' }"></div>
<div class="disc-label rounded-full bg-cover bg-center">
<img src="/favicon.svg" class="size-1/2 bg-black rounded-full p-5">
<div v-if="platineStore.isLoadingTrack" class="loading-indicator">
<div class="spinner"></div>
</div>
</div>
<div v-if="!platineStore.isLoadingTrack" class="absolute top-1/2 right-8 size-1/12 rounded-full bg-esyellow">
</div>
</div>
<!-- <div class="w-full h-1/5 text-base">
{{ platineStore.currentTrack?.title }}
<br>
{{ platineStore.currentTrack?.artist?.name }}
</div> -->
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { usePlatineStore } from '~/store/platine'
import type { Track } from '~~/types'
const props = defineProps<{ track?: Track }>()
const platineStore = usePlatineStore()
const discRef = ref<HTMLElement>()
const platine = ref<HTMLElement>()
const isMounted = ref(false)
// Initialisation du lecteur
onMounted(() => {
isMounted.value = true
if (discRef.value) {
platineStore.initPlatine(discRef.value)
}
})
// Nettoyage
onUnmounted(() => {
isMounted.value = false
platineStore.cleanup()
})
// Surveillance des changements de piste
watch(() => props.track, (newTrack) => {
if (newTrack) {
platineStore.loadTrack(newTrack)
}
})
</script>
<style lang="scss">
.platine {
overflow: hidden;
position: relative;
width: 100%;
height: 100%;
.card {
position: absolute !important;
top: -20%;
left: 50%;
bottom: 0;
transform: translate(-50%, 50%);
}
}
.disc {
position: relative;
aspect-ratio: 1;
width: 100%;
overflow: hidden;
border-radius: 50%;
cursor: grab;
background-position: center;
background-size: cover;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.4);
.loading & {
box-shadow: none;
}
}
.disc.is-scratching {
cursor: grabbing;
}
.disc-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
justify-content: center;
align-items: center;
text-align: center;
background-size: cover;
width: 45%;
aspect-ratio: 1/1;
// background: no-repeat url(/favicon.svg) center center;
background-size: 30%;
border-radius: 50%;
cursor: pointer !important;
}
.disc-middle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 20px;
height: 20px;
background: rgb(26, 26, 26);
border-radius: 50%;
}
.button {
border-radius: 0;
border: none;
background: rgb(69, 69, 69);
font-size: 0.75rem;
padding: 0.4rem;
color: #fff;
line-height: 1.3;
cursor: pointer;
will-change: box-shadow;
transition:
box-shadow 0.2s ease-out,
transform 0.05s ease-in;
}
.power.is-active {
transform: translate(1px, 2px);
color: red;
}
.button[disabled] {
opacity: 0.5;
}
.loading-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
}
.cover {
position: absolute;
top: 0;
left: 0;
border-radius: 100%;
object-fit: cover;
width: 100%;
height: 100%;
transition: opacity 3s ease;
.loading & {
opacity: 0;
transition: opacity 0.3s ease;
}
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.bobine {
@apply bg-slate-900 bg-opacity-50 backdrop-blur absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 rounded-full;
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<button tabindex="-1"
class="play-button pointer-events-none rounded-full size-24 flex items-center justify-center text-esyellow backdrop-blur-sm bg-black/25 transition-all duration-200 ease-in-out transform active:scale-90 scale-110 text-4xl font-bold"
:class="{ loading: isLoading }" :disabled="isLoading">
<template v-if="isLoading">
<img src="/loader.svg" alt="Chargement" class="size-16" />
</template>
<template v-else>
{{ isPlaying ? 'I I' : '' }}
</template>
</button>
</template>
<script setup lang="ts">
import { usePlatineStore } from '~/store/platine'
import type { Box, Track } from '~/../types/types'
const platineStore = usePlatineStore()
const props = defineProps<{ objectToPlay: Box | Track }>()
const isCurrentTrack = computed(() => {
if (!('activeSide' in props.objectToPlay)) {
return platineStore.currentTrack?.id === props.objectToPlay.id
}
return false
})
const isPlaying = computed(() => {
return platineStore.isPlaying && isCurrentTrack.value
})
const isLoading = computed(() => {
return platineStore.isLoadingTrack && isCurrentTrack.value
})
</script>
<style>
.loading,
.play-button-changed {
opacity: 1 !important;
}
</style>

View File

@@ -24,7 +24,7 @@ import { useDataStore } from '~/store/data'
import { useCardStore } from '~/store/card'
import { usePlayerStore } from '~/store/player'
import { useUiStore } from '~/store/ui'
import type { Box } from '~~/types/types'
import type { Box } from '~~/types'
const uiStore = useUiStore()

View File

@@ -0,0 +1,177 @@
<template>
<div class="flex flex-col fixed right-0 top-0 z-50" :class="props.class">
<button @click="closeDatBox" v-if="uiStore.isBoxSelected"
class="px-4 py-2 text-black hover:text-black bg-esyellow transition-colors z-50" aria-label="close the box">
close
</button>
</div>
<div class="controls flex justify-center z-50 relative" v-bind="attrs">
<SearchInput @search="onSearch" />
<SelectCardRank @change="onRankChange" />
<SelectCardSuit @change="onSuitChange" />
</div>
<div ref="deck" class="deck flex flex-wrap justify-center gap-4" :class="{ 'pb-36': playerStore.currentTrack }"
@dragover.prevent @drop.prevent="handleGlobalDrop">
<card v-for="(track, i) in filteredTracks" :key="track.id" :track="track" :tabindex="i"
@card-click="playerStore.playPlaylistTrack(track)" :is-face-up="isCardRevealed(track.id)"
@click-card-symbol="openCardSharer()" />
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useDataStore } from '~/store/data'
import { useCardStore } from '~/store/card'
import { usePlayerStore } from '~/store/player'
import { useUiStore } from '~/store/ui'
import type { Box, Track } from '~~/types'
import SelectCardSuit from '~/components/ui/SelectCardSuit.vue'
import SelectCardRank from '~/components/ui/SelectCardRank.vue'
import SearchInput from '~/components/ui/SearchInput.vue'
// Define the events this component emits
const emit = defineEmits<{
(e: 'click', event: MouseEvent): void;
}>()
const props = defineProps<{
box: Box;
class?: string;
}>()
// Use useAttrs to get all other attributes
const attrs = useAttrs()
const cardStore = useCardStore()
const dataStore = useDataStore()
const playerStore = usePlayerStore()
const uiStore = useUiStore()
const deck = ref()
const tracks = computed(() => dataStore.getTracksByboxId(props.box.id))
// Suivre si un glisser est en cours depuis le bucket
const isDraggingFromBucket = ref(false)
// Gérer le dépôt d'une carte en dehors du bucket
const handleGlobalDrop = (e: DragEvent) => {
if (isDraggingFromBucket.value) {
e.preventDefault()
e.stopPropagation()
// Récupérer les données de la carte glissée
const cardData = e.dataTransfer?.getData('application/json')
if (cardData) {
try {
const track = JSON.parse(cardData)
// Retirer la carte du panier
cardStore.removeFromBucket(track.id)
// La carte réapparaîtra automatiquement dans la playlist
// grâce à la computed property filteredTracks
} catch (e) {
console.error('Erreur lors du traitement de la carte glissée', e)
}
}
}
isDraggingFromBucket.value = false
}
// Gérer le début du glisser depuis le bucket
const handleBucketDragStart = () => {
isDraggingFromBucket.value = true
}
// Configurer les écouteurs d'événements
onMounted(() => {
document.addEventListener('drop', handleGlobalDrop)
document.addEventListener('dragover', (e) => e.preventDefault()) // Nécessaire pour permettre le drop
document.addEventListener('bucket-drag-start', handleBucketDragStart)
})
// Nettoyer les écouteurs d'événements
onUnmounted(() => {
document.removeEventListener('drop', handleGlobalDrop)
document.removeEventListener('dragover', (e) => e.preventDefault())
document.removeEventListener('bucket-drag-start', handleBucketDragStart)
})
// Utiliser une computed property pour filteredTracks qui réagit aux changements
const filteredTracks = computed(() => {
let result = [...tracks.value]
// Exclure les pistes déjà dans le panier
result = result.filter(track => !cardStore.isInBucket(track.id))
// Appliquer les autres filtres
if (selectedSuit.value) {
result = result.filter(track => track.card?.suit === selectedSuit.value)
}
if (selectedRank.value) {
result = result.filter(track => track.card?.rank === selectedRank.value)
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(track => {
const artistName = typeof track.artist === 'object' ? track.artist?.name : String(track.artist || '')
return (
track.title?.toLowerCase().includes(query) ||
artistName.toLowerCase().includes(query) ||
String(track.year || '').includes(query)
)
})
}
return result
})
// Variables réactives pour les filtres
const selectedSuit = ref('')
const selectedRank = ref('')
const searchQuery = ref('')
const isCardRevealed = (trackId: number) => {
// Si une recherche est en cours, révéler automatiquement les cartes correspondantes
if (searchQuery.value || (selectedRank.value && selectedSuit.value)) return true
return cardStore.isCardRevealed(trackId)
}
const closeDatBox = (event: MouseEvent) => {
uiStore.closeBox()
emit('click', event)
}
const openCardSharer = () => {
uiStore.openCardSharer()
}
const onSuitChange = (suit: string) => {
selectedSuit.value = suit
applyFilters()
}
const onRankChange = (rank: string) => {
selectedRank.value = rank
applyFilters()
}
const onSearch = (query: string) => {
searchQuery.value = query
applyFilters()
}
// Applique tous les filtres (couleur, rang et recherche)
// La computed property filteredTracks se mettra automatiquement à jour
// car elle dépend des mêmes réactifs que cette fonction
const applyFilters = () => {
// Cette fonction ne fait plus que déclencher la réévaluation des dépendances
// La computed property filteredTracks fera le reste
}
</script>
<style>
.deck {
position: relative;
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<div draggable="true" @dragstart="onDragStart" @dragend="onDragEnd"
:class="['draggable', { 'is-dragging': isDragging }]">
<slot :is-dragging="isDragging" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
data: any
type?: string
}>()
const emit = defineEmits(['dragStart', 'dragEnd'])
const isDragging = ref(false)
const onDragStart = (e: DragEvent) => {
isDragging.value = true
e.dataTransfer?.setData('application/json', JSON.stringify(props.data))
emit('dragStart', props.data)
}
const onDragEnd = () => {
isDragging.value = false
emit('dragEnd')
}
</script>
<style scoped>
.draggable {
cursor: grab;
user-select: none;
}
.draggable.is-dragging {
opacity: 0.5;
cursor: grabbing;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div @dragover.prevent="onDragOver" @dragenter.prevent="onDragEnter" @dragleave="onDragLeave" @drop.prevent="onDrop"
:class="['droppable', { 'is-drag-over': isDragOver }]">
<slot :is-dragging-over="isDragOver" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
accept?: string
onDrop: (data: any) => void
}>()
const isDragOver = ref(false)
const onDragOver = (e: DragEvent) => {
if (!isDragOver.value) isDragOver.value = true
}
const onDragEnter = (e: DragEvent) => {
isDragOver.value = true
}
const onDragLeave = () => {
isDragOver.value = false
}
const onDrop = (e: DragEvent) => {
isDragOver.value = false
const data = e.dataTransfer?.getData('application/json')
if (data) {
try {
props.onDrop(JSON.parse(data))
} catch (e) {
console.error('Erreur lors du drop', e)
}
}
}
</script>
<style scoped>
.droppable {
min-height: 100px;
transition: all 0.2s ease;
}
.droppable.is-drag-over {
background-color: rgba(0, 0, 0, 0.1);
border: 2px dashed #4CAF50;
}
</style>

View File

@@ -0,0 +1,127 @@
<template>
<Teleport to="body">
<Transition name="fade" mode="out-in">
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
@click.self="close">
<div class="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-gray-900">Partager cette carte</h2>
<button @click="close" class="text-gray-400 hover:text-gray-500">
<span class="sr-only">Fermer</span>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div v-if="currentTrack" class="space-y-4">
<div class="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg">
<img :src="currentTrack.coverId || '/card-dock.svg'" :alt="currentTrack.title"
class="w-12 h-12 rounded-md object-cover">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">{{ currentTrack.title }}</p>
<p class="text-sm text-gray-500 truncate">{{ typeof currentTrack.artist === 'object' ?
currentTrack.artist?.name : currentTrack.artist || 'Artiste inconnu' }}</p>
</div>
</div>
<div class="space-y-2">
<label for="share-link" class="block text-sm font-medium text-gray-700">Lien de partage</label>
<div class="flex rounded-md shadow-sm">
<input type="text" id="share-link" readonly :value="shareLink"
class="flex-1 min-w-0 block w-full px-3 py-2 rounded-l-md border border-gray-300 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
@focus="selectText">
<button @click="copyToClipboard"
class="inline-flex items-center px-3 py-2 border border-l-0 border-gray-300 bg-gray-50 text-gray-700 text-sm font-medium rounded-r-md hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
<div class="flex justify-end space-x-3 pt-2">
<button @click="close"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Fermer
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useUiStore } from '~/store/ui'
import type { Track } from '~~/types'
const uiStore = useUiStore()
const currentTrack = ref<Track | null>(null)
// Utilisation d'une ref locale pour éviter les réactivités inutiles
const isOpen = ref(false)
// Mise à jour de l'état uniquement quand nécessaire
watch(() => uiStore.showCardSharer, (newVal) => {
isOpen.value = newVal
})
const shareLink = computed(() => {
if (!currentTrack.value) return ''
return `${window.location.origin}/track/${currentTrack.value.id}`
})
const open = (track: Track) => {
currentTrack.value = track
isOpen.value = true
uiStore.openCardSharer()
}
const close = () => {
isOpen.value = false
uiStore.showCardSharer = false
// Nettoyage différé pour permettre l'animation
setTimeout(() => {
if (!isOpen.value) {
currentTrack.value = null
}
}, 300)
}
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(shareLink.value)
// Vous pourriez ajouter un toast ou une notification ici
console.log('Lien copié dans le presse-papier')
} catch (err) {
console.error('Erreur lors de la copie :', err)
}
}
const selectText = (event: Event) => {
const input = event.target as HTMLInputElement
input.select()
}
defineExpose({
open,
close
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

18
appOLDD/error.vue Normal file
View File

@@ -0,0 +1,18 @@
<template>
<div>
<h1
class="text-white pt-6 text-lg md:text-xl lg:text-2xl text-center font-bold tracking-widest text-shadow"
>
{{ error?.statusCode }}
</h1>
<NuxtLink to="/">Go back home</NuxtLink>
</div>
</template>
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps({
error: Object as () => NuxtError
})
</script>

View File

@@ -0,0 +1,55 @@
<template>
<slot />
<Bucket />
<Platine v-if="playerStore.currentTrack" />
</template>
<script setup lang="ts">
import type { Track } from '~~/types'
import { usePlayerStore } from '~/store/player'
const playerStore = usePlayerStore()
const onCardDropped = (card: Track) => {
console.log('Carte déposée dans le bucket:', card)
}
</script>
<style scoped>
.bucket,
.platine {
position: fixed;
bottom: -100%;
right: 0;
height: auto;
}
.bucket {
z-index: 70;
bottom: -260px;
width: 100%;
overflow-x: scroll;
transition: bottom .3s ease;
&:hover,
.card-dragging & {
bottom: 0;
}
.bucket-card-wrapper {
width: 100%;
}
}
.platine {
bottom: -100%;
transition: bottom 2s ease;
&.mounted {
z-index: 80;
bottom: 0;
width: 100%;
max-width: 450px;
}
}
</style>

202
appOLDD/pages/card/[id].vue Normal file
View File

@@ -0,0 +1,202 @@
<template>
<div class="card-page">
<div v-if="loading" class="loading">
<div class="spinner"></div>
</div>
<Transition name="card-fade" mode="out-in">
<div v-if="!loading && track" class="card-container" @click="playTrack">
<Card :track="track" :is-face-up="true" class="card-item" />
</div>
<div v-else-if="error" class="error-message">
{{ error }}
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { usePlayerStore } from '~/store/player'
import { useCardStore } from '~/store/card'
import { useDataStore } from '~/store/data'
import type { Track } from '~~/types'
const route = useRoute()
const playerStore = usePlayerStore()
const cardStore = useCardStore()
const dataStore = useDataStore()
const track = ref<Track | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const hasUserInteracted = ref(false)
const audioElement = ref<HTMLAudioElement | null>(null)
// Récupérer les données de la piste
const fetchTrack = async () => {
try {
loading.value = true
error.value = null
// S'assurer que les données sont chargées
if (!dataStore.isLoaded) {
await dataStore.loadData()
}
// Récupérer la piste par son ID
const trackId = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id || ''
const foundTrack = dataStore.getTrackById(trackId)
if (foundTrack) {
track.value = foundTrack
// Marquer la carte comme révélée
if (foundTrack.id) {
cardStore.revealCard(Number(foundTrack.id))
}
} else {
error.value = 'Carte non trouvée'
}
} catch (err) {
console.error('Erreur lors du chargement de la piste:', err)
error.value = 'Une erreur est survenue lors du chargement de la piste'
} finally {
loading.value = false
}
}
// Gérer la première interaction utilisateur
const handleFirstInteraction = () => {
if (!hasUserInteracted.value) {
hasUserInteracted.value = true
if (track.value) {
playerStore.playTrack(track.value)
}
}
}
// Configurer les écouteurs d'événements pour la première interaction
const setupInteractionListeners = () => {
const events: (keyof WindowEventMap)[] = ['click', 'touchstart', 'keydown']
const handleInteraction = () => {
handleFirstInteraction()
events.forEach(event => {
window.removeEventListener(event, handleInteraction as EventListener)
})
}
events.forEach(event => {
window.addEventListener(event, handleInteraction as EventListener, { once: true } as AddEventListenerOptions)
})
return () => {
events.forEach(event => {
window.removeEventListener(event, handleInteraction as EventListener)
})
}
}
// Lire la piste
const playTrack = () => {
if (track.value) {
playerStore.playTrack(track.value)
}
}
// Charger les données au montage du composant
onMounted(async () => {
await fetchTrack()
// Configurer les écouteurs d'événements pour la première interaction
const cleanup = setupInteractionListeners()
// Nettoyer les écouteurs lors du démontage du composant
onBeforeUnmount(() => {
cleanup()
})
})
</script>
<style scoped>
.card-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
}
.loading {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
border-top-color: #4299e1;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.card-container {
perspective: 1000px;
cursor: pointer;
transition: transform 0.3s ease;
}
.card-container:hover {
transform: scale(1.02);
}
.card-item {
transform-style: preserve-3d;
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
animation: cardAppear 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
opacity: 0;
transform: translateY(20px) rotateY(10deg);
}
@keyframes cardAppear {
0% {
opacity: 0;
transform: translateY(20px) rotateY(10deg);
}
100% {
opacity: 1;
transform: translateY(0) rotateY(0);
}
}
.error-message {
color: #feb2b2;
background-color: #742a2a;
padding: 1rem 2rem;
border-radius: 0.5rem;
font-weight: 500;
}
/* Animation de transition */
.card-fade-enter-active,
.card-fade-leave-active {
transition: opacity 0.3s ease;
}
.card-fade-enter-from,
.card-fade-leave-to {
opacity: 0;
}
</style>

27
appOLDD/pages/index.vue Normal file
View File

@@ -0,0 +1,27 @@
<template>
<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(3px 3px 0 rgb(0 0 0 / 0.7));
}
</style>

View File

@@ -0,0 +1,3 @@
<template>
holo
</template>

View File

@@ -0,0 +1,46 @@
<template>
<div class="container">
<h1>Liste des pages Story</h1>
<ul>
<li v-for="page in pages" :key="page.name">
<NuxtLink :to="`/story/${page.name}`">
{{ page.name }}
</NuxtLink>
</li>
</ul>
</div>
</template>
<script setup>
const pages = [
{ name: 'holo' },
{ name: 'mix' },
{ name: 'test' }
]
</script>
<style scoped>
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
ul {
list-style: none;
padding: 0;
}
li {
margin: 0.5rem 0;
}
a {
color: #2563eb;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div class="mix">
<Platine :track="track1" />
<Platine :track="track2" />
</div>
</template>
<script setup>
import { useDataStore } from '~/store/data'
const dataStore = useDataStore()
const track1 = ref(null)
const track2 = ref(null)
// Configuration du layout
definePageMeta({
layout: 'empty'
})
onMounted(async () => {
await dataStore.loadData()
track1.value = dataStore.getRandomPlaylistTrack()
track2.value = dataStore.getRandomPlaylistTrack()
})
</script>
<style>
.mix {
display: flex;
width: 100%;
height: 100vh;
}
/* Écran portrait (plus haut que large) */
@media (orientation: portrait) {
.mix {
flex-direction: column;
}
.platine {
height: 50vh;
.disc {
height: 100%;
width: auto;
}
}
}
/* Écran paysage (plus large que haut) */
@media (orientation: landscape) {
.mix {
flex-direction: row;
}
.platine {
width: 50vw;
}
}
</style>

View File

@@ -0,0 +1,4 @@
<template>
<card />
<card />
</template>

View File

@@ -50,7 +50,7 @@ type DiscProgress = {
progress: number
}
class Disc {
public el: HTMLDivElement
public el: HTMLElement
private _playbackSpeed = 1
private _duration = 0
@@ -82,17 +82,13 @@ class Disc {
public isReversed: boolean = false
public callbacks = {
// @ts-expect-error: unused var
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onDragStart: (): void => {},
onDragEnded: (secondsPlayed: number): void => {},
onStop: () => {},
// @ts-expect-error: unused var
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onLoop: (params: DiscProgress) => {}
onStop: (): void => {},
onLoop: (params: DiscProgress): void => {}
}
constructor(el: HTMLDivElement) {
constructor(el: HTMLElement) {
this.el = el
this._center = getElementCenter(this.el)
@@ -172,19 +168,26 @@ class Disc {
// Empêcher le comportement par défaut pour éviter le défilement
e.preventDefault()
// Appeler le callback onDragStart
this.callbacks.onDragStart()
// Obtenir les coordonnées du toucher ou de la souris
const getCoords = (event: PointerEvent | TouchEvent): { x: number; y: number } => {
if ('touches' in event) {
// Gestion des événements tactiles
const touchEvent = event as TouchEvent
if (touchEvent.touches?.[0]) {
return {
x: event.touches[0].clientX,
y: event.touches[0].clientY
}
} else {
return {
x: event.clientX,
y: event.clientY
x: touchEvent.touches[0].clientX,
y: touchEvent.touches[0].clientY
}
}
// Gestion des événements de souris
const mouseEvent = event as PointerEvent
return {
x: mouseEvent.clientX ?? this._center.x,
y: mouseEvent.clientY ?? this._center.y
}
}
const startCoords = getCoords(e)

View File

@@ -0,0 +1,95 @@
class Sampler {
public audioContext: AudioContext = new AudioContext()
public gainNode: GainNode = new GainNode(this.audioContext)
public audioBuffer: AudioBuffer | null = null
public audioBufferReversed: AudioBuffer | null = null
public audioSource: AudioBufferSourceNode | null = null
public duration: number = 0
public isReversed: boolean = false
constructor() {
this.gainNode.connect(this.audioContext.destination)
}
async getAudioBuffer(audioUrl: string) {
const response = await fetch(audioUrl)
const arrayBuffer = await response.arrayBuffer()
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer)
return audioBuffer
}
async loadTrack(audioUrl: string) {
this.audioBuffer = await this.getAudioBuffer(audioUrl)
this.audioBufferReversed = this.getReversedAudioBuffer(this.audioBuffer)
this.duration = this.audioBuffer.duration
}
getReversedAudioBuffer(audioBuffer: AudioBuffer) {
const bufferArray = audioBuffer.getChannelData(0).slice().reverse()
const audioBufferReversed = this.audioContext.createBuffer(
1,
audioBuffer.length,
audioBuffer.sampleRate
)
audioBufferReversed.getChannelData(0).set(bufferArray)
return audioBufferReversed
}
changeDirection(isReversed: boolean, secondsPlayed: number) {
this.isReversed = isReversed
this.play(secondsPlayed)
}
play(offset = 0) {
this.pause()
const buffer = this.isReversed ? this.audioBufferReversed : this.audioBuffer
const cueTime = this.isReversed ? this.duration - offset : offset
this.audioSource = this.audioContext.createBufferSource()
this.audioSource.buffer = buffer
this.audioSource.loop = false
this.audioSource.connect(this.gainNode)
this.audioSource.start(0, cueTime)
}
updateSpeed(speed: number, isReversed: boolean, secondsPlayed: number) {
if (!this.audioSource) {
return
}
if (isReversed !== this.isReversed) {
this.changeDirection(isReversed, secondsPlayed)
}
const { currentTime } = this.audioContext
const speedAbsolute = Math.abs(speed)
this.audioSource.playbackRate.cancelScheduledValues(currentTime)
this.audioSource.playbackRate.linearRampToValueAtTime(
Math.max(0.001, speedAbsolute),
currentTime
)
}
pause() {
if (!this.audioSource) {
return
}
this.audioSource.stop()
}
}
export default Sampler

View File

@@ -0,0 +1,52 @@
import { defineNuxtPlugin } from '#app'
import { useHead } from '#imports'
export default defineNuxtPlugin((nuxtApp) => {
// Fonction pour ajouter une classe au body
const addBodyClass = (className: string) => {
if (process.client) {
document.body.classList.add(className)
} else {
// Pour le SSR, on utilise useHead
useHead({
bodyAttrs: {
class: className
}
})
}
}
// Fonction pour supprimer une classe du body
const removeBodyClass = (className: string) => {
if (process.client) {
document.body.classList.remove(className)
}
// Pas besoin de gérer la suppression côté SSR
}
// Fonction pour vérifier si une classe est présente
const hasBodyClass = (className: string) => {
if (process.client) {
return document.body.classList.contains(className)
}
return false
}
// Exposition des méthodes via le plugin
return {
provide: {
bodyClass: {
add: addBodyClass,
remove: removeBodyClass,
has: hasBodyClass,
toggle: (className: string) => {
if (hasBodyClass(className)) {
removeBodyClass(className)
} else {
addBodyClass(className)
}
}
}
}
}
})

View File

@@ -1,5 +1,7 @@
import { useUiStore } from '~/store/ui'
import { usePlayerStore } from '~/store/player'
import { useCardStore } from '~/store/card'
import { useDataStore } from '~/store/data'
export default defineNuxtPlugin((nuxtApp) => {
// Ne s'exécuter que côté client
@@ -7,6 +9,8 @@ export default defineNuxtPlugin((nuxtApp) => {
const ui = useUiStore()
const player = usePlayerStore()
const cardStore = useCardStore()
const dataStore = useDataStore()
function isInputElement(target: EventTarget | null): boolean {
return (
@@ -41,7 +45,25 @@ export default defineNuxtPlugin((nuxtApp) => {
switch (e.code) {
// Gestion de la barre d'espace pour play/pause
case 'Space':
case 'KeyR': // R pour révéler toutes les cartes
e.preventDefault()
e.stopPropagation()
if (!isInputElement(e.target as HTMLElement) && ui.getSelectedBox) {
const tracks = dataStore.getTracksByboxId(ui.getSelectedBox.id)
cardStore.revealAllCards(tracks)
}
break
case 'KeyH': // H pour cacher toutes les cartes
e.preventDefault()
e.stopPropagation()
if (!isInputElement(e.target as HTMLElement) && ui.getSelectedBox) {
const tracks = dataStore.getTracksByboxId(ui.getSelectedBox.id)
cardStore.hideAllCards(tracks)
}
break
case 'Space': // Espace pour play/pause
e.preventDefault()
e.stopPropagation()
@@ -61,15 +83,6 @@ export default defineNuxtPlugin((nuxtApp) => {
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':

171
appOLDD/store/card.ts Normal file
View File

@@ -0,0 +1,171 @@
import { defineStore } from 'pinia'
import type { Track } from '~~/types'
export const useCardStore = defineStore('card', {
state: () => ({
// Stocke les IDs des cartes déjà révélées
revealedCards: new Set<number>(),
// Stocke les pistes dans le panier
bucket: [] as Track[],
// Stocke l'état d'ouverture du panier
isBucketOpen: false
}),
actions: {
// Mettre à jour l'ordre des pistes dans le panier
updateBucketOrder(newOrder: Track[]) {
this.bucket = [...newOrder]
this.saveBucketToLocalStorage()
},
// Marquer une carte comme révélée
revealCard(trackId: number) {
this.revealedCards.add(trackId)
this.saveToLocalStorage()
},
hideCard(trackId: number) {
this.revealedCards.delete(trackId)
this.saveToLocalStorage()
},
flipCard(track: any) {
if (this.isRevealed(track.id)) {
this.hideCard(track.id)
} else {
this.revealCard(track.id)
}
},
// Basculer l'état de révélation de toutes les cartes
revealAllCards(tracks: Track[]) {
tracks.forEach((track) => {
this.revealCard(track.id)
})
this.saveToLocalStorage()
},
hideAllCards(tracks: Track[]) {
tracks.forEach((track) => {
this.hideCard(track.id)
})
this.saveToLocalStorage()
},
// Sauvegarder l'état dans le localStorage
saveToLocalStorage() {
if (typeof window !== 'undefined') {
try {
localStorage.setItem(
'cardStore',
JSON.stringify({
revealedCards: Array.from(this.revealedCards)
})
)
} catch (e) {
console.error('Failed to save card store to localStorage', e)
}
}
},
// Charger l'état depuis le localStorage
loadFromLocalStorage() {
if (typeof window !== 'undefined') {
try {
const saved = localStorage.getItem('cardStore')
if (saved) {
const { revealedCards } = JSON.parse(saved)
if (Array.isArray(revealedCards)) {
this.revealedCards = new Set(
revealedCards.filter((id): id is number => typeof id === 'number')
)
}
}
} catch (e) {
console.error('Failed to load card store from localStorage', e)
}
}
},
// Initialiser le store
initialize() {
this.loadFromLocalStorage()
},
// Gestion du panier
addToBucket(track: Track) {
// Vérifie si la piste n'est pas déjà dans le panier
if (!this.bucket.some((item) => item.id === track.id)) {
this.bucket.push(track)
this.saveBucketToLocalStorage()
}
},
removeFromBucket(trackId: number) {
const index = this.bucket.findIndex((item) => item.id === trackId)
if (index !== -1) {
this.bucket.splice(index, 1)
this.saveBucketToLocalStorage()
}
},
clearBucket() {
this.bucket = []
this.saveBucketToLocalStorage()
},
toggleBucket() {
this.isBucketOpen = !this.isBucketOpen
},
// Sauvegarder le panier dans le localStorage
saveBucketToLocalStorage() {
if (typeof window !== 'undefined') {
try {
localStorage.setItem('cardStoreBucket', JSON.stringify(this.bucket))
} catch (e) {
console.error('Failed to save bucket to localStorage', e)
}
}
},
// Charger le panier depuis le localStorage
loadBucketFromLocalStorage() {
if (typeof window !== 'undefined') {
try {
const saved = localStorage.getItem('cardStoreBucket')
if (saved) {
const bucket = JSON.parse(saved)
if (Array.isArray(bucket)) {
this.bucket = bucket
}
}
} catch (e) {
console.error('Failed to load bucket from localStorage', e)
}
}
},
// Vérifier si une carte est révélée
isCardRevealed(trackId: number): boolean {
return this.revealedCards.has(trackId)
}
},
getters: {
// Getter pour la réactivité dans les templates
isRevealed: (state) => (trackId: number) => {
return state.revealedCards.has(trackId)
},
// Getters pour le panier
bucketCount: (state) => state.bucket.length,
isInBucket: (state) => (trackId: number) => {
return state.bucket.some((track) => track.id === trackId)
},
bucketTotalDuration: (state) => {
return state.bucket.reduce((total, track) => total + ((track as any).duration || 0), 0)
}
}
})

View File

@@ -57,6 +57,12 @@ export const useDataStore = defineStore('data', {
if (box) {
box.activeSide = side
}
},
getRandomPlaylistTrack() {
if (this.tracks.length === 0) return null
const randomIndex = Math.floor(Math.random() * this.tracks.length)
return this.tracks[randomIndex]
}
},
@@ -138,6 +144,45 @@ export const useDataStore = defineStore('data', {
const index = tracksInBox.findIndex((t) => t.id === track.id)
return index > 0 ? tracksInBox[index - 1] : null
}
},
getYearColor: () => (year: number) => {
// Palette élargie avec des différences plus marquées
const colorMap: Record<number, string> = {
// Années récentes - teintes froides et claires
2025: '#3a4a6c', // bleu-gris clair
2024: '#1e3a7a', // bleu vif
2023: '#1a4d5c', // bleu-vert émeraude
2022: '#3a4a6a', // bleu-gris moyen
// Années 2020-2021 - transition
2021: '#3a2e6a', // bleu-violet
2020: '#2a467a', // bleu-gris chaud
// Années 2010-2019 - teintes moyennes
2019: '#2a2a7a', // bleu nuit profond
2018: '#1e2a8a', // bleu roi
2017: '#1a5a6a', // bleu canard vif
2016: '#1a5a4a', // vert bleuté
2015: '#1a3a7a', // bleu marine
2014: '#4a1e7a', // violet profond
2013: '#1a5a4a', // vert émeraude
2012: '#1e3a9a', // bleu ciel profond
// Années 2000-2011 - teintes chaudes et foncées
2011: '#1e293b', // slate-800 de base
2010: '#2a467a', // bleu-gris chaud
2009: '#3a4a6a', // bleu-gris moyen
2008: '#1a3a8a', // bleu nuit clair
2007: '#5a2a4a', // bordeaux
2006: '#5a1e6a', // violet profond
2005: '#3a1a7a', // bleu-violet foncé
2004: '#2a1a5a', // bleu nuit profond
2003: '#3a3a5a', // bleu-gris foncé
2002: '#1a5a4a', // vert foncé
2001: '#5a3a2a', // marron chaud
2000: '#3a3a5a' // bleu-gris foncé
}
return colorMap[year] || '#1e293b' // slate-800 par défaut
}
}
})

169
appOLDD/store/platine.ts Normal file
View File

@@ -0,0 +1,169 @@
import { defineStore } from 'pinia'
import type { Track } from '~~/types'
import Disc from '~/platine-tools/disc'
import Sampler from '~/platine-tools/sampler'
import { useCardStore } from '~/store/card'
export const usePlatineStore = defineStore('platine', () => {
// State
const currentTrack = ref<Track | null>(null)
const isPlaying = ref(false)
const isLoadingTrack = ref(false)
const isFirstDrag = ref(true)
const progressPercentage = ref(0)
const currentTurns = ref(0)
const totalTurns = ref(0)
const isMuted = ref(false)
// Refs pour les instances
const disc = ref<Disc | null>(null)
const sampler = ref<Sampler | null>(null)
const discRef = ref<HTMLElement>()
// Actions
const initPlatine = (element: HTMLElement) => {
discRef.value = element
disc.value = new Disc(element)
sampler.value = new Sampler()
// Configurer les callbacks du disque
if (disc.value) {
disc.value.callbacks.onStop = () => {
sampler.value?.pause()
}
disc.value.callbacks.onDragStart = () => {
if (isFirstDrag.value) {
isFirstDrag.value = false
togglePlay()
if (sampler.value && disc.value) {
sampler.value.play(disc.value.secondsPlayed)
disc.value.powerOn()
}
}
}
disc.value.callbacks.onDragEnded = () => {
if (!isPlaying.value) return
sampler.value?.play(disc.value?.secondsPlayed || 0)
}
disc.value.callbacks.onLoop = ({ playbackSpeed, isReversed, secondsPlayed }) => {
sampler.value?.updateSpeed(playbackSpeed, isReversed, secondsPlayed)
updateTurns()
}
}
}
const updateTurns = () => {
if (!disc.value) return
currentTurns.value = disc.value.secondsPlayed * 0.75
totalTurns.value = (disc.value as any)._duration * 0.75
progressPercentage.value = Math.min(
100,
(disc.value.secondsPlayed / (disc.value as any)._duration) * 100
)
}
const loadTrack = async (track: Track) => {
const cardStore = useCardStore()
if (!sampler.value || !track) return
currentTrack.value = track
isLoadingTrack.value = true
try {
await sampler.value.loadTrack(track.filePath)
if (disc.value) {
disc.value.setDuration(sampler.value.duration)
updateTurns()
play()
}
} finally {
isLoadingTrack.value = false
cardStore.revealCard(track.id)
}
}
const play = (position = 0) => {
if (!disc.value || !sampler.value || !currentTrack.value) return
isPlaying.value = true
sampler.value.play(position)
disc.value.powerOn()
}
const pause = () => {
if (!disc.value || !sampler.value) return
isPlaying.value = false
sampler.value.pause()
disc.value.powerOff()
}
const togglePlay = () => {
if (isPlaying.value) {
pause()
} else {
play()
}
}
const toggleMute = () => {
if (!sampler.value) return
isMuted.value = !isMuted.value
if (isMuted.value) {
sampler.value.mute()
} else {
sampler.value.unmute()
}
}
const seek = (position: number) => {
if (!disc.value) return
disc.value.secondsPlayed = position
if (sampler.value) {
sampler.value.play(position)
}
}
// Nettoyage
const cleanup = () => {
if (disc.value) {
disc.value.stop()
disc.value.powerOff()
}
if (sampler.value) {
sampler.value.pause()
}
}
return {
// State
currentTrack,
isPlaying,
isLoadingTrack,
progressPercentage,
currentTurns,
totalTurns,
isMuted,
// Getters
coverUrl: computed(() => currentTrack.value?.coverId || '/card-dock.svg'),
// Actions
initPlatine,
loadTrack,
play,
pause,
togglePlay,
toggleMute,
seek,
cleanup
}
})
export default usePlatineStore

279
appOLDD/store/player.ts Normal file
View File

@@ -0,0 +1,279 @@
// ~/store/player.ts
import { defineStore } from 'pinia'
import type { Track, Box } from '~/../types/types'
import { useDataStore } from '~/store/data'
import { useCardStore } from '~/store/card'
import { usePlatineStore } from '~/store/platine'
export const usePlayerStore = defineStore('player', {
state: () => ({
currentTrack: null as Track | null,
position: 0,
progressionLast: 0,
isLoading: false,
history: [] as string[]
}),
actions: {
attachAudio() {
const platineStore = usePlatineStore()
// Écouter les changements de piste dans le platineStore
watch(
() => platineStore.currentTrack,
(newTrack) => {
if (newTrack) {
this.currentTrack = newTrack
// Révéler la carte quand la lecture commence
const cardStore = useCardStore()
if (!cardStore.isCardRevealed(newTrack.id)) {
requestAnimationFrame(() => {
cardStore.revealCard(newTrack.id)
})
}
} else {
this.currentTrack = null
}
}
)
// Écouter les changements d'état de lecture
watch(
() => platineStore.isPlaying,
(isPlaying) => {
if (isPlaying) {
// Gérer la logique de lecture suivante quand la lecture se termine
if (platineStore.currentTrack?.type === 'playlist') {
const dataStore = useDataStore()
const nextTrack = dataStore.getNextPlaylistTrack(platineStore.currentTrack)
if (nextTrack) {
platineStore.loadTrack(nextTrack)
platineStore.play()
}
}
}
}
)
},
async playBox(box: Box) {
const platineStore = usePlatineStore()
// Si c'est la même box, on toggle simplement la lecture
if (this.currentTrack?.boxId === box.id && this.currentTrack?.side === box.activeSide) {
platineStore.togglePlay()
return
}
// Sinon, on charge la première piste de la box
try {
const dataStore = useDataStore()
const firstTrack = dataStore.getFirstTrackOfBox(box)
if (firstTrack) {
this.currentTrack = firstTrack
await platineStore.loadTrack(firstTrack)
await platineStore.play()
}
} catch (error) {
console.error('Error playing box:', error)
}
},
async playTrack(track: Track) {
const platineStore = usePlatineStore()
// Si c'est la même piste, on toggle simplement la lecture
if (this.currentTrack?.id === track.id) {
platineStore.togglePlay()
return
}
// Sinon, on charge et on lit la piste
this.currentTrack = track
await platineStore.loadTrack(track)
platineStore.play()
},
async playCompilationTrack(track: Track) {
const platineStore = usePlatineStore()
// Si c'est la même piste, on toggle simplement la lecture
if (this.currentTrack?.id === track.id) {
platineStore.togglePlay()
return
}
// Pour les compilations, on charge la piste avec le point de départ
this.currentTrack = track
await platineStore.loadTrack(track)
// Si c'est une compilation, on définit la position de départ
if (track.type === 'compilation' && track.start !== undefined) {
platineStore.seek(track.start)
}
platineStore.play()
},
async playPlaylistTrack(track: Track) {
const platineStore = usePlatineStore()
// Toggle simple si c'est la même piste
if (this.currentTrack?.id === track.id) {
platineStore.togglePlay()
return
}
// Sinon, on charge et on lit la piste
this.currentTrack = track
await platineStore.loadTrack(track)
platineStore.play()
},
async loadTrack(track: Track) {
const platineStore = usePlatineStore()
await platineStore.loadTrack(track)
},
async loadAndPlayTrack(track: Track) {
const platineStore = usePlatineStore()
try {
this.isLoading = true
// Charger la piste
await platineStore.loadTrack(track)
// Pour les compilations, on définit la position de départ
if (track.type === 'compilation' && track.start !== undefined) {
platineStore.seek(track.start)
}
// Lancer la lecture
await platineStore.play()
this.history.push(track.id.toString()) // S'assurer que l'ID est une chaîne
this.isLoading = false
} catch (error) {
console.error('Error loading/playing track:', error)
this.isLoading = false
}
},
togglePlay() {
const platineStore = usePlatineStore()
platineStore.togglePlay()
},
updateTime() {
const platineStore = usePlatineStore()
// Mettre à jour la position actuelle
if (platineStore.currentTrack) {
this.position = platineStore.currentTurns / 0.75 // Convertir les tours en secondes
// Calculer et mettre en cache la progression
const duration = platineStore.totalTurns / 0.75 // Durée totale en secondes
const progression = (this.position / duration) * 100
if (!isNaN(progression) && isFinite(progression)) {
this.progressionLast = progression
}
}
},
// update current track when changing time in compilation
async updateCurrentTrack() {
const platineStore = usePlatineStore()
const currentTrack = this.currentTrack
if (currentTrack && currentTrack.type === 'compilation') {
const dataStore = useDataStore()
const tracks = dataStore
.getTracksByboxId(currentTrack.boxId, currentTrack.side)
.slice()
.filter((t) => t.type === 'compilation')
.sort((a, b) => (a.start ?? 0) - (b.start ?? 0))
if (tracks.length > 0) {
const now = platineStore.currentTurns / 0.75
// find the last track whose start <= now (fallback to first track)
let nextTrack = tracks[0]
for (const t of tracks) {
const s = t.start ?? 0
if (s <= now) {
nextTrack = t
} else {
break
}
}
if (nextTrack && nextTrack.id !== currentTrack.id) {
// only update metadata reference; do not reload audio
this.currentTrack = nextTrack
// Révéler la carte avec une animation fluide
const cardStore = useCardStore()
if (nextTrack.id && !cardStore.isCardRevealed(nextTrack.id)) {
// Utiliser requestAnimationFrame pour une meilleure synchronisation avec le rendu
requestAnimationFrame(() => {
cardStore.revealCard(nextTrack.id)
})
}
}
}
}
}
},
getters: {
isCurrentBox: (state) => {
return (box: Box) => {
if (box.type === 'compilation') {
return box.id === state.currentTrack?.boxId && box.activeSide === state.currentTrack?.side
} else {
return box.id === state.currentTrack?.boxId
}
}
},
isCurrentSide: (state) => {
return (side: string) => side === state.currentTrack?.side
},
isPlaylistTrack: () => {
return (track: Track) => {
return track.type === 'playlist'
}
},
isCompilationTrack: () => {
return (track: Track) => {
return track.type === 'compilation'
}
},
isPaused() {
const platineStore = usePlatineStore()
return !platineStore.isPlaying
},
getCurrentTrack: (state) => state.currentTrack,
getCurrentBox: (state) => {
return state.currentTrack ? state.currentTrack.boxId : null
},
getCurrentProgression() {
const platineStore = usePlatineStore()
if (!platineStore.currentTrack) return 0
// Calculer la progression en fonction des tours actuels et totaux
if (platineStore.totalTurns > 0) {
return (platineStore.currentTurns / platineStore.totalTurns) * 100
}
return 0
},
getCurrentCoverUrl(state) {
const id = state.currentTrack?.coverId
if (!id) return null
return id.startsWith('http') ? id : `https://f4.bcbits.com/img/${id}_4.jpg`
}
}
})

View File

@@ -6,7 +6,8 @@ export const useUiStore = defineStore('ui', {
state: () => ({
// UI-only state can live here later
showSearch: false,
searchQuery: ''
searchQuery: '',
showCardSharer: false
}),
actions: {
@@ -48,6 +49,10 @@ export const useUiStore = defineStore('ui', {
})
},
openCardSharer() {
this.showCardSharer = true
},
scrollToBox(box: Box) {
if (box) {
const boxElement = document.getElementById(box.id)

146
data/Artists.json Normal file
View File

@@ -0,0 +1,146 @@
[
{
"id": 0,
"name": "L'efondras",
"url": "https://leffondras.bandcamp.com/music",
"coverId": "0024705317"
},
{
"id": 1,
"name": "The kundalini genie",
"url": "https://the-kundalini-genie.bandcamp.com",
"coverId": "0012045550"
},
{
"id": 2,
"name": "Fontaines D.C.",
"url": "https://fontainesdc.bandcamp.com",
"coverId": "0027327090"
},
{
"id": 3,
"name": "Fontanarosa",
"url": "https://fontanarosa.bandcamp.com",
"coverId": "0035380235"
},
{
"id": 4,
"name": "Johnny mafia",
"url": "https://johnnymafia.bandcamp.com",
"coverId": "0035009392"
},
{
"id": 5,
"name": "New candys",
"url": "https://newcandys.bandcamp.com",
"coverId": "0039963261"
},
{
"id": 6,
"name": "Magic shoppe",
"url": "https://magicshoppe.bandcamp.com",
"coverId": "0030748374"
},
{
"id": 7,
"name": "Les jaguars",
"url": "https://radiomartiko.bandcamp.com/album/surf-qu-b-cois",
"coverId": "0016551336"
},
{
"id": 8,
"name": "TRAAMS",
"url": "https://traams.bandcamp.com",
"coverId": "0028348410"
},
{
"id": 9,
"name": "Blue orchid",
"url": "https://blue-orchid.bandcamp.com",
"coverId": "0034796193"
},
{
"id": 10,
"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"
}
]

44
data/Compilations.json Normal file
View File

@@ -0,0 +1,44 @@
[
{
"id": "ES00",
"type": "compilation",
"name": "manifeste",
"description": "Zero is for manifesto",
"duration": 5264,
"sides": {
"A": {
"name": "manifeste",
"description": "Zero is for manifesto",
"duration": 2794,
"color1": "#ffffff",
"color2": "#48959d"
},
"B": {
"name": "manifeste B",
"description": "Even Zero has a b-side",
"duration": 2470,
"color1": "#0d01b9",
"color2": "#3b7589"
}
}
},
{
"id": "ES01",
"type": "compilation",
"name": "...",
"description": "...",
"duration": 7260,
"sides": {
"A": {
"duration": 3487,
"color1": "#c7b3aa",
"color2": "#000100"
},
"B": {
"duration": 3773,
"color1": "#f7dd01",
"color2": "#010103"
}
}
}
]

486
data/Songs.json Normal file
View File

@@ -0,0 +1,486 @@
[
{
"order": 1,
"boxId": "ES00",
"side": "A",
"title": "The grinding wheel",
"artist": 0,
"start": 0,
"link": "https://arakirecords.bandcamp.com/track/the-grinding-wheel",
"coverId": "a3236746052",
"year": 2024
},
{
"order": 2,
"boxId": "ES00",
"side": "A",
"title": "Bleach",
"artist": 1,
"start": 392,
"link": "https://the-kundalini-genie.bandcamp.com/track/bleach-2",
"coverId": "a1714786533",
"year": 2024
},
{
"order": 3,
"boxId": "ES00",
"side": "A",
"title": "Televised mind",
"artist": 2,
"start": 896,
"link": "https://fontainesdc.bandcamp.com/track/televised-mind",
"coverId": "a3772806156",
"year": 2024
},
{
"order": 4,
"boxId": "ES00",
"side": "A",
"title": "In it",
"artist": 3,
"start": 1139,
"link": "https://howlinbananarecords.bandcamp.com/track/in-it",
"coverId": "a1720372066",
"year": 2024
},
{
"order": 5,
"boxId": "ES00",
"side": "A",
"title": "Bad michel",
"artist": 4,
"start": 1245,
"link": "https://johnnymafia.bandcamp.com/track/bad-michel-3",
"coverId": "a0984622869",
"year": 2024
},
{
"order": 6,
"boxId": "ES00",
"side": "A",
"title": "Overall",
"artist": 5,
"start": 1394,
"link": "https://newcandys.bandcamp.com/track/overall",
"coverId": "a0559661270",
"year": 2024
},
{
"order": 7,
"boxId": "ES00",
"side": "A",
"title": "Blowup",
"artist": 6,
"start": 1674,
"link": "https://magicshoppe.bandcamp.com/track/blowup",
"coverId": "a1444895293",
"year": 2024
},
{
"order": 8,
"boxId": "ES00",
"side": "A",
"title": "Guitar jet",
"artist": 7,
"start": 1880,
"link": "https://radiomartiko.bandcamp.com/track/guitare-jet",
"coverId": "a1494681687",
"year": 2024
},
{
"order": 9,
"boxId": "ES00",
"side": "A",
"title": "Intercontinental radio waves",
"artist": 8,
"start": 2024,
"link": "https://traams.bandcamp.com/track/intercontinental-radio-waves",
"coverId": "a0046738552",
"year": 2024
},
{
"order": 10,
"boxId": "ES00",
"side": "A",
"title": "Here comes the sun",
"artist": 9,
"start": 2211,
"link": "https://blue-orchid.bandcamp.com/track/here-come-the-sun",
"coverId": "a4102567047",
"year": 2024
},
{
"order": 11,
"boxId": "ES00",
"side": "A",
"title": "Like in the movies",
"artist": 10,
"start": 2560,
"link": "https://bruitblanc.bandcamp.com/track/like-in-the-movies-2",
"coverId": "a2203158939",
"year": 2024
},
{
"order": 1,
"boxId": "ES00",
"side": "B",
"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",
"year": 2024
},
{
"order": 2,
"boxId": "ES00",
"side": "B",
"title": "Bleedin' Gums Mushrool",
"artist": 1,
"start": 263,
"link": "https://the-kundalini-genie.bandcamp.com/track/bleedin-gums-mushroom",
"coverId": "a1714786533",
"year": 2024
},
{
"order": 3,
"boxId": "ES00",
"side": "B",
"title": "A lucid dream",
"artist": 2,
"start": 554,
"link": "https://fontainesdc.bandcamp.com/track/a-lucid-dream",
"coverId": "a3772806156",
"year": 2024
},
{
"order": 4,
"boxId": "ES00",
"side": "B",
"title": "Lights off",
"artist": 3,
"start": 781,
"link": "https://howlinbananarecords.bandcamp.com/track/lights-off",
"coverId": "a1720372066",
"year": 2024
},
{
"order": 5,
"boxId": "ES00",
"side": "B",
"title": "I'm sentimental",
"artist": 4,
"start": 969,
"link": "https://johnnymafia.bandcamp.com/track/im-sentimental-2",
"coverId": "a2333676849",
"year": 2024
},
{
"order": 6,
"boxId": "ES00",
"side": "B",
"title": "Thrill or trip",
"artist": 5,
"start": 1128,
"link": "https://newcandys.bandcamp.com/track/thrill-or-trip",
"coverId": "a0559661270",
"year": 2024
},
{
"order": 7,
"boxId": "ES00",
"side": "B",
"title": "Redhead",
"artist": 6,
"start": 1303,
"link": "https://magicshoppe.bandcamp.com/track/redhead",
"coverId": "a0594426943",
"year": 2024
},
{
"order": 8,
"boxId": "ES00",
"side": "B",
"title": "Supersonic twist",
"artist": 7,
"start": 1584,
"link": "https://open.spotify.com/track/66voQIZAJ3zD3Eju2qtNjF",
"coverId": "a1494681687",
"year": 2024
},
{
"order": 9,
"boxId": "ES00",
"side": "B",
"title": "Flowers",
"artist": 8,
"start": 1749,
"link": "https://traams.bandcamp.com/track/flowers",
"coverId": "a3644668199",
"year": 2024
},
{
"order": 10,
"boxId": "ES00",
"side": "B",
"title": "The shade",
"artist": 9,
"start": 1924,
"link": "https://blue-orchid.bandcamp.com/track/the-shade",
"coverId": "a0804204790",
"year": 2024
},
{
"order": 11,
"boxId": "ES00",
"side": "B",
"title": "Like in the movies",
"artist": 10,
"start": 2186,
"link": "https://bruitblanc.bandcamp.com/track/like-in-the-movies",
"coverId": "a3647322740",
"year": 2024
},
{
"order": 1,
"boxId": "ES01",
"side": "A",
"title": "He Walked In",
"artist": 11,
"start": 0,
"link": "https://kidcongothepinkmonkeybirds.bandcamp.com/track/he-walked-in",
"coverId": "a0336300523",
"year": 2025
},
{
"order": 2,
"boxId": "ES01",
"side": "A",
"title": "The Third Wave",
"artist": 12,
"start": 841,
"link": "https://firefriend.bandcamp.com/track/the-third-wave",
"coverId": "a2803689859",
"year": 2025
},
{
"order": 3,
"boxId": "ES01",
"side": "A",
"title": "Broadcaster",
"artist": 13,
"start": 1104.5,
"link": "https://squiduk.bandcamp.com/track/broadcaster",
"coverId": "a3391719769",
"year": 2025
},
{
"order": 4,
"boxId": "ES01",
"side": "A",
"title": "Mourn",
"artist": 14,
"start": 1441,
"link": "https://lysistrata.bandcamp.com/track/mourn-2",
"coverId": "a0872900041",
"year": 2025
},
{
"order": 5,
"boxId": "ES01",
"side": "A",
"title": "Let it Blow",
"artist": 15,
"start": 1844.8,
"link": "https://pabloxbroadcastingservices.bandcamp.com/track/let-it-blow",
"coverId": "a4000148031",
"year": 2025
},
{
"order": 6,
"boxId": "ES01",
"side": "A",
"title": "Sunday Mourning",
"artist": 16,
"start": 2091.7,
"link": "https://nightbeats.bandcamp.com/track/sunday-mourning",
"coverId": "a0031987121",
"year": 2025
},
{
"order": 7,
"boxId": "ES01",
"side": "A",
"title": "3030 Instrumental",
"artist": 17,
"start": 2339.3,
"link": "https://delthefunkyhomosapien.bandcamp.com/track/3030",
"coverId": "a1948146136",
"year": 2025
},
{
"order": 8,
"boxId": "ES01",
"side": "A",
"title": "Immortality Break",
"artist": 18,
"start": 2530.5,
"link": "https://theaa.bandcamp.com/track/immortality-break",
"coverId": "a2749250329",
"year": 2025
},
{
"order": 9,
"boxId": "ES01",
"side": "A",
"title": "Lazy Bones",
"artist": 19,
"start": 2718,
"link": "https://woodenshjips.bandcamp.com/track/lazy-bones",
"coverId": "a1884221104",
"year": 2025
},
{
"order": 10,
"boxId": "ES01",
"side": "A",
"title": "On the Train of Aches",
"artist": 20,
"start": 2948,
"link": "https://silasjdirge.bandcamp.com/track/on-the-train-of-aches",
"coverId": "a1124177379",
"year": 2025
},
{
"order": 11,
"boxId": "ES01",
"side": "A",
"title": "Me",
"artist": 21,
"start": 3265,
"link": "https://secretcolours.bandcamp.com/track/me",
"coverId": "a1497022499",
"year": 2025
},
{
"order": 1,
"boxId": "ES01",
"side": "B",
"title": "Lady Hawke Blues",
"artist": 11,
"start": 0,
"link": "https://kidcongothepinkmonkeybirds.bandcamp.com/track/lady-hawke-blues",
"coverId": "a2532623230",
"year": 2025
},
{
"order": 2,
"boxId": "ES01",
"side": "B",
"title": "Dreamscapes",
"artist": 12,
"start": 235,
"link": "https://littlecloudrecords.bandcamp.com/track/dreamscapes",
"coverId": "a3498981203",
"year": 2025
},
{
"order": 3,
"boxId": "ES01",
"side": "B",
"title": "Crispy Skin",
"artist": 13,
"start": 644.2,
"link": "https://squiduk.bandcamp.com/track/crispy-skin-2",
"coverId": "a2516727021",
"year": 2025
},
{
"order": 4,
"boxId": "ES01",
"side": "B",
"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",
"year": 2025
},
{
"order": 5,
"boxId": "ES01",
"side": "B",
"title": "Better Off Alone",
"artist": 15,
"start": 1698,
"link": "https://pabloxbroadcastingservices.bandcamp.com/track/better-off-alone",
"coverId": "a4000148031",
"year": 2025
},
{
"order": 6,
"boxId": "ES01",
"side": "B",
"title": "Celebration #1",
"artist": 16,
"start": 2235,
"link": "https://nightbeats.bandcamp.com/track/celebration-1",
"coverId": "a0031987121",
"year": 2025
},
{
"order": 7,
"boxId": "ES01",
"side": "B",
"title": "3030 Instrumental",
"artist": 17,
"start": 2458.3,
"link": "https://delthefunkyhomosapien.bandcamp.com/track/3030",
"coverId": "a1948146136",
"year": 2025
},
{
"order": 8,
"boxId": "ES01",
"side": "B",
"title": "The Emptiness Of Nothingness",
"artist": 18,
"start": 2864.5,
"link": "https://theaa.bandcamp.com/track/the-emptiness-of-nothingness",
"coverId": "a1053923875",
"year": 2025
},
{
"order": 9,
"boxId": "ES01",
"side": "B",
"title": "Rising",
"artist": 19,
"start": 3145,
"link": "https://woodenshjips.bandcamp.com/track/rising",
"coverId": "a1884221104",
"year": 2025
},
{
"order": 10,
"boxId": "ES01",
"side": "B",
"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",
"year": 2025
},
{
"order": 11,
"boxId": "ES01",
"side": "B",
"title": "Guajira Con Arpa",
"artist": 23,
"start": 3586,
"link": "https://elpalmasmusic.bandcamp.com/track/guajira-con-arpa",
"coverId": "a3463036407",
"year": 2025
}
]

BIN
data/music.db Normal file

Binary file not shown.

View File

@@ -8,14 +8,18 @@ services:
restart: unless-stopped
working_dir: /app
ports:
- "${PORT}:${PORT_EXPOSED}"
- '${PORT}:${PORT_EXPOSED}'
volumes:
- $MEDIA_DIR:/app/mnt/media
- evilspins:/app/data
environment:
VIRTUAL_HOST: "${DOMAIN}"
LETSENCRYPT_HOST: "${DOMAIN}"
PUID: "${PUID}"
PGID: "${PGID}"
VIRTUAL_HOST: '${DOMAIN}'
LETSENCRYPT_HOST: '${DOMAIN}'
PUID: '${PUID}'
PGID: '${PGID}'
volumes:
evilspins:
networks:
default:

11
drizzle.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import 'dotenv/config'
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
out: './drizzle',
schema: './server/db/schema.ts',
dialect: 'sqlite',
dbCredentials: {
url: process.env.PATH_DB!
}
})

3
env.sh
View File

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

View File

@@ -4,9 +4,26 @@ import tsconfigPaths from 'vite-tsconfig-paths'
const isProd = process.env.NODE_ENV === 'production'
export default defineNuxtConfig({
runtimeConfig: {
pathFiles: process.env.PATH_FILES,
pathDb: process.env.PATH_DB
},
nitro: {
experimental: {
tasks: true
},
scheduledTasks: {
'*/5 * * * *': ['syncTracks']
}
},
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
modules: ['@nuxt/eslint', '@nuxtjs/tailwindcss', '@pinia/nuxt'],
typescript: {
tsConfig: {
include: ['types/**/*.ts']
}
},
vite: {
plugins: [tsconfigPaths()]
},

18057
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
{
"name": "nuxt-app",
"name": "evilspins",
"type": "module",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nuxt build",
@@ -11,14 +12,19 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --check .",
"format:fix": "prettier --write ."
"format:fix": "prettier --write .",
"db:push": "drizzle-kit push",
"db:reset": "rm -rf drizzle data/music.db && drizzle-kit push",
"db:sync": "curl -X POST -H \"Content-Type: application/json\" -d '{}' http://localhost:7901/api/test/test-db-sync"
},
"dependencies": {
"@libsql/client": "^0.17.0",
"@nuxt/eslint": "1.9.0",
"@nuxtjs/tailwindcss": "6.14.0",
"@pinia/nuxt": "0.11.2",
"atropos": "^2.0.2",
"nuxt": "^4.2.0",
"drizzle-orm": "^0.45.1",
"nuxt": "^4.3.0",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-router": "^4.5.1",
@@ -27,7 +33,7 @@
"engines": {
"pnpm": ">=10 <11"
},
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748",
"packageManager": "pnpm@10.28.0",
"devDependencies": {
"@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.1",
@@ -35,7 +41,9 @@
"@nuxtjs/eslint-config-typescript": "^12.1.0",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"eslint": "^8.57.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.31.8",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-vue": "9.3.0",
@@ -43,6 +51,8 @@
"globals": "^16.5.0",
"patch-package": "^8.0.1",
"sass-embedded": "^1.93.2",
"tsx": "^4.21.0",
"typescript": "^5.7.3",
"vite-tsconfig-paths": "^5.1.4"
}
}

4997
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

7
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,7 @@
ignoredBuiltDependencies:
- esbuild
onlyBuiltDependencies:
- '@parcel/watcher'
- better-sqlite3
- unrs-resolver

1
public/card-dock.svg Normal file
View File

@@ -0,0 +1 @@
<svg width="224" xmlns="http://www.w3.org/2000/svg" height="320" id="screenshot-0fc9bf1a-468d-8062-8007-12c4a2492f68" viewBox="0 0 224 320" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-0fc9bf1a-468d-8062-8007-12c4a2492f68"><defs><clipPath id="frame-clip-0fc9bf1a-468d-8062-8007-12c4a2492f68-render-1" class="frame-clip frame-clip-def"><rect rx="0" ry="0" x="0" y="0" width="224" height="320" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"/></clipPath></defs><g class="frame-container-wrapper"><g class="frame-container-blur"><g class="frame-container-shadows"><g clip-path="url(#frame-clip-0fc9bf1a-468d-8062-8007-12c4a2492f68-render-1)" fill="none"><g class="fills" id="fills-0fc9bf1a-468d-8062-8007-12c4a2492f68"><rect width="224" height="320" class="frame-background" x="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(255, 255, 255); fill-opacity: 1;" ry="0" rx="0" y="0"/></g><g class="frame-children"><g id="shape-0fc9bf1a-468d-8062-8007-12c9d568a6f9"><g class="fills" id="fills-0fc9bf1a-468d-8062-8007-12c9d568a6f9"><rect rx="20" ry="20" x="0" y="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="224" height="320" style="fill: rgb(0, 0, 0); fill-opacity: 0.15;"/></g></g><g id="shape-0fc9bf1a-468d-8062-8007-12c8693c73a0"><g class="fills" id="fills-0fc9bf1a-468d-8062-8007-12c8693c73a0"><ellipse cx="112" cy="160.00000000000003" rx="144" ry="144" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(177, 178, 181); fill-opacity: 0.2;"/></g></g><g id="shape-0fc9bf1a-468d-8062-8007-12c85a924e31"><g class="fills" id="fills-0fc9bf1a-468d-8062-8007-12c85a924e31"><ellipse cx="112" cy="160.00000000000003" rx="112.5" ry="112.50000000000004" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(177, 178, 181); fill-opacity: 0.2;"/></g></g><g id="shape-0fc9bf1a-468d-8062-8007-12c7cdce5e0a"><g class="fills" id="fills-0fc9bf1a-468d-8062-8007-12c7cdce5e0a"><ellipse cx="112" cy="160" rx="82" ry="82.00000000000003" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(177, 178, 181); fill-opacity: 0.2;"/></g></g><g id="shape-0fc9bf1a-468d-8062-8007-12c820876e0e"><g class="fills" id="fills-0fc9bf1a-468d-8062-8007-12c820876e0e"><ellipse cx="112" cy="160" rx="51" ry="51" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(177, 178, 181); fill-opacity: 0.2;"/></g></g></g></g></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

1
public/cassette.svg Normal file
View File

@@ -0,0 +1 @@
<svg data-v-161a534a="" data-v-ef1138a9="" width="20" height="20" viewBox="0 0 20 20" fill="none" role="img" class="icon cassette-tape-outline-icon" aria-hidden="true" style="--v7dd280ad: var(--gray700);"><g data-v-161a534a=""><path data-v-161a534a="" fill-rule="evenodd" clip-rule="evenodd" d="M17.7 4.3H2.3L2.3 15.7H17.7V4.3ZM2.3 3.5C1.85817 3.5 1.5 3.85817 1.5 4.3V15.7C1.5 16.1418 1.85817 16.5 2.3 16.5H17.7C18.1418 16.5 18.5 16.1418 18.5 15.7V4.3C18.5 3.85817 18.1418 3.5 17.7 3.5H2.3Z" stroke="none"></path><path data-v-161a534a="" fill-rule="evenodd" clip-rule="evenodd" d="M8.5 9C8.5 10.2703 7.47025 11.3 6.2 11.3C4.92974 11.3 3.9 10.2703 3.9 9C3.9 7.72974 4.92974 6.7 6.2 6.7C7.47025 6.7 8.5 7.72974 8.5 9ZM6.2 10.5C7.02842 10.5 7.7 9.82843 7.7 9C7.7 8.17157 7.02842 7.5 6.2 7.5C5.37157 7.5 4.7 8.17157 4.7 9C4.7 9.82843 5.37157 10.5 6.2 10.5Z" stroke="none"></path><path data-v-161a534a="" fill-rule="evenodd" clip-rule="evenodd" d="M16.2 9C16.2 10.2703 15.1702 11.3 13.9 11.3C12.6297 11.3 11.6 10.2703 11.6 9C11.6 7.72974 12.6297 6.7 13.9 6.7C15.1702 6.7 16.2 7.72974 16.2 9ZM13.9 10.5C14.7284 10.5 15.4 9.82843 15.4 9C15.4 8.17157 14.7284 7.5 13.9 7.5C13.0716 7.5 12.4 8.17157 12.4 9C12.4 9.82843 13.0716 10.5 13.9 10.5Z" stroke="none"></path><path data-v-161a534a="" fill-rule="evenodd" clip-rule="evenodd" d="M6.36642 14C6.22966 14 6.10238 14.0698 6.02896 14.1852L4.73746 16.2147L4.06253 15.7852L5.35403 13.7557C5.5743 13.4096 5.95614 13.2 6.36642 13.2H13.6336C14.0438 13.2 14.4257 13.4096 14.646 13.7557L15.9375 15.7852L15.2625 16.2147L13.971 14.1852C13.8976 14.0698 13.7703 14 13.6336 14H6.36642Z" stroke="none"></path></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

17
public/disc.svg Normal file
View File

@@ -0,0 +1,17 @@
<svg data-v-daf08565="" data-v-ef1138a9="" width="20" height="20" viewBox="0 0 20 20" fill="none" role="img"
class="icon vinyl-record-outline-icon" aria-hidden="true" style="--v0954d04a: var(--gray700);">
<g data-v-daf08565="">
<path data-v-daf08565=""
d="M10 11C10.5523 11 11 10.5523 11 10C11 9.44772 10.5523 9 10 9C9.44772 9 9 9.44772 9 10C9 10.5523 9.44772 11 10 11Z"
stroke="none"></path>
<path data-v-daf08565="" fill-rule="evenodd" clip-rule="evenodd"
d="M13 10C13 11.6569 11.6569 13 10 13C8.34315 13 7 11.6569 7 10C7 8.34315 8.34315 7 10 7C11.6569 7 13 8.34315 13 10ZM10 12.1892C11.2091 12.1892 12.1892 11.2091 12.1892 10C12.1892 8.79094 11.2091 7.81081 10 7.81081C8.79094 7.81081 7.81081 8.79094 7.81081 10C7.81081 11.2091 8.79094 12.1892 10 12.1892Z"
stroke="none"></path>
<path data-v-daf08565=""
d="M12.3606 13.4425L12.0793 12.9862L9.80855 14.512L10.9231 16.2177L13.1938 14.6919L13.0425 14.3444L14.7376 13.1105C15.1903 12.7894 17.0431 11.2251 17.2951 9.62497L17.5002 8.39218L16.6563 6.35491L16.5832 7.60639C16.5764 7.75055 16.5615 7.92059 16.5332 8.11074C16.3877 9.08943 15.8891 10.6011 14.3388 11.8603L13.8788 12.234C13.5086 12.5349 13.2133 12.7749 12.9854 12.9589C12.686 13.2007 12.5025 13.3462 12.4167 13.4079C12.4012 13.419 12.3888 13.4274 12.3796 13.4331C12.37 13.4391 12.3636 13.4422 12.3606 13.4425Z"
stroke="none"></path>
<path data-v-daf08565="" fill-rule="evenodd" clip-rule="evenodd"
d="M10 17.2C13.9765 17.2 17.2 13.9765 17.2 10C17.2 6.02355 13.9765 2.8 10 2.8C6.02355 2.8 2.8 6.02355 2.8 10C2.8 13.9765 6.02355 17.2 10 17.2ZM10 18C14.4183 18 18 14.4183 18 10C18 5.58172 14.4183 2 10 2C5.58172 2 2 5.58172 2 10C2 14.4183 5.58172 18 10 18Z"
stroke="none"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

65
scripts/update-esid.ts Normal file
View File

@@ -0,0 +1,65 @@
import { db } from '../server/services/db'
import { tracks, compilations, playlists, sql } from '../server/schema'
import { eq, and } from 'drizzle-orm'
async function updateEsid() {
console.log('🚀 Démarrage de la mise à jour des esid')
try {
// 1. Mettre à jour les esid pour les compilations
console.log('📋 Mise à jour des esid pour les compilations...')
await db.run(sql`
UPDATE tracks
SET esid = (
SELECT c.box_id || c.side || t."order"
FROM compilations c
WHERE c.id = tracks.source_id AND tracks.source_type = 'compilation'
)
WHERE source_type = 'compilation';
`)
// 2. Mettre à jour les esid pour les playlists
console.log('📋 Mise à jour des esid pour les playlists...')
// Récupérer toutes les playlists
const allPlaylists = await db.select().from(playlists).all()
for (const playlist of allPlaylists) {
// Récupérer les tracks de la playlist triés par ordre
const playlistTracks = await db
.select()
.from(tracks)
.where(and(eq(tracks.sourceType, 'playlist'), eq(tracks.sourceId, playlist.id)))
.orderBy(tracks.order)
// Mettre à jour chaque track avec le bon esid
for (let i = 0; i < playlistTracks.length; i++) {
const track = playlistTracks[i]
const esidSuffix =
i < 26
? String.fromCharCode(65 + i) // A-Z pour les 26 premières pistes
: (i + 1).toString() // Numéros pour les suivantes
await db
.update(tracks)
.set({ esid: `${playlist.name}${esidSuffix}` })
.where(eq(tracks.id, track.id))
}
console.log(`✅ Playlist ${playlist.name} mise à jour (${playlistTracks.length} pistes)`)
}
console.log('\n✅ Tous les esid ont été mis à jour avec succès !')
} catch (error) {
console.error('❌ Erreur lors de la mise à jour des esid:', error)
process.exit(1)
}
}
// Exécuter le script
updateEsid()
.then(() => process.exit(0))
.catch((error) => {
console.error('Erreur inattendue:', error)
process.exit(1)
})

View File

@@ -1,151 +0,0 @@
import { eventHandler } from 'h3'
export default eventHandler(() => {
return [
{
id: 0,
name: "L'efondras",
url: 'https://leffondras.bandcamp.com/music',
coverId: '0024705317'
},
{
id: 1,
name: 'The kundalini genie',
url: 'https://the-kundalini-genie.bandcamp.com',
coverId: '0012045550'
},
{
id: 2,
name: 'Fontaines D.C.',
url: 'https://fontainesdc.bandcamp.com',
coverId: '0027327090'
},
{
id: 3,
name: 'Fontanarosa',
url: 'https://fontanarosa.bandcamp.com',
coverId: '0035380235'
},
{
id: 4,
name: 'Johnny mafia',
url: 'https://johnnymafia.bandcamp.com',
coverId: '0035009392'
},
{
id: 5,
name: 'New candys',
url: 'https://newcandys.bandcamp.com',
coverId: '0039963261'
},
{
id: 6,
name: 'Magic shoppe',
url: 'https://magicshoppe.bandcamp.com',
coverId: '0030748374'
},
{
id: 7,
name: 'Les jaguars',
url: 'https://radiomartiko.bandcamp.com/album/surf-qu-b-cois',
coverId: '0016551336'
},
{
id: 8,
name: 'TRAAMS',
url: 'https://traams.bandcamp.com',
coverId: '0028348410'
},
{
id: 9,
name: 'Blue orchid',
url: 'https://blue-orchid.bandcamp.com',
coverId: '0034796193'
},
{
id: 10,
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'
}
]
})

View File

@@ -1,68 +0,0 @@
import { eventHandler } from 'h3'
import type { Box } from '~~/types/types'
export default eventHandler<Box[]>(() => {
return [
{
id: 'ES01',
type: 'compilation',
name: '...',
description: '...',
state: 'box-hidden',
duration: 3487 + 3773, // Somme des durées A et B
sides: {
A: {
name: '...',
description: '...',
duration: 3487,
color1: '#c7b3aa',
color2: '#000100'
},
B: {
name: '... B',
description: '...',
duration: 3773,
color1: '#f7dd01',
color2: '#010103'
}
},
activeSide: 'A'
},
{
id: 'ES00',
type: 'compilation',
name: 'manifeste',
description: 'Zero is for manifesto',
state: 'box-hidden',
duration: 2794 + 2470, // Somme des durées A et B
sides: {
A: {
name: 'manifeste',
description: 'Zero is for manifesto',
duration: 2794,
color1: '#ffffff',
color2: '#48959d'
},
B: {
name: 'manifeste B',
description: 'Even Zero has a b-side',
duration: 2470,
color1: '#0d01b9',
color2: '#3b7589'
}
},
activeSide: 'A'
},
{
id: 'ESPLAYLIST',
type: 'playlist',
name: 'playlists',
duration: 0,
description: '♠♦♣♥',
state: 'box-hidden',
activeSide: 'A',
color1: '#fdec50ff',
color2: '#fdec50ff'
}
]
})

41
server/api/card/[slug].ts Normal file
View File

@@ -0,0 +1,41 @@
import { eq } from 'drizzle-orm'
import { useDB, schema } from '../../db'
export default eventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
if (!slug) {
throw createError({
statusCode: 400,
statusMessage: 'ESID manquant dans la requête'
})
}
const db = useDB()
const card = await db.select().from(schema.cards).where(eq(schema.cards.slug, slug)).get()
if (!card) {
throw createError({
statusCode: 404,
statusMessage: 'Morceau non trouvé'
})
}
return {
id: card.id,
esid: card.esid,
title: card.title,
artist: card.artist,
url_audio: card.url_audio,
url_image: card.url_image,
year: card.year,
month: card.month,
day: card.day,
hour: card.hour,
slug: card.slug,
suit: card.suit,
rank: card.rank,
createdAt: card.createdAt,
updatedAt: card.updatedAt
}
})

View File

@@ -0,0 +1,41 @@
import { eq } from 'drizzle-orm'
import { useDB, schema } from '../../db'
export default eventHandler(async (event) => {
const esid = getRouterParam(event, 'esid')
if (!esid) {
throw createError({
statusCode: 400,
statusMessage: 'ESID manquant dans la requête'
})
}
const db = useDB()
const card = await db.select().from(schema.cards).where(eq(schema.cards.esid, esid)).get()
if (!card) {
throw createError({
statusCode: 404,
statusMessage: 'Morceau non trouvé'
})
}
return {
id: card.id,
esid: card.esid,
title: card.title,
artist: card.artist,
url_audio: card.url_audio,
url_image: card.url_image,
year: card.year,
month: card.month,
day: card.day,
hour: card.hour,
slug: card.slug,
suit: card.suit,
rank: card.rank,
createdAt: card.createdAt,
updatedAt: card.updatedAt
}
})

84
server/api/cards/index.ts Normal file
View File

@@ -0,0 +1,84 @@
import { and, eq, ilike, or, sql } from 'drizzle-orm'
import { useDB, schema } from '../../db'
const PAGE_SIZE = 20 // Nombre d'éléments par page
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const page = Number(query.page) || 1
const search = query.search?.toString()
const cardRank = query.rank?.toString()
const cardSuit = query.suit?.toString()
const year = query.year?.toString()
const db = useDB()
const offset = (page - 1) * PAGE_SIZE
// Log pour débogage
console.log('Requête avec paramètres:', { search, cardRank, cardSuit, year })
console.log('Schéma des cards:', Object.keys(schema.cards))
// Construction des conditions de filtrage
const conditions = []
if (search) {
const searchTerm = `%${search}%`
conditions.push(
or(ilike(schema.cards.title, searchTerm), ilike(schema.cards.artist, searchTerm))
)
}
if (cardRank) {
conditions.push(eq(schema.cards.rank, cardRank))
}
if (cardSuit) {
conditions.push(eq(schema.cards.suit, cardSuit))
}
if (year) {
conditions.push(eq(schema.cards.year, year))
}
// Requête pour le comptage total
const countQuery = db
.select({ count: sql<number>`count(*)` })
.from(schema.cards)
.$dynamic()
// Log pour débogage SQL
console.log('Requête de comptage SQL:', countQuery.toSQL())
// Requête pour les données paginées
const cardsQuery = db
.select()
.from(schema.cards)
.$dynamic()
.limit(PAGE_SIZE)
.offset(offset)
.orderBy(schema.cards.title)
// Application des conditions si elles existent
if (conditions.length > 0) {
const where = and(...conditions)
countQuery.where(where)
cardsQuery.where(where)
}
const [countResult, cards] = await Promise.all([countQuery, cardsQuery])
const totalItems = countResult[0]?.count || 0
const totalPages = Math.ceil(totalItems / PAGE_SIZE)
return {
data: cards,
pagination: {
currentPage: page,
pageSize: PAGE_SIZE,
totalItems,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1
}
}
})

View File

@@ -0,0 +1,27 @@
import { syncCardsWithDatabase } from '../services/cardSync.service'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const folderPath = config.pathFiles || process.env.PATH_FILES
if (!folderPath) {
throw createError({
statusCode: 500,
message: 'PATH_FILES not configured'
})
}
try {
const result = await syncCardsWithDatabase(folderPath)
return {
success: true,
...result
}
} catch (error: any) {
throw createError({
statusCode: 500,
message: error.message
})
}
})

View File

@@ -0,0 +1,21 @@
import { syncCardsWithDatabase } from '../../services/cardSync.service'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const folderPath = config.pathFiles || process.env.PATH_FILES || 'mnt/media/files/music'
try {
const result = await syncCardsWithDatabase(folderPath)
return {
success: true,
...result
}
} catch (error: any) {
return {
success: false,
error: error.message,
stack: error.stack
}
}
})

View File

@@ -0,0 +1,29 @@
import { scanMusicFolder } from '../../utils/fileScanner'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const folderPath = config.pathFiles || process.env.PATH_FILES || 'mnt/media/files/music'
try {
// Test 1: Vérifier que le dossier existe
const { access } = await import('node:fs/promises')
await access(folderPath)
// Test 2: Scanner le dossier
const cards = await scanMusicFolder(folderPath)
return {
success: true,
folderPath,
cardsFound: cards.length,
cards: cards.slice(0, 5), // Afficher seulement les 5 premiers
sample: cards[0] // Un exemple complet
}
} catch (error: any) {
return {
success: false,
error: error.message,
folderPath
}
}
})

View File

@@ -1,73 +0,0 @@
import fs from 'fs'
import path from 'path'
import { eventHandler } from 'h3'
import { getCardFromDate } from '../../../utils/cards'
export default eventHandler(async (event) => {
const dirPath = path.join(process.cwd(), '/mnt/media/files/music')
const urlPrefix = `https://files.erudi.fr/music`
try {
let allTracks: any[] = []
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}/cover/${encodeURIComponent(file).replace(EXT_RE, '.jpg')}`
return {
id: Number(`${year}${index + 1}`),
boxId: `ESPLAYLIST`,
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
}
}
})

32
server/db/index.ts Normal file
View File

@@ -0,0 +1,32 @@
import { drizzle } from 'drizzle-orm/libsql'
import * as schema from './schema'
let _db: ReturnType<typeof drizzle> | null = null
export function useDB() {
if (_db) return _db
const config = useRuntimeConfig()
let dbPath = config.pathDb || process.env.PATH_DB
if (!dbPath) {
throw new Error('PATH_DB is not configured')
}
// Convertir le chemin en URL file:// si ce n'est pas déjà une URL
if (!dbPath.startsWith('file:') && !dbPath.startsWith('libsql:') && !dbPath.startsWith('http')) {
// Si c'est un chemin relatif, le rendre absolu
if (!dbPath.startsWith('/')) {
dbPath = `file:${process.cwd()}/${dbPath}`
} else {
dbPath = `file:${dbPath}`
}
}
console.log('🗄️ Connexion à la DB:', dbPath)
_db = drizzle(dbPath, { schema })
return _db
}
export { schema }

23
server/db/schema.ts Normal file
View File

@@ -0,0 +1,23 @@
import { sqliteTable, text, int } from 'drizzle-orm/sqlite-core'
export const cards = sqliteTable('cards', {
id: int('id').primaryKey({ autoIncrement: true }),
esid: text('esid').notNull(),
url_audio: text('url_audio').notNull(),
url_image: text('url_image').notNull(),
year: text('year').notNull(),
month: text('month').notNull(),
day: text('day').notNull(),
hour: text('hour').notNull(),
artist: text('artist').notNull(),
title: text('title').notNull(),
slug: text('slug').notNull(),
suit: text('suit').notNull(),
rank: text('rank').notNull(),
createdAt: int('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: int('updated_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date())
})

Some files were not shown because too many files have changed in this diff Show More