yeah
This commit is contained in:
2
.github/workflows/setup-env.sh
vendored
2
.github/workflows/setup-env.sh
vendored
@@ -32,5 +32,3 @@ changeEnvVar "PORT" $PORT
|
|||||||
changeEnvVar "APP_DIR" "/var/docker-web/apps/$APP_NAME"
|
changeEnvVar "APP_DIR" "/var/docker-web/apps/$APP_NAME"
|
||||||
|
|
||||||
set -a && source .env && set +a
|
set -a && source .env && set +a
|
||||||
cat .env
|
|
||||||
echo $APP_NAME
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@
|
|||||||
.nitro
|
.nitro
|
||||||
.cache
|
.cache
|
||||||
dist
|
dist
|
||||||
|
drizzle
|
||||||
|
|
||||||
# Node dependencies
|
# Node dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
|||||||
76
app/app.vue
76
app/app.vue
@@ -1,82 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="min-h-screen bg-gray-100">
|
||||||
<NuxtRouteAnnouncer />
|
<NuxtRouteAnnouncer />
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
|
||||||
|
|||||||
@@ -1,56 +1,39 @@
|
|||||||
<template>
|
<template>
|
||||||
<article v-bind="attrs" :role="props.role" :draggable="isFaceUp" :class="[
|
<article :role="props.role" :class="[
|
||||||
'card cursor-pointer',
|
'card cursor-pointer',
|
||||||
isFaceUp ? 'face-up' : 'face-down',
|
isFaceUp ? 'face-up' : 'face-down'
|
||||||
{ 'current-track': playerStore.currentTrack?.id === track.id },
|
]" :tabindex="props.tabindex" :aria-disabled="false" @click="$emit('click', $event)"
|
||||||
{ 'is-dragging': isDragging }
|
@keydown.enter="$emit('click', $event)" @keydown.space.prevent="$emit('click', $event)">
|
||||||
]" :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">
|
<div class="flip-inner" ref="cardElement">
|
||||||
<!-- Face-Up -->
|
<!-- Face-Up -->
|
||||||
<main
|
<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 rounded-2xl shadow-lg flex flex-col overflow-hidden">
|
||||||
|
|
||||||
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 right-7"
|
<div class="flex items-center justify-center size-7 absolute top-7 right-7">
|
||||||
@click.stop="clickCardSymbol">
|
|
||||||
<div class="suit text-7xl absolute"
|
<div class="suit text-7xl absolute"
|
||||||
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
|
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.card?.suit]">
|
||||||
<img draggable="false" :src="`/${props.track.card?.suit}.svg`" />
|
<img :src="`/${props.card?.suit}.svg`" />
|
||||||
</div>
|
</div>
|
||||||
<div class="rank text-white font-bold absolute -mt-1">
|
<div class="rank text-white font-bold absolute -mt-1">
|
||||||
{{ props.track.card?.rank }}
|
{{ props.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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cover -->
|
<!-- Cover -->
|
||||||
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer">
|
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer">
|
||||||
<playButton :objectToPlay="track" />
|
<playButton :objectToPlay="card" />
|
||||||
<img draggable="false" v-if="isFaceUp" :src="coverUrl" alt="Pochette de l'album" loading="lazy"
|
<img v-if="isFaceUp" :src="props.card.url_image" alt="Pochette de l'album" loading="lazy"
|
||||||
class="w-full h-full object-cover object-center" />
|
class="w-full h-full object-cover object-center" />
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div
|
<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">
|
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">
|
<h2 class="select-text text-sm text-neutral-500 first-letter:uppercase truncate">
|
||||||
{{ props.track.title || 'title' }}
|
{{ props.card.title || 'title' }}
|
||||||
</h2>
|
</h2>
|
||||||
<p v-if="isPlaylistTrack && track.artist && typeof track.artist === 'object'"
|
<p class="select-text text-base text-neutral-800 font-bold capitalize truncate">
|
||||||
class="select-text text-base text-neutral-800 font-bold capitalize truncate">
|
{{ props.card.artist || 'artist' }}
|
||||||
{{ 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -60,317 +43,35 @@
|
|||||||
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">
|
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"
|
<figure class="h-full flex text-center rounded-xl justify-center items-center"
|
||||||
:style="{ backgroundColor: cardColor }">
|
:style="{ backgroundColor: cardColor }">
|
||||||
<playButton :objectToPlay="track" />
|
<playButton :objectToPlay="card" />
|
||||||
<img draggable="false" src="/face-down.svg" />
|
<img src="/face-down.svg" />
|
||||||
</figure>
|
</figure>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Track } from '~~/types/types'
|
import type { Card } from '~~/types/types'
|
||||||
import { usePlayerStore } from '~/store/player'
|
|
||||||
import { useDataStore } from '~/store/data'
|
const emit = defineEmits(['click'])
|
||||||
import { useNuxtApp } from '#app'
|
|
||||||
import ModalSharer from '~/components/ui/ModalSharer.vue'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
track: Track;
|
card: Card;
|
||||||
isFaceUp?: boolean;
|
isFaceUp?: boolean;
|
||||||
role?: string;
|
role?: string;
|
||||||
tabindex?: string | number;
|
tabindex?: string | number;
|
||||||
'onUpdate:isFaceUp'?: (value: boolean) => void;
|
|
||||||
}>(), {
|
}>(), {
|
||||||
isFaceUp: true,
|
isFaceUp: false,
|
||||||
role: 'button',
|
role: 'button',
|
||||||
tabindex: '0'
|
tabindex: '0'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Use useAttrs to get all other attributes
|
import { getYearColor } from '~/utils/colors'
|
||||||
const attrs = useAttrs()
|
|
||||||
|
|
||||||
const modalSharer = ref<InstanceType<typeof ModalSharer> | null>(null)
|
const cardColor = computed(() => getYearColor(props.card.year || 0))
|
||||||
const showModalSharer = ref(false)
|
const isRedCard = computed(() => (props.card?.suit === '♥' || props.card?.suit === '♦'))
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@@ -391,7 +92,6 @@ onUnmounted(() => {
|
|||||||
.card {
|
.card {
|
||||||
perspective: 1000px;
|
perspective: 1000px;
|
||||||
@apply transition-all scale-100 w-56 h-80 min-w-56 min-h-80;
|
@apply transition-all scale-100 w-56 h-80 min-w-56 min-h-80;
|
||||||
touch-action: none;
|
|
||||||
|
|
||||||
.flip-inner {
|
.flip-inner {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -440,7 +140,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:focus,
|
&:focus,
|
||||||
&.current-track {
|
&.current-card {
|
||||||
@apply z-50 scale-110;
|
@apply z-50 scale-110;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
@@ -453,7 +153,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:focus,
|
&:focus,
|
||||||
&.current-track {
|
&.current-card {
|
||||||
.play-button {
|
.play-button {
|
||||||
@apply opacity-100;
|
@apply opacity-100;
|
||||||
}
|
}
|
||||||
@@ -474,7 +174,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.play-button {
|
.play-button {
|
||||||
@apply absolute bottom-1/2 top-24 opacity-0 hover:opacity-100;
|
@apply absolute bottom-1/2 top-28 opacity-0 hover:opacity-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pochette:active,
|
.pochette:active,
|
||||||
@@ -498,22 +198,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>
|
</style>
|
||||||
@@ -12,25 +12,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { usePlatineStore } from '~/store/platine'
|
const props = withDefaults(defineProps<{
|
||||||
import type { Box, Track } from '~/../types/types'
|
isLoading?: boolean;
|
||||||
|
isPlaying?: boolean;
|
||||||
const platineStore = usePlatineStore()
|
}>(), {
|
||||||
const props = defineProps<{ objectToPlay: Box | Track }>()
|
isLoading: false,
|
||||||
|
isPlaying: false
|
||||||
const isCurrentTrack = computed(() => {
|
|
||||||
if (!('activeSide' in props.objectToPlay)) {
|
|
||||||
return platineStore.currentTrack?.id === props.objectToPlay.id
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
const isPlaying = computed(() => {
|
|
||||||
return platineStore.isPlaying && isCurrentTrack.value
|
|
||||||
})
|
|
||||||
|
|
||||||
const isLoading = computed(() => {
|
|
||||||
return platineStore.isLoadingTrack && isCurrentTrack.value
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1
|
error
|
||||||
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { NuxtError } from '#app'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
error: Object as () => NuxtError
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,55 +1,3 @@
|
|||||||
<template>
|
<template>
|
||||||
<slot />
|
<slot />
|
||||||
<Bucket />
|
|
||||||
<Platine v-if="playerStore.currentTrack" />
|
|
||||||
</template>
|
</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>
|
|
||||||
|
|||||||
43
app/pages/card/[slug].vue
Normal file
43
app/pages/card/[slug].vue
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<section class="screen-centered">
|
||||||
|
<Card :card="card" :isFaceUp="isFaceUp" @click="clickOnSlugCard" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Card } from '~~/types/types'
|
||||||
|
const route = useRoute()
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
const isFaceUp = ref(false)
|
||||||
|
|
||||||
|
const { data: card, pending, error } = await useFetch<Card>(`/api/card/${slug}`)
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: computed(() =>
|
||||||
|
card.value ? `${card.value.artist} - ${card.value.title}` : 'Loading...'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const clickOnSlugCard = () => {
|
||||||
|
isFaceUp.value = true
|
||||||
|
const audio = new Audio(card.value?.url_audio)
|
||||||
|
audio.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
clickOnSlugCard()
|
||||||
|
}, 700)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.screen-centered {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
height: 100dvh;
|
||||||
|
width: 100dvw;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,27 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<boxes />
|
<div>
|
||||||
|
here is the New front
|
||||||
|
</div>
|
||||||
</template>
|
</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>
|
|
||||||
|
|||||||
39
app/utils/colors.ts
Normal file
39
app/utils/colors.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export const getYearColor = (year: number): string => {
|
||||||
|
// Palette élargie avec des différences plus marquées
|
||||||
|
const colorMap: Record<number, string> = {
|
||||||
|
// Années récentes - teintes froides et claires
|
||||||
|
2025: '#3a4a6c', // bleu-gris clair
|
||||||
|
2024: '#1e3a7a', // bleu vif
|
||||||
|
2023: '#1a4d5c', // bleu-vert émeraude
|
||||||
|
2022: '#3a4a6a', // bleu-gris moyen
|
||||||
|
|
||||||
|
// Années 2020-2021 - transition
|
||||||
|
2021: '#3a2e6a', // bleu-violet
|
||||||
|
2020: '#2a467a', // bleu-gris chaud
|
||||||
|
|
||||||
|
// Années 2010-2019 - teintes moyennes
|
||||||
|
2019: '#2a2a7a', // bleu nuit profond
|
||||||
|
2018: '#1e2a8a', // bleu roi
|
||||||
|
2017: '#1a5a6a', // bleu canard vif
|
||||||
|
2016: '#1a5a4a', // vert bleuté
|
||||||
|
2015: '#1a3a7a', // bleu marine
|
||||||
|
2014: '#4a1e7a', // violet profond
|
||||||
|
2013: '#1a5a4a', // vert émeraude
|
||||||
|
2012: '#1e3a9a', // bleu ciel profond
|
||||||
|
|
||||||
|
// Années 2000-2011 - teintes chaudes et foncées
|
||||||
|
2011: '#1e293b', // slate-800 de base
|
||||||
|
2010: '#2a467a', // bleu-gris chaud
|
||||||
|
2009: '#3a4a6a', // bleu-gris moyen
|
||||||
|
2008: '#1a3a8a', // bleu nuit clair
|
||||||
|
2007: '#5a2a4a', // bordeaux
|
||||||
|
2006: '#5a1e6a', // violet profond
|
||||||
|
2005: '#3a1a7a', // bleu-violet foncé
|
||||||
|
2004: '#2a1a5a', // bleu nuit profond
|
||||||
|
2003: '#3a3a5a', // bleu-gris foncé
|
||||||
|
2002: '#1a5a4a', // vert foncé
|
||||||
|
2001: '#5a3a2a', // marron chaud
|
||||||
|
2000: '#3a3a5a' // bleu-gris foncé
|
||||||
|
}
|
||||||
|
return colorMap[year] || '#1e293b' // slate-800 par défaut
|
||||||
|
}
|
||||||
82
appOLDD/app.vue
Normal file
82
appOLDD/app.vue
Normal 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>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
||||||
import type { Box } from '~~/types/types'
|
import type { Box } from '~~/types'
|
||||||
import { useDataStore } from '~/store/data'
|
import { useDataStore } from '~/store/data'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
9
appOLDD/components/Boxes.vue
Normal file
9
appOLDD/components/Boxes.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
wait for it
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
console.log('wait for it')
|
||||||
|
</script>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Box } from '~~/types/types'
|
import type { Box } from '~~/types'
|
||||||
import { useDataStore } from '~/store/data'
|
import { useDataStore } from '~/store/data'
|
||||||
import { usePlayerStore } from '~/store/player'
|
import { usePlayerStore } from '~/store/player'
|
||||||
import { useUiStore } from '~/store/ui'
|
import { useUiStore } from '~/store/ui'
|
||||||
519
appOLDD/components/Card.vue
Normal file
519
appOLDD/components/Card.vue
Normal 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>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { usePlatineStore } from '~/store/platine'
|
import { usePlatineStore } from '~/store/platine'
|
||||||
import type { Track } from '~~/types/types'
|
import type { Track } from '~~/types'
|
||||||
|
|
||||||
const props = defineProps<{ track?: Track }>()
|
const props = defineProps<{ track?: Track }>()
|
||||||
const platineStore = usePlatineStore()
|
const platineStore = usePlatineStore()
|
||||||
42
appOLDD/components/PlayButton.vue
Normal file
42
appOLDD/components/PlayButton.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<button tabindex="-1"
|
||||||
|
class="play-button pointer-events-none rounded-full size-24 flex items-center justify-center text-esyellow backdrop-blur-sm bg-black/25 transition-all duration-200 ease-in-out transform active:scale-90 scale-110 text-4xl font-bold"
|
||||||
|
:class="{ loading: isLoading }" :disabled="isLoading">
|
||||||
|
<template v-if="isLoading">
|
||||||
|
<img src="/loader.svg" alt="Chargement" class="size-16" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ isPlaying ? 'I I' : '▶' }}
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { usePlatineStore } from '~/store/platine'
|
||||||
|
import type { Box, Track } from '~/../types/types'
|
||||||
|
|
||||||
|
const platineStore = usePlatineStore()
|
||||||
|
const props = defineProps<{ objectToPlay: Box | Track }>()
|
||||||
|
|
||||||
|
const isCurrentTrack = computed(() => {
|
||||||
|
if (!('activeSide' in props.objectToPlay)) {
|
||||||
|
return platineStore.currentTrack?.id === props.objectToPlay.id
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
const isPlaying = computed(() => {
|
||||||
|
return platineStore.isPlaying && isCurrentTrack.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = computed(() => {
|
||||||
|
return platineStore.isLoadingTrack && isCurrentTrack.value
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loading,
|
||||||
|
.play-button-changed {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -24,7 +24,7 @@ import { useDataStore } from '~/store/data'
|
|||||||
import { useCardStore } from '~/store/card'
|
import { useCardStore } from '~/store/card'
|
||||||
import { usePlayerStore } from '~/store/player'
|
import { usePlayerStore } from '~/store/player'
|
||||||
import { useUiStore } from '~/store/ui'
|
import { useUiStore } from '~/store/ui'
|
||||||
import type { Box } from '~~/types/types'
|
import type { Box } from '~~/types'
|
||||||
|
|
||||||
const uiStore = useUiStore()
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ import { useDataStore } from '~/store/data'
|
|||||||
import { useCardStore } from '~/store/card'
|
import { useCardStore } from '~/store/card'
|
||||||
import { usePlayerStore } from '~/store/player'
|
import { usePlayerStore } from '~/store/player'
|
||||||
import { useUiStore } from '~/store/ui'
|
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 SelectCardSuit from '~/components/ui/SelectCardSuit.vue'
|
||||||
import SelectCardRank from '~/components/ui/SelectCardRank.vue'
|
import SelectCardRank from '~/components/ui/SelectCardRank.vue'
|
||||||
import SearchInput from '~/components/ui/SearchInput.vue'
|
import SearchInput from '~/components/ui/SearchInput.vue'
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useUiStore } from '~/store/ui'
|
import { useUiStore } from '~/store/ui'
|
||||||
import type { Track } from '~~/types/types'
|
import type { Track } from '~~/types'
|
||||||
|
|
||||||
const uiStore = useUiStore()
|
const uiStore = useUiStore()
|
||||||
const currentTrack = ref<Track | null>(null)
|
const currentTrack = ref<Track | null>(null)
|
||||||
18
appOLDD/error.vue
Normal file
18
appOLDD/error.vue
Normal 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>
|
||||||
55
appOLDD/layouts/default.vue
Normal file
55
appOLDD/layouts/default.vue
Normal 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>
|
||||||
@@ -22,7 +22,7 @@ import { useRoute } from 'vue-router'
|
|||||||
import { usePlayerStore } from '~/store/player'
|
import { usePlayerStore } from '~/store/player'
|
||||||
import { useCardStore } from '~/store/card'
|
import { useCardStore } from '~/store/card'
|
||||||
import { useDataStore } from '~/store/data'
|
import { useDataStore } from '~/store/data'
|
||||||
import type { Track } from '~~/types/types'
|
import type { Track } from '~~/types'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const playerStore = usePlayerStore()
|
const playerStore = usePlayerStore()
|
||||||
27
appOLDD/pages/index.vue
Normal file
27
appOLDD/pages/index.vue
Normal 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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { Track } from '~~/types/types'
|
import type { Track } from '~~/types'
|
||||||
|
|
||||||
export const useCardStore = defineStore('card', {
|
export const useCardStore = defineStore('card', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { Track } from '~~/types/types'
|
import type { Track } from '~~/types'
|
||||||
import Disc from '~/platine-tools/disc'
|
import Disc from '~/platine-tools/disc'
|
||||||
import Sampler from '~/platine-tools/sampler'
|
import Sampler from '~/platine-tools/sampler'
|
||||||
import { useCardStore } from '~/store/card'
|
import { useCardStore } from '~/store/card'
|
||||||
@@ -74,7 +74,7 @@ export const usePlatineStore = defineStore('platine', () => {
|
|||||||
isLoadingTrack.value = true
|
isLoadingTrack.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sampler.value.loadTrack(track.url)
|
await sampler.value.loadTrack(track.filePath)
|
||||||
if (disc.value) {
|
if (disc.value) {
|
||||||
disc.value.setDuration(sampler.value.duration)
|
disc.value.setDuration(sampler.value.duration)
|
||||||
updateTurns()
|
updateTurns()
|
||||||
146
data/Artists.json
Normal file
146
data/Artists.json
Normal 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
44
data/Compilations.json
Normal 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
486
data/Songs.json
Normal 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
BIN
data/music.db
Normal file
Binary file not shown.
@@ -8,14 +8,18 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
ports:
|
ports:
|
||||||
- "${PORT}:${PORT_EXPOSED}"
|
- '${PORT}:${PORT_EXPOSED}'
|
||||||
volumes:
|
volumes:
|
||||||
- $MEDIA_DIR:/app/mnt/media
|
- $MEDIA_DIR:/app/mnt/media
|
||||||
|
- evilspins:/app/data
|
||||||
environment:
|
environment:
|
||||||
VIRTUAL_HOST: "${DOMAIN}"
|
VIRTUAL_HOST: '${DOMAIN}'
|
||||||
LETSENCRYPT_HOST: "${DOMAIN}"
|
LETSENCRYPT_HOST: '${DOMAIN}'
|
||||||
PUID: "${PUID}"
|
PUID: '${PUID}'
|
||||||
PGID: "${PGID}"
|
PGID: '${PGID}'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
evilspins:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
|
|||||||
11
drizzle.config.ts
Normal file
11
drizzle.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
|
import { defineConfig } from 'drizzle-kit'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
out: './drizzle',
|
||||||
|
schema: './server/db/schema.ts',
|
||||||
|
dialect: 'sqlite',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.PATH_DB!
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -4,9 +4,26 @@ import tsconfigPaths from 'vite-tsconfig-paths'
|
|||||||
const isProd = process.env.NODE_ENV === 'production'
|
const isProd = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
|
runtimeConfig: {
|
||||||
|
pathFiles: process.env.PATH_FILES,
|
||||||
|
pathDb: process.env.PATH_DB
|
||||||
|
},
|
||||||
|
nitro: {
|
||||||
|
experimental: {
|
||||||
|
tasks: true
|
||||||
|
},
|
||||||
|
scheduledTasks: {
|
||||||
|
'*/5 * * * *': ['syncTracks']
|
||||||
|
}
|
||||||
|
},
|
||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
modules: ['@nuxt/eslint', '@nuxtjs/tailwindcss', '@pinia/nuxt'],
|
modules: ['@nuxt/eslint', '@nuxtjs/tailwindcss', '@pinia/nuxt'],
|
||||||
|
typescript: {
|
||||||
|
tsConfig: {
|
||||||
|
include: ['types/**/*.ts']
|
||||||
|
}
|
||||||
|
},
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tsconfigPaths()]
|
plugins: [tsconfigPaths()]
|
||||||
},
|
},
|
||||||
|
|||||||
21
package.json
21
package.json
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "nuxt-app",
|
"name": "evilspins",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
@@ -12,18 +13,18 @@
|
|||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"format": "prettier --check .",
|
"format": "prettier --check .",
|
||||||
"format:fix": "prettier --write .",
|
"format:fix": "prettier --write .",
|
||||||
"migrate": "tsx server/database/migrate.ts",
|
"db:push": "drizzle-kit push",
|
||||||
"db:reset": "rm -f server/database/evilspins.db && npm run migrate"
|
"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": {
|
"dependencies": {
|
||||||
|
"@libsql/client": "^0.17.0",
|
||||||
"@nuxt/eslint": "1.9.0",
|
"@nuxt/eslint": "1.9.0",
|
||||||
"@nuxtjs/tailwindcss": "6.14.0",
|
"@nuxtjs/tailwindcss": "6.14.0",
|
||||||
"@pinia/nuxt": "0.11.2",
|
"@pinia/nuxt": "0.11.2",
|
||||||
"@types/chokidar": "^2.1.7",
|
|
||||||
"atropos": "^2.0.2",
|
"atropos": "^2.0.2",
|
||||||
"better-sqlite3": "^12.5.0",
|
"drizzle-orm": "^0.45.1",
|
||||||
"chokidar": "^5.0.0",
|
"nuxt": "^4.3.0",
|
||||||
"nuxt": "^4.2.0",
|
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
@@ -32,15 +33,16 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"pnpm": ">=10 <11"
|
"pnpm": ">=10 <11"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.27.0",
|
"packageManager": "pnpm@10.28.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.4.1",
|
"@eslint/compat": "^1.4.1",
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@nuxt/eslint-config": "^1.10.0",
|
"@nuxt/eslint-config": "^1.10.0",
|
||||||
"@nuxtjs/eslint-config-typescript": "^12.1.0",
|
"@nuxtjs/eslint-config-typescript": "^12.1.0",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||||
"@typescript-eslint/parser": "^8.46.3",
|
"@typescript-eslint/parser": "^8.46.3",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"drizzle-kit": "^0.31.8",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
@@ -50,6 +52,7 @@
|
|||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"sass-embedded": "^1.93.2",
|
"sass-embedded": "^1.93.2",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3691
pnpm-lock.yaml
generated
3691
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
7
pnpm-workspace.yaml
Normal file
7
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
ignoredBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
|
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@parcel/watcher'
|
||||||
|
- better-sqlite3
|
||||||
|
- unrs-resolver
|
||||||
1
public/cassette.svg
Normal file
1
public/cassette.svg
Normal 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
17
public/disc.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<svg data-v-daf08565="" data-v-ef1138a9="" width="20" height="20" viewBox="0 0 20 20" fill="none" role="img"
|
||||||
|
class="icon vinyl-record-outline-icon" aria-hidden="true" style="--v0954d04a: var(--gray700);">
|
||||||
|
<g data-v-daf08565="">
|
||||||
|
<path data-v-daf08565=""
|
||||||
|
d="M10 11C10.5523 11 11 10.5523 11 10C11 9.44772 10.5523 9 10 9C9.44772 9 9 9.44772 9 10C9 10.5523 9.44772 11 10 11Z"
|
||||||
|
stroke="none"></path>
|
||||||
|
<path data-v-daf08565="" fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
d="M13 10C13 11.6569 11.6569 13 10 13C8.34315 13 7 11.6569 7 10C7 8.34315 8.34315 7 10 7C11.6569 7 13 8.34315 13 10ZM10 12.1892C11.2091 12.1892 12.1892 11.2091 12.1892 10C12.1892 8.79094 11.2091 7.81081 10 7.81081C8.79094 7.81081 7.81081 8.79094 7.81081 10C7.81081 11.2091 8.79094 12.1892 10 12.1892Z"
|
||||||
|
stroke="none"></path>
|
||||||
|
<path data-v-daf08565=""
|
||||||
|
d="M12.3606 13.4425L12.0793 12.9862L9.80855 14.512L10.9231 16.2177L13.1938 14.6919L13.0425 14.3444L14.7376 13.1105C15.1903 12.7894 17.0431 11.2251 17.2951 9.62497L17.5002 8.39218L16.6563 6.35491L16.5832 7.60639C16.5764 7.75055 16.5615 7.92059 16.5332 8.11074C16.3877 9.08943 15.8891 10.6011 14.3388 11.8603L13.8788 12.234C13.5086 12.5349 13.2133 12.7749 12.9854 12.9589C12.686 13.2007 12.5025 13.3462 12.4167 13.4079C12.4012 13.419 12.3888 13.4274 12.3796 13.4331C12.37 13.4391 12.3636 13.4422 12.3606 13.4425Z"
|
||||||
|
stroke="none"></path>
|
||||||
|
<path data-v-daf08565="" fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
d="M10 17.2C13.9765 17.2 17.2 13.9765 17.2 10C17.2 6.02355 13.9765 2.8 10 2.8C6.02355 2.8 2.8 6.02355 2.8 10C2.8 13.9765 6.02355 17.2 10 17.2ZM10 18C14.4183 18 18 14.4183 18 10C18 5.58172 14.4183 2 10 2C5.58172 2 2 5.58172 2 10C2 14.4183 5.58172 18 10 18Z"
|
||||||
|
stroke="none"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
65
scripts/update-esid.ts
Normal file
65
scripts/update-esid.ts
Normal 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)
|
||||||
|
})
|
||||||
@@ -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
|
|
||||||
@@ -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`
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
@@ -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
41
server/api/card/[slug].ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { useDB, schema } from '../../db'
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'ESID manquant dans la requête'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = useDB()
|
||||||
|
const card = await db.select().from(schema.cards).where(eq(schema.cards.slug, slug)).get()
|
||||||
|
|
||||||
|
if (!card) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Morceau non trouvé'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: card.id,
|
||||||
|
esid: card.esid,
|
||||||
|
title: card.title,
|
||||||
|
artist: card.artist,
|
||||||
|
url_audio: card.url_audio,
|
||||||
|
url_image: card.url_image,
|
||||||
|
year: card.year,
|
||||||
|
month: card.month,
|
||||||
|
day: card.day,
|
||||||
|
hour: card.hour,
|
||||||
|
slug: card.slug,
|
||||||
|
suit: card.suit,
|
||||||
|
rank: card.rank,
|
||||||
|
createdAt: card.createdAt,
|
||||||
|
updatedAt: card.updatedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
41
server/api/cards/[esid].ts
Normal file
41
server/api/cards/[esid].ts
Normal 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
84
server/api/cards/index.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
27
server/api/sync-cards.post.ts
Normal file
27
server/api/sync-cards.post.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { syncCardsWithDatabase } from '../services/cardSync.service'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const folderPath = config.pathFiles || process.env.PATH_FILES
|
||||||
|
|
||||||
|
if (!folderPath) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'PATH_FILES not configured'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncCardsWithDatabase(folderPath)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
...result
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
21
server/api/test/test-db-sync.post.ts
Normal file
21
server/api/test/test-db-sync.post.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { syncCardsWithDatabase } from '../../services/cardSync.service'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const folderPath = config.pathFiles || process.env.PATH_FILES || 'mnt/media/files/music'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncCardsWithDatabase(folderPath)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
...result
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
29
server/api/test/test-scanner.get.ts
Normal file
29
server/api/test/test-scanner.get.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { scanMusicFolder } from '../../utils/fileScanner'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const folderPath = config.pathFiles || process.env.PATH_FILES || 'mnt/media/files/music'
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: Vérifier que le dossier existe
|
||||||
|
const { access } = await import('node:fs/promises')
|
||||||
|
await access(folderPath)
|
||||||
|
|
||||||
|
// Test 2: Scanner le dossier
|
||||||
|
const cards = await scanMusicFolder(folderPath)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
folderPath,
|
||||||
|
cardsFound: cards.length,
|
||||||
|
cards: cards.slice(0, 5), // Afficher seulement les 5 premiers
|
||||||
|
sample: cards[0] // Un exemple complet
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
folderPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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.
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
32
server/db/index.ts
Normal file
32
server/db/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/libsql'
|
||||||
|
import * as schema from './schema'
|
||||||
|
|
||||||
|
let _db: ReturnType<typeof drizzle> | null = null
|
||||||
|
|
||||||
|
export function useDB() {
|
||||||
|
if (_db) return _db
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
let dbPath = config.pathDb || process.env.PATH_DB
|
||||||
|
|
||||||
|
if (!dbPath) {
|
||||||
|
throw new Error('PATH_DB is not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir le chemin en URL file:// si ce n'est pas déjà une URL
|
||||||
|
if (!dbPath.startsWith('file:') && !dbPath.startsWith('libsql:') && !dbPath.startsWith('http')) {
|
||||||
|
// Si c'est un chemin relatif, le rendre absolu
|
||||||
|
if (!dbPath.startsWith('/')) {
|
||||||
|
dbPath = `file:${process.cwd()}/${dbPath}`
|
||||||
|
} else {
|
||||||
|
dbPath = `file:${dbPath}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🗄️ Connexion à la DB:', dbPath)
|
||||||
|
|
||||||
|
_db = drizzle(dbPath, { schema })
|
||||||
|
return _db
|
||||||
|
}
|
||||||
|
|
||||||
|
export { schema }
|
||||||
23
server/db/schema.ts
Normal file
23
server/db/schema.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { sqliteTable, text, int } from 'drizzle-orm/sqlite-core'
|
||||||
|
|
||||||
|
export const cards = sqliteTable('cards', {
|
||||||
|
id: int('id').primaryKey({ autoIncrement: true }),
|
||||||
|
esid: text('esid').notNull(),
|
||||||
|
url_audio: text('url_audio').notNull(),
|
||||||
|
url_image: text('url_image').notNull(),
|
||||||
|
year: text('year').notNull(),
|
||||||
|
month: text('month').notNull(),
|
||||||
|
day: text('day').notNull(),
|
||||||
|
hour: text('hour').notNull(),
|
||||||
|
artist: text('artist').notNull(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
slug: text('slug').notNull(),
|
||||||
|
suit: text('suit').notNull(),
|
||||||
|
rank: text('rank').notNull(),
|
||||||
|
createdAt: int('created_at', { mode: 'timestamp' })
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date()),
|
||||||
|
updatedAt: int('updated_at', { mode: 'timestamp' })
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date())
|
||||||
|
})
|
||||||
586
server/db/start.prompt
Normal file
586
server/db/start.prompt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
21
server/plugins/initialSync.ts
Normal file
21
server/plugins/initialSync.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { syncCardsWithDatabase } from '../services/cardSync.service'
|
||||||
|
|
||||||
|
export default defineNitroPlugin(async (nitroApp) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const folderPath = config.pathFiles || process.env.PATH_FILES
|
||||||
|
|
||||||
|
if (!folderPath) {
|
||||||
|
console.warn('⚠️ 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
58
server/services/cardSync.service.ts
Normal file
58
server/services/cardSync.service.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { eq, notInArray } from 'drizzle-orm'
|
||||||
|
import { useDB, schema } from '../db'
|
||||||
|
import { scanMusicFolder } from '../utils/fileScanner'
|
||||||
|
|
||||||
|
const { cards } = schema
|
||||||
|
|
||||||
|
export async function syncCardsWithDatabase(folderPath: string) {
|
||||||
|
const db = useDB()
|
||||||
|
const scannedCards = await scanMusicFolder(folderPath)
|
||||||
|
|
||||||
|
console.log(`📁 ${scannedCards.length} cards trouvées dans le dossier`)
|
||||||
|
|
||||||
|
// 1. Récupérer les cards existantes en DB
|
||||||
|
const existingCards = await db.select().from(cards).all()
|
||||||
|
const existingEsids = new Set(existingCards.map((t) => t.esid))
|
||||||
|
|
||||||
|
// 2. Identifier les nouvelles cards à ajouter
|
||||||
|
const cardsToInsert = scannedCards.filter((card) => !existingEsids.has(card.esid))
|
||||||
|
|
||||||
|
// 3. Identifier les cards à supprimer
|
||||||
|
const scannedEsids = new Set(scannedCards.map((t) => t.esid))
|
||||||
|
const cardsToDelete = existingCards.filter((t) => !scannedEsids.has(t.esid))
|
||||||
|
|
||||||
|
// 4. Insérer les nouvelles cards
|
||||||
|
if (cardsToInsert.length > 0) {
|
||||||
|
// Dans la fonction syncCardsWithDatabase
|
||||||
|
await db.insert(cards).values(
|
||||||
|
cardsToInsert.map((card) => ({
|
||||||
|
url_audio: card.url_audio,
|
||||||
|
url_image: card.url_image,
|
||||||
|
year: card.year,
|
||||||
|
month: card.month,
|
||||||
|
day: card.day,
|
||||||
|
hour: card.hour,
|
||||||
|
artist: card.artist,
|
||||||
|
title: card.title,
|
||||||
|
esid: card.esid,
|
||||||
|
slug: card.slug,
|
||||||
|
createdAt: card.createdAt,
|
||||||
|
suit: card.suit,
|
||||||
|
rank: card.rank
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
console.log(`✅ ${cardsToInsert.length} cards ajoutées`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Supprimer les cards obsolètes avec une requête distincte pour chaque esid
|
||||||
|
for (const cardToDelete of cardsToDelete) {
|
||||||
|
await db.delete(cards).where(eq(cards.esid, cardToDelete.esid))
|
||||||
|
console.log(`🗑️ ${cardsToDelete.length} cards supprimées`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
added: cardsToInsert.length,
|
||||||
|
deleted: cardsToDelete.length,
|
||||||
|
total: scannedCards.length
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
import fs from 'fs'
|
|
||||||
import path from 'path'
|
|
||||||
import { Database } from 'sqlite'
|
|
||||||
import { fileURLToPath } from 'url'
|
|
||||||
import chokidar from 'chokidar'
|
|
||||||
import { db } from '../database'
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
|
||||||
const __dirname = path.dirname(__filename)
|
|
||||||
const PLAYLISTS_DIR = path.join(process.cwd(), 'public/ESPLAYLISTS')
|
|
||||||
const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.m4a', '.flac']
|
|
||||||
|
|
||||||
export class PlaylistSyncService {
|
|
||||||
private watcher: chokidar.FSWatcher | null = null
|
|
||||||
|
|
||||||
constructor(private db: Database) {}
|
|
||||||
|
|
||||||
async initialize() {
|
|
||||||
await this.scanPlaylists()
|
|
||||||
this.setupWatcher()
|
|
||||||
}
|
|
||||||
|
|
||||||
private async scanPlaylists() {
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(PLAYLISTS_DIR)) {
|
|
||||||
console.warn(`Playlists directory not found: ${PLAYLISTS_DIR}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const playlists = fs
|
|
||||||
.readdirSync(PLAYLISTS_DIR, { withFileTypes: true })
|
|
||||||
.filter((dirent) => dirent.isDirectory())
|
|
||||||
.map((dirent) => dirent.name)
|
|
||||||
|
|
||||||
for (const playlistName of playlists) {
|
|
||||||
await this.syncPlaylist(playlistName)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error scanning playlists:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async syncPlaylist(playlistName: string) {
|
|
||||||
const playlistPath = path.join(PLAYLISTS_DIR, playlistName)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stats = fs.statSync(playlistPath)
|
|
||||||
|
|
||||||
// Vérifier ou créer la playlist dans la base de données
|
|
||||||
let playlist = await this.db.get('SELECT * FROM playlists WHERE name = ?', [playlistName])
|
|
||||||
|
|
||||||
if (!playlist) {
|
|
||||||
const result = await this.db.run(
|
|
||||||
'INSERT INTO playlists (name, path, last_modified) VALUES (?, ?, ?)',
|
|
||||||
[playlistName, playlistPath, new Date(stats.mtime).toISOString()]
|
|
||||||
)
|
|
||||||
playlist = await this.db.get('SELECT * FROM playlists WHERE id = ?', [result.lastID])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Récupérer les pistes actuelles
|
|
||||||
const currentTracks = fs
|
|
||||||
.readdirSync(playlistPath)
|
|
||||||
.filter((file) => {
|
|
||||||
const ext = path.extname(file).toLowerCase()
|
|
||||||
return AUDIO_EXTENSIONS.includes(ext)
|
|
||||||
})
|
|
||||||
.map((file, index) => ({
|
|
||||||
path: path.join(playlistName, file),
|
|
||||||
order: index
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mettre à jour les pistes dans la base de données
|
|
||||||
await this.db.run('BEGIN TRANSACTION')
|
|
||||||
try {
|
|
||||||
// Supprimer les anciennes entrées
|
|
||||||
await this.db.run('DELETE FROM playlist_tracks WHERE playlist_id = ?', [playlist.id])
|
|
||||||
|
|
||||||
// Ajouter les nouvelles pistes
|
|
||||||
for (const track of currentTracks) {
|
|
||||||
await this.db.run(
|
|
||||||
'INSERT INTO playlist_tracks (playlist_id, track_path, track_order) VALUES (?, ?, ?)',
|
|
||||||
[playlist.id, track.path, track.order]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mettre à jour la date de modification
|
|
||||||
await this.db.run('UPDATE playlists SET last_modified = ? WHERE id = ?', [
|
|
||||||
new Date().toISOString(),
|
|
||||||
playlist.id
|
|
||||||
])
|
|
||||||
|
|
||||||
await this.db.run('COMMIT')
|
|
||||||
console.log(`Playlist "${playlistName}" synchronized with ${currentTracks.length} tracks`)
|
|
||||||
} catch (error) {
|
|
||||||
await this.db.run('ROLLBACK')
|
|
||||||
console.error(`Error syncing playlist ${playlistName}:`, error)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error accessing playlist directory ${playlistPath}:`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupWatcher() {
|
|
||||||
if (!fs.existsSync(PLAYLISTS_DIR)) {
|
|
||||||
console.warn(`Playlists directory not found, watcher not started: ${PLAYLISTS_DIR}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.watcher = chokidar.watch(PLAYLISTS_DIR, {
|
|
||||||
ignored: /(^|[\/\\])\../, // ignore les fichiers cachés
|
|
||||||
persistent: true,
|
|
||||||
ignoreInitial: true,
|
|
||||||
depth: 2 // surveille un seul niveau de sous-dossiers
|
|
||||||
})
|
|
||||||
|
|
||||||
this.watcher
|
|
||||||
.on('add', (filePath) => this.handleFileChange('add', filePath))
|
|
||||||
.on('change', (filePath) => this.handleFileChange('change', filePath))
|
|
||||||
.on('unlink', (filePath) => this.handleFileChange('unlink', filePath))
|
|
||||||
.on('addDir', (dirPath) => this.handleDirChange('addDir', dirPath))
|
|
||||||
.on('unlinkDir', (dirPath) => this.handleDirChange('unlinkDir', dirPath))
|
|
||||||
.on('error', (error) => console.error('Watcher error:', error))
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleFileChange(event: string, filePath: string) {
|
|
||||||
const relativePath = path.relative(PLAYLISTS_DIR, filePath)
|
|
||||||
const playlistName = path.dirname(relativePath)
|
|
||||||
const ext = path.extname(filePath).toLowerCase()
|
|
||||||
|
|
||||||
// Ignorer les fichiers qui ne sont pas des fichiers audio
|
|
||||||
if (playlistName === '.' || !AUDIO_EXTENSIONS.includes(ext)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`File ${event}: ${relativePath}`)
|
|
||||||
await this.syncPlaylist(playlistName)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleDirChange(event: string, dirPath: string) {
|
|
||||||
const relativePath = path.relative(PLAYLISTS_DIR, dirPath)
|
|
||||||
|
|
||||||
if (relativePath === '') return // Ignorer le dossier racine
|
|
||||||
|
|
||||||
console.log(`Directory ${event}: ${relativePath}`)
|
|
||||||
if (event === 'addDir') {
|
|
||||||
await this.syncPlaylist(relativePath)
|
|
||||||
} else if (event === 'unlinkDir') {
|
|
||||||
// Supprimer la playlist de la base de données
|
|
||||||
await this.db.run('DELETE FROM playlists WHERE name = ?', [relativePath])
|
|
||||||
console.log(`Removed playlist from database: ${relativePath}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
if (this.watcher) {
|
|
||||||
return this.watcher.close()
|
|
||||||
}
|
|
||||||
return Promise.resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Singleton instance
|
|
||||||
export const playlistSyncService = new PlaylistSyncService(db)
|
|
||||||
23
server/tasks/syncCards.ts
Normal file
23
server/tasks/syncCards.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { syncCardsWithDatabase } from '../services/cardSync.service'
|
||||||
|
|
||||||
|
export default defineTask({
|
||||||
|
meta: {
|
||||||
|
name: 'sync-tracks',
|
||||||
|
description: 'Synchronise les tracks avec le système de fichiers'
|
||||||
|
},
|
||||||
|
async run() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const folderPath = config.pathFiles || process.env.PATH_FILES || 'mnt/media/files/music'
|
||||||
|
|
||||||
|
console.log('⏰ [TASK] Démarrage de la synchronisation planifiée...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncCardsWithDatabase(folderPath)
|
||||||
|
console.log('✅ [TASK] Synchronisation terminée:', result)
|
||||||
|
return { result }
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ [TASK] Erreur lors de la synchronisation:', error)
|
||||||
|
return { error: error.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../.nuxt/tsconfig.server.json"
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
// Types pour les tables de la base de données
|
|
||||||
|
|
||||||
export interface Box {
|
|
||||||
id: string
|
|
||||||
type: 'compilation' | 'playlist'
|
|
||||||
name: string
|
|
||||||
description?: string
|
|
||||||
state?: string
|
|
||||||
duration: number
|
|
||||||
activeSide?: string
|
|
||||||
color1?: string
|
|
||||||
color2?: string
|
|
||||||
createdAt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Side {
|
|
||||||
id: number
|
|
||||||
boxId: string
|
|
||||||
side: string
|
|
||||||
name?: string
|
|
||||||
description?: string
|
|
||||||
duration: number
|
|
||||||
color1?: string
|
|
||||||
color2?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Artist {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
url?: string
|
|
||||||
coverId?: string
|
|
||||||
createdAt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Track {
|
|
||||||
id: number
|
|
||||||
boxId: string
|
|
||||||
side?: string
|
|
||||||
trackOrder: number
|
|
||||||
title: string
|
|
||||||
artistId?: number
|
|
||||||
start: number
|
|
||||||
link?: string
|
|
||||||
coverId?: string
|
|
||||||
url?: string
|
|
||||||
type: 'compilation' | 'playlist'
|
|
||||||
year?: number
|
|
||||||
date?: string
|
|
||||||
card?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Types pour les réponses API
|
|
||||||
|
|
||||||
export interface BoxWithSides extends Box {
|
|
||||||
sides?: Record<string, Omit<Side, 'id' | 'boxId'>>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrackWithArtist extends Track {
|
|
||||||
artistName?: string
|
|
||||||
artistUrl?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
|
||||||
items: T[]
|
|
||||||
pagination: {
|
|
||||||
page: number
|
|
||||||
limit: number
|
|
||||||
total: number
|
|
||||||
totalPages: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TracksSearchResponse {
|
|
||||||
tracks: TrackWithArtist[]
|
|
||||||
pagination: {
|
|
||||||
page: number
|
|
||||||
limit: number
|
|
||||||
total: number
|
|
||||||
totalPages: number
|
|
||||||
}
|
|
||||||
search?: string
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import Database from 'better-sqlite3'
|
|
||||||
import fs from 'fs'
|
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
let db: Database.Database | null = null
|
|
||||||
|
|
||||||
export function getDatabase(): Database.Database {
|
|
||||||
if (db) return db
|
|
||||||
|
|
||||||
const dbDir = path.join(process.cwd(), 'server/database')
|
|
||||||
const dbPath = path.join(dbDir, 'evilspins.db')
|
|
||||||
|
|
||||||
// Créer le dossier si nécessaire
|
|
||||||
if (!fs.existsSync(dbDir)) {
|
|
||||||
fs.mkdirSync(dbDir, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connexion à la base
|
|
||||||
db = new Database(dbPath, {
|
|
||||||
verbose: process.env.NODE_ENV === 'development' ? console.log : undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
// Activer les clés étrangères
|
|
||||||
db.pragma('foreign_keys = ON')
|
|
||||||
|
|
||||||
// Initialiser le schéma si la DB est vide
|
|
||||||
initializeSchema(db)
|
|
||||||
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
|
|
||||||
function initializeSchema(database: Database.Database) {
|
|
||||||
const schemaPath = path.join(process.cwd(), 'server/database/schema.sql')
|
|
||||||
|
|
||||||
// Vérifier si les tables existent déjà
|
|
||||||
const tableCheck = database
|
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='tracks'")
|
|
||||||
.get()
|
|
||||||
|
|
||||||
if (!tableCheck) {
|
|
||||||
console.log('🔧 Initialisation du schéma de la base de données...')
|
|
||||||
const schema = fs.readFileSync(schemaPath, 'utf-8')
|
|
||||||
|
|
||||||
// Exécuter chaque statement SQL
|
|
||||||
const statements = schema
|
|
||||||
.split(';')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter((s) => s.length > 0)
|
|
||||||
|
|
||||||
for (const statement of statements) {
|
|
||||||
database.exec(statement)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ Schéma initialisé avec succès')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function closeDatabase() {
|
|
||||||
if (db) {
|
|
||||||
db.close()
|
|
||||||
db = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
119
server/utils/fileScanner.ts
Normal file
119
server/utils/fileScanner.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { readdir, readFile } from 'node:fs/promises'
|
||||||
|
import { join, extname, basename } from 'node:path'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { slugify } from './slugify'
|
||||||
|
import { getCardFromDate } from './getCardFromDate'
|
||||||
|
import type { Card } from '@/types/types'
|
||||||
|
|
||||||
|
const listAudioExts = ['.mp3', '.opus', 'flac']
|
||||||
|
const listImageExts = ['.jpg', '.jpeg', '.webp']
|
||||||
|
|
||||||
|
export async function scanMusicFolder(folderPath: string): Promise<Card[]> {
|
||||||
|
try {
|
||||||
|
const files = await readdir(folderPath)
|
||||||
|
const cardMap = new Map<string, Card>()
|
||||||
|
|
||||||
|
// D'abord, on traite tous les fichiers audio
|
||||||
|
for (const file of files) {
|
||||||
|
const ext = extname(file).toLowerCase()
|
||||||
|
|
||||||
|
// On ne traite que les fichiers audio
|
||||||
|
if (!listAudioExts.includes(ext)) continue
|
||||||
|
|
||||||
|
const parsed = await parseFilename(join(folderPath, file))
|
||||||
|
if (parsed) {
|
||||||
|
// On vérifie s'il existe une image avec le même nom de base
|
||||||
|
const baseName = basename(file, ext)
|
||||||
|
let imageUrl = ''
|
||||||
|
|
||||||
|
// On cherche une image correspondante
|
||||||
|
for (const imgExt of listImageExts) {
|
||||||
|
const potentialImage = baseName + imgExt
|
||||||
|
if (files.includes(potentialImage)) {
|
||||||
|
imageUrl = process.env.URL_PREFIX + baseName + imgExt
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cardMap.set(parsed.esid, {
|
||||||
|
...parsed,
|
||||||
|
url_audio: process.env.URL_PREFIX + baseName + ext,
|
||||||
|
url_image: imageUrl,
|
||||||
|
suit: parsed.suit,
|
||||||
|
rank: parsed.rank
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(cardMap.values())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du scan du dossier:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseFilename(
|
||||||
|
filename: string
|
||||||
|
): Promise<Omit<Card, 'url_audio' | 'url_image'> | null> {
|
||||||
|
// Format: yyyymmddhh__artist__title.ext
|
||||||
|
const nameWithoutExt = basename(filename, extname(filename))
|
||||||
|
const parts = nameWithoutExt.split('__')
|
||||||
|
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
console.warn(`Nom de fichier invalide: ${filename}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [datetime, artist, title] = parts
|
||||||
|
|
||||||
|
if (!datetime || !artist || !title) {
|
||||||
|
console.warn(`Format de fichier invalide: ${filename} - manque des parties`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (datetime.length !== 10) {
|
||||||
|
console.warn(`Format de date invalide: ${filename}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utilisation d'un hash basé sur le contenu du fichier pour un ESID stable
|
||||||
|
let fileHash = ''
|
||||||
|
try {
|
||||||
|
const fileContent = await readFile(filename)
|
||||||
|
fileHash = createHash('md5').update(fileContent).digest('hex').substring(0, 8)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Impossible de lire le fichier pour générer le hash: ${filename}`)
|
||||||
|
fileHash = createHash('md5').update(filename).digest('hex').substring(0, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = datetime.substring(0, 4)
|
||||||
|
const month = datetime.substring(4, 6)
|
||||||
|
const day = datetime.substring(6, 8)
|
||||||
|
const hour = datetime.substring(8, 10)
|
||||||
|
// Créer l'ID unique pour la card
|
||||||
|
const esid = createHash('md5')
|
||||||
|
.update(`${year}${month}${day}${hour}${artist}${title}`)
|
||||||
|
.digest('hex')
|
||||||
|
|
||||||
|
const date = new Date(
|
||||||
|
parseInt(year, 10),
|
||||||
|
parseInt(month, 10) - 1,
|
||||||
|
parseInt(day, 10),
|
||||||
|
parseInt(hour, 10)
|
||||||
|
)
|
||||||
|
const card = getCardFromDate(date)
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day,
|
||||||
|
hour,
|
||||||
|
artist: artist.replace(/_/g, ' '), // Remplacer les _ par des espaces
|
||||||
|
title: title.replace(/_/g, ' '),
|
||||||
|
esid,
|
||||||
|
slug: slugify(`${artist} ${title}`),
|
||||||
|
createdAt: date,
|
||||||
|
suit: card.suit,
|
||||||
|
rank: card.rank
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { CardSuit, CardRank } from '~/types/cards'
|
import type { Suit, Rank } from '../../types/types'
|
||||||
|
|
||||||
export function getCardFromDate(date: Date): { suit: CardSuit; rank: CardRank } {
|
export function getCardFromDate(date: Date): { suit: Suit; rank: Rank } {
|
||||||
const month = date.getMonth() + 1
|
const month = date.getMonth() + 1
|
||||||
const day = date.getDate()
|
const day = date.getDate()
|
||||||
const hour = date.getHours()
|
const hour = date.getHours()
|
||||||
|
|
||||||
const suit: CardSuit =
|
const suit: Suit =
|
||||||
month >= 12 || month <= 2
|
month >= 12 || month <= 2
|
||||||
? '♠'
|
? '♠'
|
||||||
: month >= 3 && month <= 5
|
: month >= 3 && month <= 5
|
||||||
@@ -14,7 +14,7 @@ export function getCardFromDate(date: Date): { suit: CardSuit; rank: CardRank }
|
|||||||
? '♦'
|
? '♦'
|
||||||
: '♣'
|
: '♣'
|
||||||
|
|
||||||
const ranks: CardRank[] = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
|
const ranks: Rank[] = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
|
||||||
const rank = ranks[(day + hour) % ranks.length]
|
const rank = ranks[(day + hour) % ranks.length]
|
||||||
|
|
||||||
return { suit, rank }
|
return { suit, rank }
|
||||||
9
server/utils/slugify.ts
Normal file
9
server/utils/slugify.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function slugify(str: string): string {
|
||||||
|
return str
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+/, '')
|
||||||
|
.replace(/-+$/, '')
|
||||||
|
}
|
||||||
@@ -18,12 +18,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"**/*.ts",
|
"./components/**/*.js",
|
||||||
"**/*.d.ts",
|
"./components/**/*.ts",
|
||||||
"**/*.tsx",
|
"./components/**/*.jsx",
|
||||||
"**/*.vue",
|
"./components/**/*.tsx",
|
||||||
".nuxt/**/*.ts",
|
"./components/**/*.vue",
|
||||||
"./types/**/*.d.ts"
|
"./app.vue",
|
||||||
|
"./error.vue",
|
||||||
|
"./types/**/*.ts"
|
||||||
],
|
],
|
||||||
|
"typeRoots": ["./node_modules/@types", "./types"],
|
||||||
"exclude": ["node_modules", ".output", "dist"]
|
"exclude": ["node_modules", ".output", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,18 @@
|
|||||||
// types.ts
|
export interface Card {
|
||||||
export type BoxType = 'playlist' | 'compilation'
|
url_audio: string
|
||||||
|
url_image: string
|
||||||
export interface BoxSide {
|
year: number
|
||||||
duration: number
|
month: string
|
||||||
color1: string
|
day: string
|
||||||
color2: string
|
hour: string
|
||||||
name?: string
|
artist: string
|
||||||
description?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Box {
|
|
||||||
id: string
|
|
||||||
type: BoxType
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
state: BoxState
|
|
||||||
duration: number
|
|
||||||
tracks?: Track[]
|
|
||||||
sides?: {
|
|
||||||
A: BoxSide
|
|
||||||
B: BoxSide
|
|
||||||
}
|
|
||||||
activeSide: 'A' | 'B'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Artist {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
url: string
|
|
||||||
coverId: string
|
|
||||||
}
|
|
||||||
export interface Track {
|
|
||||||
id: number
|
|
||||||
side?: 'A' | 'B'
|
|
||||||
order?: number
|
|
||||||
boxId: string
|
|
||||||
title: string
|
title: string
|
||||||
artist?: Artist | number | string
|
esid: string
|
||||||
start?: number
|
slug: string
|
||||||
duration?: number
|
createdAt: Date
|
||||||
url: string
|
suit: Suit
|
||||||
coverId?: string
|
rank: Rank
|
||||||
date?: Date
|
|
||||||
card?: { suit: CardSuit; rank: CardRank }
|
|
||||||
link?: string
|
|
||||||
type: BoxType
|
|
||||||
year?: number
|
|
||||||
color: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BoxState = 'box-hidden' | 'box-list' | 'box-selected'
|
export type Suit = '♠' | '♣' | '♦' | '♥'
|
||||||
export type CardSuit = '♠' | '♣' | '♦' | '♥'
|
export type Rank = 'A' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | 'J' | 'Q' | 'K'
|
||||||
export type CardRank = 'A' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | 'J' | 'Q' | 'K'
|
|
||||||
|
|||||||
52
utils/tabManager.ts
Normal file
52
utils/tabManager.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Gestionnaire d'onglets pour les cartes NFC
|
||||||
|
export function openOrFocusCard(slug: string) {
|
||||||
|
const url = `${window.location.origin}/card/${slug}`
|
||||||
|
|
||||||
|
// Vérifier si un onglet est déjà ouvert
|
||||||
|
const isTabOpen = localStorage.getItem('nfc-active-tab') === 'true'
|
||||||
|
|
||||||
|
if (isTabOpen) {
|
||||||
|
// Si un onglet est déjà ouvert, on envoie un message pour le rafraîchir
|
||||||
|
const channel = new BroadcastChannel('nfc-channel')
|
||||||
|
channel.postMessage({
|
||||||
|
type: 'nfc-focus',
|
||||||
|
slug: slug,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
// On se concentre sur l'onglet existant
|
||||||
|
window.focus()
|
||||||
|
|
||||||
|
// On ferme le canal après un court délai
|
||||||
|
setTimeout(() => channel.close(), 1000)
|
||||||
|
|
||||||
|
// On empêche l'ouverture d'un nouvel onglet
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aucun onglet ouvert, on en ouvre un nouveau
|
||||||
|
window.open(url, '_blank')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction pour formater l'URL à utiliser dans la carte NFC
|
||||||
|
export function getNfcCardUrl(slug: string): string {
|
||||||
|
return `javascript:(function(){
|
||||||
|
const url = '${window.location.origin}/card/${slug}';
|
||||||
|
const isTabOpen = localStorage.getItem('nfc-active-tab') === 'true';
|
||||||
|
|
||||||
|
if (isTabOpen) {
|
||||||
|
const channel = new BroadcastChannel('nfc-channel');
|
||||||
|
channel.postMessage({
|
||||||
|
type: 'nfc-focus',
|
||||||
|
slug: '${slug}',
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
setTimeout(() => channel.close(), 1000);
|
||||||
|
window.focus();
|
||||||
|
window.location.href = url;
|
||||||
|
} else {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
})();`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user