sql #33

Open
valere wants to merge 34 commits from sql into master
114 changed files with 6561 additions and 3950 deletions

2
.env
View File

@@ -1,3 +1,5 @@
DOMAIN=evilspins.com
PORT=7901
PORT_EXPOSED=3000
NODE_ENV=production

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

@@ -5,4 +5,3 @@
"trailingComma": "none",
"printWidth": 100
}

View File

@@ -1,5 +1,5 @@
# Builder
FROM node:20-alpine AS builder
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
@@ -9,12 +9,12 @@ COPY . .
RUN npm run build
# Runtime
FROM node:20-alpine AS runner
RUN apk add --no-cache python3 make g++ sqlite
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 ./server/database ./server/database
COPY ./data ./data
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

View File

@@ -1,82 +1,36 @@
<template>
<div>
<div class="min-h-screen dark:bg-neutral-900" @keydown.esc="resetFocus">
<NuxtRouteAnnouncer />
<NuxtLayout>
<NuxtPage />
<NuxtPage ref="pageContent" />
</NuxtLayout>
</div>
</template>
<script setup>
import { useUiStore } from '~/store/ui'
import { usePlayerStore } from '~/store/player'
import { watch, computed } from 'vue'
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const ui = useUiStore()
const player = usePlayerStore()
useHead({
bodyAttrs: {
class: 'bg-slate-100'
const pageContent = ref<HTMLElement | null>(null)
const resetFocus = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
// On déplace le focus sur l'élément racine
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
// On force le focus sur le body
document.body.focus()
}
}
// Ajout du gestionnaire d'événement au montage du composant
onMounted(() => {
window.addEventListener('keydown', resetFocus)
})
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' }
)
// Nettoyage du gestionnaire d'événement lors du démontage du composant
onUnmounted(() => {
window.removeEventListener('keydown', resetFocus)
})
</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

@@ -1,56 +1,32 @@
<template>
<article v-bind="attrs" :role="props.role" :draggable="isFaceUp" :class="[
<article :role="props.role" :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">
showPlayButtonFaceUp ? 'show-play-button-face-up' : ''
]" :tabindex="props.tabindex" :aria-disabled="false" @click="$emit('click', $event)"
@keydown.enter="$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">
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 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>
<Rank :card="props.card" />
<!-- 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 class="cover">
<PlayButton />
<img :src="props.card.url_image" alt="cover-image de l'album" :loading="props.imageLoadingType"
@load="$emit('image-loaded', $event)" class="cover-image 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' }}
{{ props.card.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 class="select-text text-base text-neutral-800 font-bold capitalize truncate">
{{ props.card.artist || 'artist' }}
</p>
</div>
</main>
@@ -60,317 +36,43 @@
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" />
<PlayButton />
<img 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/types'
import { usePlayerStore } from '~/store/player'
import { useDataStore } from '~/store/data'
import { useNuxtApp } from '#app'
import ModalSharer from '~/components/ui/ModalSharer.vue'
import type { Card } from '~~/types/types'
const emit = defineEmits(['click', 'image-loaded'])
const props = withDefaults(defineProps<{
track: Track;
card: Card;
isFaceUp?: boolean;
role?: string;
tabindex?: string | number;
'onUpdate:isFaceUp'?: (value: boolean) => void;
imageLoadingType?: 'lazy' | 'eager';
showPlayButtonFaceUp?: boolean;
}>(), {
isFaceUp: true,
isFaceUp: false,
role: 'button',
tabindex: '0'
tabindex: '0',
imageLoadingType: 'eager',
showPlayButtonFaceUp: false
})
// Use useAttrs to get all other attributes
const attrs = useAttrs()
import { getYearColor } from '~/utils/colors'
const modalSharer = ref<InstanceType<typeof ModalSharer> | null>(null)
const showModalSharer = ref(false)
const cardColor = computed(() => getYearColor(props.card.year || 0))
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;
}>()
/* loading states of the card */
const isApiLoaded = ref(false)
const isImageLoaded = ref(false)
const isTrackLoaded = ref(false)
// 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">
@@ -390,8 +92,7 @@ onUnmounted(() => {
/* Flip effect */
.card {
perspective: 1000px;
@apply transition-all scale-100 w-56 h-80 min-w-56 min-h-80;
touch-action: none;
@apply transition-all scale-100 size-full;
.flip-inner {
position: relative;
@@ -413,8 +114,18 @@ onUnmounted(() => {
}
.face-up {
border-radius: 1rem;
border: none;
transform: rotateY(0deg);
transition: box-shadow 0.6s;
.cover {
@apply flex-1 flex justify-center items-center overflow-hidden cursor-pointer;
.cover-image {
border-radius: 0.75rem;
}
}
}
.face-down {
@@ -440,7 +151,7 @@ onUnmounted(() => {
}
&:focus,
&.current-track {
&.current-card {
@apply z-50 scale-110;
outline: none;
@@ -448,12 +159,12 @@ onUnmounted(() => {
@apply shadow-2xl;
transition:
box-shadow 0.6s,
transform 0.6s;
transform 6s;
}
}
&:focus,
&.current-track {
&.current-card {
.play-button {
@apply opacity-100;
}
@@ -463,9 +174,12 @@ onUnmounted(() => {
opacity: 0;
}
.face-up:hover {
.play-button {
opacity: 1;
.face-up {
&:hover {
.play-button {
opacity: 1;
}
}
.flip-inner {
@@ -474,10 +188,24 @@ onUnmounted(() => {
}
.play-button {
@apply absolute bottom-1/2 top-24 opacity-0 hover:opacity-100;
@apply absolute opacity-0 hover:opacity-100;
}
.pochette:active,
&.show-play-button {
.play-button {
opacity: 1;
}
}
&.show-play-button-face-up {
.face-up {
.play-button {
opacity: 1;
}
}
}
.cover-image:active,
.face-down:active {
.play-button {
@apply scale-90;
@@ -498,22 +226,4 @@ onUnmounted(() => {
}
}
}
/* 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

@@ -1,58 +1,328 @@
<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 class="platine" :class="{ 'loading': isLoadingTrack, 'mounted': isMounted, 'playing': isPlaying }" ref="platine">
<div v-if="false" class="debug">
<button @click="Reverse">
<b v-if="isReversed">reversed</b>
<b v-else>normal</b>
</button>
<button @click="Rewind">
Rewind
</button>
<button @click="Wind">
Wind
</button>
<!-- <div>{{ progressPercentage }}</div> -->
<div>{{ (currentSpeed).toFixed(2) }}</div>
</div>
<div class="disc" ref="discRef" id="disc">
<div class="bobine" :style="{
height: progressPercentage + '%',
width: progressPercentage + '%'
}"></div>
<button class="power-button" @click="Power" @touchstart="Power" :disabled="isLoadingTrack">
<img class="Macaron" src="/favicon.svg" v-if="!isEndOfTrack">
<div class="spinner" v-if="isLoadingTrack" />
<img class="Macaron" @click="Rewind" src="/rewind.svg" v-if="isEndOfTrack" />
</button>
<div class="turn-point" v-if="!isLoadingTrack">
</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/types'
import type { Card } from '~~/types/types'
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue'
import Disc from '~/utils/platine/disc'
import Sampler from '~/utils/platine/sampler'
import { duration } from 'drizzle-orm/gel-core';
const props = defineProps<{ track?: Track }>()
const platineStore = usePlatineStore()
const props = defineProps<{ card?: Card, autoplay?: boolean }>()
const autoplay = props.autoplay ?? false
// State
const isLoadingTrack = ref(false)
const isFirstPlay = ref(true)
const isEndOfTrack = ref(false)
const progressPercentage = ref(0)
const currentTurns = ref(0)
const totalTurns = ref(0)// Refs pour les instances
// Refs pour les instances
const disc = ref<Disc | null>(null)
const sampler = ref<Sampler | null>(null)
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)
const isPlaying = computed(() => Math.abs(Math.round(sampler.value?.currentSpeed || 0)) !== 0)
const isReversed = computed(() => disc.value?.isReversed || false)
const currentSpeed = computed(() => sampler.value?.currentSpeed || 0)
// Actions
const initPlatine = (element: HTMLElement) => {
// console.log('[INIT] Platine')
discRef.value = element
disc.value = new Disc(element)
sampler.value = new Sampler()
// Configurer les callbacks du disque
if (disc.value) {
disc.value.callbacks.onStop = () => {
// console.log('[DISC] On Stop')
sampler.value?.pause()
}
disc.value.callbacks.onDragStart = () => {
// Activer le son à chaque fois qu'on glisse, pas seulement au premier play
if (sampler.value && disc.value) {
// On joue toujours le son quand on glisse, même après une pause
sampler.value.play(disc.value.secondsPlayed || 0)
}
}
disc.value.callbacks.onLoop = ({ playbackSpeed, isReversed, secondsPlayed }) => {
// Ne mettre à jour que si nécessaire et s'assurer que la position est valide
if (Math.abs((sampler.value?.currentSpeed || 0) - playbackSpeed) > 0.01) {
const safeSecondsPlayed = Math.max(0, secondsPlayed)
sampler.value?.updateSpeed(playbackSpeed, isReversed, safeSecondsPlayed)
}
updateTurns()
}
}
})
}
const updateTurns = () => {
if (!disc.value) return
const newTurns = disc.value.secondsPlayed * 0.75
const newTotalTurns = (disc.value as any)._duration * 0.75
// Calcul du pourcentage de progression pour l'affichage visuel (17% à 100%)
const minPercentage = 17
const maxPercentage = 100
const progressRatio = disc.value.secondsPlayed / (disc.value as any)._duration
const newProgressPercentage = minPercentage + (progressRatio * (maxPercentage - minPercentage))
// Ne mettre à jour que si les valeurs ont changé de manière significative
if (
Math.abs(currentTurns.value - newTurns) > 0.01 ||
Math.abs(totalTurns.value - newTotalTurns) > 0.01 ||
Math.abs(progressPercentage.value - newProgressPercentage) > 0.1
) {
currentTurns.value = newTurns
totalTurns.value = newTotalTurns
progressPercentage.value = newProgressPercentage
isEndOfTrack.value = disc.value.secondsPlayed + 0.1 >= (disc.value as any)._duration
}
}
const loadCard = async (card: Card) => {
// console.log('[LOAD CARD]', card)
if (!sampler.value || !card) return
isLoadingTrack.value = true
// console.log(disc.value)
try {
await sampler.value.loadTrack(card.url_audio)
if (disc.value) {
// console.log('[DISC] Set Duration', sampler.value.duration)
disc.value.setDuration(sampler.value.duration)
updateTurns()
}
} finally {
isLoadingTrack.value = false
}
}
const play = (position = 0) => {
isFirstPlay.value = false
if (!disc.value || !sampler.value || !props.card) return
sampler.value.play(position)
disc.value.powerOn()
}
const pause = () => {
// console.log('[PAUSE]')
if (!disc.value || !sampler.value) return
sampler.value.pause()
disc.value.powerOff()
}
const togglePlay = () => {
if (isPlaying.value) {
pause()
} else {
// Reprendre la lecture à la position actuelle
const currentPosition = disc.value?.secondsPlayed || 0
play(currentPosition)
}
}
// Nettoyage
const cleanup = () => {
// console.log('[CLEANUP] Platine')
if (disc.value) {
disc.value.stop()
disc.value.powerOff()
}
if (sampler.value) {
sampler.value.pause()
}
}
const Power = (e: MouseEvent) => {
if (isEndOfTrack.value) {
Rewind()
}
togglePlay()
}
const Reverse = () => {
if (!disc.value || !sampler.value) return
// Sauvegarder la position actuelle
const currentPosition = disc.value.secondsPlayed || 0
const wasPlaying = !disc.value.isStopped()
// Inverser la direction du disque et du sampler
disc.value.reverse()
sampler.value.reverse(wasPlaying ? currentPosition : 0)
}
const Rewind = async () => {
if (!disc.value || !sampler.value) return
// Sauvegarder l'état actuel
disc.value.isStopped() ? play() : null
const wasPlaying = !disc.value.isStopped()
const currentSpeed = disc.value.playbackSpeed
// Fonction pour l'effet de scratch/pull up
const scratchEffect = async () => {
// Ralentir progressivement
const slowDownDuration = 300 // ms
const startTime = performance.now()
const startSpeed = wasPlaying ? currentSpeed : 0.1
const slowDown = (timestamp: number) => {
const elapsed = timestamp - startTime
const progress = Math.min(elapsed / slowDownDuration, 1)
// Courbe d'accélération pour un effet plus naturel
const easeOut = 1 - Math.pow(1 - progress, 2)
// Ralentir progressivement jusqu'à presque l'arrêt
const newSpeed = startSpeed * (1 - easeOut) + 0.05
sampler.value?.setPlaybackRate(newSpeed)
if (progress < 1) {
requestAnimationFrame(slowDown)
} else {
// Une fois ralenti, effectuer le rembobinage
performRewind()
}
}
requestAnimationFrame(slowDown)
play()
}
const performRewind = () => {
// Mettre en pause la lecture actuelle
if (wasPlaying) {
sampler.value?.pause()
}
// Remettre à zéro la position
sampler.value?.play(0)
disc.value?.setAngle(0)
// Effet de scratch rapide
const scratchDuration = 200 // ms
const scratchStart = performance.now()
const scratch = (timestamp: number) => {
const elapsed = timestamp - scratchStart
const progress = Math.min(elapsed / scratchDuration, 1)
// Créer un effet de scratch en variant la vitesse
if (progress < 0.5) {
// Phase de scratch vers l'arrière
const scratchProgress = progress * 2
const scratchSpeed = 1 - (scratchProgress * 1.8) // Ralenti jusqu'à -0.8
sampler.value?.setPlaybackRate(scratchSpeed)
} else {
// Phase de pull up
const pullProgress = (progress - 0.5) * 2
const pullSpeed = -0.8 + (pullProgress * 1.8) // Accélère de -0.8 à 1.0
sampler.value?.setPlaybackRate(pullSpeed)
}
if (progress < 1) {
requestAnimationFrame(scratch)
} else {
// Remettre la vitesse normale à la fin
sampler.value?.setPlaybackRate(1)
// Si c'était en lecture avant, relancer la lecture
if (wasPlaying) {
setTimeout(() => {
sampler.value?.play(0)
}, 50)
}
}
}
requestAnimationFrame(scratch)
}
// Démarrer l'effet
scratchEffect()
}
const Wind = () => {
disc.value.secondsPlayed = duration
sampler.value.secondsPlayed = duration
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space' || e.key === ' ' || e.keyCode === 32) {
e.preventDefault() // Empêcher le défilement de la page
togglePlay()
}
}
// Initialisation du lecteur
onMounted(async () => {
isMounted.value = true
if (discRef.value) {
initPlatine(discRef.value)
await loadCard(props.card!)
if (autoplay) {
await nextTick()
play()
}
}
window.addEventListener('keydown', handleKeyDown)
})
// Nettoyage de l'écouteur d'événement
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
isMounted.value = false
platineStore.cleanup()
cleanup()
})
// Surveillance des changements de piste
watch(() => props.track, (newTrack) => {
if (newTrack) {
platineStore.loadTrack(newTrack)
watch(() => props.card, (newCard) => {
// console.log('[WATCH] Card', newCard)
if (newCard) {
loadCard(newCard)
}
})
@@ -62,137 +332,110 @@ watch(() => props.track, (newTrack) => {
.platine {
overflow: hidden;
position: relative;
display: flex;
width: 100%;
height: 100%;
.card {
position: absolute !important;
top: -20%;
.disc {
pointer-events: auto;
position: absolute;
background-color: transparent;
position: relative;
aspect-ratio: 1;
width: 100%;
overflow: hidden;
border-radius: 50%;
cursor: grab;
background-position: center;
background-size: cover;
padding: 14px;
.loading & {
box-shadow: none;
}
}
.turn-point {
@apply absolute top-1/2 right-8 size-1/12 rounded-full bg-esyellow;
}
.disc.is-scratching {
cursor: grabbing;
}
.power-button {
position: absolute;
z-index: 100;
cursor: pointer;
transition: all 0.1s;
display: flex;
justify-content: center;
align-items: center;
transform-origin: center;
width: 33%;
height: 33%;
border-radius: 100%;
top: 50%;
left: 50%;
bottom: 0;
transform: translate(-50%, 50%);
transform: translate(-50%, -50%);
.Macaron {
position: absolute;
filter: grayscale(1);
transition: transform 0.1s, filter 0.8s;
@apply size-2/3;
}
.spinner {
@apply size-1/2;
}
&:active {
.Macaron {
transform: scale(0.8);
}
}
}
}
.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;
&.playing .power-button .Macaron {
filter: grayscale(0);
}
}
.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;
.disc-middle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 20px;
height: 20px;
background: rgb(26, 26, 26);
border-radius: 50%;
}
}
.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);
.spinner {
width: 40px;
height: 40px;
border: 1px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
// @apply md:border-8;
}
}
.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;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.bobine {
width: 17%;
height: 17%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
@apply relative bg-black bg-opacity-60 backdrop-blur-sm rounded-full;
}
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<section class="playing-card" :class="{ 'platine-open': platineOpen }">
<Card :card="props.card!" :is-face-up="props.isFaceUp" @click="clickOnCard" :role="platineOpen ? 'img' : 'button'"
showPlayButtonFaceUp />
<Platine v-if="platineOpen && card" :card="card" autoplay />
<CloseButton v-if="platineOpen" @click="platineOpen = false" />
</section>
</template>
<script setup lang="ts">
import type { Card } from '~~/types/types'
const props = defineProps<{ card: Card, isFaceUp: boolean }>()
const platineOpen = ref(false)
const clickOnCard = () => {
setTimeout(() => {
platineOpen.value = !platineOpen.value
}, 600);
}
</script>
<style scoped lang="scss">
$open-speed: 0.4s;
.playing-card {
@apply size-card;
position: relative;
.card,
.platine {
height: 100%;
width: 100%;
transition: width $open-speed, height $open-speed, min-width $open-speed,
min-height $open-speed, transform 0.1s;
}
:deep(.card) {
// transition: width $open-speed, height $open-speed, transform 0.1s;
position: absolute;
.face-up,
.cover {
transition: border-radius $open-speed, box-shadow $open-speed;
&:active {
.play-button {
@apply scale-90;
}
}
}
}
&.platine-open {
$open-size: 20rem;
width: $open-size;
height: $open-size;
min-height: $open-size;
min-width: $open-size;
.card {
pointer-events: none;
&:focus {
@apply z-auto scale-100;
}
}
&:deep(.card) {
.face-up {
border-radius: 100%;
@apply shadow-xl;
.cover {
border-radius: 100%;
}
.rank,
.play-button {
opacity: 0;
}
}
.play-button,
.card-body,
.face-down {
display: none;
opacity: 0;
}
}
}
}
</style>

24
app/components/Rank.vue Normal file
View File

@@ -0,0 +1,24 @@
<template>
<div class="rank 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.card?.suit]">
<img :src="`/${props.card?.suit}.svg`" />
</div>
<div class="rank text-white font-bold absolute -mt-1">
{{ props.card?.rank }}
</div>
</div>
</template>
<script setup lang="ts">
import type { Card } from '~~/types/types';
const props = defineProps<{ card?: Card }>()
const isRedCard = computed(() => (props.card?.suit === '♥' || props.card?.suit === '♦'))
</script>
<style>
.rank {
transition: opacity 0.5s ease-in-out;
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<button ref="buttonRef">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"></g>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g>
<g id="SVGRepo_iconCarrier">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M15.7071 5.29289C16.0976 5.68342 16.0976 6.31658 15.7071 6.70711L10.4142 12L15.7071 17.2929C16.0976 17.6834 16.0976 18.3166 15.7071 18.7071C15.3165 19.0976 14.6834 19.0976 14.2929 18.7071L8.46963 12.8839C7.98148 12.3957 7.98148 11.6043 8.46963 11.1161L14.2929 5.29289C14.6834 4.90237 15.3165 4.90237 15.7071 5.29289Z"
fill="#0F1729"></path>
</g>
</svg>
</button>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const buttonRef = ref<HTMLButtonElement | null>(null)
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && buttonRef.value) {
buttonRef.value.click()
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
})
</script>
<style scoped lang="scss">
button {
@apply bottom-4 md:top-4 left-4 size-20 fill-slate-600 bg-slate-500;
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<button tabindex="-1" class="play-button" :class="{ loading: isLoading }" :disabled="isLoading">
<template v-if="props.isLoading">
<img src="/loader.svg" alt="Chargement" class="size-16" />
</template>
<template v-else>
{{ props.isPlaying ? 'I I' : '' }}
</template>
</button>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
isLoading?: boolean;
isPlaying?: boolean;
}>(), {
isLoading: false,
isPlaying: false
})
</script>
<style scoped lang="scss">
.play-button {
@apply pointer-events-none rounded-full size-24 flex items-center justify-center text-esyellow backdrop-blur-sm bg-black/25 transition-all duration-100 ease-in-out transform active:scale-90 scale-110 text-4xl font-bold;
}
.loading,
.play-button-changed {
opacity: 1 !important;
}
</style>

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>

View File

@@ -1,55 +1,3 @@
<template>
<slot />
<Bucket />
<Platine v-if="playerStore.currentTrack" />
</template>
<script setup lang="ts">
import type { Track } from '~~/types/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>

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

@@ -0,0 +1,24 @@
<template>
<PlayingCard :card :isFaceUp />
</template>
<script setup lang="ts">
import type { Card } from '~~/types/types'
const isFaceUp = ref(false)
const route = useRoute()
const slug = route.params.slug as string
const { data: card, pending, error } = await useFetch<Card>(`/api/card/${slug}`)
useHead({
title: computed(() =>
card.value ? `${card.value.artist} - ${card.value.title}` : 'Loading...'
)
})
onMounted(() => {
setTimeout(() => {
isFaceUp.value = true
}, 700)
})
</script>

View File

@@ -1,27 +1,62 @@
<template>
<boxes />
<section class="deck">
<PlayingCard v-for="(card, index) in cards" :key="index" :card="card" :isFaceUp="isFaceUp[index] || false"
@click="isFaceUp[index] = true" />
</section>
</template>
<script setup>
import { useUiStore } from '~/store/ui'
import { useDataStore } from '~/store/data'
<script setup lang="ts">
import type { Card } from '~~/types/types'
// Configuration du layout
definePageMeta({
layout: 'default'
})
const nbCards = Math.floor(Math.random() * 14) + 1 // Nombre de cartes à afficher (aléatoire entre 1 et 15)
const cards = ref<Card[]>([])
const isFaceUp = ref<boolean[]>([])
const uiStore = useUiStore()
// Chargement des cartes
const loadCards = async () => {
try {
const promises = Array(nbCards).fill(0).map(() =>
$fetch<Card>('/api/card/random')
)
const results = await Promise.all(promises)
cards.value = results
isFaceUp.value = new Array(nbCards).fill(false)
} catch (error) {
console.error('Error loading cards:', error)
}
}
// Animation de retournement des cartes
const flipCards = () => {
isFaceUp.value.forEach((_, index) => {
setTimeout(() => {
isFaceUp.value[index] = false
}, 400 * (index + 1))
})
}
onMounted(async () => {
const dataStore = useDataStore()
await dataStore.loadData()
uiStore.listBoxes()
await loadCards()
setTimeout(flipCards, 600)
})
useHead({
title: computed(() =>
cards.value[0] ? `${cards.value[0].artist} - ${cards.value[0].title}` : 'Loading...'
)
})
</script>
<style>
.logo {
filter: drop-shadow(3px 3px 0 rgb(0 0 0 / 0.7));
<style lang="scss" scoped>
.deck {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
min-height: 100vh;
.playing-card {
margin: 1rem;
}
}
</style>

23
app/pages/random.vue Normal file
View File

@@ -0,0 +1,23 @@
<template>
<PlayingCard :card :isFaceUp />
</template>
<script setup lang="ts">
import type { Card } from '~~/types/types'
const { data: card, pending, error } = await useFetch<Card>('/api/card/random')
const isFaceUp = ref(false)
useHead({
title: computed(() =>
card.value ? `${card.value.artist} - ${card.value.title}` : 'Loading...'
)
})
onMounted(() => {
setTimeout(() => {
isFaceUp.value = true
}, 700)
})
</script>

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
}

View File

@@ -74,15 +74,17 @@ class Disc {
private _inertiaVelocity: number = 0
private _isInertiaActive: boolean = false
private _basePlaybackSpeed: number = 1 // Vitesse de lecture normale
private _inertiaFriction: number = 0.93 // Coefficient de frottement pour l'inertie (plus proche de 1 = plus long)
private _inertiaFriction: number = 1 // Coefficient de frottement pour l'inertie (plus proche de 1 = plus long)
private _lastDragVelocity: number = 0 // Dernière vitesse de drag
private _lastDragTime: number = 0 // Dernier temps de drag
private _inertiaAmplification: number = 25 // Facteur d'amplification de l'inertie
private _previousDuration: number = 0 // Pour suivre les changements de durée
public isReversed: boolean = false
public callbacks = {
onDragStart: (): void => {},
onDragProgress: (): void => {},
onDragEnded: (secondsPlayed: number): void => {},
onStop: (): void => {},
onLoop: (params: DiscProgress): void => {}
@@ -129,7 +131,7 @@ class Disc {
this._draggingSpeeds = limit(this._draggingSpeeds, 10)
this._playbackSpeed = average(this._draggingSpeeds)
this._playbackSpeed = clamp(this._playbackSpeed, -4, 4)
// this._playbackSpeed = clamp(this._playbackSpeed, -4, 4)
}
get secondsPlayed() {
@@ -146,12 +148,9 @@ class Disc {
}
powerOn() {
if (!this.rafId) {
this.start()
}
this._isPoweredOn = true
this._basePlaybackSpeed = 1
this._playbackSpeed = 1
this._basePlaybackSpeed = this.isReversed ? -1 : 1
this.start()
}
powerOff() {
@@ -160,11 +159,21 @@ class Disc {
}
public setDuration(duration: number) {
this._previousDuration = this._duration
this._duration = duration
this._maxAngle = duration * RPS * TAU
}
onDragStart(e: PointerEvent | TouchEvent) {
// Vérifier si l'événement provient d'un élément avec la classe 'power-button' ou 'power-logo'
const target = e.target as HTMLElement
if (target.closest('.power-button, .power-logo')) {
// Ne rien faire si l'événement provient du bouton power
e.preventDefault()
e.stopPropagation()
return
}
// Empêcher le comportement par défaut pour éviter le défilement
e.preventDefault()
@@ -239,15 +248,18 @@ class Disc {
const anglePointerToCenter = angleBetween(this._center, pointerPosition)
const angle_DraggingFromToCenter = angleBetween(this._center, this._draggingFrom)
const angleDragged = angleDifference(angle_DraggingFromToCenter, anglePointerToCenter)
let angleDragged = angleDifference(angle_DraggingFromToCenter, anglePointerToCenter)
// Calcul de la vitesse de déplacement angulaire (radians par milliseconde)
// On inverse le signe pour que le sens de l'inertie soit naturel
// Le signe est inversé pour que le glissement vers la droite fasse tourner vers la droite
if (deltaTime > 0) {
this._lastDragVelocity = -angleDragged / deltaTime
}
this._lastDragTime = currentTime
// Appliquer la rotation au disque
// Le signe est inversé pour que le glissement vers la droite fasse tourner vers la droite
this.setAngle(this._currentAngle - angleDragged)
this._draggingFrom = { ...pointerPosition }
}
@@ -258,12 +270,15 @@ class Disc {
// Activer l'inertie avec la vitesse de drag actuelle
this._isInertiaActive = true
// Augmenter la sensibilité du drag avec le facteur d'amplification
this._inertiaVelocity = this._lastDragVelocity * this._inertiaAmplification
// Ajuster la direction de l'inertie en fonction du mode reverse
const direction = this.isReversed ? -1 : 1
this._inertiaVelocity = this._lastDragVelocity * this._inertiaAmplification * direction
this.isDragging = false
// Toujours conserver la vitesse de base actuelle (1 si allumé, 0 si éteint)
this._basePlaybackSpeed = this._isPoweredOn ? 1 : 0
this._basePlaybackSpeed = this._isPoweredOn ? (this.isReversed ? -1 : 1) : 0
// Si le lecteur est éteint, s'assurer que la vitesse de base est bien à 0
if (!this._isPoweredOn) {
@@ -275,25 +290,41 @@ class Disc {
autoRotate(currentTimestamp: number) {
const timestampElapsed = currentTimestamp - this.previousTimestamp
const direction = this.isReversed ? -1 : 1
// Vérifier si on est au début du morceau en mode reverse
if (this.isReversed && this.secondsPlayed <= 0) {
this._currentAngle = 0
this._inertiaVelocity = 0
this._isInertiaActive = false
this._playbackSpeed = 0
this._basePlaybackSpeed = 0 // Arrêter complètement la lecture
this.el.style.transform = 'rotate(0rad)'
this.callbacks.onStop()
// Éteindre le lecteur pour éviter toute reprise automatique
this._isPoweredOn = false
this.stop()
return
}
if (this._isInertiaActive) {
// Appliquer l'inertie
const inertiaRotation = this._inertiaVelocity * timestampElapsed
this.setAngle(this._currentAngle + inertiaRotation)
// Appliquer l'inertie en tenant compte de la direction
const inertiaRotation = this._inertiaVelocity * timestampElapsed * direction
this.setAngle(this._currentAngle + inertiaRotation, true)
// Si le lecteur est allumé, faire une transition fluide vers la vitesse de lecture
if (this._isPoweredOn) {
const targetVelocity =
RADIANS_PER_MILLISECOND * Math.abs(this._basePlaybackSpeed) * direction
// Si on est proche de la vitesse de lecture normale, on désactive l'inertie
if (
Math.abs(this._inertiaVelocity - RADIANS_PER_MILLISECOND * this._basePlaybackSpeed) <
0.0001
) {
if (Math.abs(this._inertiaVelocity - targetVelocity) < 0.0001) {
this._isInertiaActive = false
this._inertiaVelocity = RADIANS_PER_MILLISECOND * this._basePlaybackSpeed
this._inertiaVelocity = targetVelocity
} else {
// Réduire progressivement la vitesse d'inertie vers la vitesse de lecture
this._inertiaVelocity +=
(RADIANS_PER_MILLISECOND * this._basePlaybackSpeed - this._inertiaVelocity) * 0.1
this._inertiaVelocity += (targetVelocity - this._inertiaVelocity) * 0.1
}
} else {
// Si le lecteur est éteint, appliquer un frottement normal
@@ -303,18 +334,61 @@ class Disc {
if (Math.abs(this._inertiaVelocity) < 0.0001) {
this._isInertiaActive = false
this._inertiaVelocity = 0
this._playbackSpeed = 0 // Mettre à jour la vitesse de lecture à 0 uniquement à la fin
this._playbackSpeed = 0
}
}
} else {
// Rotation normale à la vitesse de lecture de base
const baseRotation = RADIANS_PER_MILLISECOND * this._basePlaybackSpeed * timestampElapsed
this.setAngle(this._currentAngle + baseRotation)
// Rotation normale à la vitesse de lecture de base, dans la direction actuelle
const baseRotation =
RADIANS_PER_MILLISECOND * Math.abs(this._basePlaybackSpeed) * timestampElapsed * direction
this.setAngle(this._currentAngle + baseRotation, true)
}
}
setAngle(angle: number) {
this._currentAngle = clamp(angle, 0, this._maxAngle)
setAngle(angle: number, checkBounds = false) {
// Vérifier les limites si demandé
if (checkBounds) {
// Arrêt au début (angle < 0)
if (angle < 0) {
this._currentAngle = 0
this._inertiaVelocity = 0
this._isInertiaActive = false
this._playbackSpeed = 0
this._basePlaybackSpeed = 1
this.el.style.transform = 'rotate(0rad)'
// this.callbacks.onStop()
// this._isPoweredOn = false
// this.stop()
return 0
}
// Arrêt à la fin (angle >= _maxAngle)
else if (angle >= this._maxAngle) {
this._currentAngle = this._maxAngle
this._inertiaVelocity = 0
this._isInertiaActive = false
this._playbackSpeed = 0
this._basePlaybackSpeed = 0
this.el.style.transform = `rotate(${this._maxAngle}rad)`
this.callbacks.onStop()
this._isPoweredOn = false
this.stop()
return this._maxAngle
}
}
// Si on dépasse les limites, on reste aux bornes
if (angle < 0) {
this._currentAngle = 0
} else if (angle > this._maxAngle) {
this._currentAngle = this._maxAngle
} else {
this._currentAngle = angle
}
// Appliquer la rotation à l'élément
if (this.el) {
this.el.style.transform = `rotate(${this._currentAngle}rad)`
}
return this._currentAngle
}
@@ -326,55 +400,88 @@ class Disc {
}
stop() {
if (this.rafId) {
cancelAnimationFrame(this.rafId)
this.rafId = null
}
this.callbacks.onStop()
this._isInertiaActive = false
this._inertiaVelocity = 0
cancelAnimationFrame(this.rafId!)
this.rafId = null
}
rewind() {
this.setAngle(0)
/**
* Vérifie si le disque est à l'arrêt
*/
isStopped(): boolean {
return this.rafId === null && !this._isInertiaActive
}
/**
* Inverse la direction de rotation du disque
* @returns true si l'inversion a réussi, false sinon
*/
reverse(): boolean {
if (!this.el) return false
// Inverser la direction
this.isReversed = !this.isReversed
// Inverser la vitesse de base si nécessaire
if (this._isPoweredOn) {
this._basePlaybackSpeed = this.isReversed ? -1 : 1
}
// Mettre à jour la direction de l'animation
this.el.style.animationDirection = this.isReversed ? 'reverse' : 'normal'
// Inverser la vitesse d'inertie si elle est active
if (this._isInertiaActive) {
this._inertiaVelocity = -this._inertiaVelocity
}
return true
}
loop() {
const currentTimestamp = performance.now()
if (!this.isDragging) {
if (this._isPoweredOn) {
this.autoRotate(currentTimestamp)
} else {
// Mettre à jour le timestamp même quand le lecteur est éteint
// pour éviter un saut lors de la reprise
this.previousTimestamp = currentTimestamp
}
}
const timestampDifferenceMS = currentTimestamp - this.previousTimestamp
const rotated = this._currentAngle - this._previousAngle
const rotationNormal = RADIANS_PER_MILLISECOND * timestampDifferenceMS
// Ne mettre à jour la rotation que si le lecteur est allumé et pas en train de glisser
if (this._isPoweredOn && !this.isDragging) {
this.autoRotate(currentTimestamp)
}
this.playbackSpeed = rotated / rotationNormal || 0
this.isReversed = this._currentAngle < this._previousAngle
this._previousAngle = this._currentAngle
this.previousTimestamp = performance.now()
// Calculer la vitesse de lecture uniquement pendant le glissement ou l'inertie
if (this.isDragging || this._isInertiaActive) {
const rotated = this._currentAngle - this._previousAngle
const rotationNormal = RADIANS_PER_MILLISECOND * timestampDifferenceMS
this.playbackSpeed = rotated / rotationNormal || 0
} else if (this._isPoweredOn) {
// En mode lecture normale, utiliser la vitesse de base
this._playbackSpeed = this._basePlaybackSpeed
} else {
// Si le lecteur est éteint, vitesse à 0
this._playbackSpeed = 0
}
// Mettre à jour la rotation visuelle
this.el.style.transform = `rotate(${this._currentAngle}rad)`
const { playbackSpeed, isReversed, secondsPlayed, _duration } = this
const progress = secondsPlayed / _duration
// Appeler le callback onLoop avec les informations de lecture
const secondsPlayed = this.secondsPlayed
const progress = this._duration > 0 ? secondsPlayed / this._duration : 0
this.callbacks.onLoop({
playbackSpeed,
isReversed,
secondsPlayed,
progress
})
// Ne pas appeler onLoop si rien n'a changé
if (this._previousAngle !== this._currentAngle || this._previousDuration !== this._duration) {
this.callbacks.onLoop({
playbackSpeed: this._playbackSpeed, // Utiliser _playbackSpeed directement
isReversed: this.isReversed,
secondsPlayed,
progress
})
}
this._previousAngle = this._currentAngle
this.previousTimestamp = currentTimestamp
// Continuer la boucle d'animation
this.rafId = requestAnimationFrame(this.loop)
}
}

View File

@@ -8,6 +8,7 @@ class Sampler {
public duration: number = 0
public isReversed: boolean = false
public currentSpeed: number = 0
constructor() {
this.gainNode.connect(this.audioContext.destination)
@@ -44,16 +45,24 @@ class Sampler {
}
changeDirection(isReversed: boolean, secondsPlayed: number) {
// S'assurer que la position est dans les limites
const safePosition = Math.max(0, Math.min(secondsPlayed, this.duration))
this.isReversed = isReversed
this.play(secondsPlayed)
this.play(safePosition)
}
play(offset = 0) {
this.pause()
if (!this.audioBuffer || !this.audioBufferReversed) {
return
}
const buffer = this.isReversed ? this.audioBufferReversed : this.audioBuffer
const cueTime = this.isReversed ? this.duration - offset : offset
// S'assurer que l'offset est dans les limites
const safeOffset = Math.max(0, Math.min(offset, this.duration))
const cueTime = this.isReversed ? this.duration - safeOffset : safeOffset
this.audioSource = this.audioContext.createBufferSource()
this.audioSource.buffer = buffer
@@ -61,7 +70,9 @@ class Sampler {
this.audioSource.connect(this.gainNode)
this.audioSource.start(0, cueTime)
// S'assurer que le cueTime n'est pas négatif
const startTime = Math.max(0, cueTime)
this.audioSource.start(0, startTime)
}
updateSpeed(speed: number, isReversed: boolean, secondsPlayed: number) {
@@ -69,6 +80,9 @@ class Sampler {
return
}
// Mettre à jour la vitesse actuelle
this.currentSpeed = speed
if (isReversed !== this.isReversed) {
this.changeDirection(isReversed, secondsPlayed)
}
@@ -90,6 +104,52 @@ class Sampler {
this.audioSource.stop()
}
/**
* Inverse la direction de lecture
* @returns La nouvelle direction (true = inversé, false = normal)
*/
reverse(secondsPlayed: number = 0): boolean {
if (!this.audioBuffer) return false
// Inverser la direction
this.isReversed = !this.isReversed
// Si on a une position, on relance la lecture à cette position
if (secondsPlayed > 0) {
// S'assurer que la position est dans les limites
const safePosition = Math.max(0, Math.min(secondsPlayed, this.duration))
this.play(safePosition)
}
return this.isReversed
}
mute() {
this.gainNode.gain.value = 0
}
unmute() {
this.gainNode.gain.value = 1
}
/**
* Définit le taux de lecture (vitesse de lecture)
* @param rate Taux de lecture (1.0 = vitesse normale, 0.5 = moitié de vitesse, 2.0 = double vitesse, etc.)
*/
setPlaybackRate(rate: number) {
if (!this.audioSource) return
const currentTime = this.audioContext.currentTime
this.audioSource.playbackRate.cancelScheduledValues(currentTime)
this.audioSource.playbackRate.linearRampToValueAtTime(
Math.max(0.001, Math.abs(rate)), // Éviter les valeurs négatives ou nulles
currentTime + 0.05 // Petit délai pour éviter les clics
)
// Mettre à jour la vitesse actuelle
this.currentSpeed = rate
}
}
export default Sampler

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

@@ -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

@@ -15,7 +15,7 @@
</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'

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

@@ -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

@@ -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, Track } from '~~/types/types'
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'

View File

@@ -58,7 +58,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useUiStore } from '~/store/ui'
import type { Track } from '~~/types/types'
import type { Track } from '~~/types'
const uiStore = useUiStore()
const currentTrack = ref<Track | null>(null)

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>

View File

@@ -22,7 +22,7 @@ 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/types'
import type { Track } from '~~/types'
const route = useRoute()
const playerStore = usePlayerStore()

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

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import type { Track } from '~~/types/types'
import type { Track } from '~~/types'
export const useCardStore = defineStore('card', {
state: () => ({

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import type { Track } from '~~/types/types'
import type { Track } from '~~/types'
import Disc from '~/platine-tools/disc'
import Sampler from '~/platine-tools/sampler'
import { useCardStore } from '~/store/card'
@@ -74,7 +74,7 @@ export const usePlatineStore = defineStore('platine', () => {
isLoadingTrack.value = true
try {
await sampler.value.loadTrack(track.url)
await sampler.value.loadTrack(track.filePath)
if (disc.value) {
disc.value.setDuration(sampler.value.duration)
updateTurns()

View File

@@ -1,3 +0,0 @@
body {
background-color: red !important;
}

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: 'data/music.db'
}
})

View File

@@ -4,9 +4,32 @@ import tsconfigPaths from 'vite-tsconfig-paths'
const isProd = process.env.NODE_ENV === 'production'
export default defineNuxtConfig({
runtimeConfig: {
NUXT_PATH_FILES: 'mnt/media/files/music',
NUXT_URL_PREFIX: 'https://files.erudi.fr/music/'
},
nitro: {
experimental: {
tasks: true
},
scheduledTasks: {
'*/1 * * * *': ['sync-tracks']
}
},
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
modules: ['@nuxt/eslint', '@nuxtjs/tailwindcss', '@pinia/nuxt'],
components: [
{
path: '~/components',
pathPrefix: false
}
],
typescript: {
tsConfig: {
include: ['types/**/*.ts']
}
},
vite: {
plugins: [tsconfigPaths()]
},

View File

@@ -1,6 +1,7 @@
{
"name": "nuxt-app",
"name": "evilspins",
"type": "module",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nuxt build",
@@ -12,19 +13,21 @@
"lint:fix": "eslint . --fix",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"migrate": "tsx server/database/migrate.ts",
"db:reset": "rm -f server/database/evilspins.db && npm run migrate"
"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",
"@types/chokidar": "^2.1.7",
"atropos": "^2.0.2",
"better-sqlite3": "^12.5.0",
"chokidar": "^5.0.0",
"nuxt": "^4.2.0",
"blurhash": "^2.0.5",
"drizzle-orm": "^0.45.1",
"nuxt": "^4.3.0",
"pinia": "^3.0.3",
"sharp": "^0.34.5",
"vue": "^3.5.18",
"vue-router": "^4.5.1",
"vuedraggable": "^4.1.0"
@@ -32,15 +35,16 @@
"engines": {
"pnpm": ">=10 <11"
},
"packageManager": "pnpm@10.27.0",
"packageManager": "pnpm@10.28.0",
"devDependencies": {
"@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.1",
"@nuxt/eslint-config": "^1.10.0",
"@nuxtjs/eslint-config-typescript": "^12.1.0",
"@types/better-sqlite3": "^7.6.13",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"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",
@@ -50,6 +54,7 @@
"patch-package": "^8.0.1",
"sass-embedded": "^1.93.2",
"tsx": "^4.21.0",
"typescript": "^5.7.3",
"vite-tsconfig-paths": "^5.1.4"
}
}

3966
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/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

4
public/old.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg fill="none" height="36" viewBox="0 0 36 36" width="36">
<path d="M11.29 2.92C14.85 1.33 18.87 1.06 22.61 2.15L22.96 2.26C26.56 3.40 29.67 5.74 31.75 8.89L31.95 9.19C33.90 12.28 34.77 15.93 34.42 19.56L34.38 19.93C34.04 22.79 32.96 25.51 31.25 27.83C29.53 30.14 27.23 31.97 24.59 33.12C21.95 34.27 19.05 34.71 16.18 34.40C13.32 34.08 10.59 33.02 8.26 31.32L7.97 31.10C4.87 28.73 2.71 25.33 1.88 21.52L3.34 21.20L4.81 20.88C5.49 24.00 7.25 26.77 9.79 28.72L10.27 29.07C12.19 30.40 14.41 31.22 16.74 31.44C19.06 31.65 21.40 31.27 23.53 30.31C25.66 29.35 27.50 27.86 28.88 25.98C30.26 24.10 31.13 21.89 31.40 19.58L31.46 18.98C31.68 16.00 30.90 13.03 29.25 10.54C27.60 8.05 25.17 6.18 22.34 5.22L21.77 5.04C19.02 4.23 16.08 4.33 13.38 5.31C10.68 6.29 8.37 8.11 6.77 10.5H10.5L10.65 10.50C11.03 10.54 11.38 10.73 11.63 11.02C11.88 11.31 12.01 11.69 11.99 12.07C11.97 12.46 11.81 12.82 11.53 13.08C11.25 13.35 10.88 13.49 10.5 13.5H1.5V4.5L1.50 4.34C1.54 3.97 1.71 3.63 1.99 3.38C2.27 3.13 2.62 3.00 3 3.00C3.37 3.00 3.72 3.13 4.00 3.38C4.28 3.63 4.45 3.97 4.49 4.34L4.5 4.5V8.51C6.21 6.07 8.56 4.13 11.29 2.92ZM24 18L15 12.75V23.25L24 18ZM3.02 19.73C2.63 19.82 2.29 20.05 2.08 20.39C1.86 20.72 1.79 21.13 1.88 21.52L4.81 20.88C4.77 20.69 4.69 20.50 4.57 20.34C4.46 20.18 4.32 20.04 4.15 19.94C3.99 19.83 3.80 19.76 3.61 19.72C3.41 19.69 3.21 19.69 3.02 19.73Z" fill="white">
</path>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

41
public/rewind.svg Normal file
View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
fill="none"
height="50"
viewBox="0 0 50 50"
width="50"
version="1.1"
id="svg1"
sodipodi:docname="rewind.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:zoom="8.9665068"
inkscape:cx="15.334846"
inkscape:cy="20.018944"
inkscape:window-width="1920"
inkscape:window-height="1132"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<path
d="M 15.44514,3.5259965 C 20.516099,1.2611577 26.242296,0.87656242 31.569652,2.4291878 l 0.49855,0.1566869 c 5.127937,1.6238467 9.557904,4.9570057 12.520712,9.4439503 l 0.284886,0.427328 c 2.777632,4.401479 4.016884,9.600638 3.518334,14.771307 l -0.05698,0.527038 c -0.484305,4.073861 -2.022686,7.948303 -4.458456,11.252973 -2.450014,3.290426 -5.726196,5.897127 -9.486683,7.535218 -3.760488,1.638091 -7.891326,2.264839 -11.979431,1.823267 -4.073861,-0.455817 -7.962546,-1.965709 -11.281461,-4.387235 L 10.716042,43.666347 C 6.3003187,40.290455 3.2235566,35.447404 2.0412822,30.020337 l 2.0796633,-0.455816 2.0939076,-0.455817 c 0.9686103,4.444212 3.4756017,8.389875 7.0936459,11.167507 l 0.683725,0.498549 c 2.7349,1.894488 5.897128,3.062518 9.216042,3.375892 3.304671,0.29913 6.63783,-0.242152 9.671859,-1.609602 3.034029,-1.36745 5.654975,-3.489846 7.620684,-6.167769 1.965709,-2.677922 3.204961,-5.825906 3.589556,-9.116332 l 0.08546,-0.854656 C 44.489204,22.157501 43.378151,17.926953 41.027847,14.38013 38.677543,10.833307 35.216185,8.1696282 31.185057,6.8021784 L 30.373134,6.5457816 C 26.45596,5.3919958 22.268144,5.5344384 18.422192,6.9303768 14.576239,8.3263152 11.285813,10.918772 9.0067299,14.323153 h 5.3131121 0.213664 c 0.541283,0.05698 1.039832,0.327618 1.395939,0.740702 0.356106,0.413083 0.541282,0.954366 0.512793,1.495648 -0.02849,0.555526 -0.256396,1.06832 -0.655236,1.438671 -0.39884,0.384595 -0.925877,0.584015 -1.46716,0.598259 H 1.5 V 5.776591 5.5486827 C 1.5569771,5.0216448 1.7991297,4.5373396 2.1979692,4.1812329 2.5968087,3.8251261 3.0953581,3.6399506 3.6366404,3.6399506 c 0.5270379,0 1.0255874,0.1851755 1.4244269,0.5412823 0.3988395,0.3561067 0.6409921,0.8404119 0.6979692,1.3674498 l 0.014244,0.2279083 v 5.711952 C 8.2090508,8.0129413 11.556454,5.2495531 15.44514,3.5259965 Z M 3.6651289,27.470613 C 3.1096024,27.598811 2.6252973,27.92643 2.3261676,28.410735 2.0127937,28.880796 1.9130838,29.464811 2.0412822,30.020337 L 6.2148531,29.108704 C 6.157876,28.838063 6.0439219,28.567422 5.8729906,28.339513 5.7163037,28.111605 5.5168839,27.912185 5.2747313,27.769743 5.046823,27.613056 4.7761819,27.513346 4.5055408,27.456369 4.2206554,27.413636 3.93577,27.413636 3.6651289,27.470613 Z"
fill="#ffffff"
id="path1"
sodipodi:nodetypes="ccccccccscccccccccssccsccscccsccccccssscccccccccccc"
style="stroke-width:1.42443" />
</svg>

After

Width:  |  Height:  |  Size: 3.3 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,168 +0,0 @@
# Migration vers SQLite - Guide d'utilisation
## 📦 Installation
```bash
pnpm add better-sqlite3
pnpm add -D @types/better-sqlite3
```
## 🚀 Migration des données
### 1. Exécuter la migration
```bash
pnpm tsx server/database/migrate.ts
```
Cette commande va :
- Créer la base de données SQLite dans `server/database/evilspins.db`
- Créer les tables (boxes, sides, artists, tracks)
- Importer toutes vos données existantes
### 2. Vérifier la migration
Lancez votre serveur Nuxt :
```bash
pnpm dev
```
Testez les nouveaux endpoints :
```bash
# Récupérer toutes les boxes
curl http://localhost:3000/api/boxes
# Récupérer tous les artistes
curl http://localhost:3000/api/artists
# Récupérer les tracks de compilation (première page)
curl http://localhost:3000/api/tracks/compilation
# Filtrer par boxId
curl http://localhost:3000/api/tracks/compilation?boxId=ES00
# Pagination
curl http://localhost:3000/api/tracks/compilation?page=2&limit=10
```
## 📁 Structure créée
```
server/
├── database/
│ ├── evilspins.db # Base SQLite (créée automatiquement)
│ ├── schema.sql # Schéma de la base
│ └── migrate.ts # Script de migration
├── utils/
│ └── database.ts # Utilitaire de connexion
└── api/
├── boxes.ts # ✅ Nouveau (SQLite)
├── artists.ts # ✅ Nouveau (SQLite)
└── tracks/
├── compilation.ts # ✅ Nouveau (SQLite avec pagination)
└── playlist.ts # ⚠️ À adapter
```
## 🔄 Côté client : utiliser la pagination
Exemple pour charger les tracks progressivement :
```typescript
// Au lieu de charger tout d'un coup
const { data } = await useFetch('/api/tracks/compilation')
// Maintenant avec pagination
const { data } = await useFetch('/api/tracks/compilation', {
query: {
page: 1,
limit: 50,
boxId: 'ES00',
side: 'A'
}
})
// data.tracks -> tableau de tracks
// data.pagination -> { page, limit, total, totalPages }
```
## 📊 Avantages obtenus
**Performances** : Plus de chargement massif, pagination efficace
**Scalabilité** : Peut gérer des milliers de tracks sans ralentir
**Filtrage** : Recherche et filtres côté serveur (ultra rapide)
**Déploiement** : Un seul fichier `.db` à déployer
## 🔧 À faire ensuite
### 1. Adapter l'endpoint playlist
L'endpoint `tracks/playlist.ts` lit des fichiers sur disque. Options :
**Option A** : Scanner le dossier au démarrage et insérer dans SQLite
**Option B** : Garder la lecture filesystem mais optimiser avec un cache
### 2. Modifier le frontend
Mettre à jour vos composants Vue pour utiliser la pagination :
```vue
<script setup>
const page = ref(1)
const { data, refresh } = await useFetch('/api/tracks/compilation', {
query: { page, limit: 20 }
})
function loadMore() {
page.value++
refresh()
}
</script>
```
### 3. Ajouter des fonctionnalités
Exemples de requêtes possibles maintenant :
```typescript
// Recherche par titre
GET /api/tracks/compilation?search=love
// Tracks d'un artiste
GET /api/tracks/compilation?artistId=5
// Tri personnalisé
GET /api/tracks/compilation?sortBy=title&order=asc
```
## 🐛 Dépannage
### Erreur "Cannot find module 'better-sqlite3'"
```bash
pnpm add better-sqlite3
```
### La base ne se crée pas
Vérifiez les permissions :
```bash
chmod -R 755 server/database
```
### Données manquantes après migration
Re-exécutez la migration :
```bash
pnpm tsx scripts/migrate.ts
```
## 📝 Notes
- La base SQLite est créée automatiquement au premier lancement
- Elle est incluse dans `.gitignore` par défaut (à ajuster selon vos besoins)
- Pour un déploiement, commitez le fichier `.db` OU re-exécutez la migration en production

View File

@@ -1,23 +0,0 @@
import { eventHandler } from 'h3'
import { getDatabase } from '../utils/database'
export default eventHandler(() => {
const db = getDatabase()
const artists = db
.prepare(
`
SELECT id, name, url, cover_id
FROM artists
ORDER BY id
`
)
.all()
return artists.map((artist: any) => ({
id: artist.id,
name: artist.name,
url: artist.url,
coverId: `https://f4.bcbits.com/img/${artist.cover_id}_4.jpg`
}))
})

View File

@@ -1,61 +0,0 @@
import { eventHandler } from 'h3'
import { getDatabase } from '../utils/database'
export default eventHandler(() => {
const db = getDatabase()
// Récupérer les boxes
const boxes = db
.prepare(
`
SELECT id, type, name, description, state, duration, active_side, color1, color2
FROM boxes
ORDER BY id
`
)
.all()
// Récupérer les sides pour chaque box
const sides = db
.prepare(
`
SELECT box_id, side, name, description, duration, color1, color2
FROM sides
`
)
.all()
// Grouper les sides par box_id
const sidesByBoxId: Record<string, any> = {}
for (const side of sides) {
if (!sidesByBoxId[side.box_id]) {
sidesByBoxId[side.box_id] = {}
}
sidesByBoxId[side.box_id][side.side] = {
name: side.name,
description: side.description,
duration: side.duration,
color1: side.color1,
color2: side.color2
}
}
// Formater les résultats
return boxes.map((box: any) => ({
id: box.id,
type: box.type,
name: box.name,
description: box.description,
state: box.state,
duration: box.duration,
activeSide: box.active_side,
...(box.type === 'compilation'
? {
sides: sidesByBoxId[box.id] || {}
}
: {
color1: box.color1,
color2: box.color2
})
}))
})

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: 'Slug 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
}
})

15
server/api/card/random.ts Normal file
View File

@@ -0,0 +1,15 @@
import { useDB, schema } from '../../db'
import { sql } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const db = useDB()
const count = await db
.select({ count: sql<number>`count(*)` })
.from(schema.cards)
.get()
const randomOffset = Math.floor(Math.random() * count.count)
const randomCard = await db.select().from(schema.cards).limit(1).offset(randomOffset).get()
return randomCard
})

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 runtimeConfig = useRuntimeConfig()
const folderPath = runtimeConfig.NUXT_PATH_FILES
if (!folderPath) {
throw createError({
statusCode: 500,
message: 'NUXT_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 runtimeConfig = useRuntimeConfig()
const folderPath = runtimeConfig.NUXT_PATH_FILES
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 runtimeConfig = useRuntimeConfig()
const folderPath = runtimeConfig.NUXT_PATH_FILES
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,93 +0,0 @@
import { eventHandler, getQuery } from 'h3'
import { getDatabase } from '../../utils/database'
export default eventHandler((event) => {
const db = getDatabase()
const query = getQuery(event)
// Paramètres de pagination
const page = parseInt(query.page as string) || 1
const limit = parseInt(query.limit as string) || 50
const offset = (page - 1) * limit
// Filtres optionnels
const boxId = query.boxId as string | undefined
const side = query.side as string | undefined
// Construction de la requête
let sql = `
SELECT
t.id,
t.box_id,
t.side,
t.track_order,
t.title,
t.artist_id,
t.start,
t.link,
t.cover_id,
t.url,
t.type,
a.name as artist_name
FROM tracks t
LEFT JOIN artists a ON t.artist_id = a.id
WHERE t.type = 'compilation'
`
const params: any[] = []
if (boxId) {
sql += ' AND t.box_id = ?'
params.push(boxId)
}
if (side) {
sql += ' AND t.side = ?'
params.push(side)
}
sql += ' ORDER BY t.box_id, t.side, t.track_order'
sql += ' LIMIT ? OFFSET ?'
params.push(limit, offset)
const tracks = db.prepare(sql).all(...params)
// Compter le total pour la pagination
let countSql = "SELECT COUNT(*) as total FROM tracks WHERE type = 'compilation'"
const countParams: any[] = []
if (boxId) {
countSql += ' AND box_id = ?'
countParams.push(boxId)
}
if (side) {
countSql += ' AND side = ?'
countParams.push(side)
}
const { total } = db.prepare(countSql).get(...countParams) as { total: number }
return {
tracks: tracks.map((track: any) => ({
id: track.id,
boxId: track.box_id,
side: track.side,
order: track.track_order,
title: track.title,
artist: track.artist_id,
artistName: track.artist_name,
start: track.start,
link: track.link,
coverId: track.cover_id,
url: track.url,
type: track.type
})),
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
}
})

View File

@@ -1,93 +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[] = []
const items = await fs.promises.readdir(dirPath, { withFileTypes: true })
// Process files
const files = items
.filter((item) => item.isFile() && !item.name.startsWith('.') && !item.name.endsWith('.jpg'))
.map((item) => item.name)
// Process folders
const folders = items
.filter((item) => item.isDirectory() && !item.name.startsWith('.'))
.map((folder, index) => ({
id: `folder-${index}`,
boxId: 'ESFOLDER',
title: folder.name.replace(/_/g, ' ').trim(),
type: 'folder',
order: 0,
date: new Date(),
card: getCardFromDate(new Date())
}))
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())
// Combine folders and tracks
const allItems = [...folders, ...tracks]
// Sort by date (newest first) and assign order
allItems.sort((a, b) => b.date.getTime() - a.date.getTime())
allItems.forEach((item, i) => (item.order = i + 1))
return allItems
} catch (error) {
return {
success: false,
error: (error as Error).message
}
}
})

View File

@@ -1,91 +0,0 @@
import { eventHandler, getQuery } from 'h3'
import { getDatabase } from '../../utils/database'
export default eventHandler((event) => {
const db = getDatabase()
const query = getQuery(event)
const search = (query.search as string) || ''
const page = parseInt(query.page as string) || 1
const limit = parseInt(query.limit as string) || 50
const offset = (page - 1) * limit
// Construction de la requête de recherche
let sql = `
SELECT
t.id,
t.box_id,
t.side,
t.track_order,
t.title,
t.artist_id,
t.start,
t.link,
t.cover_id,
t.url,
t.type,
a.name as artist_name,
a.url as artist_url
FROM tracks t
LEFT JOIN artists a ON t.artist_id = a.id
WHERE 1=1
`
const params: any[] = []
// Recherche par titre ou artiste
if (search) {
sql += ` AND (t.title LIKE ? OR a.name LIKE ?)`
const searchPattern = `%${search}%`
params.push(searchPattern, searchPattern)
}
sql += ' ORDER BY t.box_id, t.track_order'
sql += ' LIMIT ? OFFSET ?'
params.push(limit, offset)
const tracks = db.prepare(sql).all(...params)
// Compter le total
let countSql = `
SELECT COUNT(*) as total
FROM tracks t
LEFT JOIN artists a ON t.artist_id = a.id
WHERE 1=1
`
const countParams: any[] = []
if (search) {
countSql += ` AND (t.title LIKE ? OR a.name LIKE ?)`
const searchPattern = `%${search}%`
countParams.push(searchPattern, searchPattern)
}
const { total } = db.prepare(countSql).get(...countParams) as { total: number }
return {
tracks: tracks.map((track: any) => ({
id: track.id,
boxId: track.box_id,
side: track.side,
order: track.track_order,
title: track.title,
artist: track.artist_id,
artistName: track.artist_name,
artistUrl: track.artist_url,
start: track.start,
link: track.link,
coverId: track.cover_id,
url: track.url,
type: track.type
})),
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
},
search
}
})

Binary file not shown.

View File

@@ -1,678 +0,0 @@
import { getDatabase } from '../utils/database'
// Import des données depuis vos anciens fichiers
const boxes = [
{
id: 'ES01',
type: 'compilation',
name: '...',
description: '...',
state: 'box-hidden',
duration: 3487 + 3773,
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,
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'
}
]
const artists = [
{ 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'
}
]
const tracks = [
{
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'
},
{
order: 2,
boxId: 'ES00',
side: 'A',
title: 'Bleach',
artist: 1,
start: 392,
link: 'https://the-kundalini-genie.bandcamp.com/track/bleach-2',
coverId: 'a1714786533'
},
{
order: 3,
boxId: 'ES00',
side: 'A',
title: 'Televised mind',
artist: 2,
start: 896,
link: 'https://fontainesdc.bandcamp.com/track/televised-mind',
coverId: 'a3772806156'
},
{
order: 4,
boxId: 'ES00',
side: 'A',
title: 'In it',
artist: 3,
start: 1139,
link: 'https://howlinbananarecords.bandcamp.com/track/in-it',
coverId: 'a1720372066'
},
{
order: 5,
boxId: 'ES00',
side: 'A',
title: 'Bad michel',
artist: 4,
start: 1245,
link: 'https://johnnymafia.bandcamp.com/track/bad-michel-3',
coverId: 'a0984622869'
},
{
order: 6,
boxId: 'ES00',
side: 'A',
title: 'Overall',
artist: 5,
start: 1394,
link: 'https://newcandys.bandcamp.com/track/overall',
coverId: 'a0559661270'
},
{
order: 7,
boxId: 'ES00',
side: 'A',
title: 'Blowup',
artist: 6,
start: 1674,
link: 'https://magicshoppe.bandcamp.com/track/blowup',
coverId: 'a1444895293'
},
{
order: 8,
boxId: 'ES00',
side: 'A',
title: 'Guitar jet',
artist: 7,
start: 1880,
link: 'https://radiomartiko.bandcamp.com/track/guitare-jet',
coverId: 'a1494681687'
},
{
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'
},
{
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'
},
{
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'
},
{
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'
},
{
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'
},
{
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'
},
{
order: 4,
boxId: 'ES00',
side: 'B',
title: 'Lights off',
artist: 3,
start: 781,
link: 'https://howlinbananarecords.bandcamp.com/track/lights-off',
coverId: 'a1720372066'
},
{
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'
},
{
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'
},
{
order: 7,
boxId: 'ES00',
side: 'B',
title: 'Redhead',
artist: 6,
start: 1303,
link: 'https://magicshoppe.bandcamp.com/track/redhead',
coverId: 'a0594426943'
},
{
order: 8,
boxId: 'ES00',
side: 'B',
title: 'Supersonic twist',
artist: 7,
start: 1584,
link: 'https://open.spotify.com/track/66voQIZAJ3zD3Eju2qtNjF',
coverId: 'a1494681687'
},
{
order: 9,
boxId: 'ES00',
side: 'B',
title: 'Flowers',
artist: 8,
start: 1749,
link: 'https://traams.bandcamp.com/track/flowers',
coverId: 'a3644668199'
},
{
order: 10,
boxId: 'ES00',
side: 'B',
title: 'The shade',
artist: 9,
start: 1924,
link: 'https://blue-orchid.bandcamp.com/track/the-shade',
coverId: 'a0804204790'
},
{
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'
},
{
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'
},
{
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'
},
{
order: 3,
boxId: 'ES01',
side: 'A',
title: 'Broadcaster',
artist: 13,
start: 1104.5,
link: 'https://squiduk.bandcamp.com/track/broadcaster',
coverId: 'a3391719769'
},
{
order: 4,
boxId: 'ES01',
side: 'A',
title: 'Mourn',
artist: 14,
start: 1441,
link: 'https://lysistrata.bandcamp.com/track/mourn-2',
coverId: 'a0872900041'
},
{
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'
},
{
order: 6,
boxId: 'ES01',
side: 'A',
title: 'Sunday Mourning',
artist: 16,
start: 2091.7,
link: 'https://nightbeats.bandcamp.com/track/sunday-mourning',
coverId: 'a0031987121'
},
{
order: 7,
boxId: 'ES01',
side: 'A',
title: '3030 Instrumental',
artist: 17,
start: 2339.3,
link: 'https://delthefunkyhomosapien.bandcamp.com/track/3030',
coverId: 'a1948146136'
},
{
order: 8,
boxId: 'ES01',
side: 'A',
title: 'Immortality Break',
artist: 18,
start: 2530.5,
link: 'https://theaa.bandcamp.com/track/immortality-break',
coverId: 'a2749250329'
},
{
order: 9,
boxId: 'ES01',
side: 'A',
title: 'Lazy Bones',
artist: 19,
start: 2718,
link: 'https://woodenshjips.bandcamp.com/track/lazy-bones',
coverId: 'a1884221104'
},
{
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'
},
{
order: 11,
boxId: 'ES01',
side: 'A',
title: 'Me',
artist: 21,
start: 3265,
link: 'https://secretcolours.bandcamp.com/track/me',
coverId: 'a1497022499'
},
{
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'
},
{
order: 2,
boxId: 'ES01',
side: 'B',
title: 'Dreamscapes',
artist: 12,
start: 235,
link: 'https://littlecloudrecords.bandcamp.com/track/dreamscapes',
coverId: 'a3498981203'
},
{
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'
},
{
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'
},
{
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'
},
{
order: 6,
boxId: 'ES01',
side: 'B',
title: 'Celebration #1',
artist: 16,
start: 2235,
link: 'https://nightbeats.bandcamp.com/track/celebration-1',
coverId: 'a0031987121'
},
{
order: 7,
boxId: 'ES01',
side: 'B',
title: '3030 Instrumental',
artist: 17,
start: 2458.3,
link: 'https://delthefunkyhomosapien.bandcamp.com/track/3030',
coverId: 'a1948146136'
},
{
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'
},
{
order: 9,
boxId: 'ES01',
side: 'B',
title: 'Rising',
artist: 19,
start: 3145,
link: 'https://woodenshjips.bandcamp.com/track/rising',
coverId: 'a1884221104'
},
{
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'
},
{
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'
}
]
export async function migrate() {
console.log('🚀 Début de la migration...')
const db = getDatabase()
// Vider les tables existantes
db.exec('DELETE FROM tracks')
db.exec('DELETE FROM sides')
db.exec('DELETE FROM artists')
db.exec('DELETE FROM boxes')
console.log('🗑️ Tables vidées')
// Insérer les boxes
const insertBox = db.prepare(`
INSERT INTO boxes (id, type, name, description, state, duration, active_side, color1, color2)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
const insertSide = db.prepare(`
INSERT INTO sides (box_id, side, name, description, duration, color1, color2)
VALUES (?, ?, ?, ?, ?, ?, ?)
`)
for (const box of boxes) {
insertBox.run(
box.id,
box.type,
box.name,
box.description,
box.state,
box.duration,
box.activeSide,
box.color1 || null,
box.color2 || null
)
// Insérer les sides si c'est une compilation
if (box.sides) {
for (const [sideName, sideData] of Object.entries(box.sides)) {
insertSide.run(
box.id,
sideName,
sideData.name,
sideData.description,
sideData.duration,
sideData.color1,
sideData.color2
)
}
}
}
console.log(`${boxes.length} boxes insérées`)
// Insérer les artists
const insertArtist = db.prepare(`
INSERT INTO artists (id, name, url, cover_id)
VALUES (?, ?, ?, ?)
`)
for (const artist of artists) {
insertArtist.run(artist.id, artist.name, artist.url, artist.coverId)
}
console.log(`${artists.length} artistes insérés`)
// Insérer les tracks
const insertTrack = db.prepare(`
INSERT INTO tracks (box_id, side, track_order, title, artist_id, start, link, cover_id, url, type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
for (const track of tracks) {
const url = `https://files.erudi.fr/evilspins/${track.boxId}${track.side}.mp3`
const coverId = `https://f4.bcbits.com/img/${track.coverId}_4.jpg`
insertTrack.run(
track.boxId,
track.side,
track.order,
track.title,
track.artist,
track.start,
track.link,
coverId,
url,
'compilation'
)
}
console.log(`${tracks.length} tracks insérées`)
console.log('🎉 Migration terminée avec succès !')
}
// Exécuter la migration si appelé directement
if (import.meta.url === `file://${process.argv[1]}`) {
migrate()
.then(() => process.exit(0))
.catch((err) => {
console.error('❌ Erreur lors de la migration:', err)
process.exit(1)
})
}

View File

@@ -1,63 +0,0 @@
-- Boxes table
CREATE TABLE IF NOT EXISTS boxes (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
state TEXT,
duration INTEGER,
active_side TEXT,
color1 TEXT,
color2 TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Sides table (pour les compilations qui ont A et B)
CREATE TABLE IF NOT EXISTS sides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
box_id TEXT NOT NULL,
side TEXT NOT NULL,
name TEXT,
description TEXT,
duration INTEGER,
color1 TEXT,
color2 TEXT,
FOREIGN KEY (box_id) REFERENCES boxes(id) ON DELETE CASCADE,
UNIQUE(box_id, side)
);
-- Artists table
CREATE TABLE IF NOT EXISTS artists (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
url TEXT,
cover_id TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Tracks table
CREATE TABLE IF NOT EXISTS tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
box_id TEXT NOT NULL,
side TEXT,
track_order INTEGER,
title TEXT NOT NULL,
artist_id INTEGER,
start INTEGER,
link TEXT,
cover_id TEXT,
url TEXT,
type TEXT,
year INTEGER,
date DATETIME,
card TEXT,
FOREIGN KEY (box_id) REFERENCES boxes(id) ON DELETE CASCADE,
FOREIGN KEY (artist_id) REFERENCES artists(id) ON DELETE SET NULL
);
-- Index pour les requêtes fréquentes
CREATE INDEX IF NOT EXISTS idx_tracks_box_id ON tracks(box_id);
CREATE INDEX IF NOT EXISTS idx_tracks_artist_id ON tracks(artist_id);
CREATE INDEX IF NOT EXISTS idx_tracks_type ON tracks(type);
CREATE INDEX IF NOT EXISTS idx_tracks_year ON tracks(year);
CREATE INDEX IF NOT EXISTS idx_sides_box_id ON sides(box_id);

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

@@ -0,0 +1,27 @@
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
let dbPath = 'data/music.db'
// 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 }

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

@@ -0,0 +1,24 @@
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(),
blurhash: text('blurhash').notNull(), // blurhash of the image
createdAt: int('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: int('updated_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date())
})

586
server/db/start.prompt Normal file
View File

@@ -0,0 +1,586 @@
Je développe une application musicale en Node.js qui gère des morceaux de musique (tracks). Voici l'architecture actuelle des données :Tracks : Morceaux de musique stockés sous forme de fichiers audio.
Playlists : Ensembles de tracks regroupés dans un dossier spécifique sur le disque (ex. : un dossier par playlist). Actuellement, à chaque requête, le serveur scanne récursivement le dossier pour lister les tracks, ce qui génère la playlist dynamiquement.
Compilations : Ensembles de tracks mixés ensemble, représentant un seul fichier audio unifié. Actuellement, les compilations sont hardcodées dans le code (pas de scan dynamique).
Problèmes actuels :Performances : Le scan des dossiers pour les playlists prend ~6 secondes par requête, ce qui est inacceptable pour une bonne UX.
Manque d'uniformité : Les playlists sont générées dynamiquement (lourd), tandis que les compilations sont statiques (hardcodées), rendant la maintenance difficile.
Pas de persistance : Aucune base de données, donc pas de cache ni de requêtes rapides.
Objectifs :Utiliser une base de données SQLite comme cache pour stocker les métadonnées des tracks, playlists et compilations, afin d'éviter les scans disque à chaque requête.
Uniformiser l'architecture : Stocker à la fois les playlists (issues de scans de dossiers) et les compilations en base.
Améliorer les performances : Les requêtes doivent renvoyer les données depuis la DB en <1 seconde, avec un scan disque initial ou périodique pour mise à jour.
ORM : Utiliser Drizzle ORM pour interagir avec SQLite (facile à setup en Node.js).
Schéma DB suggéré (à affiner si needed) :
Table tracks : id, title, artist, duration, file_path (chemin du fichier), source_type ('playlist' ou 'compilation'), source_id (FK vers playlist ou compilation).
Table compilations : id, name, file_path (chemin du fichier mixé), tracks_list (JSON ou relation many-to-many si needed).
Contraintes techniques :DB : SQLite uniquement (fichier local, pas de serveur).
Environnement : Node.js (version récente, ex. 18+). Pas d'installation de paquets incompatibles. Deployable dans une app Nuxt 4, la partie typescript sera écrite dans le dossier server prevu dans nuxt avec h3.
Gestion des mises à jour : Implémenter un mécanisme pour re-scanner les dossiers playlists seulement si des changements sont détectés (ex. : via fs.watch ou comparaison de timestamps). Pour les compilations, permettre un import manuel ou initial.
Population initiale : Au démarrage de l'app, scanner les dossiers playlists et importer les compilations hardcodées actuelles en DB.
Output attendu :Étapes détaillées pour implémenter cela (setup DB, schéma avec Drizzle, code pour population initiale et mises à jour).
Exemples de code Node.js : Setup de Drizzle avec SQLite.
Fonctions pour scanner le dossier playlists
Exemples de requêtes (ex. : getPlaylistById depuis DB, avec fallback sur scan si cache invalide).
Gestion des erreurs et optimisations perf.
Si possible, un schéma DB en SQL ou Drizzle schema.ts.
Assure-toi que la solution est scalable pour ~1000 tracks initiaux, et teste les perfs dans tes exemples.
voici ma structure de projet actuelle :
compilation :
import { eventHandler } from 'h3'
export default eventHandler(() => {
const tracks = [
{
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'
},
{
order: 2,
boxId: 'ES00',
side: 'A',
title: 'Bleach',
artist: 1,
start: 392,
link: 'https://the-kundalini-genie.bandcamp.com/track/bleach-2',
coverId: 'a1714786533'
},
{
order: 3,
boxId: 'ES00',
side: 'A',
title: 'Televised mind',
artist: 2,
start: 896,
link: 'https://fontainesdc.bandcamp.com/track/televised-mind',
coverId: 'a3772806156'
},
{
order: 4,
boxId: 'ES00',
side: 'A',
title: 'In it',
artist: 3,
start: 1139,
link: 'https://howlinbananarecords.bandcamp.com/track/in-it',
coverId: 'a1720372066'
},
{
order: 5,
boxId: 'ES00',
side: 'A',
title: 'Bad michel',
artist: 4,
start: 1245,
link: 'https://johnnymafia.bandcamp.com/track/bad-michel-3',
coverId: 'a0984622869'
},
{
order: 6,
boxId: 'ES00',
side: 'A',
title: 'Overall',
artist: 5,
start: 1394,
link: 'https://newcandys.bandcamp.com/track/overall',
coverId: 'a0559661270'
},
{
order: 7,
boxId: 'ES00',
side: 'A',
title: 'Blowup',
artist: 6,
start: 1674,
link: 'https://magicshoppe.bandcamp.com/track/blowup',
coverId: 'a1444895293'
},
{
order: 8,
boxId: 'ES00',
side: 'A',
title: 'Guitar jet',
artist: 7,
start: 1880,
link: 'https://radiomartiko.bandcamp.com/track/guitare-jet',
coverId: 'a1494681687'
},
{
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'
},
{
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'
},
{
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'
},
{
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'
},
{
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'
},
{
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'
},
{
order: 4,
boxId: 'ES00',
side: 'B',
title: 'Lights off',
artist: 3,
start: 781,
link: 'https://howlinbananarecords.bandcamp.com/track/lights-off',
coverId: 'a1720372066'
},
{
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'
},
{
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'
},
{
order: 7,
boxId: 'ES00',
side: 'B',
title: 'Redhead',
artist: 6,
start: 1303,
link: 'https://magicshoppe.bandcamp.com/track/redhead',
coverId: 'a0594426943'
},
{
order: 8,
boxId: 'ES00',
side: 'B',
title: 'Supersonic twist',
artist: 7,
start: 1584,
link: 'https://open.spotify.com/track/66voQIZAJ3zD3Eju2qtNjF',
coverId: 'a1494681687'
},
{
order: 9,
boxId: 'ES00',
side: 'B',
title: 'Flowers',
artist: 8,
start: 1749,
link: 'https://traams.bandcamp.com/track/flowers',
coverId: 'a3644668199'
},
{
order: 10,
boxId: 'ES00',
side: 'B',
title: 'The shade',
artist: 9,
start: 1924,
link: 'https://blue-orchid.bandcamp.com/track/the-shade',
coverId: 'a0804204790'
},
{
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'
},
{
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'
},
{
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'
},
{
order: 3,
boxId: 'ES01',
side: 'A',
title: 'Broadcaster',
artist: 13,
start: 1104.5,
link: 'https://squiduk.bandcamp.com/track/broadcaster',
coverId: 'a3391719769'
},
{
order: 4,
boxId: 'ES01',
side: 'A',
title: 'Mourn',
artist: 14,
start: 1441,
link: 'https://lysistrata.bandcamp.com/track/mourn-2',
coverId: 'a0872900041'
},
{
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'
},
{
order: 6,
boxId: 'ES01',
side: 'A',
title: 'Sunday Mourning',
artist: 16,
start: 2091.7,
link: 'https://nightbeats.bandcamp.com/track/sunday-mourning',
coverId: 'a0031987121'
},
{
order: 7,
boxId: 'ES01',
side: 'A',
title: '3030 Instrumental',
artist: 17,
start: 2339.3,
link: 'https://delthefunkyhomosapien.bandcamp.com/track/3030',
coverId: 'a1948146136'
},
{
order: 8,
boxId: 'ES01',
side: 'A',
title: 'Immortality Break',
artist: 18,
start: 2530.5,
link: 'https://theaa.bandcamp.com/track/immortality-break',
coverId: 'a2749250329'
},
{
order: 9,
boxId: 'ES01',
side: 'A',
title: 'Lazy Bones',
artist: 19,
start: 2718,
link: 'https://woodenshjips.bandcamp.com/track/lazy-bones',
coverId: 'a1884221104'
},
{
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'
},
{
order: 11,
boxId: 'ES01',
side: 'A',
title: 'Me',
artist: 21,
start: 3265,
link: 'https://secretcolours.bandcamp.com/track/me',
coverId: 'a1497022499'
},
{
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'
},
{
order: 2,
boxId: 'ES01',
side: 'B',
title: 'Dreamscapes',
artist: 12,
start: 235,
link: 'https://littlecloudrecords.bandcamp.com/track/dreamscapes',
coverId: 'a3498981203'
},
{
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'
},
{
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'
},
{
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'
},
{
order: 6,
boxId: 'ES01',
side: 'B',
title: 'Celebration #1',
artist: 16,
start: 2235,
link: 'https://nightbeats.bandcamp.com/track/celebration-1',
coverId: 'a0031987121'
},
{
order: 7,
boxId: 'ES01',
side: 'B',
title: '3030 Instrumental',
artist: 17,
start: 2458.3,
link: 'https://delthefunkyhomosapien.bandcamp.com/track/3030',
coverId: 'a1948146136'
},
{
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'
},
{
order: 9,
boxId: 'ES01',
side: 'B',
title: 'Rising',
artist: 19,
start: 3145,
link: 'https://woodenshjips.bandcamp.com/track/rising',
coverId: 'a1884221104'
},
{
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'
},
{
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'
}
]
return tracks.map((track, index) => ({
id: index + 1,
...track,
filePath: `https://files.erudi.fr/evilspins/${track.boxId}${track.side}.mp3`,
coverId: `https://f4.bcbits.com/img/${track.coverId}_4.jpg`,
type: 'compilation'
}))
})
playlist:
import fs from 'fs'
import path from 'path'
import 'dotenv/config'
import { eventHandler } from 'h3'
import { getCardFromDate } from '../../../utils/cards'
export default eventHandler(async (event) => {
const dirPath = path.join(process.env.AUDIO_FILES_BASE_PATH)
const urlPrefix = `https://files.erudi.fr/music`
try {
let allTracks: any[] = []
const items = await fs.promises.readdir(dirPath, { withFileTypes: true })
// Process files
const files = items
.filter((item) => item.isFile() && !item.name.startsWith('.') && !item.name.endsWith('.jpg'))
.map((item) => item.name)
// Process folders
const folders = items
.filter((item) => item.isDirectory() && !item.name.startsWith('.'))
.map((folder, index) => ({
id: `folder-${index}`,
boxId: 'ESFOLDER',
title: folder.name.replace(/_/g, ' ').trim(),
type: 'folder',
order: 0,
date: new Date(),
card: getCardFromDate(new Date())
}))
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 filePath = `${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(),
filePath,
coverId,
card,
order: 0,
type: 'playlist'
}
})
tracks.sort((a, b) => b.date.getTime() - a.date.getTime())
// Combine folders and tracks
const allItems = [...folders, ...tracks]
// Sort by date (newest first) and assign order
allItems.sort((a, b) => b.date.getTime() - a.date.getTime())
allItems.forEach((item, i) => (item.order = i + 1))
return allItems
} catch (error) {
return {
success: false,
error: (error as Error).message
}
}
})

View File

@@ -0,0 +1,21 @@
import { syncCardsWithDatabase } from '../services/cardSync.service'
export default defineNitroPlugin(async (nitroApp) => {
const runtimeConfig = useRuntimeConfig()
const folderPath = runtimeConfig.NUXT_PATH_FILES
if (!folderPath) {
console.warn('⚠️ NUXT_PATH_FILES non configuré')
return
}
// Sync au démarrage
console.log('🚀 Synchronisation initiale au démarrage...')
try {
const result = await syncCardsWithDatabase(folderPath)
console.log('✅ Synchronisation initiale terminée:', result)
} catch (error: any) {
console.error('❌ Erreur lors de la sync initiale:', error)
}
})

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