Compare commits

...

24 Commits

Author SHA1 Message Date
9705257178 Update app/components/Platine.vue
All checks were successful
Deploy App / build (push) Successful in 35s
Deploy App / deploy (push) Successful in 28s
2026-01-26 16:20:43 +00:00
2f78442deb Update server/api/tracks/playlist.ts
All checks were successful
Deploy App / build (push) Successful in 3m45s
Deploy App / deploy (push) Successful in 20s
2026-01-26 16:11:11 +00:00
valere
c586cc3932 PLATINE blur bobine
All checks were successful
Deploy App / build (push) Successful in 1m53s
Deploy App / deploy (push) Successful in 15s
2026-01-04 23:01:26 +01:00
valere
11694d36dd CI copy server files
All checks were successful
Deploy App / build (push) Successful in 51s
Deploy App / deploy (push) Successful in 14s
2026-01-04 10:51:27 +01:00
valere
3b05938162 CI install sqlite3
All checks were successful
Deploy App / build (push) Successful in 2m36s
Deploy App / deploy (push) Successful in 17s
2026-01-04 10:34:10 +01:00
valere
f75a1481bd platine mobile size
All checks were successful
Deploy App / build (push) Successful in 20s
Deploy App / deploy (push) Successful in 14s
2026-01-02 22:34:11 +01:00
valere
bb791e35d1 platine transition
All checks were successful
Deploy App / build (push) Successful in 2m4s
Deploy App / deploy (push) Successful in 16s
2026-01-01 20:50:37 +01:00
valere
a5fe876e3f bucket cards management
All checks were successful
Deploy App / build (push) Successful in 3m57s
Deploy App / deploy (push) Successful in 17s
2025-12-31 17:23:11 +01:00
valere
9001025837 SQLITE 3 2025-12-31 16:31:53 +01:00
valere
afb20fe75f bucket + card sharer
All checks were successful
Deploy App / build (push) Successful in 1m57s
Deploy App / deploy (push) Successful in 16s
2025-12-26 19:27:33 +01:00
valere
d8fe645e5c unified gost cards (mouse + touch)
All checks were successful
Deploy App / build (push) Successful in 31s
Deploy App / deploy (push) Successful in 15s
2025-12-24 12:25:58 +01:00
valere
ad938abf79 draggable / touchable card v0.1
All checks were successful
Deploy App / build (push) Successful in 1m53s
Deploy App / deploy (push) Successful in 18s
2025-12-24 06:00:15 +01:00
valere
1f4f7868ca drop old code
All checks were successful
Deploy App / build (push) Successful in 1m0s
Deploy App / deploy (push) Successful in 14s
2025-12-23 12:56:42 +01:00
valere
2c826e29ea compose elt v0.1
Some checks failed
Deploy App / build (push) Has been cancelled
Deploy App / deploy (push) Has been cancelled
2025-12-23 12:55:37 +01:00
valere
8efafc4642 Bucket v0.1
All checks were successful
Deploy App / build (push) Successful in 2m1s
Deploy App / deploy (push) Successful in 16s
2025-12-22 09:56:27 +01:00
valere
ecc1c22475 raccourciclavier [R]eveal [H]ide 2025-12-22 09:52:16 +01:00
valere
5948b4efbd clean type 2025-12-22 09:46:38 +01:00
valere
55cae0b9c6 toogle cards 2025-12-21 20:20:20 +01:00
valere
c0d79591c3 PLATINE drag to play & random tracks
All checks were successful
Deploy App / build (push) Successful in 1m59s
Deploy App / deploy (push) Successful in 17s
2025-12-19 11:41:47 +01:00
valere
1c4cbfe21c platine as component
All checks were successful
Deploy App / build (push) Successful in 53s
Deploy App / deploy (push) Successful in 15s
2025-12-18 19:59:23 +01:00
valere
2be5724c9f platine / bobine progression
All checks were successful
Deploy App / build (push) Successful in 51s
Deploy App / deploy (push) Successful in 14s
2025-12-17 22:15:40 +01:00
valere
dc2cba500c set route /mix & /draggable
All checks were successful
Deploy App / build (push) Successful in 3m13s
Deploy App / deploy (push) Successful in 18s
2025-12-17 19:34:25 +01:00
valere
65aaa71a3d FEAT: playlist filters
All checks were successful
Deploy App / build (push) Successful in 3m34s
Deploy App / deploy (push) Successful in 19s
2025-12-08 23:48:21 +01:00
valere
6176995032 Platine etape 1 2025-12-07 19:44:21 +01:00
63 changed files with 6129 additions and 21171 deletions

3
.env Executable file
View File

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

View File

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

View File

@@ -1,5 +1,6 @@
# Builder # Builder
FROM node:20-bookworm AS builder FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install --legacy-peer-deps RUN npm install --legacy-peer-deps
@@ -8,9 +9,12 @@ COPY . .
RUN npm run build RUN npm run build
# Runtime # Runtime
FROM node:20-slim AS runner FROM node:20-alpine AS runner
RUN apk add --no-cache python3 make g++ sqlite
WORKDIR /app WORKDIR /app
COPY --from=builder /app/.output ./.output COPY --from=builder /app/.output ./.output
COPY package*.json ./ COPY package*.json ./
COPY ./server/database ./server/database
EXPOSE 3000 EXPOSE 3000
CMD ["node", ".output/server/index.mjs"] CMD ["node", ".output/server/index.mjs"]

View File

@@ -75,10 +75,6 @@ input[type='email'] {
@apply bg-slate-900 text-esyellow; @apply bg-slate-900 text-esyellow;
} }
* {
user-select: none;
}
img { img {
user-drag: none; user-drag: none;
user-select: none; user-select: none;

View File

@@ -5,10 +5,11 @@
<img v-if="isCompilation" class="cover absolute" :src="`/${box.id}/${box.activeSide}/cover.jpg`" alt="" /> <img v-if="isCompilation" class="cover absolute" :src="`/${box.id}/${box.activeSide}/cover.jpg`" alt="" />
<div v-else class="size-full flex flex-col justify-center items-center text-7xl text-black" <div v-else class="size-full flex flex-col justify-center items-center text-7xl text-black"
v-html="box.description" /> v-html="box.description" />
<CinemaScreen />
</div> </div>
<div class="face back flex flex-row flex-wrap items-start p-4 overflow-hidden" <div class="face back flex flex-row flex-wrap items-start p-4 overflow-hidden"
:class="{ 'overflow-y-scroll': !isCompilation }" ref="backFace"> :class="{ 'overflow-y-scroll': !isCompilation }" ref="backFace">
<li class="list-none text-xxs w-1/2 flex flex-row" <!-- <li class="list-none text-xxs w-1/2 flex flex-row"
v-for="track in dataStore.getTracksByboxId(box.id, box.activeSide)" :key="track.id" :track="track"> v-for="track in dataStore.getTracksByboxId(box.id, box.activeSide)" :key="track.id" :track="track">
<span class="text-slate-700" v-if="isNotManifesto"> {{ track.order }}. </span> <span class="text-slate-700" v-if="isNotManifesto"> {{ track.order }}. </span>
<p class="text-left text-slate-700"> <p class="text-left text-slate-700">
@@ -18,7 +19,7 @@
<br /> <br />
{{ track.artist.name }} {{ track.artist.name }}
</p> </p>
</li> </li> -->
</div> </div>
<div class="face right" ref="rightFace" /> <div class="face right" ref="rightFace" />
<div class="face left" ref="leftFace" /> <div class="face left" ref="leftFace" />
@@ -142,13 +143,6 @@ function rotateBox() {
applyTransform(0.8) applyTransform(0.8)
} }
watch(
() => props.box.activeSide,
() => {
rotateBox()
}
)
// --- Inertie --- // --- Inertie ---
function tickInertia() { function tickInertia() {
if (!enableInertia) return if (!enableInertia) return
@@ -246,6 +240,10 @@ onBeforeUnmount(() => {
}) })
// --- Watchers --- // --- Watchers ---
watch(
() => props.box.activeSide,
() => rotateBox()
)
watch( watch(
() => props.box.state, () => props.box.state,
() => applyBoxState() () => applyBoxState()
@@ -255,7 +253,10 @@ watch(
() => applyColor(), () => applyColor(),
{ deep: true } { deep: true }
) )
watch(isDraggable, (enabled) => (enabled ? addListeners() : removeListeners())) watch(
isDraggable,
(enabled) => (enabled ? addListeners() : removeListeners())
)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -2,10 +2,12 @@
<div class="boxes" :class="{ 'box-selected': uiStore.isBoxSelected }"> <div class="boxes" :class="{ 'box-selected': uiStore.isBoxSelected }">
<box v-for="(box, i) in dataStore.boxes" :key="box.id" :tabindex="dataStore.boxes.length - i" <box v-for="(box, i) in dataStore.boxes" :key="box.id" :tabindex="dataStore.boxes.length - i"
:box="getBoxToDisplay(box)" @click="openBox(box)" class="text-center" :class="box.state" :id="box.id"> :box="getBoxToDisplay(box)" @click="openBox(box)" class="text-center" :class="box.state" :id="box.id">
<playButton @click.stop="playSelectedBox(box)" :objectToPlay="box" class="relative z-40 m-auto" />
<template v-if="box.state === 'box-selected'"> <template v-if="box.state === 'box-selected'">
<deckCompilation :box="getBoxToDisplay(box)" class="box-page" v-if="box.type === 'compilation'" <template v-if="box.type === 'compilation'">
:key="`${box.id}-${box.activeSide}`" @click.stop /> <playButton @click.stop="playSelectedBox(box)" :objectToPlay="box" class="relative z-40 m-auto" />
<deckCompilation :box="getBoxToDisplay(box)" class="box-page" :key="`${box.id}-${box.activeSide}`"
@click.stop />
</template>
<deckPlaylist :box="box" class="box-page" v-if="box.type === 'playlist'" @click.stop /> <deckPlaylist :box="box" class="box-page" v-if="box.type === 'playlist'" @click.stop />
</template> </template>
</box> </box>

169
app/components/Bucket.vue Normal file
View File

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

View File

@@ -1,17 +1,22 @@
<template> <template>
<article class="card w-56 h-80" :class="[ <article v-bind="attrs" :role="props.role" :draggable="isFaceUp" :class="[
'card cursor-pointer',
isFaceUp ? 'face-up' : 'face-down', isFaceUp ? 'face-up' : 'face-down',
{ 'current-track': playerStore.currentTrack?.id === track.id } { 'current-track': playerStore.currentTrack?.id === track.id },
]"> { 'is-dragging': isDragging }
<div class="flip-inner"> ]" :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 --> <!-- 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 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" <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.track.card?.suit]">
<img :src="`/${props.track.card?.suit}.svg`" /> <img draggable="false" :src="`/${props.track.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.track.card?.rank }}
@@ -25,62 +30,346 @@
</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-t-xl cursor-pointer"
@click="playerStore.playTrack(track)">
<playButton :objectToPlay="track" /> <playButton :objectToPlay="track" />
<img v-if="isFaceUp" :src="coverUrl" alt="Pochette de l'album" loading="lazy" <img draggable="false" v-if="isFaceUp" :src="coverUrl" 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="p-3 text-center bg-white rounded-b-xl"> 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"> <div v-if="isOrder" class="label">
{{ props.track.order }} {{ props.track.order }}
</div> </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 }} {{ props.track.title || 'title' }}
</h2> </h2>
<p v-if="isPlaylistTrack" class="select-text text-base text-neutral-800 font-bold capitalize truncate"> <p v-if="isPlaylistTrack && track.artist && typeof track.artist === 'object'"
{{ props.track.artist.name }} class="select-text text-base text-neutral-800 font-bold capitalize truncate">
{{ track.artist.name || 'artist' }}
</p> </p>
<p class="select-text"> <p v-else-if="isPlaylistTrack" class="select-text text-base text-neutral-800 font-bold capitalize truncate">
{{ props.track.url.split('/')[4]?.split('__')[0] }} {{ typeof track.artist === 'string' ? track.artist : 'artist' }}
</p> </p>
</div> </div>
</main> </main>
<!-- Face-Down --> <!-- Face-Down -->
<footer <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"> 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 @click.stop="playerStore.playTrack(track)" <figure class="h-full flex text-center rounded-xl justify-center items-center"
class="h-full flex text-center bg-slate-800 rounded-xl justify-center cursor-pointer"> :style="{ backgroundColor: cardColor }">
<playButton :objectToPlay="track" /> <playButton :objectToPlay="track" />
<img src="/face-down.svg" /> <img draggable="false" 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 { Track } from '~~/types/types'
import { usePlayerStore } from '~/store/player' 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 }>(), { const props = withDefaults(defineProps<{
isFaceUp: false 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 playerStore = usePlayerStore()
const isManifesto = computed(() => props.track.boxId.startsWith('ES00')) const isManifesto = computed(() => props.track.boxId.startsWith('ES00'))
const isOrder = computed(() => props.track.order && !isManifesto) const isOrder = computed(() => props.track.order && !isManifesto)
const isPlaylistTrack = computed(() => props.track.type === 'playlist') const isPlaylistTrack = computed(() => props.track.type === 'playlist')
const isRedCard = computed(() => props.track.card?.suit === '♥' || props.track.card?.suit === '♦') const isRedCard = computed(() => (props.track.card?.suit === '♥' || props.track.card?.suit === '♦'))
const coverUrl = computed(() => { const dataStore = useDataStore()
if (!props.track.coverId) return '' const cardColor = computed(() => dataStore.getYearColor(props.track.year || 0))
return props.track.coverId.startsWith('http') const coverUrl = computed(() => props.track.coverId || '/card-dock.svg')
? props.track.coverId
: `https://f4.bcbits.com/img/${props.track.coverId}_4.jpg` 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>
@@ -101,7 +390,8 @@ const coverUrl = computed(() => {
/* Flip effect */ /* Flip effect */
.card { .card {
perspective: 1000px; perspective: 1000px;
@apply transition-all scale-100; @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;
@@ -149,18 +439,24 @@ const coverUrl = computed(() => {
} }
} }
&.current-track, &:focus,
&:focus { &.current-track {
@apply z-50; @apply z-50 scale-110;
outline: none;
.face-up { .face-up {
@apply shadow-none; @apply shadow-2xl;
transition: transition:
box-shadow 0.6s, box-shadow 0.6s,
transform 0.6s; transform 0.6s;
} }
}
@apply scale-110; &:focus,
&.current-track {
.play-button {
@apply opacity-100;
}
} }
.play-button { .play-button {
@@ -187,5 +483,37 @@ const coverUrl = computed(() => {
@apply scale-90; @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> </style>

View File

@@ -1,3 +1,3 @@
<template> <template>
<video class="fixed h-full w-full object-cover" ref="video" muted autoplay src=""></video> <video class="h-full w-full object-cover" ref="video" muted autoplay src=""></video>
</template> </template>

View File

@@ -2,7 +2,7 @@
<transition name="fade"> <transition name="fade">
<div v-if="data.isLoading" class="fixed inset-0 z-50 flex items-center justify-center"> <div v-if="data.isLoading" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60 backdrop-blur-md" /> <div class="absolute inset-0 bg-black/60 backdrop-blur-md" />
<img src="/loader.svg" alt="Loading" class="border-esyellow/30 border-2 relative h-40 w-40 p-6 rounded-full"> <img src="/loader.svg" alt="Loading" class="border-esyellow/30 border-4 relative h-40 w-40 p-6 rounded-full">
</div> </div>
</transition> </transition>
</template> </template>

View File

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

198
app/components/Platine.vue Normal file
View File

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

View File

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

View File

@@ -1,42 +0,0 @@
<template>
<div ref="deck" class="deck flex flex-wrap justify-center gap-4" :class="{ 'pb-36': playerStore.currentTrack }">
<button @click="closeDatBox" v-if="uiStore.isBoxSelected"
class="absolute top-10 right-10 px-4 py-2 text-black hover:text-black bg-esyellow transition-colors z-50"
aria-label="close the box">
close
</button>
<button class="absolute top-24 right-10 px-4 py-2 text-black hover:text-black bg-esyellow transition-colors z-50"
@click="cardStore.revealAllCards(tracks)">
reveal
</button>
<card v-for="(track, i) in tracks" :key="track.id" :track="track" tabindex="i"
:is-face-up="isCardRevealed(track.id)" />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useDataStore } from '~/store/data'
import { useCardStore } from '~/store/card'
import { usePlayerStore } from '~/store/player'
import { useUiStore } from '~/store/ui'
import type { Box } from '~~/types/types'
const props = defineProps<{
box: Box
}>()
const cardStore = useCardStore()
const dataStore = useDataStore()
const playerStore = usePlayerStore()
const uiStore = useUiStore()
const deck = ref()
const tracks = computed(() => dataStore.getTracksByboxId(props.box.id))
const isCardRevealed = (trackId: number) => cardStore.isCardRevealed(trackId)
const closeDatBox = () => {
uiStore.closeBox()
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
<template>
<div class="relative">
<input v-model="searchQuery" type="text" placeholder="Rechercher..."
class="px-4 py-2 pl-10 w-48 m-4 h-12 font-bold text-black bg-esyellow border border-none rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-estyellow-dark focus:border-estyellow-dark"
@input="handleSearch">
<div class="absolute inset-y-0 left-0 flex items-center pl-6 pointer-events-none">
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits(['search'])
const searchQuery = ref('')
const handleSearch = () => {
emit('search', searchQuery.value.trim().toLowerCase())
}
</script>

View File

@@ -0,0 +1,37 @@
<template>
<select v-model="selectedRank" @change="handleChange"
class="px-4 py-2 m-4 font-bold h-12 border-none text-center bg-esyellow transition-colors border border-esyellow-dark rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-estyellow-dark focus:border-estyellow-dark cursor-pointer appearance-none">
<option value="">rank</option>
<option v-for="rank in ranks" :key="rank.value" :value="rank.value">
{{ rank.label }}
</option>
</select>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits(['change'])
const ranks = [
{ value: 'A', label: 'Ace' },
{ value: '2', label: '2' },
{ value: '3', label: '3' },
{ value: '4', label: '4' },
{ value: '5', label: '5' },
{ value: '6', label: '6' },
{ value: '7', label: '7' },
{ value: '8', label: '8' },
{ value: '9', label: '9' },
{ value: '10', label: '10' },
{ value: 'J', label: 'Jack' },
{ value: 'Q', label: 'Queen' },
{ value: 'K', label: 'King' }
]
const selectedRank = ref('')
const handleChange = () => {
emit('change', selectedRank.value)
}
</script>

View File

@@ -0,0 +1,28 @@
<template>
<select v-model="selectedSuit" @change="handleChange"
class="px-4 py-2 m-4 text-black font-bold h-12 border-none text-center hover:text-black bg-esyellow transition-colors border border-esyellow-dark rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-esyellow-dark focus:border-esyellow-dark cursor-pointer appearance-none">
<option value=""></option>
<option v-for="suit in suits" :key="suit.value" :value="suit.value">
{{ suit.label }}
</option>
</select>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits(['change'])
const suits = [
{ value: '♥', label: '♥' },
{ value: '♦', label: '♦' },
{ value: '♣', label: '♣' },
{ value: '♠', label: '♠' }
]
const selectedSuit = ref('')
const handleChange = () => {
emit('change', selectedSuit.value)
}
</script>

View File

@@ -1,9 +1,55 @@
<template> <template>
<div class="w-full flex flex-col items-center"> <slot />
<slot /> <Bucket />
<searchModal /> <Platine v-if="playerStore.currentTrack" />
<loader />
<Player class="w-full border-t border-gray-200" />
<CinemaScreen />
</div>
</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>

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

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

View File

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

View File

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

View File

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

View File

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

3
app/pages/story/holo.vue Normal file
View File

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

46
app/pages/story/index.vue Normal file
View File

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

60
app/pages/story/mix.vue Normal file
View File

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

4
app/pages/story/test.vue Normal file
View File

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

View File

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

382
app/platine-tools/disc.ts Normal file
View File

@@ -0,0 +1,382 @@
const TAU = Math.PI * 2
const targetFPS = 60
const RPS = 0.75
const RPM = RPS * 60
const RADIANS_PER_MINUTE = RPM * TAU
const RADIANS_PER_SECOND = RADIANS_PER_MINUTE / 60
const RADIANS_PER_MILLISECOND = RADIANS_PER_SECOND * 0.001
const ROTATION_SPEED = (TAU * RPS) / targetFPS
type Vector = {
x: number
y: number
}
type NumberArray = Array<number>
const average = (arr: NumberArray) => arr.reduce((memo, val) => memo + val, 0) / arr.length
// Limit array size by cutting off from the start
const limit = (arr: NumberArray, maxLength = 10) => {
const deleteCount = Math.max(0, arr.length - maxLength)
return arr.slice(deleteCount)
}
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max))
const distanceBetween = (vec1: Vector, vec2: Vector) => Math.hypot(vec2.x - vec1.x, vec2.y - vec1.y)
const getElementCenter = (el: HTMLElement): Vector => {
const { left, top, width, height } = el.getBoundingClientRect()
const x = left + width / 2
const y = top + height / 2
return { x, y }
}
const angleBetween = (vec1: Vector, vec2: Vector) => Math.atan2(vec2.y - vec1.y, vec2.x - vec1.x)
const angleDifference = (x: number, y: number) => Math.atan2(Math.sin(x - y), Math.cos(x - y))
type DiscProgress = {
playbackSpeed: number
isReversed: boolean
secondsPlayed: number
progress: number
}
class Disc {
public el: HTMLElement
private _playbackSpeed = 1
private _duration = 0
private _isDragging = false
private _isPoweredOn = false
private _center: Vector
private _currentAngle = 0
private _previousAngle = 0
private _maxAngle = TAU
public rafId: number | null = null
public previousTimestamp: number
private _draggingSpeeds: Array<number> = []
private _draggingFrom: Vector = { x: 0, y: 0 }
// Propriétés pour l'inertie
private _inertiaVelocity: number = 0
private _isInertiaActive: boolean = false
private _basePlaybackSpeed: number = 1 // Vitesse de lecture normale
private _inertiaFriction: number = 0.93 // Coefficient de frottement pour l'inertie (plus proche de 1 = plus long)
private _lastDragVelocity: number = 0 // Dernière vitesse de drag
private _lastDragTime: number = 0 // Dernier temps de drag
private _inertiaAmplification: number = 25 // Facteur d'amplification de l'inertie
public isReversed: boolean = false
public callbacks = {
onDragStart: (): void => {},
onDragEnded: (secondsPlayed: number): void => {},
onStop: (): void => {},
onLoop: (params: DiscProgress): void => {}
}
constructor(el: HTMLElement) {
this.el = el
this._center = getElementCenter(this.el)
this.previousTimestamp = performance.now()
this.onDragStart = this.onDragStart.bind(this)
this.onDragProgress = this.onDragProgress.bind(this)
this.onDragEnd = this.onDragEnd.bind(this)
this.loop = this.loop.bind(this)
this.init()
}
init() {
// Ajout du style pour désactiver le comportement tactile par défaut
this.el.style.touchAction = 'none'
// Écouteurs pour la souris et le tactile
this.el.addEventListener('pointerdown', this.onDragStart)
this.el.addEventListener(
'touchstart',
(e) => {
// Empêcher le défilement de la page
e.preventDefault()
this.onDragStart(e)
},
{ passive: false }
)
}
get playbackSpeed() {
return this._playbackSpeed
}
set playbackSpeed(s) {
this._draggingSpeeds.push(s)
this._draggingSpeeds = limit(this._draggingSpeeds, 10)
this._playbackSpeed = average(this._draggingSpeeds)
this._playbackSpeed = clamp(this._playbackSpeed, -4, 4)
}
get secondsPlayed() {
return this._currentAngle / TAU / RPS
}
set isDragging(d) {
this._isDragging = d
this.el.classList.toggle('is-scratching', d)
}
get isDragging() {
return this._isDragging
}
powerOn() {
if (!this.rafId) {
this.start()
}
this._isPoweredOn = true
this._basePlaybackSpeed = 1
this._playbackSpeed = 1
}
powerOff() {
this._isPoweredOn = false
this._basePlaybackSpeed = 0
}
public setDuration(duration: number) {
this._duration = duration
this._maxAngle = duration * RPS * TAU
}
onDragStart(e: PointerEvent | TouchEvent) {
// Empêcher le comportement par défaut pour éviter le défilement
e.preventDefault()
// Appeler le callback onDragStart
this.callbacks.onDragStart()
// Obtenir les coordonnées du toucher ou de la souris
const getCoords = (event: PointerEvent | TouchEvent): { x: number; y: number } => {
// Gestion des événements tactiles
const touchEvent = event as TouchEvent
if (touchEvent.touches?.[0]) {
return {
x: touchEvent.touches[0].clientX,
y: touchEvent.touches[0].clientY
}
}
// Gestion des événements de souris
const mouseEvent = event as PointerEvent
return {
x: mouseEvent.clientX ?? this._center.x,
y: mouseEvent.clientY ?? this._center.y
}
}
const startCoords = getCoords(e)
const onMove = (moveEvent: Event) => {
if (!(moveEvent instanceof PointerEvent) && !(moveEvent instanceof TouchEvent)) return
const coords = getCoords(moveEvent)
this.onDragProgress({
clientX: coords.x,
clientY: coords.y,
preventDefault: () => moveEvent.preventDefault(),
stopPropagation: () => moveEvent.stopPropagation()
} as MouseEvent)
}
const onEnd = () => {
document.removeEventListener('pointermove', onMove)
document.removeEventListener('touchmove', onMove)
document.removeEventListener('pointerup', onEnd)
document.removeEventListener('touchend', onEnd)
this.onDragEnd()
}
document.addEventListener('pointermove', onMove)
document.addEventListener('touchmove', onMove, { passive: false })
document.addEventListener('pointerup', onEnd)
document.addEventListener('touchend', onEnd)
this._center = getElementCenter(this.el)
this._draggingFrom = startCoords
this.isDragging = true
}
onDragProgress(e: {
clientX: number
clientY: number
preventDefault: () => void
stopPropagation: () => void
}) {
const currentTime = performance.now()
const deltaTime = currentTime - this._lastDragTime
const pointerPosition: Vector = {
x: e.clientX,
y: e.clientY
}
const anglePointerToCenter = angleBetween(this._center, pointerPosition)
const angle_DraggingFromToCenter = angleBetween(this._center, this._draggingFrom)
const angleDragged = angleDifference(angle_DraggingFromToCenter, anglePointerToCenter)
// Calcul de la vitesse de déplacement angulaire (radians par milliseconde)
// On inverse le signe pour que le sens de l'inertie soit naturel
if (deltaTime > 0) {
this._lastDragVelocity = -angleDragged / deltaTime
}
this._lastDragTime = currentTime
this.setAngle(this._currentAngle - angleDragged)
this._draggingFrom = { ...pointerPosition }
}
onDragEnd() {
document.body.removeEventListener('pointermove', this.onDragProgress)
document.body.removeEventListener('pointerup', this.onDragEnd)
// Activer l'inertie avec la vitesse de drag actuelle
this._isInertiaActive = true
// Augmenter la sensibilité du drag avec le facteur d'amplification
this._inertiaVelocity = this._lastDragVelocity * this._inertiaAmplification
this.isDragging = false
// Toujours conserver la vitesse de base actuelle (1 si allumé, 0 si éteint)
this._basePlaybackSpeed = this._isPoweredOn ? 1 : 0
// Si le lecteur est éteint, s'assurer que la vitesse de base est bien à 0
if (!this._isPoweredOn) {
this._basePlaybackSpeed = 0
}
this.callbacks.onDragEnded(this.secondsPlayed)
}
autoRotate(currentTimestamp: number) {
const timestampElapsed = currentTimestamp - this.previousTimestamp
if (this._isInertiaActive) {
// Appliquer l'inertie
const inertiaRotation = this._inertiaVelocity * timestampElapsed
this.setAngle(this._currentAngle + inertiaRotation)
// Si le lecteur est allumé, faire une transition fluide vers la vitesse de lecture
if (this._isPoweredOn) {
// Si on est proche de la vitesse de lecture normale, on désactive l'inertie
if (
Math.abs(this._inertiaVelocity - RADIANS_PER_MILLISECOND * this._basePlaybackSpeed) <
0.0001
) {
this._isInertiaActive = false
this._inertiaVelocity = RADIANS_PER_MILLISECOND * this._basePlaybackSpeed
} else {
// Réduire progressivement la vitesse d'inertie vers la vitesse de lecture
this._inertiaVelocity +=
(RADIANS_PER_MILLISECOND * this._basePlaybackSpeed - this._inertiaVelocity) * 0.1
}
} else {
// Si le lecteur est éteint, appliquer un frottement normal
this._inertiaVelocity *= this._inertiaFriction
// Si la vitesse est très faible, on arrête l'inertie
if (Math.abs(this._inertiaVelocity) < 0.0001) {
this._isInertiaActive = false
this._inertiaVelocity = 0
this._playbackSpeed = 0 // Mettre à jour la vitesse de lecture à 0 uniquement à la fin
}
}
} else {
// Rotation normale à la vitesse de lecture de base
const baseRotation = RADIANS_PER_MILLISECOND * this._basePlaybackSpeed * timestampElapsed
this.setAngle(this._currentAngle + baseRotation)
}
}
setAngle(angle: number) {
this._currentAngle = clamp(angle, 0, this._maxAngle)
return this._currentAngle
}
start() {
this.previousTimestamp = performance.now()
this.loop()
}
stop() {
if (this.rafId) {
cancelAnimationFrame(this.rafId)
this.rafId = null
}
this.callbacks.onStop()
}
rewind() {
this.setAngle(0)
}
loop() {
const currentTimestamp = performance.now()
if (!this.isDragging) {
if (this._isPoweredOn) {
this.autoRotate(currentTimestamp)
} else {
// Mettre à jour le timestamp même quand le lecteur est éteint
// pour éviter un saut lors de la reprise
this.previousTimestamp = currentTimestamp
}
}
const timestampDifferenceMS = currentTimestamp - this.previousTimestamp
const rotated = this._currentAngle - this._previousAngle
const rotationNormal = RADIANS_PER_MILLISECOND * timestampDifferenceMS
this.playbackSpeed = rotated / rotationNormal || 0
this.isReversed = this._currentAngle < this._previousAngle
this._previousAngle = this._currentAngle
this.previousTimestamp = performance.now()
this.el.style.transform = `rotate(${this._currentAngle}rad)`
const { playbackSpeed, isReversed, secondsPlayed, _duration } = this
const progress = secondsPlayed / _duration
this.callbacks.onLoop({
playbackSpeed,
isReversed,
secondsPlayed,
progress
})
this._previousAngle = this._currentAngle
this.rafId = requestAnimationFrame(this.loop)
}
}
export default Disc

View File

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

View File

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

52
app/plugins/body-class.ts Normal file
View File

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

View File

@@ -1,5 +1,7 @@
import { useUiStore } from '~/store/ui' import { useUiStore } from '~/store/ui'
import { usePlayerStore } from '~/store/player' import { usePlayerStore } from '~/store/player'
import { useCardStore } from '~/store/card'
import { useDataStore } from '~/store/data'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin((nuxtApp) => {
// Ne s'exécuter que côté client // Ne s'exécuter que côté client
@@ -7,6 +9,8 @@ export default defineNuxtPlugin((nuxtApp) => {
const ui = useUiStore() const ui = useUiStore()
const player = usePlayerStore() const player = usePlayerStore()
const cardStore = useCardStore()
const dataStore = useDataStore()
function isInputElement(target: EventTarget | null): boolean { function isInputElement(target: EventTarget | null): boolean {
return ( return (
@@ -41,7 +45,25 @@ export default defineNuxtPlugin((nuxtApp) => {
switch (e.code) { switch (e.code) {
// Gestion de la barre d'espace pour play/pause // Gestion de la barre d'espace pour play/pause
case 'Space': case 'KeyR': // R pour révéler toutes les cartes
e.preventDefault()
e.stopPropagation()
if (!isInputElement(e.target as HTMLElement) && ui.getSelectedBox) {
const tracks = dataStore.getTracksByboxId(ui.getSelectedBox.id)
cardStore.revealAllCards(tracks)
}
break
case 'KeyH': // H pour cacher toutes les cartes
e.preventDefault()
e.stopPropagation()
if (!isInputElement(e.target as HTMLElement) && ui.getSelectedBox) {
const tracks = dataStore.getTracksByboxId(ui.getSelectedBox.id)
cardStore.hideAllCards(tracks)
}
break
case 'Space': // Espace pour play/pause
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
@@ -61,15 +83,6 @@ export default defineNuxtPlugin((nuxtApp) => {
ui.closeBox() ui.closeBox()
break break
// Gestion de la touche Entrée pour ouvrir une boîte
case 'Enter':
if (document.activeElement?.id) {
e.preventDefault()
ui.selectBox(document.activeElement.id)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
break
// Gestion des touches fléchées (à implémenter si nécessaire) // Gestion des touches fléchées (à implémenter si nécessaire)
case 'ArrowUp': case 'ArrowUp':
case 'ArrowDown': case 'ArrowDown':

View File

@@ -1,45 +1,40 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import type { Track } from '~~/types/types' import type { Track } from '~~/types/types'
interface CardPosition {
x: number
y: number
}
type CardPositions = Record<string, Record<number, CardPosition>>
export const useCardStore = defineStore('card', { export const useCardStore = defineStore('card', {
state: () => ({ state: () => ({
// Stocke les IDs des cartes déjà révélées // Stocke les IDs des cartes déjà révélées
revealedCards: new Set<number>(), revealedCards: new Set<number>(),
// Stocke les positions personnalisées des cartes par box // Stocke les pistes dans le panier
// Format: { [boxId]: { [trackId]: { x: number, y: number } } } bucket: [] as Track[],
cardPositions: {} as CardPositions // Stocke l'état d'ouverture du panier
isBucketOpen: false
}), }),
actions: { actions: {
// Mettre à jour l'ordre des pistes dans le panier
updateBucketOrder(newOrder: Track[]) {
this.bucket = [...newOrder]
this.saveBucketToLocalStorage()
},
// Marquer une carte comme révélée // Marquer une carte comme révélée
revealCard(trackId: number) { revealCard(trackId: number) {
this.revealedCards.add(trackId) this.revealedCards.add(trackId)
this.saveToLocalStorage() this.saveToLocalStorage()
}, },
// Vérifier si une carte est révélée hideCard(trackId: number) {
isCardRevealed(trackId: number): boolean { this.revealedCards.delete(trackId)
return this.revealedCards.has(trackId)
},
// Définir la position d'une carte dans une box
setCardPosition(boxId: string, trackId: number, position: { x: number; y: number }) {
if (!this.cardPositions[boxId]) {
this.cardPositions[boxId] = {}
}
this.cardPositions[boxId][trackId] = position
this.saveToLocalStorage() this.saveToLocalStorage()
}, },
// Obtenir la position d'une carte dans une box flipCard(track: any) {
getCardPosition(boxId: string, trackId: number): { x: number; y: number } | undefined { if (this.isRevealed(track.id)) {
return this.cardPositions[boxId]?.[trackId] this.hideCard(track.id)
} else {
this.revealCard(track.id)
}
}, },
// Basculer l'état de révélation de toutes les cartes // Basculer l'état de révélation de toutes les cartes
@@ -50,6 +45,13 @@ export const useCardStore = defineStore('card', {
this.saveToLocalStorage() this.saveToLocalStorage()
}, },
hideAllCards(tracks: Track[]) {
tracks.forEach((track) => {
this.hideCard(track.id)
})
this.saveToLocalStorage()
},
// Sauvegarder l'état dans le localStorage // Sauvegarder l'état dans le localStorage
saveToLocalStorage() { saveToLocalStorage() {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -57,8 +59,7 @@ export const useCardStore = defineStore('card', {
localStorage.setItem( localStorage.setItem(
'cardStore', 'cardStore',
JSON.stringify({ JSON.stringify({
revealedCards: Array.from(this.revealedCards), revealedCards: Array.from(this.revealedCards)
cardPositions: this.cardPositions
}) })
) )
} catch (e) { } catch (e) {
@@ -73,15 +74,12 @@ export const useCardStore = defineStore('card', {
try { try {
const saved = localStorage.getItem('cardStore') const saved = localStorage.getItem('cardStore')
if (saved) { if (saved) {
const { revealedCards, cardPositions } = JSON.parse(saved) const { revealedCards } = JSON.parse(saved)
if (Array.isArray(revealedCards)) { if (Array.isArray(revealedCards)) {
this.revealedCards = new Set( this.revealedCards = new Set(
revealedCards.filter((id): id is number => typeof id === 'number') revealedCards.filter((id): id is number => typeof id === 'number')
) )
} }
if (cardPositions && typeof cardPositions === 'object') {
this.cardPositions = cardPositions
}
} }
} catch (e) { } catch (e) {
console.error('Failed to load card store from localStorage', e) console.error('Failed to load card store from localStorage', e)
@@ -92,6 +90,64 @@ export const useCardStore = defineStore('card', {
// Initialiser le store // Initialiser le store
initialize() { initialize() {
this.loadFromLocalStorage() this.loadFromLocalStorage()
},
// Gestion du panier
addToBucket(track: Track) {
// Vérifie si la piste n'est pas déjà dans le panier
if (!this.bucket.some((item) => item.id === track.id)) {
this.bucket.push(track)
this.saveBucketToLocalStorage()
}
},
removeFromBucket(trackId: number) {
const index = this.bucket.findIndex((item) => item.id === trackId)
if (index !== -1) {
this.bucket.splice(index, 1)
this.saveBucketToLocalStorage()
}
},
clearBucket() {
this.bucket = []
this.saveBucketToLocalStorage()
},
toggleBucket() {
this.isBucketOpen = !this.isBucketOpen
},
// Sauvegarder le panier dans le localStorage
saveBucketToLocalStorage() {
if (typeof window !== 'undefined') {
try {
localStorage.setItem('cardStoreBucket', JSON.stringify(this.bucket))
} catch (e) {
console.error('Failed to save bucket to localStorage', e)
}
}
},
// Charger le panier depuis le localStorage
loadBucketFromLocalStorage() {
if (typeof window !== 'undefined') {
try {
const saved = localStorage.getItem('cardStoreBucket')
if (saved) {
const bucket = JSON.parse(saved)
if (Array.isArray(bucket)) {
this.bucket = bucket
}
}
} catch (e) {
console.error('Failed to load bucket from localStorage', e)
}
}
},
// Vérifier si une carte est révélée
isCardRevealed(trackId: number): boolean {
return this.revealedCards.has(trackId)
} }
}, },
@@ -101,9 +157,15 @@ export const useCardStore = defineStore('card', {
return state.revealedCards.has(trackId) return state.revealedCards.has(trackId)
}, },
// Obtenir toutes les positions des cartes d'une box // Getters pour le panier
getBoxCardPositions: (state) => (boxId: string) => { bucketCount: (state) => state.bucket.length,
return state.cardPositions[boxId] || {}
isInBucket: (state) => (trackId: number) => {
return state.bucket.some((track) => track.id === trackId)
},
bucketTotalDuration: (state) => {
return state.bucket.reduce((total, track) => total + ((track as any).duration || 0), 0)
} }
} }
}) })

View File

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

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

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

View File

@@ -3,62 +3,65 @@ import { defineStore } from 'pinia'
import type { Track, Box } from '~/../types/types' import type { Track, Box } from '~/../types/types'
import { useDataStore } from '~/store/data' import { useDataStore } from '~/store/data'
import { useCardStore } from '~/store/card' import { useCardStore } from '~/store/card'
import { usePlatineStore } from '~/store/platine'
export const usePlayerStore = defineStore('player', { export const usePlayerStore = defineStore('player', {
state: () => ({ state: () => ({
currentTrack: null as Track | null, currentTrack: null as Track | null,
position: 0, position: 0,
audio: null as HTMLAudioElement | null,
progressionLast: 0, progressionLast: 0,
isPlaying: false,
isLoading: false, isLoading: false,
history: [] as string[] history: [] as string[]
}), }),
actions: { actions: {
attachAudio(el: HTMLAudioElement) { attachAudio() {
this.audio = el const platineStore = usePlatineStore()
// attach listeners if not already attached (idempotent enough for our use)
this.audio.addEventListener('play', () => { // Écouter les changements de piste dans le platineStore
this.isPlaying = true watch(
// Révéler la carte quand la lecture commence () => platineStore.currentTrack,
if (this.currentTrack) { (newTrack) => {
const cardStore = useCardStore() if (newTrack) {
if (!cardStore.isCardRevealed(this.currentTrack.id)) { this.currentTrack = newTrack
requestAnimationFrame(() => { // Révéler la carte quand la lecture commence
cardStore.revealCard(this.currentTrack!.id) const cardStore = useCardStore()
}) if (!cardStore.isCardRevealed(newTrack.id)) {
requestAnimationFrame(() => {
cardStore.revealCard(newTrack.id)
})
}
} else {
this.currentTrack = null
} }
} }
}) )
this.audio.addEventListener('playing', () => {})
this.audio.addEventListener('pause', () => {
this.isPlaying = false
})
this.audio.addEventListener('ended', async () => {
const track = this.currentTrack
if (!track) return
const dataStore = useDataStore() // Écouter les changements d'état de lecture
// Comportement par défaut pour les playlists standards watch(
if (track.type === 'playlist') { () => platineStore.isPlaying,
const next = dataStore.getNextPlaylistTrack(track) (isPlaying) => {
if (next && next.boxId === track.boxId) { if (isPlaying) {
await this.playTrack(next) // Gérer la logique de lecture suivante quand la lecture se termine
return if (platineStore.currentTrack?.type === 'playlist') {
const dataStore = useDataStore()
const nextTrack = dataStore.getNextPlaylistTrack(platineStore.currentTrack)
if (nextTrack) {
platineStore.loadTrack(nextTrack)
platineStore.play()
}
}
} }
} }
)
// Si on arrive ici, c'est qu'il n'y a pas de piste suivante
this.currentTrack = null
this.isPlaying = false
})
}, },
async playBox(box: Box) { async playBox(box: Box) {
const platineStore = usePlatineStore()
// Si c'est la même box, on toggle simplement la lecture // Si c'est la même box, on toggle simplement la lecture
if (this.currentTrack?.boxId === box.id && this.currentTrack?.side === box.activeSide) { if (this.currentTrack?.boxId === box.id && this.currentTrack?.side === box.activeSide) {
this.togglePlay() platineStore.togglePlay()
return return
} }
@@ -67,7 +70,9 @@ export const usePlayerStore = defineStore('player', {
const dataStore = useDataStore() const dataStore = useDataStore()
const firstTrack = dataStore.getFirstTrackOfBox(box) const firstTrack = dataStore.getFirstTrackOfBox(box)
if (firstTrack) { if (firstTrack) {
await this.playTrack(firstTrack) this.currentTrack = firstTrack
await platineStore.loadTrack(firstTrack)
await platineStore.play()
} }
} catch (error) { } catch (error) {
console.error('Error playing box:', error) console.error('Error playing box:', error)
@@ -75,121 +80,77 @@ export const usePlayerStore = defineStore('player', {
}, },
async playTrack(track: Track) { async playTrack(track: Track) {
// Pour les autres types de pistes, on utilise la logique existante const platineStore = usePlatineStore()
this.isCompilationTrack(track)
? await this.playCompilationTrack(track)
: await this.playPlaylistTrack(track)
},
async playCompilationTrack(track: Track) {
// Si c'est la même piste, on toggle simplement la lecture // Si c'est la même piste, on toggle simplement la lecture
if (this.currentTrack?.id === track.id) { if (this.currentTrack?.id === track.id) {
// Si la lecture est en cours, on met en pause platineStore.togglePlay()
if (this.isPlaying) {
this.togglePlay()
return
}
// Si c'est une compilation, on vérifie si on est dans la plage de la piste
if (track.type === 'compilation' && track.start !== undefined) {
const dataStore = useDataStore()
const nextTrack = dataStore.getNextTrack(track)
// Si on a une piste suivante et qu'on est dans la plage de la piste courante
if (nextTrack?.start && this.position >= track.start && this.position < nextTrack.start) {
this.togglePlay()
return
}
// Si c'est la dernière piste de la compilation
else if (!nextTrack && this.position >= track.start) {
this.togglePlay()
return
}
}
}
// Sinon, on charge et on lit la piste
this.currentTrack = track
await this.loadAndPlayTrack(track)
},
async playPlaylistTrack(track: Track) {
// Toggle simple si c'est la même piste
if (this.currentTrack?.id === track.id) {
this.togglePlay()
return return
} }
// Sinon, on charge et on lit la piste // Sinon, on charge et on lit la piste
this.currentTrack = track this.currentTrack = track
await this.loadAndPlayTrack(track) await platineStore.loadTrack(track)
platineStore.play()
},
async playCompilationTrack(track: Track) {
const platineStore = usePlatineStore()
// Si c'est la même piste, on toggle simplement la lecture
if (this.currentTrack?.id === track.id) {
platineStore.togglePlay()
return
}
// Pour les compilations, on charge la piste avec le point de départ
this.currentTrack = track
await platineStore.loadTrack(track)
// Si c'est une compilation, on définit la position de départ
if (track.type === 'compilation' && track.start !== undefined) {
platineStore.seek(track.start)
}
platineStore.play()
},
async playPlaylistTrack(track: Track) {
const platineStore = usePlatineStore()
// Toggle simple si c'est la même piste
if (this.currentTrack?.id === track.id) {
platineStore.togglePlay()
return
}
// Sinon, on charge et on lit la piste
this.currentTrack = track
await platineStore.loadTrack(track)
platineStore.play()
}, },
async loadTrack(track: Track) { async loadTrack(track: Track) {
if (!this.audio) return const platineStore = usePlatineStore()
await platineStore.loadTrack(track)
return new Promise<void>((resolve) => {
this.currentTrack = track
const audio = this.audio as HTMLAudioElement
// Si c'est la même source, on ne fait rien
if (audio.src === track.url) {
resolve()
return
}
// Nouvelle source
audio.src = track.url
// Une fois que suffisamment de données sont chargées
const onCanPlay = () => {
audio.removeEventListener('canplay', onCanPlay)
resolve()
}
audio.addEventListener('canplay', onCanPlay)
// Timeout de sécurité
setTimeout(resolve, 1000)
})
}, },
async loadAndPlayTrack(track: Track) { async loadAndPlayTrack(track: Track) {
if (!this.audio) { const platineStore = usePlatineStore()
const newAudio = new Audio()
this.attachAudio(newAudio)
}
const audio = this.audio as HTMLAudioElement
try { try {
this.isLoading = true this.isLoading = true
// Mettre en pause
audio.pause()
// Pour les compilations, on utilise l'URL de la piste avec le point de départ // Charger la piste
await platineStore.loadTrack(track)
// Pour les compilations, on définit la position de départ
if (track.type === 'compilation' && track.start !== undefined) { if (track.type === 'compilation' && track.start !== undefined) {
audio.src = track.url platineStore.seek(track.start)
audio.currentTime = track.start
} else {
// Pour les playlists, on charge la piste individuelle
audio.currentTime = 0
await this.loadTrack(track)
} }
// Attendre que les métadonnées soient chargées
await new Promise<void>((resolve) => {
const onCanPlay = () => {
audio.removeEventListener('canplay', onCanPlay)
resolve()
}
audio.addEventListener('canplay', onCanPlay)
// Timeout de sécurité
setTimeout(resolve, 1000)
})
// Lancer la lecture // Lancer la lecture
await audio.play() await platineStore.play()
this.history.push(this.currentTrack?.id) this.history.push(track.id.toString()) // S'assurer que l'ID est une chaîne
this.isLoading = false this.isLoading = false
} catch (error) { } catch (error) {
console.error('Error loading/playing track:', error) console.error('Error loading/playing track:', error)
@@ -197,33 +158,30 @@ export const usePlayerStore = defineStore('player', {
} }
}, },
async togglePlay() { togglePlay() {
if (!this.audio) return const platineStore = usePlatineStore()
try { platineStore.togglePlay()
if (this.audio.paused) {
await this.audio.play()
} else {
this.audio.pause()
}
} catch (error) {
console.error('Error toggling play state:', error)
}
}, },
updateTime() { updateTime() {
const audio = this.audio const platineStore = usePlatineStore()
if (!audio) return
// update current position // Mettre à jour la position actuelle
this.position = audio.currentTime if (platineStore.currentTrack) {
this.position = platineStore.currentTurns / 0.75 // Convertir les tours en secondes
// compute and cache a stable progression value // Calculer et mettre en cache la progression
const duration = audio.duration const duration = platineStore.totalTurns / 0.75 // Durée totale en secondes
const progression = (this.position / duration) * 100 const progression = (this.position / duration) * 100
if (!isNaN(progression)) { if (!isNaN(progression) && isFinite(progression)) {
this.progressionLast = progression this.progressionLast = progression
}
} }
// update current track when changing time in compilation },
// update current track when changing time in compilation
async updateCurrentTrack() {
const platineStore = usePlatineStore()
const currentTrack = this.currentTrack const currentTrack = this.currentTrack
if (currentTrack && currentTrack.type === 'compilation') { if (currentTrack && currentTrack.type === 'compilation') {
const dataStore = useDataStore() const dataStore = useDataStore()
@@ -234,7 +192,7 @@ export const usePlayerStore = defineStore('player', {
.sort((a, b) => (a.start ?? 0) - (b.start ?? 0)) .sort((a, b) => (a.start ?? 0) - (b.start ?? 0))
if (tracks.length > 0) { if (tracks.length > 0) {
const now = audio.currentTime const now = platineStore.currentTurns / 0.75
// find the last track whose start <= now (fallback to first track) // find the last track whose start <= now (fallback to first track)
let nextTrack = tracks[0] let nextTrack = tracks[0]
for (const t of tracks) { for (const t of tracks) {
@@ -254,7 +212,7 @@ export const usePlayerStore = defineStore('player', {
if (nextTrack.id && !cardStore.isCardRevealed(nextTrack.id)) { if (nextTrack.id && !cardStore.isCardRevealed(nextTrack.id)) {
// Utiliser requestAnimationFrame pour une meilleure synchronisation avec le rendu // Utiliser requestAnimationFrame pour une meilleure synchronisation avec le rendu
requestAnimationFrame(() => { requestAnimationFrame(() => {
cardStore.revealCard(nextTrack.id!) cardStore.revealCard(nextTrack.id)
}) })
} }
} }
@@ -290,21 +248,26 @@ export const usePlayerStore = defineStore('player', {
} }
}, },
isPaused: (state) => { isPaused() {
return state.audio?.paused ?? true const platineStore = usePlatineStore()
return !platineStore.isPlaying
}, },
getCurrentTrack: (state) => state.currentTrack, getCurrentTrack: (state) => state.currentTrack,
getCurrentBox: (state) => { getCurrentBox: (state) => {
return state.currentTrack ? state.currentTrack.url : null return state.currentTrack ? state.currentTrack.boxId : null
}, },
getCurrentProgression(state) { getCurrentProgression() {
if (!state.audio) return 0 const platineStore = usePlatineStore()
const duration = state.audio.duration if (!platineStore.currentTrack) return 0
const progression = (state.position / duration) * 100
return isNaN(progression) ? state.progressionLast : progression // Calculer la progression en fonction des tours actuels et totaux
if (platineStore.totalTurns > 0) {
return (platineStore.currentTurns / platineStore.totalTurns) * 100
}
return 0
}, },
getCurrentCoverUrl(state) { getCurrentCoverUrl(state) {

View File

@@ -6,7 +6,8 @@ export const useUiStore = defineStore('ui', {
state: () => ({ state: () => ({
// UI-only state can live here later // UI-only state can live here later
showSearch: false, showSearch: false,
searchQuery: '' searchQuery: '',
showCardSharer: false
}), }),
actions: { actions: {
@@ -46,8 +47,10 @@ export const useUiStore = defineStore('ui', {
dataStore.boxes.forEach((box) => { dataStore.boxes.forEach((box) => {
box.state = 'box-list' box.state = 'box-list'
}) })
// Scroll back to the unselected box in the list },
if (selectedBox) this.scrollToBox(selectedBox)
openCardSharer() {
this.showCardSharer = true
}, },
scrollToBox(box: Box) { scrollToBox(box: Box) {

3
env.sh
View File

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

View File

@@ -19,6 +19,7 @@ export default defineNuxtConfig({
{ rel: 'apple-touch-icon', sizes: '180x180', href: '/favicon/apple-touch-icon.png' }, { rel: 'apple-touch-icon', sizes: '180x180', href: '/favicon/apple-touch-icon.png' },
{ rel: 'manifest', href: '/favicon/site.webmanifest' } { rel: 'manifest', href: '/favicon/site.webmanifest' }
], ],
viewport: 'width=device-width, initial-scale=1.0, maximum-scale=1.0',
script: isProd script: isProd
? [ ? [
{ {

18057
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,13 +11,18 @@
"lint": "eslint .", "lint": "eslint .",
"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:reset": "rm -f server/database/evilspins.db && npm run migrate"
}, },
"dependencies": { "dependencies": {
"@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",
"chokidar": "^5.0.0",
"nuxt": "^4.2.0", "nuxt": "^4.2.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"vue": "^3.5.18", "vue": "^3.5.18",
@@ -27,15 +32,16 @@
"engines": { "engines": {
"pnpm": ">=10 <11" "pnpm": ">=10 <11"
}, },
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748", "packageManager": "pnpm@10.27.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",
"eslint": "^8.57.1", "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",
"eslint-plugin-vue": "9.3.0", "eslint-plugin-vue": "9.3.0",
@@ -43,6 +49,7 @@
"globals": "^16.5.0", "globals": "^16.5.0",
"patch-package": "^8.0.1", "patch-package": "^8.0.1",
"sass-embedded": "^1.93.2", "sass-embedded": "^1.93.2",
"tsx": "^4.21.0",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
} }
} }

3464
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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

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

After

Width:  |  Height:  |  Size: 2.6 KiB

168
server/MIGRATION_GUIDE.md Normal file
View File

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

View File

@@ -1,151 +1,23 @@
import { eventHandler } from 'h3' import { eventHandler } from 'h3'
import { getDatabase } from '../utils/database'
export default eventHandler(() => { export default eventHandler(() => {
return [ const db = getDatabase()
{
id: 0, const artists = db
name: "L'efondras", .prepare(
url: 'https://leffondras.bandcamp.com/music', `
coverId: '0024705317' SELECT id, name, url, cover_id
}, FROM artists
{ ORDER BY id
id: 1, `
name: 'The kundalini genie', )
url: 'https://the-kundalini-genie.bandcamp.com', .all()
coverId: '0012045550'
}, return artists.map((artist: any) => ({
{ id: artist.id,
id: 2, name: artist.name,
name: 'Fontaines D.C.', url: artist.url,
url: 'https://fontainesdc.bandcamp.com', coverId: `https://f4.bcbits.com/img/${artist.cover_id}_4.jpg`
coverId: '0027327090' }))
},
{
id: 3,
name: 'Fontanarosa',
url: 'https://fontanarosa.bandcamp.com',
coverId: '0035380235'
},
{
id: 4,
name: 'Johnny mafia',
url: 'https://johnnymafia.bandcamp.com',
coverId: '0035009392'
},
{
id: 5,
name: 'New candys',
url: 'https://newcandys.bandcamp.com',
coverId: '0039963261'
},
{
id: 6,
name: 'Magic shoppe',
url: 'https://magicshoppe.bandcamp.com',
coverId: '0030748374'
},
{
id: 7,
name: 'Les jaguars',
url: 'https://radiomartiko.bandcamp.com/album/surf-qu-b-cois',
coverId: '0016551336'
},
{
id: 8,
name: 'TRAAMS',
url: 'https://traams.bandcamp.com',
coverId: '0028348410'
},
{
id: 9,
name: 'Blue orchid',
url: 'https://blue-orchid.bandcamp.com',
coverId: '0034796193'
},
{
id: 10,
name: 'I love UFO',
url: 'https://bruitblanc.bandcamp.com',
coverId: 'a2203158939'
},
{
id: 11,
name: 'Kid Congo & The Pink Monkey Birds',
url: 'https://kidcongothepinkmonkeybirds.bandcamp.com/',
coverId: '0017196290'
},
{
id: 12,
name: 'Firefriend',
url: 'https://firefriend.bandcamp.com/',
coverId: '0031072203'
},
{
id: 13,
name: 'Squid',
url: 'https://squiduk.bandcamp.com/',
coverId: '0037649385'
},
{
id: 14,
name: 'Lysistrata',
url: 'https://lysistrata.bandcamp.com/',
coverId: '0033900158'
},
{
id: 15,
name: 'Pablo X Broadcasting Services',
url: 'https://pabloxbroadcastingservices.bandcamp.com/',
coverId: '0036956486'
},
{
id: 16,
name: 'Night Beats',
url: 'https://nightbeats.bandcamp.com/',
coverId: '0036987720'
},
{
id: 17,
name: 'Deltron 3030',
url: 'https://delthefunkyhomosapien.bandcamp.com/',
coverId: '0005254781'
},
{
id: 18,
name: 'The Amorphous Androgynous',
url: 'https://theaa.bandcamp.com/',
coverId: '0022226700'
},
{
id: 19,
name: 'Wooden Shjips',
url: 'https://woodenshjips.bandcamp.com/',
coverId: '0012406678'
},
{
id: 20,
name: 'Silas J. Dirge',
url: 'https://silasjdirge.bandcamp.com/',
coverId: '0035751570'
},
{
id: 21,
name: 'Secret Colours',
url: 'https://secretcolours.bandcamp.com/',
coverId: '0010661379'
},
{
id: 22,
name: 'Larry McNeil And The Blue Knights',
url: 'https://www.discogs.com/artist/6528940-Larry-McNeil-And-The-Blue-Knights',
coverId:
'https://i.discogs.com/Yr05_neEXwzPwKlDeV7dimmTG34atkAMgpxbMBhHBkI/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTEyMTEw/ODE1LTE1Mjg1NjU1/NzQtMjcyOC5qcGVn.jpeg'
},
{
id: 23,
name: 'Hugo Blanco',
url: 'https://elpalmasmusic.bandcamp.com/album/color-de-tr-pico-compiled-by-el-dr-gon-criollo-el-palmas',
coverId: '0016886708'
}
]
}) })

View File

@@ -1,68 +1,61 @@
import { eventHandler } from 'h3' import { eventHandler } from 'h3'
import type { Box } from '~~/types/types' import { getDatabase } from '../utils/database'
export default eventHandler<Box[]>(() => { export default eventHandler(() => {
return [ const db = getDatabase()
{
id: 'ES01', // Récupérer les boxes
type: 'compilation', const boxes = db
name: '...', .prepare(
description: '...', `
state: 'box-hidden', SELECT id, type, name, description, state, duration, active_side, color1, color2
duration: 3487 + 3773, // Somme des durées A et B FROM boxes
sides: { ORDER BY id
A: { `
name: '...', )
description: '...', .all()
duration: 3487,
color1: '#c7b3aa', // Récupérer les sides pour chaque box
color2: '#000100' const sides = db
}, .prepare(
B: { `
name: '... B', SELECT box_id, side, name, description, duration, color1, color2
description: '...', FROM sides
duration: 3773, `
color1: '#f7dd01', )
color2: '#010103' .all()
}
}, // Grouper les sides par box_id
activeSide: 'A' const sidesByBoxId: Record<string, any> = {}
}, for (const side of sides) {
{ if (!sidesByBoxId[side.box_id]) {
id: 'ES00', sidesByBoxId[side.box_id] = {}
type: 'compilation',
name: 'manifeste',
description: 'Zero is for manifesto',
state: 'box-hidden',
duration: 2794 + 2470, // Somme des durées A et B
sides: {
A: {
name: 'manifeste',
description: 'Zero is for manifesto',
duration: 2794,
color1: '#ffffff',
color2: '#48959d'
},
B: {
name: 'manifeste B',
description: 'Even Zero has a b-side',
duration: 2470,
color1: '#0d01b9',
color2: '#3b7589'
}
},
activeSide: 'A'
},
{
id: 'ESPLAYLIST',
type: 'playlist',
name: 'playlists',
duration: 0,
description: '♠♦♣♥',
state: 'box-hidden',
activeSide: 'A',
color1: '#fdec50ff',
color2: '#fdec50ff'
} }
] 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
})
}))
}) })

View File

@@ -1,455 +1,93 @@
import { eventHandler } from 'h3' import { eventHandler, getQuery } from 'h3'
import { getDatabase } from '../../utils/database'
export default eventHandler(() => { export default eventHandler((event) => {
const tracks = [ const db = getDatabase()
{ const query = getQuery(event)
order: 1,
boxId: 'ES00', // Paramètres de pagination
side: 'A', const page = parseInt(query.page as string) || 1
title: 'The grinding wheel', const limit = parseInt(query.limit as string) || 50
artist: 0, const offset = (page - 1) * limit
start: 0,
link: 'https://arakirecords.bandcamp.com/track/the-grinding-wheel', // Filtres optionnels
coverId: 'a3236746052' const boxId = query.boxId as string | undefined
}, const side = query.side as string | undefined
{
order: 2, // Construction de la requête
boxId: 'ES00', let sql = `
side: 'A', SELECT
title: 'Bleach', t.id,
artist: 1, t.box_id,
start: 392, t.side,
link: 'https://the-kundalini-genie.bandcamp.com/track/bleach-2', t.track_order,
coverId: 'a1714786533' t.title,
}, t.artist_id,
{ t.start,
order: 3, t.link,
boxId: 'ES00', t.cover_id,
side: 'A', t.url,
title: 'Televised mind', t.type,
artist: 2, a.name as artist_name
start: 896, FROM tracks t
link: 'https://fontainesdc.bandcamp.com/track/televised-mind', LEFT JOIN artists a ON t.artist_id = a.id
coverId: 'a3772806156' WHERE t.type = 'compilation'
}, `
{
order: 4, const params: any[] = []
boxId: 'ES00',
side: 'A', if (boxId) {
title: 'In it', sql += ' AND t.box_id = ?'
artist: 3, params.push(boxId)
start: 1139, }
link: 'https://howlinbananarecords.bandcamp.com/track/in-it',
coverId: 'a1720372066' if (side) {
}, sql += ' AND t.side = ?'
{ params.push(side)
order: 5, }
boxId: 'ES00',
side: 'A', sql += ' ORDER BY t.box_id, t.side, t.track_order'
title: 'Bad michel', sql += ' LIMIT ? OFFSET ?'
artist: 4, params.push(limit, offset)
start: 1245,
link: 'https://johnnymafia.bandcamp.com/track/bad-michel-3', const tracks = db.prepare(sql).all(...params)
coverId: 'a0984622869'
}, // Compter le total pour la pagination
{ let countSql = "SELECT COUNT(*) as total FROM tracks WHERE type = 'compilation'"
order: 6, const countParams: any[] = []
boxId: 'ES00',
side: 'A', if (boxId) {
title: 'Overall', countSql += ' AND box_id = ?'
artist: 5, countParams.push(boxId)
start: 1394, }
link: 'https://newcandys.bandcamp.com/track/overall',
coverId: 'a0559661270' if (side) {
}, countSql += ' AND side = ?'
{ countParams.push(side)
order: 7, }
boxId: 'ES00',
side: 'A', const { total } = db.prepare(countSql).get(...countParams) as { total: number }
title: 'Blowup',
artist: 6, return {
start: 1674, tracks: tracks.map((track: any) => ({
link: 'https://magicshoppe.bandcamp.com/track/blowup', id: track.id,
coverId: 'a1444895293' boxId: track.box_id,
}, side: track.side,
{ order: track.track_order,
order: 8, title: track.title,
boxId: 'ES00', artist: track.artist_id,
side: 'A', artistName: track.artist_name,
title: 'Guitar jet', start: track.start,
artist: 7, link: track.link,
start: 1880, coverId: track.cover_id,
link: 'https://radiomartiko.bandcamp.com/track/guitare-jet', url: track.url,
coverId: 'a1494681687' type: track.type
}, })),
{ pagination: {
order: 9, page,
boxId: 'ES00', limit,
side: 'A', total,
title: 'Intercontinental radio waves', totalPages: Math.ceil(total / limit)
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,
url: `https://files.erudi.fr/evilspins/${track.boxId}${track.side}.mp3`,
coverId: `https://f4.bcbits.com/img/${track.coverId}_4.jpg`,
type: 'compilation'
}))
}) })

View File

@@ -4,16 +4,31 @@ import { eventHandler } from 'h3'
import { getCardFromDate } from '../../../utils/cards' import { getCardFromDate } from '../../../utils/cards'
export default eventHandler(async (event) => { export default eventHandler(async (event) => {
const basePath = path.join(process.cwd(), '/mnt/media/files/music') const dirPath = path.join(process.cwd(), '/mnt/media/files/music')
const urlPrefix = `https://files.erudi.fr/music`
try { try {
let allTracks: any[] = [] let allTracks: any[] = []
const dirPath = basePath const items = await fs.promises.readdir(dirPath, { withFileTypes: true })
const urlPrefix = `https://files.erudi.fr/music`
let files = await fs.promises.readdir(dirPath) // Process files
files = files.filter((f) => !f.startsWith('.') && !f.endsWith('.jpg')) 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 tracks = files.map((file, index) => {
const EXT_RE = /\.(mp3|flac|wav|opus)$/i const EXT_RE = /\.(mp3|flac|wav|opus)$/i
@@ -43,7 +58,7 @@ export default eventHandler(async (event) => {
const date = new Date(year, month - 1, day, hour) const date = new Date(year, month - 1, day, hour)
const card = getCardFromDate(date) const card = getCardFromDate(date)
const url = `${urlPrefix}/${encodeURIComponent(file)}` const url = `${urlPrefix}/${encodeURIComponent(file)}`
const coverId = `${urlPrefix}/cover/${encodeURIComponent(file).replace(EXT_RE, '.jpg')}` const coverId = `${urlPrefix}/${encodeURIComponent(file).replace(EXT_RE, '.jpg')}`
return { return {
id: Number(`${year}${index + 1}`), id: Number(`${year}${index + 1}`),
@@ -61,11 +76,14 @@ export default eventHandler(async (event) => {
}) })
tracks.sort((a, b) => b.date.getTime() - a.date.getTime()) tracks.sort((a, b) => b.date.getTime() - a.date.getTime())
// assign a stable order after sort (1..N) // Combine folders and tracks
tracks.forEach((t, i) => (t.order = i + 1)) const allItems = [...folders, ...tracks]
allTracks.push(...tracks)
return allTracks // 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) { } catch (error) {
return { return {
success: false, success: false,

View File

@@ -0,0 +1,91 @@
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.

678
server/database/migrate.ts Normal file
View File

@@ -0,0 +1,678 @@
import { getDatabase } from '../utils/database'
// Import des données depuis vos anciens fichiers
const boxes = [
{
id: 'ES01',
type: 'compilation',
name: '...',
description: '...',
state: 'box-hidden',
duration: 3487 + 3773,
sides: {
A: {
name: '...',
description: '...',
duration: 3487,
color1: '#c7b3aa',
color2: '#000100'
},
B: {
name: '... B',
description: '...',
duration: 3773,
color1: '#f7dd01',
color2: '#010103'
}
},
activeSide: 'A'
},
{
id: 'ES00',
type: 'compilation',
name: 'manifeste',
description: 'Zero is for manifesto',
state: 'box-hidden',
duration: 2794 + 2470,
sides: {
A: {
name: 'manifeste',
description: 'Zero is for manifesto',
duration: 2794,
color1: '#ffffff',
color2: '#48959d'
},
B: {
name: 'manifeste B',
description: 'Even Zero has a b-side',
duration: 2470,
color1: '#0d01b9',
color2: '#3b7589'
}
},
activeSide: 'A'
},
{
id: 'ESPLAYLIST',
type: 'playlist',
name: 'playlists',
duration: 0,
description: '♠♦♣♥',
state: 'box-hidden',
activeSide: 'A',
color1: '#fdec50ff',
color2: '#fdec50ff'
}
]
const artists = [
{ id: 0, name: "L'efondras", url: 'https://leffondras.bandcamp.com/music', coverId: '0024705317' },
{
id: 1,
name: 'The kundalini genie',
url: 'https://the-kundalini-genie.bandcamp.com',
coverId: '0012045550'
},
{ id: 2, name: 'Fontaines D.C.', url: 'https://fontainesdc.bandcamp.com', coverId: '0027327090' },
{ id: 3, name: 'Fontanarosa', url: 'https://fontanarosa.bandcamp.com', coverId: '0035380235' },
{ id: 4, name: 'Johnny mafia', url: 'https://johnnymafia.bandcamp.com', coverId: '0035009392' },
{ id: 5, name: 'New candys', url: 'https://newcandys.bandcamp.com', coverId: '0039963261' },
{ id: 6, name: 'Magic shoppe', url: 'https://magicshoppe.bandcamp.com', coverId: '0030748374' },
{
id: 7,
name: 'Les jaguars',
url: 'https://radiomartiko.bandcamp.com/album/surf-qu-b-cois',
coverId: '0016551336'
},
{ id: 8, name: 'TRAAMS', url: 'https://traams.bandcamp.com', coverId: '0028348410' },
{ id: 9, name: 'Blue orchid', url: 'https://blue-orchid.bandcamp.com', coverId: '0034796193' },
{ id: 10, name: 'I love UFO', url: 'https://bruitblanc.bandcamp.com', coverId: 'a2203158939' },
{
id: 11,
name: 'Kid Congo & The Pink Monkey Birds',
url: 'https://kidcongothepinkmonkeybirds.bandcamp.com/',
coverId: '0017196290'
},
{ id: 12, name: 'Firefriend', url: 'https://firefriend.bandcamp.com/', coverId: '0031072203' },
{ id: 13, name: 'Squid', url: 'https://squiduk.bandcamp.com/', coverId: '0037649385' },
{ id: 14, name: 'Lysistrata', url: 'https://lysistrata.bandcamp.com/', coverId: '0033900158' },
{
id: 15,
name: 'Pablo X Broadcasting Services',
url: 'https://pabloxbroadcastingservices.bandcamp.com/',
coverId: '0036956486'
},
{ id: 16, name: 'Night Beats', url: 'https://nightbeats.bandcamp.com/', coverId: '0036987720' },
{ id: 17, name: 'Deltron 3030', url: 'https://delthefunkyhomosapien.bandcamp.com/', coverId: '0005254781' },
{
id: 18,
name: 'The Amorphous Androgynous',
url: 'https://theaa.bandcamp.com/',
coverId: '0022226700'
},
{ id: 19, name: 'Wooden Shjips', url: 'https://woodenshjips.bandcamp.com/', coverId: '0012406678' },
{ id: 20, name: 'Silas J. Dirge', url: 'https://silasjdirge.bandcamp.com/', coverId: '0035751570' },
{ id: 21, name: 'Secret Colours', url: 'https://secretcolours.bandcamp.com/', coverId: '0010661379' },
{
id: 22,
name: 'Larry McNeil And The Blue Knights',
url: 'https://www.discogs.com/artist/6528940-Larry-McNeil-And-The-Blue-Knights',
coverId:
'https://i.discogs.com/Yr05_neEXwzPwKlDeV7dimmTG34atkAMgpxbMBhHBkI/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTEyMTEw/ODE1LTE1Mjg1NjU1/NzQtMjcyOC5qcGVn.jpeg'
},
{
id: 23,
name: 'Hugo Blanco',
url: 'https://elpalmasmusic.bandcamp.com/album/color-de-tr-pico-compiled-by-el-dr-gon-criollo-el-palmas',
coverId: '0016886708'
}
]
const tracks = [
{
order: 1,
boxId: 'ES00',
side: 'A',
title: 'The grinding wheel',
artist: 0,
start: 0,
link: 'https://arakirecords.bandcamp.com/track/the-grinding-wheel',
coverId: 'a3236746052'
},
{
order: 2,
boxId: 'ES00',
side: 'A',
title: 'Bleach',
artist: 1,
start: 392,
link: 'https://the-kundalini-genie.bandcamp.com/track/bleach-2',
coverId: 'a1714786533'
},
{
order: 3,
boxId: 'ES00',
side: 'A',
title: 'Televised mind',
artist: 2,
start: 896,
link: 'https://fontainesdc.bandcamp.com/track/televised-mind',
coverId: 'a3772806156'
},
{
order: 4,
boxId: 'ES00',
side: 'A',
title: 'In it',
artist: 3,
start: 1139,
link: 'https://howlinbananarecords.bandcamp.com/track/in-it',
coverId: 'a1720372066'
},
{
order: 5,
boxId: 'ES00',
side: 'A',
title: 'Bad michel',
artist: 4,
start: 1245,
link: 'https://johnnymafia.bandcamp.com/track/bad-michel-3',
coverId: 'a0984622869'
},
{
order: 6,
boxId: 'ES00',
side: 'A',
title: 'Overall',
artist: 5,
start: 1394,
link: 'https://newcandys.bandcamp.com/track/overall',
coverId: 'a0559661270'
},
{
order: 7,
boxId: 'ES00',
side: 'A',
title: 'Blowup',
artist: 6,
start: 1674,
link: 'https://magicshoppe.bandcamp.com/track/blowup',
coverId: 'a1444895293'
},
{
order: 8,
boxId: 'ES00',
side: 'A',
title: 'Guitar jet',
artist: 7,
start: 1880,
link: 'https://radiomartiko.bandcamp.com/track/guitare-jet',
coverId: 'a1494681687'
},
{
order: 9,
boxId: 'ES00',
side: 'A',
title: 'Intercontinental radio waves',
artist: 8,
start: 2024,
link: 'https://traams.bandcamp.com/track/intercontinental-radio-waves',
coverId: 'a0046738552'
},
{
order: 10,
boxId: 'ES00',
side: 'A',
title: 'Here comes the sun',
artist: 9,
start: 2211,
link: 'https://blue-orchid.bandcamp.com/track/here-come-the-sun',
coverId: 'a4102567047'
},
{
order: 11,
boxId: 'ES00',
side: 'A',
title: 'Like in the movies',
artist: 10,
start: 2560,
link: 'https://bruitblanc.bandcamp.com/track/like-in-the-movies-2',
coverId: 'a2203158939'
},
{
order: 1,
boxId: 'ES00',
side: 'B',
title: "Ce que révèle l'éclipse",
artist: 0,
start: 0,
link: 'https://arakirecords.bandcamp.com/track/ce-que-r-v-le-l-clipse',
coverId: 'a3236746052'
},
{
order: 2,
boxId: 'ES00',
side: 'B',
title: "Bleedin' Gums Mushrool",
artist: 1,
start: 263,
link: 'https://the-kundalini-genie.bandcamp.com/track/bleedin-gums-mushroom',
coverId: 'a1714786533'
},
{
order: 3,
boxId: 'ES00',
side: 'B',
title: 'A lucid dream',
artist: 2,
start: 554,
link: 'https://fontainesdc.bandcamp.com/track/a-lucid-dream',
coverId: 'a3772806156'
},
{
order: 4,
boxId: 'ES00',
side: 'B',
title: 'Lights off',
artist: 3,
start: 781,
link: 'https://howlinbananarecords.bandcamp.com/track/lights-off',
coverId: 'a1720372066'
},
{
order: 5,
boxId: 'ES00',
side: 'B',
title: "I'm sentimental",
artist: 4,
start: 969,
link: 'https://johnnymafia.bandcamp.com/track/im-sentimental-2',
coverId: 'a2333676849'
},
{
order: 6,
boxId: 'ES00',
side: 'B',
title: 'Thrill or trip',
artist: 5,
start: 1128,
link: 'https://newcandys.bandcamp.com/track/thrill-or-trip',
coverId: 'a0559661270'
},
{
order: 7,
boxId: 'ES00',
side: 'B',
title: 'Redhead',
artist: 6,
start: 1303,
link: 'https://magicshoppe.bandcamp.com/track/redhead',
coverId: 'a0594426943'
},
{
order: 8,
boxId: 'ES00',
side: 'B',
title: 'Supersonic twist',
artist: 7,
start: 1584,
link: 'https://open.spotify.com/track/66voQIZAJ3zD3Eju2qtNjF',
coverId: 'a1494681687'
},
{
order: 9,
boxId: 'ES00',
side: 'B',
title: 'Flowers',
artist: 8,
start: 1749,
link: 'https://traams.bandcamp.com/track/flowers',
coverId: 'a3644668199'
},
{
order: 10,
boxId: 'ES00',
side: 'B',
title: 'The shade',
artist: 9,
start: 1924,
link: 'https://blue-orchid.bandcamp.com/track/the-shade',
coverId: 'a0804204790'
},
{
order: 11,
boxId: 'ES00',
side: 'B',
title: 'Like in the movies',
artist: 10,
start: 2186,
link: 'https://bruitblanc.bandcamp.com/track/like-in-the-movies',
coverId: 'a3647322740'
},
{
order: 1,
boxId: 'ES01',
side: 'A',
title: 'He Walked In',
artist: 11,
start: 0,
link: 'https://kidcongothepinkmonkeybirds.bandcamp.com/track/he-walked-in',
coverId: 'a0336300523'
},
{
order: 2,
boxId: 'ES01',
side: 'A',
title: 'The Third Wave',
artist: 12,
start: 841,
link: 'https://firefriend.bandcamp.com/track/the-third-wave',
coverId: 'a2803689859'
},
{
order: 3,
boxId: 'ES01',
side: 'A',
title: 'Broadcaster',
artist: 13,
start: 1104.5,
link: 'https://squiduk.bandcamp.com/track/broadcaster',
coverId: 'a3391719769'
},
{
order: 4,
boxId: 'ES01',
side: 'A',
title: 'Mourn',
artist: 14,
start: 1441,
link: 'https://lysistrata.bandcamp.com/track/mourn-2',
coverId: 'a0872900041'
},
{
order: 5,
boxId: 'ES01',
side: 'A',
title: 'Let it Blow',
artist: 15,
start: 1844.8,
link: 'https://pabloxbroadcastingservices.bandcamp.com/track/let-it-blow',
coverId: 'a4000148031'
},
{
order: 6,
boxId: 'ES01',
side: 'A',
title: 'Sunday Mourning',
artist: 16,
start: 2091.7,
link: 'https://nightbeats.bandcamp.com/track/sunday-mourning',
coverId: 'a0031987121'
},
{
order: 7,
boxId: 'ES01',
side: 'A',
title: '3030 Instrumental',
artist: 17,
start: 2339.3,
link: 'https://delthefunkyhomosapien.bandcamp.com/track/3030',
coverId: 'a1948146136'
},
{
order: 8,
boxId: 'ES01',
side: 'A',
title: 'Immortality Break',
artist: 18,
start: 2530.5,
link: 'https://theaa.bandcamp.com/track/immortality-break',
coverId: 'a2749250329'
},
{
order: 9,
boxId: 'ES01',
side: 'A',
title: 'Lazy Bones',
artist: 19,
start: 2718,
link: 'https://woodenshjips.bandcamp.com/track/lazy-bones',
coverId: 'a1884221104'
},
{
order: 10,
boxId: 'ES01',
side: 'A',
title: 'On the Train of Aches',
artist: 20,
start: 2948,
link: 'https://silasjdirge.bandcamp.com/track/on-the-train-of-aches',
coverId: 'a1124177379'
},
{
order: 11,
boxId: 'ES01',
side: 'A',
title: 'Me',
artist: 21,
start: 3265,
link: 'https://secretcolours.bandcamp.com/track/me',
coverId: 'a1497022499'
},
{
order: 1,
boxId: 'ES01',
side: 'B',
title: 'Lady Hawke Blues',
artist: 11,
start: 0,
link: 'https://kidcongothepinkmonkeybirds.bandcamp.com/track/lady-hawke-blues',
coverId: 'a2532623230'
},
{
order: 2,
boxId: 'ES01',
side: 'B',
title: 'Dreamscapes',
artist: 12,
start: 235,
link: 'https://littlecloudrecords.bandcamp.com/track/dreamscapes',
coverId: 'a3498981203'
},
{
order: 3,
boxId: 'ES01',
side: 'B',
title: 'Crispy Skin',
artist: 13,
start: 644.2,
link: 'https://squiduk.bandcamp.com/track/crispy-skin-2',
coverId: 'a2516727021'
},
{
order: 4,
boxId: 'ES01',
side: 'B',
title: 'The Boy Who Stood Above The Earth',
artist: 14,
start: 1018,
link: 'https://lysistrata.bandcamp.com/track/the-boy-who-stood-above-the-earth-2',
coverId: 'a0350933426'
},
{
order: 5,
boxId: 'ES01',
side: 'B',
title: 'Better Off Alone',
artist: 15,
start: 1698,
link: 'https://pabloxbroadcastingservices.bandcamp.com/track/better-off-alone',
coverId: 'a4000148031'
},
{
order: 6,
boxId: 'ES01',
side: 'B',
title: 'Celebration #1',
artist: 16,
start: 2235,
link: 'https://nightbeats.bandcamp.com/track/celebration-1',
coverId: 'a0031987121'
},
{
order: 7,
boxId: 'ES01',
side: 'B',
title: '3030 Instrumental',
artist: 17,
start: 2458.3,
link: 'https://delthefunkyhomosapien.bandcamp.com/track/3030',
coverId: 'a1948146136'
},
{
order: 8,
boxId: 'ES01',
side: 'B',
title: 'The Emptiness Of Nothingness',
artist: 18,
start: 2864.5,
link: 'https://theaa.bandcamp.com/track/the-emptiness-of-nothingness',
coverId: 'a1053923875'
},
{
order: 9,
boxId: 'ES01',
side: 'B',
title: 'Rising',
artist: 19,
start: 3145,
link: 'https://woodenshjips.bandcamp.com/track/rising',
coverId: 'a1884221104'
},
{
order: 10,
boxId: 'ES01',
side: 'B',
title: 'The Last Time',
artist: 22,
start: 3447,
link: 'https://www.discogs.com/release/12110815-Larry-McNeil-And-The-Blue-Knights-Jealous-Woman',
coverId:
'https://i.discogs.com/Yr05_neEXwzPwKlDeV7dimmTG34atkAMgpxbMBhHBkI/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTEyMTEw/ODE1LTE1Mjg1NjU1/NzQtMjcyOC5qcGVn.jpeg'
},
{
order: 11,
boxId: 'ES01',
side: 'B',
title: 'Guajira Con Arpa',
artist: 23,
start: 3586,
link: 'https://elpalmasmusic.bandcamp.com/track/guajira-con-arpa',
coverId: 'a3463036407'
}
]
export async function migrate() {
console.log('🚀 Début de la migration...')
const db = getDatabase()
// Vider les tables existantes
db.exec('DELETE FROM tracks')
db.exec('DELETE FROM sides')
db.exec('DELETE FROM artists')
db.exec('DELETE FROM boxes')
console.log('🗑️ Tables vidées')
// Insérer les boxes
const insertBox = db.prepare(`
INSERT INTO boxes (id, type, name, description, state, duration, active_side, color1, color2)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
const insertSide = db.prepare(`
INSERT INTO sides (box_id, side, name, description, duration, color1, color2)
VALUES (?, ?, ?, ?, ?, ?, ?)
`)
for (const box of boxes) {
insertBox.run(
box.id,
box.type,
box.name,
box.description,
box.state,
box.duration,
box.activeSide,
box.color1 || null,
box.color2 || null
)
// Insérer les sides si c'est une compilation
if (box.sides) {
for (const [sideName, sideData] of Object.entries(box.sides)) {
insertSide.run(
box.id,
sideName,
sideData.name,
sideData.description,
sideData.duration,
sideData.color1,
sideData.color2
)
}
}
}
console.log(`${boxes.length} boxes insérées`)
// Insérer les artists
const insertArtist = db.prepare(`
INSERT INTO artists (id, name, url, cover_id)
VALUES (?, ?, ?, ?)
`)
for (const artist of artists) {
insertArtist.run(artist.id, artist.name, artist.url, artist.coverId)
}
console.log(`${artists.length} artistes insérés`)
// Insérer les tracks
const insertTrack = db.prepare(`
INSERT INTO tracks (box_id, side, track_order, title, artist_id, start, link, cover_id, url, type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
for (const track of tracks) {
const url = `https://files.erudi.fr/evilspins/${track.boxId}${track.side}.mp3`
const coverId = `https://f4.bcbits.com/img/${track.coverId}_4.jpg`
insertTrack.run(
track.boxId,
track.side,
track.order,
track.title,
track.artist,
track.start,
track.link,
coverId,
url,
'compilation'
)
}
console.log(`${tracks.length} tracks insérées`)
console.log('🎉 Migration terminée avec succès !')
}
// Exécuter la migration si appelé directement
if (import.meta.url === `file://${process.argv[1]}`) {
migrate()
.then(() => process.exit(0))
.catch((err) => {
console.error('❌ Erreur lors de la migration:', err)
process.exit(1)
})
}

View File

@@ -0,0 +1,63 @@
-- 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);

View File

@@ -0,0 +1,163 @@
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)

82
server/types/database.ts Normal file
View File

@@ -0,0 +1,82 @@
// 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
}

63
server/utils/database.ts Normal file
View File

@@ -0,0 +1,63 @@
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
}
}

View File

@@ -46,14 +46,7 @@ export interface Track {
link?: string link?: string
type: BoxType type: BoxType
year?: number year?: number
} color: string
export interface Playlist {
id: number
date: Date
title: string
url: string
filename: string
} }
export type BoxState = 'box-hidden' | 'box-list' | 'box-selected' export type BoxState = 'box-hidden' | 'box-list' | 'box-selected'