sql #33
2
.env
2
.env
@@ -1,3 +1,5 @@
|
|||||||
DOMAIN=evilspins.com
|
DOMAIN=evilspins.com
|
||||||
PORT=7901
|
PORT=7901
|
||||||
PORT_EXPOSED=3000
|
PORT_EXPOSED=3000
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
|||||||
35
.github/workflows/deploy.yml
vendored
35
.github/workflows/deploy.yml
vendored
@@ -1,35 +0,0 @@
|
|||||||
name: Deploy App
|
|
||||||
on: [push]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
container:
|
|
||||||
volumes:
|
|
||||||
- /var/docker-web:/var/docker-web
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Prepare and build app
|
|
||||||
run: |
|
|
||||||
REPO_NAME="${GITHUB_REPOSITORY##*/}"
|
|
||||||
APP_DIR="/var/docker-web/apps/${REPO_NAME}"
|
|
||||||
bash /var/docker-web/src/cli.sh down "${REPO_NAME}"
|
|
||||||
rm -rf "$APP_DIR"
|
|
||||||
mkdir "$APP_DIR"
|
|
||||||
cp -a $(find . -mindepth 1 -maxdepth 1 ! -name '.git' ! -name 'node_modules') "$APP_DIR/"
|
|
||||||
export COMPOSE_BAKE=false
|
|
||||||
docker rmi "local/${REPO_NAME}" 2>/dev/null || true
|
|
||||||
bash /var/docker-web/src/cli.sh build "${REPO_NAME}"
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
needs: build
|
|
||||||
container:
|
|
||||||
volumes:
|
|
||||||
- /var/docker-web:/var/docker-web
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Deploy with docker-web
|
|
||||||
run: |
|
|
||||||
REPO_NAME="${GITHUB_REPOSITORY##*/}"
|
|
||||||
bash /var/docker-web/src/cli.sh up "${REPO_NAME}"
|
|
||||||
23
.github/workflows/merge.yml
vendored
Normal file
23
.github/workflows/merge.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: Delete Merged Branches
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
delete-branch:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
container:
|
||||||
|
volumes:
|
||||||
|
- /var/docker-web:/var/docker-web
|
||||||
|
if: github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Delete merged branch
|
||||||
|
run: |
|
||||||
|
BRANCH_NAME="${{ github.event.pull_request.head.ref || github.event.inputs.branch_name }}"
|
||||||
|
APP_NAME="${BRANCH_NAME}_${GITHUB_REPOSITORY##*/}"
|
||||||
|
if [ "$BRANCH_NAME" != "main" ] && [ "$BRANCH_NAME" != "develop" ] && [ -n "$APP_NAME" ]; then
|
||||||
|
bash /var/docker-web/src/cli.sh rm -y "${APP_NAME}"
|
||||||
|
else
|
||||||
|
echo "Cannot delete protected branch: $BRANCH_NAME"
|
||||||
|
fi
|
||||||
41
.github/workflows/push.yml
vendored
Normal file
41
.github/workflows/push.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: Deploy App
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
container:
|
||||||
|
volumes:
|
||||||
|
- /var/docker-web:/var/docker-web
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Build app
|
||||||
|
run: |
|
||||||
|
bash ./.github/workflows/setup-env.sh
|
||||||
|
set -a && source .env && set +a
|
||||||
|
if [ -n "$APP_NAME" ]; then
|
||||||
|
set -a && source .env && set +a
|
||||||
|
bash /var/docker-web/src/cli.sh down "${APP_NAME}"
|
||||||
|
rm -rf "$APP_DIR"
|
||||||
|
mkdir "$APP_DIR"
|
||||||
|
cp -a $(find . -mindepth 1 -maxdepth 1 ! -name '.git' ! -name 'node_modules') "$APP_DIR/"
|
||||||
|
export COMPOSE_BAKE=false
|
||||||
|
docker rmi "local/${APP_NAME}" 2>/dev/null || true
|
||||||
|
bash /var/docker-web/src/cli.sh build "${APP_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
needs: build
|
||||||
|
container:
|
||||||
|
volumes:
|
||||||
|
- /var/docker-web:/var/docker-web
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
bash ./.github/workflows/setup-env.sh
|
||||||
|
set -a && source .env && set +a
|
||||||
|
if [ -n "$APP_NAME" ]; then
|
||||||
|
bash /var/docker-web/src/cli.sh up "${APP_NAME}"
|
||||||
|
fi
|
||||||
34
.github/workflows/setup-env.sh
vendored
Normal file
34
.github/workflows/setup-env.sh
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -a && source .env && set +a
|
||||||
|
|
||||||
|
changeEnvVar() {
|
||||||
|
local var_name="$1"
|
||||||
|
local new_value="$2"
|
||||||
|
local env_file=".env"
|
||||||
|
|
||||||
|
if grep -q "^${var_name}=" "$env_file"; then
|
||||||
|
sed -i "s|${var_name}=.*|${var_name}=${new_value}|" "$env_file"
|
||||||
|
else
|
||||||
|
echo "${var_name}=${new_value}" >> "$env_file"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Variables GitHub
|
||||||
|
APP_NAME="${GITHUB_REPOSITORY##*/}"
|
||||||
|
BRANCH_NAME=$GITHUB_REF_NAME
|
||||||
|
|
||||||
|
# Configuration pour les branches non-principales
|
||||||
|
if [ "$BRANCH_NAME" != "main" ] && [ "$BRANCH_NAME" != "master" ]; then
|
||||||
|
DOMAIN="$BRANCH_NAME.$DOMAIN"
|
||||||
|
APP_NAME="${BRANCH_NAME}_${APP_NAME}"
|
||||||
|
PORT=$(bash /var/docker-web/src/cli.sh ALLOCATE_PORT)
|
||||||
|
sed -i "s|${GITHUB_REPOSITORY##*/}|$APP_NAME|g" docker-compose.yml
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_DIR="/var/docker-web/apps/$APP_NAME"
|
||||||
|
|
||||||
|
changeEnvVar "DOMAIN" $DOMAIN
|
||||||
|
changeEnvVar "APP_NAME" $APP_NAME
|
||||||
|
changeEnvVar "PORT" $PORT
|
||||||
|
changeEnvVar "APP_DIR" $APP_DIR
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@
|
|||||||
.nitro
|
.nitro
|
||||||
.cache
|
.cache
|
||||||
dist
|
dist
|
||||||
|
drizzle
|
||||||
|
|
||||||
# Node dependencies
|
# Node dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
|||||||
@@ -5,4 +5,3 @@
|
|||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"printWidth": 100
|
"printWidth": 100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Builder
|
# Builder
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20 AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
@@ -9,12 +9,12 @@ COPY . .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Runtime
|
# Runtime
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20 AS runner
|
||||||
RUN apk add --no-cache python3 make g++ sqlite
|
RUN apt-get update && apt-get install -y sqlite3 && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
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
|
COPY ./data ./data
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", ".output/server/index.mjs"]
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
|
|||||||
92
app/app.vue
92
app/app.vue
@@ -1,82 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="min-h-screen dark:bg-neutral-900" @keydown.esc="resetFocus">
|
||||||
<NuxtRouteAnnouncer />
|
<NuxtRouteAnnouncer />
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage ref="pageContent" />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { useUiStore } from '~/store/ui'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { usePlayerStore } from '~/store/player'
|
|
||||||
import { watch, computed } from 'vue'
|
|
||||||
|
|
||||||
const ui = useUiStore()
|
const pageContent = ref<HTMLElement | null>(null)
|
||||||
const player = usePlayerStore()
|
|
||||||
useHead({
|
const resetFocus = (event: KeyboardEvent) => {
|
||||||
bodyAttrs: {
|
if (event.key === 'Escape') {
|
||||||
class: 'bg-slate-100'
|
// On déplace le focus sur l'élément racine
|
||||||
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
|
document.activeElement.blur()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On force le focus sur le body
|
||||||
|
document.body.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajout du gestionnaire d'événement au montage du composant
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', resetFocus)
|
||||||
})
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
// Nettoyage du gestionnaire d'événement lors du démontage du composant
|
||||||
const route = useRoute()
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', resetFocus)
|
||||||
watch(
|
|
||||||
() => player.currentTrack?.id,
|
|
||||||
(id) => {
|
|
||||||
if (!id) {
|
|
||||||
if (route.name === 'track-id') router.replace({ path: '/' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const currentParam = Number(
|
|
||||||
Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
|
||||||
)
|
|
||||||
if (route.name === 'track-id' && currentParam === id) return
|
|
||||||
router.replace({ name: 'track-id', params: { id } })
|
|
||||||
},
|
|
||||||
{ flush: 'post' }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Keep URL in sync with selected box: /box/:id when a box is selected, back to / when none
|
|
||||||
const selectedBoxId = computed(() => ui.getSelectedBox?.id)
|
|
||||||
watch(
|
|
||||||
() => selectedBoxId.value,
|
|
||||||
(id) => {
|
|
||||||
if (import.meta.client) {
|
|
||||||
if (!id) {
|
|
||||||
// Back to root path without navigation to preserve UI state/animations
|
|
||||||
if (location.pathname.startsWith('/box/')) {
|
|
||||||
history.replaceState(null, '', '/')
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const currentId = location.pathname.startsWith('/box/')
|
|
||||||
? location.pathname.split('/').pop()
|
|
||||||
: null
|
|
||||||
if (currentId === id) return
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
history.replaceState(null, '', `/box/${id}`)
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
},
|
|
||||||
{ flush: 'post' }
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
button,
|
|
||||||
input {
|
|
||||||
@apply px-4 py-2 m-4 rounded-md text-center font-bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type='email'] {
|
|
||||||
@apply bg-slate-900 text-esyellow;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
user-drag: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,56 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<article v-bind="attrs" :role="props.role" :draggable="isFaceUp" :class="[
|
<article :role="props.role" :class="[
|
||||||
'card cursor-pointer',
|
'card cursor-pointer',
|
||||||
isFaceUp ? 'face-up' : 'face-down',
|
isFaceUp ? 'face-up' : 'face-down',
|
||||||
{ 'current-track': playerStore.currentTrack?.id === track.id },
|
showPlayButtonFaceUp ? 'show-play-button-face-up' : ''
|
||||||
{ 'is-dragging': isDragging }
|
]" :tabindex="props.tabindex" :aria-disabled="false" @click="$emit('click', $event)"
|
||||||
]" :tabindex="props.tabindex" :aria-disabled="false" @click.stop="handleClick" @keydown.enter.stop="handleClick"
|
@keydown.enter="$emit('click', $event)">
|
||||||
@keydown.space.prevent.stop="handleClick" @dragstart="handleDragStart" @dragend="handleDragEnd"
|
|
||||||
@drag="handleDragMove" @touchstart.passive="!isFaceUp" @touchmove.passive="!isFaceUp">
|
|
||||||
<div class="flip-inner" ref="cardElement">
|
<div class="flip-inner" ref="cardElement">
|
||||||
<!-- Face-Up -->
|
<!-- Face-Up -->
|
||||||
<main
|
<main
|
||||||
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 hover:shadow-xl hover:scale-110 transition-all rounded-2xl shadow-lg flex flex-col overflow-hidden">
|
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 hover:shadow-xl hover:scale-110 transition-all shadow-lg flex flex-col overflow-hidden">
|
||||||
|
|
||||||
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 right-7"
|
<Rank :card="props.card" />
|
||||||
@click.stop="clickCardSymbol">
|
|
||||||
<div class="suit text-7xl absolute"
|
|
||||||
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
|
|
||||||
<img draggable="false" :src="`/${props.track.card?.suit}.svg`" />
|
|
||||||
</div>
|
|
||||||
<div class="rank text-white font-bold absolute -mt-1">
|
|
||||||
{{ props.track.card?.rank }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="flex items-center justify-center size-7 absolute top-6 left-6">
|
|
||||||
<div class="rank text-white font-bold absolute -mt-1">
|
|
||||||
{{ props.track.order }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cover -->
|
<!-- Cover -->
|
||||||
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer">
|
<figure class="cover">
|
||||||
<playButton :objectToPlay="track" />
|
<PlayButton />
|
||||||
<img draggable="false" v-if="isFaceUp" :src="coverUrl" alt="Pochette de l'album" loading="lazy"
|
<img :src="props.card.url_image" alt="cover-image de l'album" :loading="props.imageLoadingType"
|
||||||
class="w-full h-full object-cover object-center" />
|
@load="$emit('image-loaded', $event)" class="cover-image w-full h-full object-cover object-center" />
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div
|
<div
|
||||||
class="card-body p-3 text-center bg-white rounded-b-xl opacity-0 -mt-16 hover:opacity-100 hover:-mt-0 transition-all duration-300">
|
class="card-body p-3 text-center bg-white rounded-b-xl opacity-0 -mt-16 hover:opacity-100 hover:-mt-0 transition-all duration-300">
|
||||||
<div v-if="isOrder" class="label">
|
|
||||||
{{ props.track.order }}
|
|
||||||
</div>
|
|
||||||
<h2 class="select-text text-sm text-neutral-500 first-letter:uppercase truncate">
|
<h2 class="select-text text-sm text-neutral-500 first-letter:uppercase truncate">
|
||||||
{{ props.track.title || 'title' }}
|
{{ props.card.title || 'title' }}
|
||||||
</h2>
|
</h2>
|
||||||
<p v-if="isPlaylistTrack && track.artist && typeof track.artist === 'object'"
|
<p class="select-text text-base text-neutral-800 font-bold capitalize truncate">
|
||||||
class="select-text text-base text-neutral-800 font-bold capitalize truncate">
|
{{ props.card.artist || 'artist' }}
|
||||||
{{ track.artist.name || 'artist' }}
|
|
||||||
</p>
|
|
||||||
<p v-else-if="isPlaylistTrack" class="select-text text-base text-neutral-800 font-bold capitalize truncate">
|
|
||||||
{{ typeof track.artist === 'string' ? track.artist : 'artist' }}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -60,317 +36,43 @@
|
|||||||
class="face-down backdrop-blur-sm z-10 card w-56 h-80 p-3 bg-opacity-10 bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden select-none">
|
class="face-down backdrop-blur-sm z-10 card w-56 h-80 p-3 bg-opacity-10 bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden select-none">
|
||||||
<figure class="h-full flex text-center rounded-xl justify-center items-center"
|
<figure class="h-full flex text-center rounded-xl justify-center items-center"
|
||||||
:style="{ backgroundColor: cardColor }">
|
:style="{ backgroundColor: cardColor }">
|
||||||
<playButton :objectToPlay="track" />
|
<PlayButton />
|
||||||
<img draggable="false" src="/face-down.svg" />
|
<img src="/face-down.svg" />
|
||||||
</figure>
|
</figure>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<!-- Clone fantôme unifié pour drag souris ET tactile -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="isDragging && touchClone" ref="ghostElement"
|
|
||||||
class="ghost-card fixed pointer-events-none z-[9999] w-56 h-80" :style="{
|
|
||||||
left: touchClone.x + 'px',
|
|
||||||
top: touchClone.y + 'px',
|
|
||||||
transform: 'translate(-50%, -50%)'
|
|
||||||
}">
|
|
||||||
<div class="flip-inner">
|
|
||||||
<main
|
|
||||||
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 rounded-2xl shadow-2xl flex flex-col overflow-hidden bg-white bg-opacity-90">
|
|
||||||
|
|
||||||
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 left-7">
|
|
||||||
<div class="suit text-7xl absolute"
|
|
||||||
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
|
|
||||||
<img draggable="false" :src="`/${props.track.card?.suit}.svg`" />
|
|
||||||
</div>
|
|
||||||
<div class="rank text-white font-bold absolute -mt-1">
|
|
||||||
{{ props.track.card?.rank }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl">
|
|
||||||
<img draggable="false" :src="coverUrl" alt="Pochette de l'album"
|
|
||||||
class="w-full h-full object-cover object-center" />
|
|
||||||
</figure>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<!-- Modal de partage -->
|
|
||||||
<ModalSharer v-if="showModalSharer" ref="modalSharer" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Track } from '~~/types/types'
|
import type { Card } from '~~/types/types'
|
||||||
import { usePlayerStore } from '~/store/player'
|
|
||||||
import { useDataStore } from '~/store/data'
|
const emit = defineEmits(['click', 'image-loaded'])
|
||||||
import { useNuxtApp } from '#app'
|
|
||||||
import ModalSharer from '~/components/ui/ModalSharer.vue'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
track: Track;
|
card: Card;
|
||||||
isFaceUp?: boolean;
|
isFaceUp?: boolean;
|
||||||
role?: string;
|
role?: string;
|
||||||
tabindex?: string | number;
|
tabindex?: string | number;
|
||||||
'onUpdate:isFaceUp'?: (value: boolean) => void;
|
imageLoadingType?: 'lazy' | 'eager';
|
||||||
|
showPlayButtonFaceUp?: boolean;
|
||||||
}>(), {
|
}>(), {
|
||||||
isFaceUp: true,
|
isFaceUp: false,
|
||||||
role: 'button',
|
role: 'button',
|
||||||
tabindex: '0'
|
tabindex: '0',
|
||||||
|
imageLoadingType: 'eager',
|
||||||
|
showPlayButtonFaceUp: false
|
||||||
})
|
})
|
||||||
|
|
||||||
// Use useAttrs to get all other attributes
|
import { getYearColor } from '~/utils/colors'
|
||||||
const attrs = useAttrs()
|
|
||||||
|
|
||||||
const modalSharer = ref<InstanceType<typeof ModalSharer> | null>(null)
|
const cardColor = computed(() => getYearColor(props.card.year || 0))
|
||||||
const showModalSharer = ref(false)
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
/* loading states of the card */
|
||||||
(e: 'update:isFaceUp', value: boolean): void;
|
const isApiLoaded = ref(false)
|
||||||
(e: 'cardClick', track: Track): void;
|
const isImageLoaded = ref(false)
|
||||||
(e: 'clickCardSymbol', track: Track): void;
|
const isTrackLoaded = ref(false)
|
||||||
(e: 'dragstart', event: DragEvent): void;
|
|
||||||
(e: 'dragend', event: DragEvent): void;
|
|
||||||
(e: 'drag', event: DragEvent): void;
|
|
||||||
(e: 'click', event: MouseEvent): void;
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// Handle click events (mouse and keyboard)
|
|
||||||
const handleClick = (event: MouseEvent | KeyboardEvent) => {
|
|
||||||
if (!isDragging.value && !hasMovedDuringPress.value) {
|
|
||||||
emit('cardClick', props.track);
|
|
||||||
emit('click', event as MouseEvent);
|
|
||||||
}
|
|
||||||
hasMovedDuringPress.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clickCardSymbol = (event: MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
// Afficher la modale
|
|
||||||
showModalSharer.value = true;
|
|
||||||
|
|
||||||
// Donner le focus à la modale après le rendu
|
|
||||||
nextTick(() => {
|
|
||||||
if (modalSharer.value) {
|
|
||||||
modalSharer.value.open(props.track);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
emit('clickCardSymbol', props.track);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle drag start with proper event emission
|
|
||||||
const handleDragStart = (event: DragEvent) => {
|
|
||||||
if (!props.isFaceUp) {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { $bodyClass } = useNuxtApp()
|
|
||||||
$bodyClass.add('card-dragging')
|
|
||||||
dragStart(event);
|
|
||||||
emit('dragstart', event);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle drag end with proper event emission
|
|
||||||
const handleDragEnd = (event: DragEvent) => {
|
|
||||||
if (!props.isFaceUp) {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { $bodyClass } = useNuxtApp()
|
|
||||||
$bodyClass.remove('card-dragging')
|
|
||||||
dragEnd(event);
|
|
||||||
emit('dragend', event);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle drag move with proper event emission
|
|
||||||
const handleDragMove = (event: DragEvent) => {
|
|
||||||
if (!props.isFaceUp) {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dragMove(event);
|
|
||||||
emit('drag', event);
|
|
||||||
}
|
|
||||||
|
|
||||||
const playerStore = usePlayerStore()
|
|
||||||
const isManifesto = computed(() => props.track.boxId.startsWith('ES00'))
|
|
||||||
const isOrder = computed(() => props.track.order && !isManifesto)
|
|
||||||
const isPlaylistTrack = computed(() => props.track.type === 'playlist')
|
|
||||||
const isRedCard = computed(() => (props.track.card?.suit === '♥' || props.track.card?.suit === '♦'))
|
|
||||||
const dataStore = useDataStore()
|
|
||||||
const cardColor = computed(() => dataStore.getYearColor(props.track.year || 0))
|
|
||||||
const coverUrl = computed(() => props.track.coverId || '/card-dock.svg')
|
|
||||||
|
|
||||||
const isDragging = ref(false)
|
|
||||||
const cardElement = ref<HTMLElement | null>(null)
|
|
||||||
const ghostElement = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
// État unifié pour souris et tactile
|
|
||||||
const touchClone = ref<{ x: number, y: number } | null>(null)
|
|
||||||
const touchStartPos = ref<{ x: number, y: number } | null>(null)
|
|
||||||
const longPressTimer = ref<number | null>(null)
|
|
||||||
const LONG_PRESS_DURATION = 200 // ms
|
|
||||||
const hasMovedDuringPress = ref(false)
|
|
||||||
|
|
||||||
|
|
||||||
// Drag desktop - utilise maintenant ghostElement
|
|
||||||
const dragStart = (event: DragEvent) => {
|
|
||||||
if (event.dataTransfer && cardElement.value) {
|
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
|
||||||
event.dataTransfer.setData('application/json', JSON.stringify(props.track));
|
|
||||||
|
|
||||||
// Créer une image transparente pour masquer l'image par défaut du navigateur
|
|
||||||
const img = new Image();
|
|
||||||
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
||||||
event.dataTransfer.setDragImage(img, 0, 0);
|
|
||||||
|
|
||||||
// Activer le clone fantôme
|
|
||||||
isDragging.value = true
|
|
||||||
touchClone.value = {
|
|
||||||
x: event.clientX,
|
|
||||||
y: event.clientY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Nouveau: suivre le mouvement de la souris pendant le drag
|
|
||||||
const dragMove = (event: DragEvent) => {
|
|
||||||
if (isDragging.value && touchClone.value && event.clientX !== 0 && event.clientY !== 0) {
|
|
||||||
touchClone.value = {
|
|
||||||
x: event.clientX,
|
|
||||||
y: event.clientY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const instance = getCurrentInstance();
|
|
||||||
const dragEnd = (event: DragEvent) => {
|
|
||||||
isDragging.value = false
|
|
||||||
touchClone.value = null
|
|
||||||
|
|
||||||
if (event.dataTransfer?.dropEffect === 'move' && instance?.vnode?.el?.parentNode) {
|
|
||||||
instance.vnode.el.parentNode.removeChild(instance.vnode.el);
|
|
||||||
const parent = instance.parent;
|
|
||||||
if (parent?.update) {
|
|
||||||
parent.update();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Touch events
|
|
||||||
const touchStart = (event: TouchEvent) => {
|
|
||||||
const touch = event.touches[0];
|
|
||||||
if (!touch) return;
|
|
||||||
|
|
||||||
touchStartPos.value = { x: touch.clientX, y: touch.clientY };
|
|
||||||
hasMovedDuringPress.value = false;
|
|
||||||
|
|
||||||
// Démarrer un timer pour le long press
|
|
||||||
longPressTimer.value = window.setTimeout(() => {
|
|
||||||
startTouchDrag(touch);
|
|
||||||
}, LONG_PRESS_DURATION);
|
|
||||||
}
|
|
||||||
|
|
||||||
const startTouchDrag = (touch: Touch) => {
|
|
||||||
if (!touch) return;
|
|
||||||
|
|
||||||
isDragging.value = true;
|
|
||||||
touchClone.value = {
|
|
||||||
x: touch.clientX,
|
|
||||||
y: touch.clientY
|
|
||||||
};
|
|
||||||
|
|
||||||
// Vibration feedback si disponible
|
|
||||||
if (navigator.vibrate) {
|
|
||||||
navigator.vibrate(50);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const touchMove = (event: TouchEvent) => {
|
|
||||||
const touch = event.touches[0];
|
|
||||||
if (!touch || !longPressTimer.value) return;
|
|
||||||
|
|
||||||
// Annuler le long press si l'utilisateur bouge trop
|
|
||||||
const dx = touch.clientX - (touchStartPos.value?.x || 0);
|
|
||||||
const dy = touch.clientY - (touchStartPos.value?.y || 0);
|
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
|
|
||||||
if (distance > 10) { // Seuil de tolérance pour un tap
|
|
||||||
clearTimeout(longPressTimer.value)
|
|
||||||
longPressTimer.value = null
|
|
||||||
hasMovedDuringPress.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDragging.value && touchClone.value) {
|
|
||||||
event.preventDefault()
|
|
||||||
const touch = event.touches[0]
|
|
||||||
touchClone.value = {
|
|
||||||
x: touch.clientX,
|
|
||||||
y: touch.clientY
|
|
||||||
}
|
|
||||||
|
|
||||||
// Déterminer l'élément sous le doigt
|
|
||||||
checkDropTarget(touch.clientX, touch.clientY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const touchEnd = (event: TouchEvent) => {
|
|
||||||
// Annuler le timer de long press
|
|
||||||
if (longPressTimer.value) {
|
|
||||||
clearTimeout(longPressTimer.value);
|
|
||||||
longPressTimer.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier si c'était un tap simple (pas de déplacement)
|
|
||||||
if (!hasMovedDuringPress.value && touchStartPos.value) {
|
|
||||||
const touch = event.changedTouches[0];
|
|
||||||
if (touch) {
|
|
||||||
const dx = touch.clientX - touchStartPos.value.x;
|
|
||||||
const dy = touch.clientY - touchStartPos.value.y;
|
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
|
|
||||||
if (distance < 10) { // Seuil de tolérance pour un tap
|
|
||||||
handleClick(new MouseEvent('click'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Réinitialiser l'état de glisser-déposer
|
|
||||||
if (isDragging.value) {
|
|
||||||
// Vérifier si on est au-dessus d'une cible de dépôt
|
|
||||||
const touch = event.changedTouches[0];
|
|
||||||
if (touch) {
|
|
||||||
checkDropTarget(touch.clientX, touch.clientY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nettoyer
|
|
||||||
isDragging.value = false;
|
|
||||||
touchClone.value = null;
|
|
||||||
touchStartPos.value = null;
|
|
||||||
hasMovedDuringPress.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkDropTarget = (x: number, y: number): HTMLElement | null => {
|
|
||||||
const element = document.elementFromPoint(x, y);
|
|
||||||
if (element) {
|
|
||||||
const dropZone = element.closest('[data-drop-zone]');
|
|
||||||
if (dropZone) {
|
|
||||||
return dropZone as HTMLElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (longPressTimer.value) {
|
|
||||||
clearTimeout(longPressTimer.value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@@ -390,8 +92,7 @@ onUnmounted(() => {
|
|||||||
/* Flip effect */
|
/* Flip effect */
|
||||||
.card {
|
.card {
|
||||||
perspective: 1000px;
|
perspective: 1000px;
|
||||||
@apply transition-all scale-100 w-56 h-80 min-w-56 min-h-80;
|
@apply transition-all scale-100 size-full;
|
||||||
touch-action: none;
|
|
||||||
|
|
||||||
.flip-inner {
|
.flip-inner {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -413,8 +114,18 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.face-up {
|
.face-up {
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: none;
|
||||||
transform: rotateY(0deg);
|
transform: rotateY(0deg);
|
||||||
transition: box-shadow 0.6s;
|
transition: box-shadow 0.6s;
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
@apply flex-1 flex justify-center items-center overflow-hidden cursor-pointer;
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.face-down {
|
.face-down {
|
||||||
@@ -440,7 +151,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:focus,
|
&:focus,
|
||||||
&.current-track {
|
&.current-card {
|
||||||
@apply z-50 scale-110;
|
@apply z-50 scale-110;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
@@ -448,12 +159,12 @@ onUnmounted(() => {
|
|||||||
@apply shadow-2xl;
|
@apply shadow-2xl;
|
||||||
transition:
|
transition:
|
||||||
box-shadow 0.6s,
|
box-shadow 0.6s,
|
||||||
transform 0.6s;
|
transform 6s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus,
|
&:focus,
|
||||||
&.current-track {
|
&.current-card {
|
||||||
.play-button {
|
.play-button {
|
||||||
@apply opacity-100;
|
@apply opacity-100;
|
||||||
}
|
}
|
||||||
@@ -463,10 +174,13 @@ onUnmounted(() => {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.face-up:hover {
|
.face-up {
|
||||||
|
|
||||||
|
&:hover {
|
||||||
.play-button {
|
.play-button {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.flip-inner {
|
.flip-inner {
|
||||||
transform: rotateY(-170deg);
|
transform: rotateY(-170deg);
|
||||||
@@ -474,10 +188,24 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.play-button {
|
.play-button {
|
||||||
@apply absolute bottom-1/2 top-24 opacity-0 hover:opacity-100;
|
@apply absolute opacity-0 hover:opacity-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pochette:active,
|
&.show-play-button {
|
||||||
|
.play-button {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.show-play-button-face-up {
|
||||||
|
.face-up {
|
||||||
|
.play-button {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-image:active,
|
||||||
.face-down:active {
|
.face-down:active {
|
||||||
.play-button {
|
.play-button {
|
||||||
@apply scale-90;
|
@apply scale-90;
|
||||||
@@ -498,22 +226,4 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ghost card styles - maintenant unifié pour souris et tactile */
|
|
||||||
.ghost-card {
|
|
||||||
transition: none;
|
|
||||||
|
|
||||||
.card {
|
|
||||||
@apply shadow-2xl scale-95 rotate-6;
|
|
||||||
|
|
||||||
.play-button,
|
|
||||||
.card-body {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.flip-inner {
|
|
||||||
perspective: 1000px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,58 +1,328 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="platine pointer-events-none" :class="{ 'loading': platineStore.isLoadingTrack, 'mounted': isMounted }"
|
<div class="platine" :class="{ 'loading': isLoadingTrack, 'mounted': isMounted, 'playing': isPlaying }" ref="platine">
|
||||||
ref="platine">
|
<div v-if="false" class="debug">
|
||||||
<img class="cover" :src="platineStore.currentTrack?.coverId" />
|
<button @click="Reverse">
|
||||||
<div class="disc pointer-events-auto fixed bg-transparent" ref="discRef" id="disc">
|
<b v-if="isReversed">reversed</b>
|
||||||
<div class="bobine"
|
<b v-else>normal</b>
|
||||||
:style="{ height: platineStore.progressPercentage + '%', width: platineStore.progressPercentage + '%' }"></div>
|
</button>
|
||||||
|
<button @click="Rewind">
|
||||||
|
Rewind
|
||||||
<div class="disc-label rounded-full bg-cover bg-center">
|
</button>
|
||||||
<img src="/favicon.svg" class="size-1/2 bg-black rounded-full p-5">
|
<button @click="Wind">
|
||||||
<div v-if="platineStore.isLoadingTrack" class="loading-indicator">
|
Wind
|
||||||
<div class="spinner"></div>
|
</button>
|
||||||
|
<!-- <div>{{ progressPercentage }}</div> -->
|
||||||
|
<div>{{ (currentSpeed).toFixed(2) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="disc" ref="discRef" id="disc">
|
||||||
|
<div class="bobine" :style="{
|
||||||
|
height: progressPercentage + '%',
|
||||||
|
width: progressPercentage + '%'
|
||||||
|
}"></div>
|
||||||
|
<button class="power-button" @click="Power" @touchstart="Power" :disabled="isLoadingTrack">
|
||||||
|
<img class="Macaron" src="/favicon.svg" v-if="!isEndOfTrack">
|
||||||
|
<div class="spinner" v-if="isLoadingTrack" />
|
||||||
|
<img class="Macaron" @click="Rewind" src="/rewind.svg" v-if="isEndOfTrack" />
|
||||||
|
</button>
|
||||||
|
<div class="turn-point" v-if="!isLoadingTrack">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
import type { Card } from '~~/types/types'
|
||||||
import { usePlatineStore } from '~/store/platine'
|
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue'
|
||||||
import type { Track } from '~~/types/types'
|
import Disc from '~/utils/platine/disc'
|
||||||
|
import Sampler from '~/utils/platine/sampler'
|
||||||
|
import { duration } from 'drizzle-orm/gel-core';
|
||||||
|
|
||||||
const props = defineProps<{ track?: Track }>()
|
const props = defineProps<{ card?: Card, autoplay?: boolean }>()
|
||||||
const platineStore = usePlatineStore()
|
const autoplay = props.autoplay ?? false
|
||||||
|
|
||||||
|
// State
|
||||||
|
const isLoadingTrack = ref(false)
|
||||||
|
const isFirstPlay = ref(true)
|
||||||
|
const isEndOfTrack = ref(false)
|
||||||
|
|
||||||
|
const progressPercentage = ref(0)
|
||||||
|
const currentTurns = ref(0)
|
||||||
|
const totalTurns = ref(0)// Refs pour les instances
|
||||||
|
|
||||||
|
// Refs pour les instances
|
||||||
|
const disc = ref<Disc | null>(null)
|
||||||
|
const sampler = ref<Sampler | null>(null)
|
||||||
const discRef = ref<HTMLElement>()
|
const discRef = ref<HTMLElement>()
|
||||||
const platine = ref<HTMLElement>()
|
const platine = ref<HTMLElement>()
|
||||||
const isMounted = ref(false)
|
const isMounted = ref(false)
|
||||||
|
|
||||||
// Initialisation du lecteur
|
const isPlaying = computed(() => Math.abs(Math.round(sampler.value?.currentSpeed || 0)) !== 0)
|
||||||
onMounted(() => {
|
const isReversed = computed(() => disc.value?.isReversed || false)
|
||||||
isMounted.value = true
|
const currentSpeed = computed(() => sampler.value?.currentSpeed || 0)
|
||||||
if (discRef.value) {
|
|
||||||
platineStore.initPlatine(discRef.value)
|
// Actions
|
||||||
|
const initPlatine = (element: HTMLElement) => {
|
||||||
|
// console.log('[INIT] Platine')
|
||||||
|
discRef.value = element
|
||||||
|
disc.value = new Disc(element)
|
||||||
|
sampler.value = new Sampler()
|
||||||
|
|
||||||
|
// Configurer les callbacks du disque
|
||||||
|
if (disc.value) {
|
||||||
|
disc.value.callbacks.onStop = () => {
|
||||||
|
// console.log('[DISC] On Stop')
|
||||||
|
sampler.value?.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
disc.value.callbacks.onDragStart = () => {
|
||||||
|
// Activer le son à chaque fois qu'on glisse, pas seulement au premier play
|
||||||
|
if (sampler.value && disc.value) {
|
||||||
|
// On joue toujours le son quand on glisse, même après une pause
|
||||||
|
sampler.value.play(disc.value.secondsPlayed || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disc.value.callbacks.onLoop = ({ playbackSpeed, isReversed, secondsPlayed }) => {
|
||||||
|
// Ne mettre à jour que si nécessaire et s'assurer que la position est valide
|
||||||
|
if (Math.abs((sampler.value?.currentSpeed || 0) - playbackSpeed) > 0.01) {
|
||||||
|
const safeSecondsPlayed = Math.max(0, secondsPlayed)
|
||||||
|
sampler.value?.updateSpeed(playbackSpeed, isReversed, safeSecondsPlayed)
|
||||||
|
}
|
||||||
|
updateTurns()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTurns = () => {
|
||||||
|
if (!disc.value) return
|
||||||
|
|
||||||
|
const newTurns = disc.value.secondsPlayed * 0.75
|
||||||
|
const newTotalTurns = (disc.value as any)._duration * 0.75
|
||||||
|
|
||||||
|
// Calcul du pourcentage de progression pour l'affichage visuel (17% à 100%)
|
||||||
|
const minPercentage = 17
|
||||||
|
const maxPercentage = 100
|
||||||
|
const progressRatio = disc.value.secondsPlayed / (disc.value as any)._duration
|
||||||
|
const newProgressPercentage = minPercentage + (progressRatio * (maxPercentage - minPercentage))
|
||||||
|
|
||||||
|
// Ne mettre à jour que si les valeurs ont changé de manière significative
|
||||||
|
if (
|
||||||
|
Math.abs(currentTurns.value - newTurns) > 0.01 ||
|
||||||
|
Math.abs(totalTurns.value - newTotalTurns) > 0.01 ||
|
||||||
|
Math.abs(progressPercentage.value - newProgressPercentage) > 0.1
|
||||||
|
) {
|
||||||
|
currentTurns.value = newTurns
|
||||||
|
totalTurns.value = newTotalTurns
|
||||||
|
progressPercentage.value = newProgressPercentage
|
||||||
|
isEndOfTrack.value = disc.value.secondsPlayed + 0.1 >= (disc.value as any)._duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadCard = async (card: Card) => {
|
||||||
|
// console.log('[LOAD CARD]', card)
|
||||||
|
if (!sampler.value || !card) return
|
||||||
|
|
||||||
|
isLoadingTrack.value = true
|
||||||
|
// console.log(disc.value)
|
||||||
|
try {
|
||||||
|
await sampler.value.loadTrack(card.url_audio)
|
||||||
|
if (disc.value) {
|
||||||
|
// console.log('[DISC] Set Duration', sampler.value.duration)
|
||||||
|
disc.value.setDuration(sampler.value.duration)
|
||||||
|
updateTurns()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isLoadingTrack.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const play = (position = 0) => {
|
||||||
|
isFirstPlay.value = false
|
||||||
|
if (!disc.value || !sampler.value || !props.card) return
|
||||||
|
|
||||||
|
sampler.value.play(position)
|
||||||
|
disc.value.powerOn()
|
||||||
|
}
|
||||||
|
|
||||||
|
const pause = () => {
|
||||||
|
// console.log('[PAUSE]')
|
||||||
|
if (!disc.value || !sampler.value) return
|
||||||
|
|
||||||
|
sampler.value.pause()
|
||||||
|
disc.value.powerOff()
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (isPlaying.value) {
|
||||||
|
pause()
|
||||||
|
} else {
|
||||||
|
// Reprendre la lecture à la position actuelle
|
||||||
|
const currentPosition = disc.value?.secondsPlayed || 0
|
||||||
|
play(currentPosition)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
// Nettoyage
|
// Nettoyage
|
||||||
|
const cleanup = () => {
|
||||||
|
// console.log('[CLEANUP] Platine')
|
||||||
|
if (disc.value) {
|
||||||
|
disc.value.stop()
|
||||||
|
disc.value.powerOff()
|
||||||
|
}
|
||||||
|
if (sampler.value) {
|
||||||
|
sampler.value.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Power = (e: MouseEvent) => {
|
||||||
|
if (isEndOfTrack.value) {
|
||||||
|
Rewind()
|
||||||
|
}
|
||||||
|
togglePlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
const Reverse = () => {
|
||||||
|
if (!disc.value || !sampler.value) return
|
||||||
|
|
||||||
|
// Sauvegarder la position actuelle
|
||||||
|
const currentPosition = disc.value.secondsPlayed || 0
|
||||||
|
const wasPlaying = !disc.value.isStopped()
|
||||||
|
|
||||||
|
// Inverser la direction du disque et du sampler
|
||||||
|
disc.value.reverse()
|
||||||
|
sampler.value.reverse(wasPlaying ? currentPosition : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Rewind = async () => {
|
||||||
|
if (!disc.value || !sampler.value) return
|
||||||
|
|
||||||
|
// Sauvegarder l'état actuel
|
||||||
|
disc.value.isStopped() ? play() : null
|
||||||
|
const wasPlaying = !disc.value.isStopped()
|
||||||
|
const currentSpeed = disc.value.playbackSpeed
|
||||||
|
|
||||||
|
// Fonction pour l'effet de scratch/pull up
|
||||||
|
const scratchEffect = async () => {
|
||||||
|
// Ralentir progressivement
|
||||||
|
const slowDownDuration = 300 // ms
|
||||||
|
const startTime = performance.now()
|
||||||
|
const startSpeed = wasPlaying ? currentSpeed : 0.1
|
||||||
|
|
||||||
|
const slowDown = (timestamp: number) => {
|
||||||
|
const elapsed = timestamp - startTime
|
||||||
|
const progress = Math.min(elapsed / slowDownDuration, 1)
|
||||||
|
|
||||||
|
// Courbe d'accélération pour un effet plus naturel
|
||||||
|
const easeOut = 1 - Math.pow(1 - progress, 2)
|
||||||
|
|
||||||
|
// Ralentir progressivement jusqu'à presque l'arrêt
|
||||||
|
const newSpeed = startSpeed * (1 - easeOut) + 0.05
|
||||||
|
sampler.value?.setPlaybackRate(newSpeed)
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(slowDown)
|
||||||
|
} else {
|
||||||
|
// Une fois ralenti, effectuer le rembobinage
|
||||||
|
performRewind()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(slowDown)
|
||||||
|
play()
|
||||||
|
}
|
||||||
|
|
||||||
|
const performRewind = () => {
|
||||||
|
// Mettre en pause la lecture actuelle
|
||||||
|
if (wasPlaying) {
|
||||||
|
sampler.value?.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remettre à zéro la position
|
||||||
|
sampler.value?.play(0)
|
||||||
|
disc.value?.setAngle(0)
|
||||||
|
|
||||||
|
// Effet de scratch rapide
|
||||||
|
const scratchDuration = 200 // ms
|
||||||
|
const scratchStart = performance.now()
|
||||||
|
|
||||||
|
const scratch = (timestamp: number) => {
|
||||||
|
const elapsed = timestamp - scratchStart
|
||||||
|
const progress = Math.min(elapsed / scratchDuration, 1)
|
||||||
|
|
||||||
|
// Créer un effet de scratch en variant la vitesse
|
||||||
|
if (progress < 0.5) {
|
||||||
|
// Phase de scratch vers l'arrière
|
||||||
|
const scratchProgress = progress * 2
|
||||||
|
const scratchSpeed = 1 - (scratchProgress * 1.8) // Ralenti jusqu'à -0.8
|
||||||
|
sampler.value?.setPlaybackRate(scratchSpeed)
|
||||||
|
} else {
|
||||||
|
// Phase de pull up
|
||||||
|
const pullProgress = (progress - 0.5) * 2
|
||||||
|
const pullSpeed = -0.8 + (pullProgress * 1.8) // Accélère de -0.8 à 1.0
|
||||||
|
sampler.value?.setPlaybackRate(pullSpeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(scratch)
|
||||||
|
} else {
|
||||||
|
// Remettre la vitesse normale à la fin
|
||||||
|
sampler.value?.setPlaybackRate(1)
|
||||||
|
|
||||||
|
// Si c'était en lecture avant, relancer la lecture
|
||||||
|
if (wasPlaying) {
|
||||||
|
setTimeout(() => {
|
||||||
|
sampler.value?.play(0)
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(scratch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Démarrer l'effet
|
||||||
|
scratchEffect()
|
||||||
|
}
|
||||||
|
|
||||||
|
const Wind = () => {
|
||||||
|
disc.value.secondsPlayed = duration
|
||||||
|
sampler.value.secondsPlayed = duration
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === 'Space' || e.key === ' ' || e.keyCode === 32) {
|
||||||
|
e.preventDefault() // Empêcher le défilement de la page
|
||||||
|
togglePlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialisation du lecteur
|
||||||
|
onMounted(async () => {
|
||||||
|
isMounted.value = true
|
||||||
|
if (discRef.value) {
|
||||||
|
initPlatine(discRef.value)
|
||||||
|
await loadCard(props.card!)
|
||||||
|
if (autoplay) {
|
||||||
|
await nextTick()
|
||||||
|
play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Nettoyage de l'écouteur d'événement
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
isMounted.value = false
|
isMounted.value = false
|
||||||
platineStore.cleanup()
|
cleanup()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Surveillance des changements de piste
|
// Surveillance des changements de piste
|
||||||
watch(() => props.track, (newTrack) => {
|
watch(() => props.card, (newCard) => {
|
||||||
if (newTrack) {
|
// console.log('[WATCH] Card', newCard)
|
||||||
platineStore.loadTrack(newTrack)
|
if (newCard) {
|
||||||
|
loadCard(newCard)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -62,19 +332,14 @@ watch(() => props.track, (newTrack) => {
|
|||||||
.platine {
|
.platine {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
.card {
|
|
||||||
position: absolute !important;
|
|
||||||
top: -20%;
|
|
||||||
left: 50%;
|
|
||||||
bottom: 0;
|
|
||||||
transform: translate(-50%, 50%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.disc {
|
.disc {
|
||||||
|
pointer-events: auto;
|
||||||
|
position: absolute;
|
||||||
|
background-color: transparent;
|
||||||
position: relative;
|
position: relative;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -83,33 +348,57 @@ watch(() => props.track, (newTrack) => {
|
|||||||
cursor: grab;
|
cursor: grab;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.4);
|
padding: 14px;
|
||||||
|
|
||||||
.loading & {
|
.loading & {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.turn-point {
|
||||||
|
@apply absolute top-1/2 right-8 size-1/12 rounded-full bg-esyellow;
|
||||||
|
}
|
||||||
|
|
||||||
.disc.is-scratching {
|
.disc.is-scratching {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.disc-label {
|
.power-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
z-index: 100;
|
||||||
left: 50%;
|
cursor: pointer;
|
||||||
transform: translate(-50%, -50%);
|
transition: all 0.1s;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
transform-origin: center;
|
||||||
background-size: cover;
|
width: 33%;
|
||||||
width: 45%;
|
height: 33%;
|
||||||
aspect-ratio: 1/1;
|
border-radius: 100%;
|
||||||
// background: no-repeat url(/favicon.svg) center center;
|
top: 50%;
|
||||||
background-size: 30%;
|
left: 50%;
|
||||||
border-radius: 50%;
|
transform: translate(-50%, -50%);
|
||||||
cursor: pointer !important;
|
|
||||||
|
.Macaron {
|
||||||
|
position: absolute;
|
||||||
|
filter: grayscale(1);
|
||||||
|
transition: transform 0.1s, filter 0.8s;
|
||||||
|
@apply size-2/3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
@apply size-1/2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
.Macaron {
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.playing .power-button .Macaron {
|
||||||
|
filter: grayscale(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.disc-middle {
|
.disc-middle {
|
||||||
@@ -123,67 +412,14 @@ watch(() => props.track, (newTrack) => {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
|
||||||
border-radius: 0;
|
|
||||||
border: none;
|
|
||||||
background: rgb(69, 69, 69);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 0.4rem;
|
|
||||||
color: #fff;
|
|
||||||
line-height: 1.3;
|
|
||||||
cursor: pointer;
|
|
||||||
will-change: box-shadow;
|
|
||||||
transition:
|
|
||||||
box-shadow 0.2s ease-out,
|
|
||||||
transform 0.05s ease-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
.power.is-active {
|
|
||||||
transform: translate(1px, 2px);
|
|
||||||
color: red;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button[disabled] {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-indicator {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
border-radius: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
transition: opacity 3s ease;
|
|
||||||
|
|
||||||
.loading & {
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
border-top-color: #fff;
|
border-top-color: #fff;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
|
// @apply md:border-8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
@@ -193,6 +429,13 @@ watch(() => props.track, (newTrack) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bobine {
|
.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;
|
width: 17%;
|
||||||
|
height: 17%;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
@apply relative bg-black bg-opacity-60 backdrop-blur-sm rounded-full;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
94
app/components/PlayingCard.vue
Normal file
94
app/components/PlayingCard.vue
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<section class="playing-card" :class="{ 'platine-open': platineOpen }">
|
||||||
|
<Card :card="props.card!" :is-face-up="props.isFaceUp" @click="clickOnCard" :role="platineOpen ? 'img' : 'button'"
|
||||||
|
showPlayButtonFaceUp />
|
||||||
|
<Platine v-if="platineOpen && card" :card="card" autoplay />
|
||||||
|
<CloseButton v-if="platineOpen" @click="platineOpen = false" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Card } from '~~/types/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ card: Card, isFaceUp: boolean }>()
|
||||||
|
|
||||||
|
const platineOpen = ref(false)
|
||||||
|
|
||||||
|
const clickOnCard = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
platineOpen.value = !platineOpen.value
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
$open-speed: 0.4s;
|
||||||
|
|
||||||
|
.playing-card {
|
||||||
|
@apply size-card;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.card,
|
||||||
|
.platine {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
transition: width $open-speed, height $open-speed, min-width $open-speed,
|
||||||
|
min-height $open-speed, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.card) {
|
||||||
|
// transition: width $open-speed, height $open-speed, transform 0.1s;
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
.face-up,
|
||||||
|
.cover {
|
||||||
|
transition: border-radius $open-speed, box-shadow $open-speed;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
.play-button {
|
||||||
|
@apply scale-90;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.platine-open {
|
||||||
|
$open-size: 20rem;
|
||||||
|
width: $open-size;
|
||||||
|
height: $open-size;
|
||||||
|
min-height: $open-size;
|
||||||
|
min-width: $open-size;
|
||||||
|
|
||||||
|
.card {
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
@apply z-auto scale-100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:deep(.card) {
|
||||||
|
.face-up {
|
||||||
|
border-radius: 100%;
|
||||||
|
@apply shadow-xl;
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
border-radius: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank,
|
||||||
|
.play-button {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button,
|
||||||
|
.card-body,
|
||||||
|
.face-down {
|
||||||
|
display: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
24
app/components/Rank.vue
Normal file
24
app/components/Rank.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rank flex items-center justify-center size-7 absolute top-7 right-7">
|
||||||
|
<div class="suit text-7xl absolute" :class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.card?.suit]">
|
||||||
|
<img :src="`/${props.card?.suit}.svg`" />
|
||||||
|
</div>
|
||||||
|
<div class="rank text-white font-bold absolute -mt-1">
|
||||||
|
{{ props.card?.rank }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Card } from '~~/types/types';
|
||||||
|
|
||||||
|
const props = defineProps<{ card?: Card }>()
|
||||||
|
|
||||||
|
const isRedCard = computed(() => (props.card?.suit === '♥' || props.card?.suit === '♦'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rank {
|
||||||
|
transition: opacity 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
39
app/components/UI/CloseButton.vue
Normal file
39
app/components/UI/CloseButton.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<button ref="buttonRef">
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="SVGRepo_bgCarrier" stroke-width="0"></g>
|
||||||
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g>
|
||||||
|
<g id="SVGRepo_iconCarrier">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
d="M15.7071 5.29289C16.0976 5.68342 16.0976 6.31658 15.7071 6.70711L10.4142 12L15.7071 17.2929C16.0976 17.6834 16.0976 18.3166 15.7071 18.7071C15.3165 19.0976 14.6834 19.0976 14.2929 18.7071L8.46963 12.8839C7.98148 12.3957 7.98148 11.6043 8.46963 11.1161L14.2929 5.29289C14.6834 4.90237 15.3165 4.90237 15.7071 5.29289Z"
|
||||||
|
fill="#0F1729"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape' && buttonRef.value) {
|
||||||
|
buttonRef.value.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
button {
|
||||||
|
@apply bottom-4 md:top-4 left-4 size-20 fill-slate-600 bg-slate-500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
31
app/components/UI/PlayButton.vue
Normal file
31
app/components/UI/PlayButton.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<button tabindex="-1" class="play-button" :class="{ loading: isLoading }" :disabled="isLoading">
|
||||||
|
<template v-if="props.isLoading">
|
||||||
|
<img src="/loader.svg" alt="Chargement" class="size-16" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ props.isPlaying ? 'I I' : '▶' }}
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
isLoading?: boolean;
|
||||||
|
isPlaying?: boolean;
|
||||||
|
}>(), {
|
||||||
|
isLoading: false,
|
||||||
|
isPlaying: false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.play-button {
|
||||||
|
@apply pointer-events-none rounded-full size-24 flex items-center justify-center text-esyellow backdrop-blur-sm bg-black/25 transition-all duration-100 ease-in-out transform active:scale-90 scale-110 text-4xl font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.play-button-changed {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,18 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1
|
error
|
||||||
class="text-white pt-6 text-lg md:text-xl lg:text-2xl text-center font-bold tracking-widest text-shadow"
|
|
||||||
>
|
|
||||||
{{ error?.statusCode }}
|
|
||||||
</h1>
|
|
||||||
<NuxtLink to="/">Go back home</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { NuxtError } from '#app'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
error: Object as () => NuxtError
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -1,55 +1,3 @@
|
|||||||
<template>
|
<template>
|
||||||
<slot />
|
<slot />
|
||||||
<Bucket />
|
|
||||||
<Platine v-if="playerStore.currentTrack" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Track } from '~~/types/types'
|
|
||||||
import { usePlayerStore } from '~/store/player'
|
|
||||||
|
|
||||||
const playerStore = usePlayerStore()
|
|
||||||
const onCardDropped = (card: Track) => {
|
|
||||||
console.log('Carte déposée dans le bucket:', card)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.bucket,
|
|
||||||
.platine {
|
|
||||||
position: fixed;
|
|
||||||
bottom: -100%;
|
|
||||||
right: 0;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bucket {
|
|
||||||
z-index: 70;
|
|
||||||
bottom: -260px;
|
|
||||||
width: 100%;
|
|
||||||
overflow-x: scroll;
|
|
||||||
transition: bottom .3s ease;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
.card-dragging & {
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bucket-card-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.platine {
|
|
||||||
bottom: -100%;
|
|
||||||
transition: bottom 2s ease;
|
|
||||||
|
|
||||||
&.mounted {
|
|
||||||
z-index: 80;
|
|
||||||
bottom: 0;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
max-width: 450px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
24
app/pages/card/[slug].vue
Normal file
24
app/pages/card/[slug].vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<PlayingCard :card :isFaceUp />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Card } from '~~/types/types'
|
||||||
|
|
||||||
|
const isFaceUp = ref(false)
|
||||||
|
const route = useRoute()
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
const { data: card, pending, error } = await useFetch<Card>(`/api/card/${slug}`)
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: computed(() =>
|
||||||
|
card.value ? `${card.value.artist} - ${card.value.title}` : 'Loading...'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
isFaceUp.value = true
|
||||||
|
}, 700)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,27 +1,62 @@
|
|||||||
<template>
|
<template>
|
||||||
<boxes />
|
<section class="deck">
|
||||||
|
<PlayingCard v-for="(card, index) in cards" :key="index" :card="card" :isFaceUp="isFaceUp[index] || false"
|
||||||
|
@click="isFaceUp[index] = true" />
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { useUiStore } from '~/store/ui'
|
import type { Card } from '~~/types/types'
|
||||||
import { useDataStore } from '~/store/data'
|
|
||||||
|
|
||||||
// Configuration du layout
|
const nbCards = Math.floor(Math.random() * 14) + 1 // Nombre de cartes à afficher (aléatoire entre 1 et 15)
|
||||||
definePageMeta({
|
const cards = ref<Card[]>([])
|
||||||
layout: 'default'
|
const isFaceUp = ref<boolean[]>([])
|
||||||
|
|
||||||
|
// Chargement des cartes
|
||||||
|
const loadCards = async () => {
|
||||||
|
try {
|
||||||
|
const promises = Array(nbCards).fill(0).map(() =>
|
||||||
|
$fetch<Card>('/api/card/random')
|
||||||
|
)
|
||||||
|
const results = await Promise.all(promises)
|
||||||
|
cards.value = results
|
||||||
|
isFaceUp.value = new Array(nbCards).fill(false)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading cards:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animation de retournement des cartes
|
||||||
|
const flipCards = () => {
|
||||||
|
isFaceUp.value.forEach((_, index) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
isFaceUp.value[index] = false
|
||||||
|
}, 400 * (index + 1))
|
||||||
})
|
})
|
||||||
|
}
|
||||||
const uiStore = useUiStore()
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const dataStore = useDataStore()
|
await loadCards()
|
||||||
await dataStore.loadData()
|
setTimeout(flipCards, 600)
|
||||||
uiStore.listBoxes()
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: computed(() =>
|
||||||
|
cards.value[0] ? `${cards.value[0].artist} - ${cards.value[0].title}` : 'Loading...'
|
||||||
|
)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style lang="scss" scoped>
|
||||||
.logo {
|
.deck {
|
||||||
filter: drop-shadow(3px 3px 0 rgb(0 0 0 / 0.7));
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-height: 100vh;
|
||||||
|
|
||||||
|
.playing-card {
|
||||||
|
margin: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
23
app/pages/random.vue
Normal file
23
app/pages/random.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<PlayingCard :card :isFaceUp />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Card } from '~~/types/types'
|
||||||
|
|
||||||
|
const { data: card, pending, error } = await useFetch<Card>('/api/card/random')
|
||||||
|
|
||||||
|
const isFaceUp = ref(false)
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: computed(() =>
|
||||||
|
card.value ? `${card.value.artist} - ${card.value.title}` : 'Loading...'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
isFaceUp.value = true
|
||||||
|
}, 700)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
39
app/utils/colors.ts
Normal file
39
app/utils/colors.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export const getYearColor = (year: number): string => {
|
||||||
|
// Palette élargie avec des différences plus marquées
|
||||||
|
const colorMap: Record<number, string> = {
|
||||||
|
// Années récentes - teintes froides et claires
|
||||||
|
2025: '#3a4a6c', // bleu-gris clair
|
||||||
|
2024: '#1e3a7a', // bleu vif
|
||||||
|
2023: '#1a4d5c', // bleu-vert émeraude
|
||||||
|
2022: '#3a4a6a', // bleu-gris moyen
|
||||||
|
|
||||||
|
// Années 2020-2021 - transition
|
||||||
|
2021: '#3a2e6a', // bleu-violet
|
||||||
|
2020: '#2a467a', // bleu-gris chaud
|
||||||
|
|
||||||
|
// Années 2010-2019 - teintes moyennes
|
||||||
|
2019: '#2a2a7a', // bleu nuit profond
|
||||||
|
2018: '#1e2a8a', // bleu roi
|
||||||
|
2017: '#1a5a6a', // bleu canard vif
|
||||||
|
2016: '#1a5a4a', // vert bleuté
|
||||||
|
2015: '#1a3a7a', // bleu marine
|
||||||
|
2014: '#4a1e7a', // violet profond
|
||||||
|
2013: '#1a5a4a', // vert émeraude
|
||||||
|
2012: '#1e3a9a', // bleu ciel profond
|
||||||
|
|
||||||
|
// Années 2000-2011 - teintes chaudes et foncées
|
||||||
|
2011: '#1e293b', // slate-800 de base
|
||||||
|
2010: '#2a467a', // bleu-gris chaud
|
||||||
|
2009: '#3a4a6a', // bleu-gris moyen
|
||||||
|
2008: '#1a3a8a', // bleu nuit clair
|
||||||
|
2007: '#5a2a4a', // bordeaux
|
||||||
|
2006: '#5a1e6a', // violet profond
|
||||||
|
2005: '#3a1a7a', // bleu-violet foncé
|
||||||
|
2004: '#2a1a5a', // bleu nuit profond
|
||||||
|
2003: '#3a3a5a', // bleu-gris foncé
|
||||||
|
2002: '#1a5a4a', // vert foncé
|
||||||
|
2001: '#5a3a2a', // marron chaud
|
||||||
|
2000: '#3a3a5a' // bleu-gris foncé
|
||||||
|
}
|
||||||
|
return colorMap[year] || '#1e293b' // slate-800 par défaut
|
||||||
|
}
|
||||||
@@ -74,15 +74,17 @@ class Disc {
|
|||||||
private _inertiaVelocity: number = 0
|
private _inertiaVelocity: number = 0
|
||||||
private _isInertiaActive: boolean = false
|
private _isInertiaActive: boolean = false
|
||||||
private _basePlaybackSpeed: number = 1 // Vitesse de lecture normale
|
private _basePlaybackSpeed: number = 1 // Vitesse de lecture normale
|
||||||
private _inertiaFriction: number = 0.93 // Coefficient de frottement pour l'inertie (plus proche de 1 = plus long)
|
private _inertiaFriction: number = 1 // Coefficient de frottement pour l'inertie (plus proche de 1 = plus long)
|
||||||
private _lastDragVelocity: number = 0 // Dernière vitesse de drag
|
private _lastDragVelocity: number = 0 // Dernière vitesse de drag
|
||||||
private _lastDragTime: number = 0 // Dernier temps de drag
|
private _lastDragTime: number = 0 // Dernier temps de drag
|
||||||
private _inertiaAmplification: number = 25 // Facteur d'amplification de l'inertie
|
private _inertiaAmplification: number = 25 // Facteur d'amplification de l'inertie
|
||||||
|
private _previousDuration: number = 0 // Pour suivre les changements de durée
|
||||||
|
|
||||||
public isReversed: boolean = false
|
public isReversed: boolean = false
|
||||||
|
|
||||||
public callbacks = {
|
public callbacks = {
|
||||||
onDragStart: (): void => {},
|
onDragStart: (): void => {},
|
||||||
|
onDragProgress: (): void => {},
|
||||||
onDragEnded: (secondsPlayed: number): void => {},
|
onDragEnded: (secondsPlayed: number): void => {},
|
||||||
onStop: (): void => {},
|
onStop: (): void => {},
|
||||||
onLoop: (params: DiscProgress): void => {}
|
onLoop: (params: DiscProgress): void => {}
|
||||||
@@ -129,7 +131,7 @@ class Disc {
|
|||||||
this._draggingSpeeds = limit(this._draggingSpeeds, 10)
|
this._draggingSpeeds = limit(this._draggingSpeeds, 10)
|
||||||
|
|
||||||
this._playbackSpeed = average(this._draggingSpeeds)
|
this._playbackSpeed = average(this._draggingSpeeds)
|
||||||
this._playbackSpeed = clamp(this._playbackSpeed, -4, 4)
|
// this._playbackSpeed = clamp(this._playbackSpeed, -4, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
get secondsPlayed() {
|
get secondsPlayed() {
|
||||||
@@ -146,12 +148,9 @@ class Disc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
powerOn() {
|
powerOn() {
|
||||||
if (!this.rafId) {
|
|
||||||
this.start()
|
|
||||||
}
|
|
||||||
this._isPoweredOn = true
|
this._isPoweredOn = true
|
||||||
this._basePlaybackSpeed = 1
|
this._basePlaybackSpeed = this.isReversed ? -1 : 1
|
||||||
this._playbackSpeed = 1
|
this.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
powerOff() {
|
powerOff() {
|
||||||
@@ -160,11 +159,21 @@ class Disc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public setDuration(duration: number) {
|
public setDuration(duration: number) {
|
||||||
|
this._previousDuration = this._duration
|
||||||
this._duration = duration
|
this._duration = duration
|
||||||
this._maxAngle = duration * RPS * TAU
|
this._maxAngle = duration * RPS * TAU
|
||||||
}
|
}
|
||||||
|
|
||||||
onDragStart(e: PointerEvent | TouchEvent) {
|
onDragStart(e: PointerEvent | TouchEvent) {
|
||||||
|
// Vérifier si l'événement provient d'un élément avec la classe 'power-button' ou 'power-logo'
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (target.closest('.power-button, .power-logo')) {
|
||||||
|
// Ne rien faire si l'événement provient du bouton power
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Empêcher le comportement par défaut pour éviter le défilement
|
// Empêcher le comportement par défaut pour éviter le défilement
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
@@ -239,15 +248,18 @@ class Disc {
|
|||||||
|
|
||||||
const anglePointerToCenter = angleBetween(this._center, pointerPosition)
|
const anglePointerToCenter = angleBetween(this._center, pointerPosition)
|
||||||
const angle_DraggingFromToCenter = angleBetween(this._center, this._draggingFrom)
|
const angle_DraggingFromToCenter = angleBetween(this._center, this._draggingFrom)
|
||||||
const angleDragged = angleDifference(angle_DraggingFromToCenter, anglePointerToCenter)
|
let angleDragged = angleDifference(angle_DraggingFromToCenter, anglePointerToCenter)
|
||||||
|
|
||||||
// Calcul de la vitesse de déplacement angulaire (radians par milliseconde)
|
// Calcul de la vitesse de déplacement angulaire (radians par milliseconde)
|
||||||
// On inverse le signe pour que le sens de l'inertie soit naturel
|
// Le signe est inversé pour que le glissement vers la droite fasse tourner vers la droite
|
||||||
if (deltaTime > 0) {
|
if (deltaTime > 0) {
|
||||||
this._lastDragVelocity = -angleDragged / deltaTime
|
this._lastDragVelocity = -angleDragged / deltaTime
|
||||||
}
|
}
|
||||||
|
|
||||||
this._lastDragTime = currentTime
|
this._lastDragTime = currentTime
|
||||||
|
|
||||||
|
// Appliquer la rotation au disque
|
||||||
|
// Le signe est inversé pour que le glissement vers la droite fasse tourner vers la droite
|
||||||
this.setAngle(this._currentAngle - angleDragged)
|
this.setAngle(this._currentAngle - angleDragged)
|
||||||
this._draggingFrom = { ...pointerPosition }
|
this._draggingFrom = { ...pointerPosition }
|
||||||
}
|
}
|
||||||
@@ -258,12 +270,15 @@ class Disc {
|
|||||||
|
|
||||||
// Activer l'inertie avec la vitesse de drag actuelle
|
// Activer l'inertie avec la vitesse de drag actuelle
|
||||||
this._isInertiaActive = true
|
this._isInertiaActive = true
|
||||||
// Augmenter la sensibilité du drag avec le facteur d'amplification
|
|
||||||
this._inertiaVelocity = this._lastDragVelocity * this._inertiaAmplification
|
// Ajuster la direction de l'inertie en fonction du mode reverse
|
||||||
|
const direction = this.isReversed ? -1 : 1
|
||||||
|
this._inertiaVelocity = this._lastDragVelocity * this._inertiaAmplification * direction
|
||||||
|
|
||||||
this.isDragging = false
|
this.isDragging = false
|
||||||
|
|
||||||
// Toujours conserver la vitesse de base actuelle (1 si allumé, 0 si éteint)
|
// Toujours conserver la vitesse de base actuelle (1 si allumé, 0 si éteint)
|
||||||
this._basePlaybackSpeed = this._isPoweredOn ? 1 : 0
|
this._basePlaybackSpeed = this._isPoweredOn ? (this.isReversed ? -1 : 1) : 0
|
||||||
|
|
||||||
// Si le lecteur est éteint, s'assurer que la vitesse de base est bien à 0
|
// Si le lecteur est éteint, s'assurer que la vitesse de base est bien à 0
|
||||||
if (!this._isPoweredOn) {
|
if (!this._isPoweredOn) {
|
||||||
@@ -275,25 +290,41 @@ class Disc {
|
|||||||
|
|
||||||
autoRotate(currentTimestamp: number) {
|
autoRotate(currentTimestamp: number) {
|
||||||
const timestampElapsed = currentTimestamp - this.previousTimestamp
|
const timestampElapsed = currentTimestamp - this.previousTimestamp
|
||||||
|
const direction = this.isReversed ? -1 : 1
|
||||||
|
|
||||||
|
// Vérifier si on est au début du morceau en mode reverse
|
||||||
|
if (this.isReversed && this.secondsPlayed <= 0) {
|
||||||
|
this._currentAngle = 0
|
||||||
|
this._inertiaVelocity = 0
|
||||||
|
this._isInertiaActive = false
|
||||||
|
this._playbackSpeed = 0
|
||||||
|
this._basePlaybackSpeed = 0 // Arrêter complètement la lecture
|
||||||
|
this.el.style.transform = 'rotate(0rad)'
|
||||||
|
this.callbacks.onStop()
|
||||||
|
|
||||||
|
// Éteindre le lecteur pour éviter toute reprise automatique
|
||||||
|
this._isPoweredOn = false
|
||||||
|
this.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (this._isInertiaActive) {
|
if (this._isInertiaActive) {
|
||||||
// Appliquer l'inertie
|
// Appliquer l'inertie en tenant compte de la direction
|
||||||
const inertiaRotation = this._inertiaVelocity * timestampElapsed
|
const inertiaRotation = this._inertiaVelocity * timestampElapsed * direction
|
||||||
this.setAngle(this._currentAngle + inertiaRotation)
|
this.setAngle(this._currentAngle + inertiaRotation, true)
|
||||||
|
|
||||||
// Si le lecteur est allumé, faire une transition fluide vers la vitesse de lecture
|
// Si le lecteur est allumé, faire une transition fluide vers la vitesse de lecture
|
||||||
if (this._isPoweredOn) {
|
if (this._isPoweredOn) {
|
||||||
|
const targetVelocity =
|
||||||
|
RADIANS_PER_MILLISECOND * Math.abs(this._basePlaybackSpeed) * direction
|
||||||
|
|
||||||
// Si on est proche de la vitesse de lecture normale, on désactive l'inertie
|
// Si on est proche de la vitesse de lecture normale, on désactive l'inertie
|
||||||
if (
|
if (Math.abs(this._inertiaVelocity - targetVelocity) < 0.0001) {
|
||||||
Math.abs(this._inertiaVelocity - RADIANS_PER_MILLISECOND * this._basePlaybackSpeed) <
|
|
||||||
0.0001
|
|
||||||
) {
|
|
||||||
this._isInertiaActive = false
|
this._isInertiaActive = false
|
||||||
this._inertiaVelocity = RADIANS_PER_MILLISECOND * this._basePlaybackSpeed
|
this._inertiaVelocity = targetVelocity
|
||||||
} else {
|
} else {
|
||||||
// Réduire progressivement la vitesse d'inertie vers la vitesse de lecture
|
// Réduire progressivement la vitesse d'inertie vers la vitesse de lecture
|
||||||
this._inertiaVelocity +=
|
this._inertiaVelocity += (targetVelocity - this._inertiaVelocity) * 0.1
|
||||||
(RADIANS_PER_MILLISECOND * this._basePlaybackSpeed - this._inertiaVelocity) * 0.1
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Si le lecteur est éteint, appliquer un frottement normal
|
// Si le lecteur est éteint, appliquer un frottement normal
|
||||||
@@ -303,18 +334,61 @@ class Disc {
|
|||||||
if (Math.abs(this._inertiaVelocity) < 0.0001) {
|
if (Math.abs(this._inertiaVelocity) < 0.0001) {
|
||||||
this._isInertiaActive = false
|
this._isInertiaActive = false
|
||||||
this._inertiaVelocity = 0
|
this._inertiaVelocity = 0
|
||||||
this._playbackSpeed = 0 // Mettre à jour la vitesse de lecture à 0 uniquement à la fin
|
this._playbackSpeed = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Rotation normale à la vitesse de lecture de base
|
// Rotation normale à la vitesse de lecture de base, dans la direction actuelle
|
||||||
const baseRotation = RADIANS_PER_MILLISECOND * this._basePlaybackSpeed * timestampElapsed
|
const baseRotation =
|
||||||
this.setAngle(this._currentAngle + baseRotation)
|
RADIANS_PER_MILLISECOND * Math.abs(this._basePlaybackSpeed) * timestampElapsed * direction
|
||||||
|
this.setAngle(this._currentAngle + baseRotation, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setAngle(angle: number) {
|
setAngle(angle: number, checkBounds = false) {
|
||||||
this._currentAngle = clamp(angle, 0, this._maxAngle)
|
// Vérifier les limites si demandé
|
||||||
|
if (checkBounds) {
|
||||||
|
// Arrêt au début (angle < 0)
|
||||||
|
if (angle < 0) {
|
||||||
|
this._currentAngle = 0
|
||||||
|
this._inertiaVelocity = 0
|
||||||
|
this._isInertiaActive = false
|
||||||
|
this._playbackSpeed = 0
|
||||||
|
this._basePlaybackSpeed = 1
|
||||||
|
this.el.style.transform = 'rotate(0rad)'
|
||||||
|
// this.callbacks.onStop()
|
||||||
|
// this._isPoweredOn = false
|
||||||
|
// this.stop()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// Arrêt à la fin (angle >= _maxAngle)
|
||||||
|
else if (angle >= this._maxAngle) {
|
||||||
|
this._currentAngle = this._maxAngle
|
||||||
|
this._inertiaVelocity = 0
|
||||||
|
this._isInertiaActive = false
|
||||||
|
this._playbackSpeed = 0
|
||||||
|
this._basePlaybackSpeed = 0
|
||||||
|
this.el.style.transform = `rotate(${this._maxAngle}rad)`
|
||||||
|
this.callbacks.onStop()
|
||||||
|
this._isPoweredOn = false
|
||||||
|
this.stop()
|
||||||
|
return this._maxAngle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si on dépasse les limites, on reste aux bornes
|
||||||
|
if (angle < 0) {
|
||||||
|
this._currentAngle = 0
|
||||||
|
} else if (angle > this._maxAngle) {
|
||||||
|
this._currentAngle = this._maxAngle
|
||||||
|
} else {
|
||||||
|
this._currentAngle = angle
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appliquer la rotation à l'élément
|
||||||
|
if (this.el) {
|
||||||
|
this.el.style.transform = `rotate(${this._currentAngle}rad)`
|
||||||
|
}
|
||||||
|
|
||||||
return this._currentAngle
|
return this._currentAngle
|
||||||
}
|
}
|
||||||
@@ -326,55 +400,88 @@ class Disc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
if (this.rafId) {
|
this._isInertiaActive = false
|
||||||
cancelAnimationFrame(this.rafId)
|
this._inertiaVelocity = 0
|
||||||
|
cancelAnimationFrame(this.rafId!)
|
||||||
this.rafId = null
|
this.rafId = null
|
||||||
}
|
}
|
||||||
this.callbacks.onStop()
|
|
||||||
|
/**
|
||||||
|
* Vérifie si le disque est à l'arrêt
|
||||||
|
*/
|
||||||
|
isStopped(): boolean {
|
||||||
|
return this.rafId === null && !this._isInertiaActive
|
||||||
}
|
}
|
||||||
|
|
||||||
rewind() {
|
/**
|
||||||
this.setAngle(0)
|
* Inverse la direction de rotation du disque
|
||||||
|
* @returns true si l'inversion a réussi, false sinon
|
||||||
|
*/
|
||||||
|
reverse(): boolean {
|
||||||
|
if (!this.el) return false
|
||||||
|
|
||||||
|
// Inverser la direction
|
||||||
|
this.isReversed = !this.isReversed
|
||||||
|
|
||||||
|
// Inverser la vitesse de base si nécessaire
|
||||||
|
if (this._isPoweredOn) {
|
||||||
|
this._basePlaybackSpeed = this.isReversed ? -1 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour la direction de l'animation
|
||||||
|
this.el.style.animationDirection = this.isReversed ? 'reverse' : 'normal'
|
||||||
|
|
||||||
|
// Inverser la vitesse d'inertie si elle est active
|
||||||
|
if (this._isInertiaActive) {
|
||||||
|
this._inertiaVelocity = -this._inertiaVelocity
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
loop() {
|
loop() {
|
||||||
const currentTimestamp = performance.now()
|
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 timestampDifferenceMS = currentTimestamp - this.previousTimestamp
|
||||||
|
|
||||||
|
// Ne mettre à jour la rotation que si le lecteur est allumé et pas en train de glisser
|
||||||
|
if (this._isPoweredOn && !this.isDragging) {
|
||||||
|
this.autoRotate(currentTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculer la vitesse de lecture uniquement pendant le glissement ou l'inertie
|
||||||
|
if (this.isDragging || this._isInertiaActive) {
|
||||||
const rotated = this._currentAngle - this._previousAngle
|
const rotated = this._currentAngle - this._previousAngle
|
||||||
const rotationNormal = RADIANS_PER_MILLISECOND * timestampDifferenceMS
|
const rotationNormal = RADIANS_PER_MILLISECOND * timestampDifferenceMS
|
||||||
|
|
||||||
this.playbackSpeed = rotated / rotationNormal || 0
|
this.playbackSpeed = rotated / rotationNormal || 0
|
||||||
this.isReversed = this._currentAngle < this._previousAngle
|
} else if (this._isPoweredOn) {
|
||||||
|
// En mode lecture normale, utiliser la vitesse de base
|
||||||
this._previousAngle = this._currentAngle
|
this._playbackSpeed = this._basePlaybackSpeed
|
||||||
this.previousTimestamp = performance.now()
|
} else {
|
||||||
|
// Si le lecteur est éteint, vitesse à 0
|
||||||
|
this._playbackSpeed = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour la rotation visuelle
|
||||||
this.el.style.transform = `rotate(${this._currentAngle}rad)`
|
this.el.style.transform = `rotate(${this._currentAngle}rad)`
|
||||||
|
|
||||||
const { playbackSpeed, isReversed, secondsPlayed, _duration } = this
|
// Appeler le callback onLoop avec les informations de lecture
|
||||||
const progress = secondsPlayed / _duration
|
const secondsPlayed = this.secondsPlayed
|
||||||
|
const progress = this._duration > 0 ? secondsPlayed / this._duration : 0
|
||||||
|
|
||||||
|
// Ne pas appeler onLoop si rien n'a changé
|
||||||
|
if (this._previousAngle !== this._currentAngle || this._previousDuration !== this._duration) {
|
||||||
this.callbacks.onLoop({
|
this.callbacks.onLoop({
|
||||||
playbackSpeed,
|
playbackSpeed: this._playbackSpeed, // Utiliser _playbackSpeed directement
|
||||||
isReversed,
|
isReversed: this.isReversed,
|
||||||
secondsPlayed,
|
secondsPlayed,
|
||||||
progress
|
progress
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
this._previousAngle = this._currentAngle
|
this._previousAngle = this._currentAngle
|
||||||
|
this.previousTimestamp = currentTimestamp
|
||||||
|
|
||||||
|
// Continuer la boucle d'animation
|
||||||
this.rafId = requestAnimationFrame(this.loop)
|
this.rafId = requestAnimationFrame(this.loop)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ class Sampler {
|
|||||||
|
|
||||||
public duration: number = 0
|
public duration: number = 0
|
||||||
public isReversed: boolean = false
|
public isReversed: boolean = false
|
||||||
|
public currentSpeed: number = 0
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.gainNode.connect(this.audioContext.destination)
|
this.gainNode.connect(this.audioContext.destination)
|
||||||
@@ -44,16 +45,24 @@ class Sampler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
changeDirection(isReversed: boolean, secondsPlayed: number) {
|
changeDirection(isReversed: boolean, secondsPlayed: number) {
|
||||||
|
// S'assurer que la position est dans les limites
|
||||||
|
const safePosition = Math.max(0, Math.min(secondsPlayed, this.duration))
|
||||||
this.isReversed = isReversed
|
this.isReversed = isReversed
|
||||||
this.play(secondsPlayed)
|
this.play(safePosition)
|
||||||
}
|
}
|
||||||
|
|
||||||
play(offset = 0) {
|
play(offset = 0) {
|
||||||
this.pause()
|
this.pause()
|
||||||
|
|
||||||
|
if (!this.audioBuffer || !this.audioBufferReversed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const buffer = this.isReversed ? this.audioBufferReversed : this.audioBuffer
|
const buffer = this.isReversed ? this.audioBufferReversed : this.audioBuffer
|
||||||
|
|
||||||
const cueTime = this.isReversed ? this.duration - offset : offset
|
// S'assurer que l'offset est dans les limites
|
||||||
|
const safeOffset = Math.max(0, Math.min(offset, this.duration))
|
||||||
|
const cueTime = this.isReversed ? this.duration - safeOffset : safeOffset
|
||||||
|
|
||||||
this.audioSource = this.audioContext.createBufferSource()
|
this.audioSource = this.audioContext.createBufferSource()
|
||||||
this.audioSource.buffer = buffer
|
this.audioSource.buffer = buffer
|
||||||
@@ -61,7 +70,9 @@ class Sampler {
|
|||||||
|
|
||||||
this.audioSource.connect(this.gainNode)
|
this.audioSource.connect(this.gainNode)
|
||||||
|
|
||||||
this.audioSource.start(0, cueTime)
|
// S'assurer que le cueTime n'est pas négatif
|
||||||
|
const startTime = Math.max(0, cueTime)
|
||||||
|
this.audioSource.start(0, startTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSpeed(speed: number, isReversed: boolean, secondsPlayed: number) {
|
updateSpeed(speed: number, isReversed: boolean, secondsPlayed: number) {
|
||||||
@@ -69,6 +80,9 @@ class Sampler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mettre à jour la vitesse actuelle
|
||||||
|
this.currentSpeed = speed
|
||||||
|
|
||||||
if (isReversed !== this.isReversed) {
|
if (isReversed !== this.isReversed) {
|
||||||
this.changeDirection(isReversed, secondsPlayed)
|
this.changeDirection(isReversed, secondsPlayed)
|
||||||
}
|
}
|
||||||
@@ -90,6 +104,52 @@ class Sampler {
|
|||||||
|
|
||||||
this.audioSource.stop()
|
this.audioSource.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inverse la direction de lecture
|
||||||
|
* @returns La nouvelle direction (true = inversé, false = normal)
|
||||||
|
*/
|
||||||
|
reverse(secondsPlayed: number = 0): boolean {
|
||||||
|
if (!this.audioBuffer) return false
|
||||||
|
|
||||||
|
// Inverser la direction
|
||||||
|
this.isReversed = !this.isReversed
|
||||||
|
|
||||||
|
// Si on a une position, on relance la lecture à cette position
|
||||||
|
if (secondsPlayed > 0) {
|
||||||
|
// S'assurer que la position est dans les limites
|
||||||
|
const safePosition = Math.max(0, Math.min(secondsPlayed, this.duration))
|
||||||
|
this.play(safePosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.isReversed
|
||||||
|
}
|
||||||
|
|
||||||
|
mute() {
|
||||||
|
this.gainNode.gain.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
unmute() {
|
||||||
|
this.gainNode.gain.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Définit le taux de lecture (vitesse de lecture)
|
||||||
|
* @param rate Taux de lecture (1.0 = vitesse normale, 0.5 = moitié de vitesse, 2.0 = double vitesse, etc.)
|
||||||
|
*/
|
||||||
|
setPlaybackRate(rate: number) {
|
||||||
|
if (!this.audioSource) return
|
||||||
|
|
||||||
|
const currentTime = this.audioContext.currentTime
|
||||||
|
this.audioSource.playbackRate.cancelScheduledValues(currentTime)
|
||||||
|
this.audioSource.playbackRate.linearRampToValueAtTime(
|
||||||
|
Math.max(0.001, Math.abs(rate)), // Éviter les valeurs négatives ou nulles
|
||||||
|
currentTime + 0.05 // Petit délai pour éviter les clics
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mettre à jour la vitesse actuelle
|
||||||
|
this.currentSpeed = rate
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Sampler
|
export default Sampler
|
||||||
82
appOLDD/app.vue
Normal file
82
appOLDD/app.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NuxtRouteAnnouncer />
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useUiStore } from '~/store/ui'
|
||||||
|
import { usePlayerStore } from '~/store/player'
|
||||||
|
import { watch, computed } from 'vue'
|
||||||
|
|
||||||
|
const ui = useUiStore()
|
||||||
|
const player = usePlayerStore()
|
||||||
|
useHead({
|
||||||
|
bodyAttrs: {
|
||||||
|
class: 'bg-slate-100'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => player.currentTrack?.id,
|
||||||
|
(id) => {
|
||||||
|
if (!id) {
|
||||||
|
if (route.name === 'track-id') router.replace({ path: '/' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentParam = Number(
|
||||||
|
Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||||
|
)
|
||||||
|
if (route.name === 'track-id' && currentParam === id) return
|
||||||
|
router.replace({ name: 'track-id', params: { id } })
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keep URL in sync with selected box: /box/:id when a box is selected, back to / when none
|
||||||
|
const selectedBoxId = computed(() => ui.getSelectedBox?.id)
|
||||||
|
watch(
|
||||||
|
() => selectedBoxId.value,
|
||||||
|
(id) => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
if (!id) {
|
||||||
|
// Back to root path without navigation to preserve UI state/animations
|
||||||
|
if (location.pathname.startsWith('/box/')) {
|
||||||
|
history.replaceState(null, '', '/')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentId = location.pathname.startsWith('/box/')
|
||||||
|
? location.pathname.split('/').pop()
|
||||||
|
: null
|
||||||
|
if (currentId === id) return
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
history.replaceState(null, '', `/box/${id}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
@apply px-4 py-2 m-4 rounded-md text-center font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='email'] {
|
||||||
|
@apply bg-slate-900 text-esyellow;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
user-drag: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
||||||
import type { Box } from '~~/types/types'
|
import type { Box } from '~~/types'
|
||||||
import { useDataStore } from '~/store/data'
|
import { useDataStore } from '~/store/data'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
9
appOLDD/components/Boxes.vue
Normal file
9
appOLDD/components/Boxes.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
wait for it
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
console.log('wait for it')
|
||||||
|
</script>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Box } from '~~/types/types'
|
import type { Box } from '~~/types'
|
||||||
import { useDataStore } from '~/store/data'
|
import { useDataStore } from '~/store/data'
|
||||||
import { usePlayerStore } from '~/store/player'
|
import { usePlayerStore } from '~/store/player'
|
||||||
import { useUiStore } from '~/store/ui'
|
import { useUiStore } from '~/store/ui'
|
||||||
519
appOLDD/components/Card.vue
Normal file
519
appOLDD/components/Card.vue
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
<template>
|
||||||
|
<article v-bind="attrs" :role="props.role" :draggable="isFaceUp" :class="[
|
||||||
|
'card cursor-pointer',
|
||||||
|
isFaceUp ? 'face-up' : 'face-down',
|
||||||
|
{ 'current-track': playerStore.currentTrack?.id === track.id },
|
||||||
|
{ 'is-dragging': isDragging }
|
||||||
|
]" :tabindex="props.tabindex" :aria-disabled="false" @click.stop="handleClick" @keydown.enter.stop="handleClick"
|
||||||
|
@keydown.space.prevent.stop="handleClick" @dragstart="handleDragStart" @dragend="handleDragEnd"
|
||||||
|
@drag="handleDragMove" @touchstart.passive="!isFaceUp" @touchmove.passive="!isFaceUp">
|
||||||
|
<div class="flip-inner" ref="cardElement">
|
||||||
|
<!-- Face-Up -->
|
||||||
|
<main
|
||||||
|
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 hover:shadow-xl hover:scale-110 transition-all rounded-2xl shadow-lg flex flex-col overflow-hidden">
|
||||||
|
|
||||||
|
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 right-7"
|
||||||
|
@click.stop="clickCardSymbol">
|
||||||
|
<div class="suit text-7xl absolute"
|
||||||
|
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
|
||||||
|
<img draggable="false" :src="`/${props.track.card?.suit}.svg`" />
|
||||||
|
</div>
|
||||||
|
<div class="rank text-white font-bold absolute -mt-1">
|
||||||
|
{{ props.track.card?.rank }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex items-center justify-center size-7 absolute top-6 left-6">
|
||||||
|
<div class="rank text-white font-bold absolute -mt-1">
|
||||||
|
{{ props.track.order }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cover -->
|
||||||
|
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer">
|
||||||
|
<playButton :objectToPlay="track" />
|
||||||
|
<img draggable="false" v-if="isFaceUp" :src="coverUrl" alt="Pochette de l'album" loading="lazy"
|
||||||
|
class="w-full h-full object-cover object-center" />
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div
|
||||||
|
class="card-body p-3 text-center bg-white rounded-b-xl opacity-0 -mt-16 hover:opacity-100 hover:-mt-0 transition-all duration-300">
|
||||||
|
<div v-if="isOrder" class="label">
|
||||||
|
{{ props.track.order }}
|
||||||
|
</div>
|
||||||
|
<h2 class="select-text text-sm text-neutral-500 first-letter:uppercase truncate">
|
||||||
|
{{ props.track.title || 'title' }}
|
||||||
|
</h2>
|
||||||
|
<p v-if="isPlaylistTrack && track.artist && typeof track.artist === 'object'"
|
||||||
|
class="select-text text-base text-neutral-800 font-bold capitalize truncate">
|
||||||
|
{{ track.artist.name || 'artist' }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="isPlaylistTrack" class="select-text text-base text-neutral-800 font-bold capitalize truncate">
|
||||||
|
{{ typeof track.artist === 'string' ? track.artist : 'artist' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Face-Down -->
|
||||||
|
<footer
|
||||||
|
class="face-down backdrop-blur-sm z-10 card w-56 h-80 p-3 bg-opacity-10 bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden select-none">
|
||||||
|
<figure class="h-full flex text-center rounded-xl justify-center items-center"
|
||||||
|
:style="{ backgroundColor: cardColor }">
|
||||||
|
<playButton :objectToPlay="track" />
|
||||||
|
<img draggable="false" src="/face-down.svg" />
|
||||||
|
</figure>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- Clone fantôme unifié pour drag souris ET tactile -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="isDragging && touchClone" ref="ghostElement"
|
||||||
|
class="ghost-card fixed pointer-events-none z-[9999] w-56 h-80" :style="{
|
||||||
|
left: touchClone.x + 'px',
|
||||||
|
top: touchClone.y + 'px',
|
||||||
|
transform: 'translate(-50%, -50%)'
|
||||||
|
}">
|
||||||
|
<div class="flip-inner">
|
||||||
|
<main
|
||||||
|
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 rounded-2xl shadow-2xl flex flex-col overflow-hidden bg-white bg-opacity-90">
|
||||||
|
|
||||||
|
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 left-7">
|
||||||
|
<div class="suit text-7xl absolute"
|
||||||
|
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
|
||||||
|
<img draggable="false" :src="`/${props.track.card?.suit}.svg`" />
|
||||||
|
</div>
|
||||||
|
<div class="rank text-white font-bold absolute -mt-1">
|
||||||
|
{{ props.track.card?.rank }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl">
|
||||||
|
<img draggable="false" :src="coverUrl" alt="Pochette de l'album"
|
||||||
|
class="w-full h-full object-cover object-center" />
|
||||||
|
</figure>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Modal de partage -->
|
||||||
|
<ModalSharer v-if="showModalSharer" ref="modalSharer" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Track } from '~~/types'
|
||||||
|
import { usePlayerStore } from '~/store/player'
|
||||||
|
import { useDataStore } from '~/store/data'
|
||||||
|
import { useNuxtApp } from '#app'
|
||||||
|
import ModalSharer from '~/components/ui/ModalSharer.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
track: Track;
|
||||||
|
isFaceUp?: boolean;
|
||||||
|
role?: string;
|
||||||
|
tabindex?: string | number;
|
||||||
|
'onUpdate:isFaceUp'?: (value: boolean) => void;
|
||||||
|
}>(), {
|
||||||
|
isFaceUp: true,
|
||||||
|
role: 'button',
|
||||||
|
tabindex: '0'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use useAttrs to get all other attributes
|
||||||
|
const attrs = useAttrs()
|
||||||
|
|
||||||
|
const modalSharer = ref<InstanceType<typeof ModalSharer> | null>(null)
|
||||||
|
const showModalSharer = ref(false)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:isFaceUp', value: boolean): void;
|
||||||
|
(e: 'cardClick', track: Track): void;
|
||||||
|
(e: 'clickCardSymbol', track: Track): void;
|
||||||
|
(e: 'dragstart', event: DragEvent): void;
|
||||||
|
(e: 'dragend', event: DragEvent): void;
|
||||||
|
(e: 'drag', event: DragEvent): void;
|
||||||
|
(e: 'click', event: MouseEvent): void;
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Handle click events (mouse and keyboard)
|
||||||
|
const handleClick = (event: MouseEvent | KeyboardEvent) => {
|
||||||
|
if (!isDragging.value && !hasMovedDuringPress.value) {
|
||||||
|
emit('cardClick', props.track);
|
||||||
|
emit('click', event as MouseEvent);
|
||||||
|
}
|
||||||
|
hasMovedDuringPress.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clickCardSymbol = (event: MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
// Afficher la modale
|
||||||
|
showModalSharer.value = true;
|
||||||
|
|
||||||
|
// Donner le focus à la modale après le rendu
|
||||||
|
nextTick(() => {
|
||||||
|
if (modalSharer.value) {
|
||||||
|
modalSharer.value.open(props.track);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
emit('clickCardSymbol', props.track);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle drag start with proper event emission
|
||||||
|
const handleDragStart = (event: DragEvent) => {
|
||||||
|
if (!props.isFaceUp) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { $bodyClass } = useNuxtApp()
|
||||||
|
$bodyClass.add('card-dragging')
|
||||||
|
dragStart(event);
|
||||||
|
emit('dragstart', event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle drag end with proper event emission
|
||||||
|
const handleDragEnd = (event: DragEvent) => {
|
||||||
|
if (!props.isFaceUp) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { $bodyClass } = useNuxtApp()
|
||||||
|
$bodyClass.remove('card-dragging')
|
||||||
|
dragEnd(event);
|
||||||
|
emit('dragend', event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle drag move with proper event emission
|
||||||
|
const handleDragMove = (event: DragEvent) => {
|
||||||
|
if (!props.isFaceUp) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dragMove(event);
|
||||||
|
emit('drag', event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerStore = usePlayerStore()
|
||||||
|
const isManifesto = computed(() => props.track.boxId.startsWith('ES00'))
|
||||||
|
const isOrder = computed(() => props.track.order && !isManifesto)
|
||||||
|
const isPlaylistTrack = computed(() => props.track.type === 'playlist')
|
||||||
|
const isRedCard = computed(() => (props.track.card?.suit === '♥' || props.track.card?.suit === '♦'))
|
||||||
|
const dataStore = useDataStore()
|
||||||
|
const cardColor = computed(() => dataStore.getYearColor(props.track.year || 0))
|
||||||
|
const coverUrl = computed(() => props.track.coverId || '/card-dock.svg')
|
||||||
|
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const cardElement = ref<HTMLElement | null>(null)
|
||||||
|
const ghostElement = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// État unifié pour souris et tactile
|
||||||
|
const touchClone = ref<{ x: number, y: number } | null>(null)
|
||||||
|
const touchStartPos = ref<{ x: number, y: number } | null>(null)
|
||||||
|
const longPressTimer = ref<number | null>(null)
|
||||||
|
const LONG_PRESS_DURATION = 200 // ms
|
||||||
|
const hasMovedDuringPress = ref(false)
|
||||||
|
|
||||||
|
|
||||||
|
// Drag desktop - utilise maintenant ghostElement
|
||||||
|
const dragStart = (event: DragEvent) => {
|
||||||
|
if (event.dataTransfer && cardElement.value) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('application/json', JSON.stringify(props.track));
|
||||||
|
|
||||||
|
// Créer une image transparente pour masquer l'image par défaut du navigateur
|
||||||
|
const img = new Image();
|
||||||
|
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||||
|
event.dataTransfer.setDragImage(img, 0, 0);
|
||||||
|
|
||||||
|
// Activer le clone fantôme
|
||||||
|
isDragging.value = true
|
||||||
|
touchClone.value = {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nouveau: suivre le mouvement de la souris pendant le drag
|
||||||
|
const dragMove = (event: DragEvent) => {
|
||||||
|
if (isDragging.value && touchClone.value && event.clientX !== 0 && event.clientY !== 0) {
|
||||||
|
touchClone.value = {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = getCurrentInstance();
|
||||||
|
const dragEnd = (event: DragEvent) => {
|
||||||
|
isDragging.value = false
|
||||||
|
touchClone.value = null
|
||||||
|
|
||||||
|
if (event.dataTransfer?.dropEffect === 'move' && instance?.vnode?.el?.parentNode) {
|
||||||
|
instance.vnode.el.parentNode.removeChild(instance.vnode.el);
|
||||||
|
const parent = instance.parent;
|
||||||
|
if (parent?.update) {
|
||||||
|
parent.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch events
|
||||||
|
const touchStart = (event: TouchEvent) => {
|
||||||
|
const touch = event.touches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
touchStartPos.value = { x: touch.clientX, y: touch.clientY };
|
||||||
|
hasMovedDuringPress.value = false;
|
||||||
|
|
||||||
|
// Démarrer un timer pour le long press
|
||||||
|
longPressTimer.value = window.setTimeout(() => {
|
||||||
|
startTouchDrag(touch);
|
||||||
|
}, LONG_PRESS_DURATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTouchDrag = (touch: Touch) => {
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
isDragging.value = true;
|
||||||
|
touchClone.value = {
|
||||||
|
x: touch.clientX,
|
||||||
|
y: touch.clientY
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vibration feedback si disponible
|
||||||
|
if (navigator.vibrate) {
|
||||||
|
navigator.vibrate(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const touchMove = (event: TouchEvent) => {
|
||||||
|
const touch = event.touches[0];
|
||||||
|
if (!touch || !longPressTimer.value) return;
|
||||||
|
|
||||||
|
// Annuler le long press si l'utilisateur bouge trop
|
||||||
|
const dx = touch.clientX - (touchStartPos.value?.x || 0);
|
||||||
|
const dy = touch.clientY - (touchStartPos.value?.y || 0);
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (distance > 10) { // Seuil de tolérance pour un tap
|
||||||
|
clearTimeout(longPressTimer.value)
|
||||||
|
longPressTimer.value = null
|
||||||
|
hasMovedDuringPress.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDragging.value && touchClone.value) {
|
||||||
|
event.preventDefault()
|
||||||
|
const touch = event.touches[0]
|
||||||
|
touchClone.value = {
|
||||||
|
x: touch.clientX,
|
||||||
|
y: touch.clientY
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déterminer l'élément sous le doigt
|
||||||
|
checkDropTarget(touch.clientX, touch.clientY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const touchEnd = (event: TouchEvent) => {
|
||||||
|
// Annuler le timer de long press
|
||||||
|
if (longPressTimer.value) {
|
||||||
|
clearTimeout(longPressTimer.value);
|
||||||
|
longPressTimer.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si c'était un tap simple (pas de déplacement)
|
||||||
|
if (!hasMovedDuringPress.value && touchStartPos.value) {
|
||||||
|
const touch = event.changedTouches[0];
|
||||||
|
if (touch) {
|
||||||
|
const dx = touch.clientX - touchStartPos.value.x;
|
||||||
|
const dy = touch.clientY - touchStartPos.value.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (distance < 10) { // Seuil de tolérance pour un tap
|
||||||
|
handleClick(new MouseEvent('click'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Réinitialiser l'état de glisser-déposer
|
||||||
|
if (isDragging.value) {
|
||||||
|
// Vérifier si on est au-dessus d'une cible de dépôt
|
||||||
|
const touch = event.changedTouches[0];
|
||||||
|
if (touch) {
|
||||||
|
checkDropTarget(touch.clientX, touch.clientY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nettoyer
|
||||||
|
isDragging.value = false;
|
||||||
|
touchClone.value = null;
|
||||||
|
touchStartPos.value = null;
|
||||||
|
hasMovedDuringPress.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkDropTarget = (x: number, y: number): HTMLElement | null => {
|
||||||
|
const element = document.elementFromPoint(x, y);
|
||||||
|
if (element) {
|
||||||
|
const dropZone = element.closest('[data-drop-zone]');
|
||||||
|
if (dropZone) {
|
||||||
|
return dropZone as HTMLElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (longPressTimer.value) {
|
||||||
|
clearTimeout(longPressTimer.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.label {
|
||||||
|
@apply rounded-full size-7 p-2 bg-esyellow leading-3 -mt-6;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.♠,
|
||||||
|
.♣,
|
||||||
|
.♦,
|
||||||
|
.♥ {
|
||||||
|
@apply text-5xl size-14;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flip effect */
|
||||||
|
.card {
|
||||||
|
perspective: 1000px;
|
||||||
|
@apply transition-all scale-100 w-56 h-80 min-w-56 min-h-80;
|
||||||
|
touch-action: none;
|
||||||
|
|
||||||
|
.flip-inner {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transition: transform 0.6s;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-down,
|
||||||
|
.face-up {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
will-change: transform;
|
||||||
|
background-color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-up {
|
||||||
|
transform: rotateY(0deg);
|
||||||
|
transition: box-shadow 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-down {
|
||||||
|
transform: rotateY(-180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.face-down .flip-inner {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.face-up .flip-inner {
|
||||||
|
transform: rotateY(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.face-down:hover {
|
||||||
|
.play-button {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flip-inner {
|
||||||
|
transform: rotateY(170deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&.current-track {
|
||||||
|
@apply z-50 scale-110;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
.face-up {
|
||||||
|
@apply shadow-2xl;
|
||||||
|
transition:
|
||||||
|
box-shadow 0.6s,
|
||||||
|
transform 0.6s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&.current-track {
|
||||||
|
.play-button {
|
||||||
|
@apply opacity-100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-up:hover {
|
||||||
|
.play-button {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flip-inner {
|
||||||
|
transform: rotateY(-170deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button {
|
||||||
|
@apply absolute bottom-1/2 top-24 opacity-0 hover:opacity-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pochette:active,
|
||||||
|
.face-down:active {
|
||||||
|
.play-button {
|
||||||
|
@apply scale-90;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-dragging {
|
||||||
|
@apply opacity-50 scale-95 rotate-6;
|
||||||
|
cursor: grabbing !important;
|
||||||
|
|
||||||
|
.face-up {
|
||||||
|
@apply shadow-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button,
|
||||||
|
.card-body {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ghost card styles - maintenant unifié pour souris et tactile */
|
||||||
|
.ghost-card {
|
||||||
|
transition: none;
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply shadow-2xl scale-95 rotate-6;
|
||||||
|
|
||||||
|
.play-button,
|
||||||
|
.card-body {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.flip-inner {
|
||||||
|
perspective: 1000px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -24,7 +24,7 @@ import { useDataStore } from '~/store/data'
|
|||||||
import { useCardStore } from '~/store/card'
|
import { useCardStore } from '~/store/card'
|
||||||
import { usePlayerStore } from '~/store/player'
|
import { usePlayerStore } from '~/store/player'
|
||||||
import { useUiStore } from '~/store/ui'
|
import { useUiStore } from '~/store/ui'
|
||||||
import type { Box } from '~~/types/types'
|
import type { Box } from '~~/types'
|
||||||
|
|
||||||
const uiStore = useUiStore()
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ import { useDataStore } from '~/store/data'
|
|||||||
import { useCardStore } from '~/store/card'
|
import { useCardStore } from '~/store/card'
|
||||||
import { usePlayerStore } from '~/store/player'
|
import { usePlayerStore } from '~/store/player'
|
||||||
import { useUiStore } from '~/store/ui'
|
import { useUiStore } from '~/store/ui'
|
||||||
import type { Box, Track } from '~~/types/types'
|
import type { Box, Track } from '~~/types'
|
||||||
import SelectCardSuit from '~/components/ui/SelectCardSuit.vue'
|
import SelectCardSuit from '~/components/ui/SelectCardSuit.vue'
|
||||||
import SelectCardRank from '~/components/ui/SelectCardRank.vue'
|
import SelectCardRank from '~/components/ui/SelectCardRank.vue'
|
||||||
import SearchInput from '~/components/ui/SearchInput.vue'
|
import SearchInput from '~/components/ui/SearchInput.vue'
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useUiStore } from '~/store/ui'
|
import { useUiStore } from '~/store/ui'
|
||||||
import type { Track } from '~~/types/types'
|
import type { Track } from '~~/types'
|
||||||
|
|
||||||
const uiStore = useUiStore()
|
const uiStore = useUiStore()
|
||||||
const currentTrack = ref<Track | null>(null)
|
const currentTrack = ref<Track | null>(null)
|
||||||
18
appOLDD/error.vue
Normal file
18
appOLDD/error.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
class="text-white pt-6 text-lg md:text-xl lg:text-2xl text-center font-bold tracking-widest text-shadow"
|
||||||
|
>
|
||||||
|
{{ error?.statusCode }}
|
||||||
|
</h1>
|
||||||
|
<NuxtLink to="/">Go back home</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { NuxtError } from '#app'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
error: Object as () => NuxtError
|
||||||
|
})
|
||||||
|
</script>
|
||||||
55
appOLDD/layouts/default.vue
Normal file
55
appOLDD/layouts/default.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<slot />
|
||||||
|
<Bucket />
|
||||||
|
<Platine v-if="playerStore.currentTrack" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Track } from '~~/types'
|
||||||
|
import { usePlayerStore } from '~/store/player'
|
||||||
|
|
||||||
|
const playerStore = usePlayerStore()
|
||||||
|
const onCardDropped = (card: Track) => {
|
||||||
|
console.log('Carte déposée dans le bucket:', card)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bucket,
|
||||||
|
.platine {
|
||||||
|
position: fixed;
|
||||||
|
bottom: -100%;
|
||||||
|
right: 0;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bucket {
|
||||||
|
z-index: 70;
|
||||||
|
bottom: -260px;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: scroll;
|
||||||
|
transition: bottom .3s ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
.card-dragging & {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bucket-card-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.platine {
|
||||||
|
bottom: -100%;
|
||||||
|
transition: bottom 2s ease;
|
||||||
|
|
||||||
|
&.mounted {
|
||||||
|
z-index: 80;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -22,7 +22,7 @@ import { useRoute } from 'vue-router'
|
|||||||
import { usePlayerStore } from '~/store/player'
|
import { usePlayerStore } from '~/store/player'
|
||||||
import { useCardStore } from '~/store/card'
|
import { useCardStore } from '~/store/card'
|
||||||
import { useDataStore } from '~/store/data'
|
import { useDataStore } from '~/store/data'
|
||||||
import type { Track } from '~~/types/types'
|
import type { Track } from '~~/types'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const playerStore = usePlayerStore()
|
const playerStore = usePlayerStore()
|
||||||
27
appOLDD/pages/index.vue
Normal file
27
appOLDD/pages/index.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<boxes />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useUiStore } from '~/store/ui'
|
||||||
|
import { useDataStore } from '~/store/data'
|
||||||
|
|
||||||
|
// Configuration du layout
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'default'
|
||||||
|
})
|
||||||
|
|
||||||
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const dataStore = useDataStore()
|
||||||
|
await dataStore.loadData()
|
||||||
|
uiStore.listBoxes()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.logo {
|
||||||
|
filter: drop-shadow(3px 3px 0 rgb(0 0 0 / 0.7));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { Track } from '~~/types/types'
|
import type { Track } from '~~/types'
|
||||||
|
|
||||||
export const useCardStore = defineStore('card', {
|
export const useCardStore = defineStore('card', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { Track } from '~~/types/types'
|
import type { Track } from '~~/types'
|
||||||
import Disc from '~/platine-tools/disc'
|
import Disc from '~/platine-tools/disc'
|
||||||
import Sampler from '~/platine-tools/sampler'
|
import Sampler from '~/platine-tools/sampler'
|
||||||
import { useCardStore } from '~/store/card'
|
import { useCardStore } from '~/store/card'
|
||||||
@@ -74,7 +74,7 @@ export const usePlatineStore = defineStore('platine', () => {
|
|||||||
isLoadingTrack.value = true
|
isLoadingTrack.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sampler.value.loadTrack(track.url)
|
await sampler.value.loadTrack(track.filePath)
|
||||||
if (disc.value) {
|
if (disc.value) {
|
||||||
disc.value.setDuration(sampler.value.duration)
|
disc.value.setDuration(sampler.value.duration)
|
||||||
updateTurns()
|
updateTurns()
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
body {
|
|
||||||
background-color: red !important;
|
|
||||||
}
|
|
||||||
146
data/Artists.json
Normal file
146
data/Artists.json
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "L'efondras",
|
||||||
|
"url": "https://leffondras.bandcamp.com/music",
|
||||||
|
"coverId": "0024705317"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "The kundalini genie",
|
||||||
|
"url": "https://the-kundalini-genie.bandcamp.com",
|
||||||
|
"coverId": "0012045550"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "Fontaines D.C.",
|
||||||
|
"url": "https://fontainesdc.bandcamp.com",
|
||||||
|
"coverId": "0027327090"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "Fontanarosa",
|
||||||
|
"url": "https://fontanarosa.bandcamp.com",
|
||||||
|
"coverId": "0035380235"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"name": "Johnny mafia",
|
||||||
|
"url": "https://johnnymafia.bandcamp.com",
|
||||||
|
"coverId": "0035009392"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"name": "New candys",
|
||||||
|
"url": "https://newcandys.bandcamp.com",
|
||||||
|
"coverId": "0039963261"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"name": "Magic shoppe",
|
||||||
|
"url": "https://magicshoppe.bandcamp.com",
|
||||||
|
"coverId": "0030748374"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"name": "Les jaguars",
|
||||||
|
"url": "https://radiomartiko.bandcamp.com/album/surf-qu-b-cois",
|
||||||
|
"coverId": "0016551336"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"name": "TRAAMS",
|
||||||
|
"url": "https://traams.bandcamp.com",
|
||||||
|
"coverId": "0028348410"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"name": "Blue orchid",
|
||||||
|
"url": "https://blue-orchid.bandcamp.com",
|
||||||
|
"coverId": "0034796193"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"name": "I love UFO",
|
||||||
|
"url": "https://bruitblanc.bandcamp.com",
|
||||||
|
"coverId": "a2203158939"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"name": "Kid Congo & The Pink Monkey Birds",
|
||||||
|
"url": "https://kidcongothepinkmonkeybirds.bandcamp.com/",
|
||||||
|
"coverId": "0017196290"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"name": "Firefriend",
|
||||||
|
"url": "https://firefriend.bandcamp.com/",
|
||||||
|
"coverId": "0031072203"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"name": "Squid",
|
||||||
|
"url": "https://squiduk.bandcamp.com/",
|
||||||
|
"coverId": "0037649385"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"name": "Lysistrata",
|
||||||
|
"url": "https://lysistrata.bandcamp.com/",
|
||||||
|
"coverId": "0033900158"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"name": "Pablo X Broadcasting Services",
|
||||||
|
"url": "https://pabloxbroadcastingservices.bandcamp.com/",
|
||||||
|
"coverId": "0036956486"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"name": "Night Beats",
|
||||||
|
"url": "https://nightbeats.bandcamp.com/",
|
||||||
|
"coverId": "0036987720"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"name": "Deltron 3030",
|
||||||
|
"url": "https://delthefunkyhomosapien.bandcamp.com/",
|
||||||
|
"coverId": "0005254781"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"name": "The Amorphous Androgynous",
|
||||||
|
"url": "https://theaa.bandcamp.com/",
|
||||||
|
"coverId": "0022226700"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"name": "Wooden Shjips",
|
||||||
|
"url": "https://woodenshjips.bandcamp.com/",
|
||||||
|
"coverId": "0012406678"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"name": "Silas J. Dirge",
|
||||||
|
"url": "https://silasjdirge.bandcamp.com/",
|
||||||
|
"coverId": "0035751570"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"name": "Secret Colours",
|
||||||
|
"url": "https://secretcolours.bandcamp.com/",
|
||||||
|
"coverId": "0010661379"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"name": "Larry McNeil And The Blue Knights",
|
||||||
|
"url": "https://www.discogs.com/artist/6528940-Larry-McNeil-And-The-Blue-Knights",
|
||||||
|
"coverId": "https://i.discogs.com/Yr05_neEXwzPwKlDeV7dimmTG34atkAMgpxbMBhHBkI/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTEyMTEw/ODE1LTE1Mjg1NjU1/NzQtMjcyOC5qcGVn.jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"name": "Hugo Blanco",
|
||||||
|
"url": "https://elpalmasmusic.bandcamp.com/album/color-de-tr-pico-compiled-by-el-dr-gon-criollo-el-palmas",
|
||||||
|
"coverId": "0016886708"
|
||||||
|
}
|
||||||
|
]
|
||||||
44
data/Compilations.json
Normal file
44
data/Compilations.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "ES00",
|
||||||
|
"type": "compilation",
|
||||||
|
"name": "manifeste",
|
||||||
|
"description": "Zero is for manifesto",
|
||||||
|
"duration": 5264,
|
||||||
|
"sides": {
|
||||||
|
"A": {
|
||||||
|
"name": "manifeste",
|
||||||
|
"description": "Zero is for manifesto",
|
||||||
|
"duration": 2794,
|
||||||
|
"color1": "#ffffff",
|
||||||
|
"color2": "#48959d"
|
||||||
|
},
|
||||||
|
"B": {
|
||||||
|
"name": "manifeste B",
|
||||||
|
"description": "Even Zero has a b-side",
|
||||||
|
"duration": 2470,
|
||||||
|
"color1": "#0d01b9",
|
||||||
|
"color2": "#3b7589"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ES01",
|
||||||
|
"type": "compilation",
|
||||||
|
"name": "...",
|
||||||
|
"description": "...",
|
||||||
|
"duration": 7260,
|
||||||
|
"sides": {
|
||||||
|
"A": {
|
||||||
|
"duration": 3487,
|
||||||
|
"color1": "#c7b3aa",
|
||||||
|
"color2": "#000100"
|
||||||
|
},
|
||||||
|
"B": {
|
||||||
|
"duration": 3773,
|
||||||
|
"color1": "#f7dd01",
|
||||||
|
"color2": "#010103"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
486
data/Songs.json
Normal file
486
data/Songs.json
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"order": 1,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "The grinding wheel",
|
||||||
|
"artist": 0,
|
||||||
|
"start": 0,
|
||||||
|
"link": "https://arakirecords.bandcamp.com/track/the-grinding-wheel",
|
||||||
|
"coverId": "a3236746052",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 2,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Bleach",
|
||||||
|
"artist": 1,
|
||||||
|
"start": 392,
|
||||||
|
"link": "https://the-kundalini-genie.bandcamp.com/track/bleach-2",
|
||||||
|
"coverId": "a1714786533",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 3,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Televised mind",
|
||||||
|
"artist": 2,
|
||||||
|
"start": 896,
|
||||||
|
"link": "https://fontainesdc.bandcamp.com/track/televised-mind",
|
||||||
|
"coverId": "a3772806156",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 4,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "In it",
|
||||||
|
"artist": 3,
|
||||||
|
"start": 1139,
|
||||||
|
"link": "https://howlinbananarecords.bandcamp.com/track/in-it",
|
||||||
|
"coverId": "a1720372066",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 5,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Bad michel",
|
||||||
|
"artist": 4,
|
||||||
|
"start": 1245,
|
||||||
|
"link": "https://johnnymafia.bandcamp.com/track/bad-michel-3",
|
||||||
|
"coverId": "a0984622869",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 6,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Overall",
|
||||||
|
"artist": 5,
|
||||||
|
"start": 1394,
|
||||||
|
"link": "https://newcandys.bandcamp.com/track/overall",
|
||||||
|
"coverId": "a0559661270",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 7,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Blowup",
|
||||||
|
"artist": 6,
|
||||||
|
"start": 1674,
|
||||||
|
"link": "https://magicshoppe.bandcamp.com/track/blowup",
|
||||||
|
"coverId": "a1444895293",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 8,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Guitar jet",
|
||||||
|
"artist": 7,
|
||||||
|
"start": 1880,
|
||||||
|
"link": "https://radiomartiko.bandcamp.com/track/guitare-jet",
|
||||||
|
"coverId": "a1494681687",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 9,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Intercontinental radio waves",
|
||||||
|
"artist": 8,
|
||||||
|
"start": 2024,
|
||||||
|
"link": "https://traams.bandcamp.com/track/intercontinental-radio-waves",
|
||||||
|
"coverId": "a0046738552",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 10,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Here comes the sun",
|
||||||
|
"artist": 9,
|
||||||
|
"start": 2211,
|
||||||
|
"link": "https://blue-orchid.bandcamp.com/track/here-come-the-sun",
|
||||||
|
"coverId": "a4102567047",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 11,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Like in the movies",
|
||||||
|
"artist": 10,
|
||||||
|
"start": 2560,
|
||||||
|
"link": "https://bruitblanc.bandcamp.com/track/like-in-the-movies-2",
|
||||||
|
"coverId": "a2203158939",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 1,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Ce que révèle l'éclipse",
|
||||||
|
"artist": 0,
|
||||||
|
"start": 0,
|
||||||
|
"link": "https://arakirecords.bandcamp.com/track/ce-que-r-v-le-l-clipse",
|
||||||
|
"coverId": "a3236746052",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 2,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Bleedin' Gums Mushrool",
|
||||||
|
"artist": 1,
|
||||||
|
"start": 263,
|
||||||
|
"link": "https://the-kundalini-genie.bandcamp.com/track/bleedin-gums-mushroom",
|
||||||
|
"coverId": "a1714786533",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 3,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "A lucid dream",
|
||||||
|
"artist": 2,
|
||||||
|
"start": 554,
|
||||||
|
"link": "https://fontainesdc.bandcamp.com/track/a-lucid-dream",
|
||||||
|
"coverId": "a3772806156",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 4,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Lights off",
|
||||||
|
"artist": 3,
|
||||||
|
"start": 781,
|
||||||
|
"link": "https://howlinbananarecords.bandcamp.com/track/lights-off",
|
||||||
|
"coverId": "a1720372066",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 5,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "I'm sentimental",
|
||||||
|
"artist": 4,
|
||||||
|
"start": 969,
|
||||||
|
"link": "https://johnnymafia.bandcamp.com/track/im-sentimental-2",
|
||||||
|
"coverId": "a2333676849",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 6,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Thrill or trip",
|
||||||
|
"artist": 5,
|
||||||
|
"start": 1128,
|
||||||
|
"link": "https://newcandys.bandcamp.com/track/thrill-or-trip",
|
||||||
|
"coverId": "a0559661270",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 7,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Redhead",
|
||||||
|
"artist": 6,
|
||||||
|
"start": 1303,
|
||||||
|
"link": "https://magicshoppe.bandcamp.com/track/redhead",
|
||||||
|
"coverId": "a0594426943",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 8,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Supersonic twist",
|
||||||
|
"artist": 7,
|
||||||
|
"start": 1584,
|
||||||
|
"link": "https://open.spotify.com/track/66voQIZAJ3zD3Eju2qtNjF",
|
||||||
|
"coverId": "a1494681687",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 9,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Flowers",
|
||||||
|
"artist": 8,
|
||||||
|
"start": 1749,
|
||||||
|
"link": "https://traams.bandcamp.com/track/flowers",
|
||||||
|
"coverId": "a3644668199",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 10,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "The shade",
|
||||||
|
"artist": 9,
|
||||||
|
"start": 1924,
|
||||||
|
"link": "https://blue-orchid.bandcamp.com/track/the-shade",
|
||||||
|
"coverId": "a0804204790",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 11,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Like in the movies",
|
||||||
|
"artist": 10,
|
||||||
|
"start": 2186,
|
||||||
|
"link": "https://bruitblanc.bandcamp.com/track/like-in-the-movies",
|
||||||
|
"coverId": "a3647322740",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 1,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "He Walked In",
|
||||||
|
"artist": 11,
|
||||||
|
"start": 0,
|
||||||
|
"link": "https://kidcongothepinkmonkeybirds.bandcamp.com/track/he-walked-in",
|
||||||
|
"coverId": "a0336300523",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 2,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "The Third Wave",
|
||||||
|
"artist": 12,
|
||||||
|
"start": 841,
|
||||||
|
"link": "https://firefriend.bandcamp.com/track/the-third-wave",
|
||||||
|
"coverId": "a2803689859",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 3,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Broadcaster",
|
||||||
|
"artist": 13,
|
||||||
|
"start": 1104.5,
|
||||||
|
"link": "https://squiduk.bandcamp.com/track/broadcaster",
|
||||||
|
"coverId": "a3391719769",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 4,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Mourn",
|
||||||
|
"artist": 14,
|
||||||
|
"start": 1441,
|
||||||
|
"link": "https://lysistrata.bandcamp.com/track/mourn-2",
|
||||||
|
"coverId": "a0872900041",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 5,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Let it Blow",
|
||||||
|
"artist": 15,
|
||||||
|
"start": 1844.8,
|
||||||
|
"link": "https://pabloxbroadcastingservices.bandcamp.com/track/let-it-blow",
|
||||||
|
"coverId": "a4000148031",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 6,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Sunday Mourning",
|
||||||
|
"artist": 16,
|
||||||
|
"start": 2091.7,
|
||||||
|
"link": "https://nightbeats.bandcamp.com/track/sunday-mourning",
|
||||||
|
"coverId": "a0031987121",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 7,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "3030 Instrumental",
|
||||||
|
"artist": 17,
|
||||||
|
"start": 2339.3,
|
||||||
|
"link": "https://delthefunkyhomosapien.bandcamp.com/track/3030",
|
||||||
|
"coverId": "a1948146136",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 8,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Immortality Break",
|
||||||
|
"artist": 18,
|
||||||
|
"start": 2530.5,
|
||||||
|
"link": "https://theaa.bandcamp.com/track/immortality-break",
|
||||||
|
"coverId": "a2749250329",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 9,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Lazy Bones",
|
||||||
|
"artist": 19,
|
||||||
|
"start": 2718,
|
||||||
|
"link": "https://woodenshjips.bandcamp.com/track/lazy-bones",
|
||||||
|
"coverId": "a1884221104",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 10,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "On the Train of Aches",
|
||||||
|
"artist": 20,
|
||||||
|
"start": 2948,
|
||||||
|
"link": "https://silasjdirge.bandcamp.com/track/on-the-train-of-aches",
|
||||||
|
"coverId": "a1124177379",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 11,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Me",
|
||||||
|
"artist": 21,
|
||||||
|
"start": 3265,
|
||||||
|
"link": "https://secretcolours.bandcamp.com/track/me",
|
||||||
|
"coverId": "a1497022499",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 1,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Lady Hawke Blues",
|
||||||
|
"artist": 11,
|
||||||
|
"start": 0,
|
||||||
|
"link": "https://kidcongothepinkmonkeybirds.bandcamp.com/track/lady-hawke-blues",
|
||||||
|
"coverId": "a2532623230",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 2,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Dreamscapes",
|
||||||
|
"artist": 12,
|
||||||
|
"start": 235,
|
||||||
|
"link": "https://littlecloudrecords.bandcamp.com/track/dreamscapes",
|
||||||
|
"coverId": "a3498981203",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 3,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Crispy Skin",
|
||||||
|
"artist": 13,
|
||||||
|
"start": 644.2,
|
||||||
|
"link": "https://squiduk.bandcamp.com/track/crispy-skin-2",
|
||||||
|
"coverId": "a2516727021",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 4,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "The Boy Who Stood Above The Earth",
|
||||||
|
"artist": 14,
|
||||||
|
"start": 1018,
|
||||||
|
"link": "https://lysistrata.bandcamp.com/track/the-boy-who-stood-above-the-earth-2",
|
||||||
|
"coverId": "a0350933426",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 5,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Better Off Alone",
|
||||||
|
"artist": 15,
|
||||||
|
"start": 1698,
|
||||||
|
"link": "https://pabloxbroadcastingservices.bandcamp.com/track/better-off-alone",
|
||||||
|
"coverId": "a4000148031",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 6,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Celebration #1",
|
||||||
|
"artist": 16,
|
||||||
|
"start": 2235,
|
||||||
|
"link": "https://nightbeats.bandcamp.com/track/celebration-1",
|
||||||
|
"coverId": "a0031987121",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 7,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "3030 Instrumental",
|
||||||
|
"artist": 17,
|
||||||
|
"start": 2458.3,
|
||||||
|
"link": "https://delthefunkyhomosapien.bandcamp.com/track/3030",
|
||||||
|
"coverId": "a1948146136",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 8,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "The Emptiness Of Nothingness",
|
||||||
|
"artist": 18,
|
||||||
|
"start": 2864.5,
|
||||||
|
"link": "https://theaa.bandcamp.com/track/the-emptiness-of-nothingness",
|
||||||
|
"coverId": "a1053923875",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 9,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Rising",
|
||||||
|
"artist": 19,
|
||||||
|
"start": 3145,
|
||||||
|
"link": "https://woodenshjips.bandcamp.com/track/rising",
|
||||||
|
"coverId": "a1884221104",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 10,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "The Last Time",
|
||||||
|
"artist": 22,
|
||||||
|
"start": 3447,
|
||||||
|
"link": "https://www.discogs.com/release/12110815-Larry-McNeil-And-The-Blue-Knights-Jealous-Woman",
|
||||||
|
"coverId": "https://i.discogs.com/Yr05_neEXwzPwKlDeV7dimmTG34atkAMgpxbMBhHBkI/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTEyMTEw/ODE1LTE1Mjg1NjU1/NzQtMjcyOC5qcGVn.jpeg",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 11,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Guajira Con Arpa",
|
||||||
|
"artist": 23,
|
||||||
|
"start": 3586,
|
||||||
|
"link": "https://elpalmasmusic.bandcamp.com/track/guajira-con-arpa",
|
||||||
|
"coverId": "a3463036407",
|
||||||
|
"year": 2025
|
||||||
|
}
|
||||||
|
]
|
||||||
BIN
data/music.db
Normal file
BIN
data/music.db
Normal file
Binary file not shown.
@@ -8,14 +8,18 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
ports:
|
ports:
|
||||||
- "${PORT}:${PORT_EXPOSED}"
|
- '${PORT}:${PORT_EXPOSED}'
|
||||||
volumes:
|
volumes:
|
||||||
- $MEDIA_DIR:/app/mnt/media
|
- $MEDIA_DIR:/app/mnt/media
|
||||||
|
- evilspins:/app/data
|
||||||
environment:
|
environment:
|
||||||
VIRTUAL_HOST: "${DOMAIN}"
|
VIRTUAL_HOST: '${DOMAIN}'
|
||||||
LETSENCRYPT_HOST: "${DOMAIN}"
|
LETSENCRYPT_HOST: '${DOMAIN}'
|
||||||
PUID: "${PUID}"
|
PUID: '${PUID}'
|
||||||
PGID: "${PGID}"
|
PGID: '${PGID}'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
evilspins:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
|
|||||||
11
drizzle.config.ts
Normal file
11
drizzle.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
|
import { defineConfig } from 'drizzle-kit'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
out: './drizzle',
|
||||||
|
schema: './server/db/schema.ts',
|
||||||
|
dialect: 'sqlite',
|
||||||
|
dbCredentials: {
|
||||||
|
url: 'data/music.db'
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -4,9 +4,32 @@ import tsconfigPaths from 'vite-tsconfig-paths'
|
|||||||
const isProd = process.env.NODE_ENV === 'production'
|
const isProd = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
|
runtimeConfig: {
|
||||||
|
NUXT_PATH_FILES: 'mnt/media/files/music',
|
||||||
|
NUXT_URL_PREFIX: 'https://files.erudi.fr/music/'
|
||||||
|
},
|
||||||
|
nitro: {
|
||||||
|
experimental: {
|
||||||
|
tasks: true
|
||||||
|
},
|
||||||
|
scheduledTasks: {
|
||||||
|
'*/1 * * * *': ['sync-tracks']
|
||||||
|
}
|
||||||
|
},
|
||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
modules: ['@nuxt/eslint', '@nuxtjs/tailwindcss', '@pinia/nuxt'],
|
modules: ['@nuxt/eslint', '@nuxtjs/tailwindcss', '@pinia/nuxt'],
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
path: '~/components',
|
||||||
|
pathPrefix: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
typescript: {
|
||||||
|
tsConfig: {
|
||||||
|
include: ['types/**/*.ts']
|
||||||
|
}
|
||||||
|
},
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tsconfigPaths()]
|
plugins: [tsconfigPaths()]
|
||||||
},
|
},
|
||||||
|
|||||||
23
package.json
23
package.json
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "nuxt-app",
|
"name": "evilspins",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
@@ -12,19 +13,21 @@
|
|||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"format": "prettier --check .",
|
"format": "prettier --check .",
|
||||||
"format:fix": "prettier --write .",
|
"format:fix": "prettier --write .",
|
||||||
"migrate": "tsx server/database/migrate.ts",
|
"db:push": "drizzle-kit push",
|
||||||
"db:reset": "rm -f server/database/evilspins.db && npm run migrate"
|
"db:reset": "rm -rf drizzle data/music.db && drizzle-kit push",
|
||||||
|
"db:sync": "curl -X POST -H \"Content-Type: application/json\" -d '{}' http://localhost:7901/api/test/test-db-sync"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@libsql/client": "^0.17.0",
|
||||||
"@nuxt/eslint": "1.9.0",
|
"@nuxt/eslint": "1.9.0",
|
||||||
"@nuxtjs/tailwindcss": "6.14.0",
|
"@nuxtjs/tailwindcss": "6.14.0",
|
||||||
"@pinia/nuxt": "0.11.2",
|
"@pinia/nuxt": "0.11.2",
|
||||||
"@types/chokidar": "^2.1.7",
|
|
||||||
"atropos": "^2.0.2",
|
"atropos": "^2.0.2",
|
||||||
"better-sqlite3": "^12.5.0",
|
"blurhash": "^2.0.5",
|
||||||
"chokidar": "^5.0.0",
|
"drizzle-orm": "^0.45.1",
|
||||||
"nuxt": "^4.2.0",
|
"nuxt": "^4.3.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
"vuedraggable": "^4.1.0"
|
"vuedraggable": "^4.1.0"
|
||||||
@@ -32,15 +35,16 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"pnpm": ">=10 <11"
|
"pnpm": ">=10 <11"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.27.0",
|
"packageManager": "pnpm@10.28.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.4.1",
|
"@eslint/compat": "^1.4.1",
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@nuxt/eslint-config": "^1.10.0",
|
"@nuxt/eslint-config": "^1.10.0",
|
||||||
"@nuxtjs/eslint-config-typescript": "^12.1.0",
|
"@nuxtjs/eslint-config-typescript": "^12.1.0",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||||
"@typescript-eslint/parser": "^8.46.3",
|
"@typescript-eslint/parser": "^8.46.3",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"drizzle-kit": "^0.31.8",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
@@ -50,6 +54,7 @@
|
|||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"sass-embedded": "^1.93.2",
|
"sass-embedded": "^1.93.2",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3966
pnpm-lock.yaml
generated
3966
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
7
pnpm-workspace.yaml
Normal file
7
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
ignoredBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
|
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@parcel/watcher'
|
||||||
|
- better-sqlite3
|
||||||
|
- unrs-resolver
|
||||||
1
public/cassette.svg
Normal file
1
public/cassette.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg data-v-161a534a="" data-v-ef1138a9="" width="20" height="20" viewBox="0 0 20 20" fill="none" role="img" class="icon cassette-tape-outline-icon" aria-hidden="true" style="--v7dd280ad: var(--gray700);"><g data-v-161a534a=""><path data-v-161a534a="" fill-rule="evenodd" clip-rule="evenodd" d="M17.7 4.3H2.3L2.3 15.7H17.7V4.3ZM2.3 3.5C1.85817 3.5 1.5 3.85817 1.5 4.3V15.7C1.5 16.1418 1.85817 16.5 2.3 16.5H17.7C18.1418 16.5 18.5 16.1418 18.5 15.7V4.3C18.5 3.85817 18.1418 3.5 17.7 3.5H2.3Z" stroke="none"></path><path data-v-161a534a="" fill-rule="evenodd" clip-rule="evenodd" d="M8.5 9C8.5 10.2703 7.47025 11.3 6.2 11.3C4.92974 11.3 3.9 10.2703 3.9 9C3.9 7.72974 4.92974 6.7 6.2 6.7C7.47025 6.7 8.5 7.72974 8.5 9ZM6.2 10.5C7.02842 10.5 7.7 9.82843 7.7 9C7.7 8.17157 7.02842 7.5 6.2 7.5C5.37157 7.5 4.7 8.17157 4.7 9C4.7 9.82843 5.37157 10.5 6.2 10.5Z" stroke="none"></path><path data-v-161a534a="" fill-rule="evenodd" clip-rule="evenodd" d="M16.2 9C16.2 10.2703 15.1702 11.3 13.9 11.3C12.6297 11.3 11.6 10.2703 11.6 9C11.6 7.72974 12.6297 6.7 13.9 6.7C15.1702 6.7 16.2 7.72974 16.2 9ZM13.9 10.5C14.7284 10.5 15.4 9.82843 15.4 9C15.4 8.17157 14.7284 7.5 13.9 7.5C13.0716 7.5 12.4 8.17157 12.4 9C12.4 9.82843 13.0716 10.5 13.9 10.5Z" stroke="none"></path><path data-v-161a534a="" fill-rule="evenodd" clip-rule="evenodd" d="M6.36642 14C6.22966 14 6.10238 14.0698 6.02896 14.1852L4.73746 16.2147L4.06253 15.7852L5.35403 13.7557C5.5743 13.4096 5.95614 13.2 6.36642 13.2H13.6336C14.0438 13.2 14.4257 13.4096 14.646 13.7557L15.9375 15.7852L15.2625 16.2147L13.971 14.1852C13.8976 14.0698 13.7703 14 13.6336 14H6.36642Z" stroke="none"></path></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
17
public/disc.svg
Normal file
17
public/disc.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<svg data-v-daf08565="" data-v-ef1138a9="" width="20" height="20" viewBox="0 0 20 20" fill="none" role="img"
|
||||||
|
class="icon vinyl-record-outline-icon" aria-hidden="true" style="--v0954d04a: var(--gray700);">
|
||||||
|
<g data-v-daf08565="">
|
||||||
|
<path data-v-daf08565=""
|
||||||
|
d="M10 11C10.5523 11 11 10.5523 11 10C11 9.44772 10.5523 9 10 9C9.44772 9 9 9.44772 9 10C9 10.5523 9.44772 11 10 11Z"
|
||||||
|
stroke="none"></path>
|
||||||
|
<path data-v-daf08565="" fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
d="M13 10C13 11.6569 11.6569 13 10 13C8.34315 13 7 11.6569 7 10C7 8.34315 8.34315 7 10 7C11.6569 7 13 8.34315 13 10ZM10 12.1892C11.2091 12.1892 12.1892 11.2091 12.1892 10C12.1892 8.79094 11.2091 7.81081 10 7.81081C8.79094 7.81081 7.81081 8.79094 7.81081 10C7.81081 11.2091 8.79094 12.1892 10 12.1892Z"
|
||||||
|
stroke="none"></path>
|
||||||
|
<path data-v-daf08565=""
|
||||||
|
d="M12.3606 13.4425L12.0793 12.9862L9.80855 14.512L10.9231 16.2177L13.1938 14.6919L13.0425 14.3444L14.7376 13.1105C15.1903 12.7894 17.0431 11.2251 17.2951 9.62497L17.5002 8.39218L16.6563 6.35491L16.5832 7.60639C16.5764 7.75055 16.5615 7.92059 16.5332 8.11074C16.3877 9.08943 15.8891 10.6011 14.3388 11.8603L13.8788 12.234C13.5086 12.5349 13.2133 12.7749 12.9854 12.9589C12.686 13.2007 12.5025 13.3462 12.4167 13.4079C12.4012 13.419 12.3888 13.4274 12.3796 13.4331C12.37 13.4391 12.3636 13.4422 12.3606 13.4425Z"
|
||||||
|
stroke="none"></path>
|
||||||
|
<path data-v-daf08565="" fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
d="M10 17.2C13.9765 17.2 17.2 13.9765 17.2 10C17.2 6.02355 13.9765 2.8 10 2.8C6.02355 2.8 2.8 6.02355 2.8 10C2.8 13.9765 6.02355 17.2 10 17.2ZM10 18C14.4183 18 18 14.4183 18 10C18 5.58172 14.4183 2 10 2C5.58172 2 2 5.58172 2 10C2 14.4183 5.58172 18 10 18Z"
|
||||||
|
stroke="none"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
4
public/old.svg
Normal file
4
public/old.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg fill="none" height="36" viewBox="0 0 36 36" width="36">
|
||||||
|
<path d="M11.29 2.92C14.85 1.33 18.87 1.06 22.61 2.15L22.96 2.26C26.56 3.40 29.67 5.74 31.75 8.89L31.95 9.19C33.90 12.28 34.77 15.93 34.42 19.56L34.38 19.93C34.04 22.79 32.96 25.51 31.25 27.83C29.53 30.14 27.23 31.97 24.59 33.12C21.95 34.27 19.05 34.71 16.18 34.40C13.32 34.08 10.59 33.02 8.26 31.32L7.97 31.10C4.87 28.73 2.71 25.33 1.88 21.52L3.34 21.20L4.81 20.88C5.49 24.00 7.25 26.77 9.79 28.72L10.27 29.07C12.19 30.40 14.41 31.22 16.74 31.44C19.06 31.65 21.40 31.27 23.53 30.31C25.66 29.35 27.50 27.86 28.88 25.98C30.26 24.10 31.13 21.89 31.40 19.58L31.46 18.98C31.68 16.00 30.90 13.03 29.25 10.54C27.60 8.05 25.17 6.18 22.34 5.22L21.77 5.04C19.02 4.23 16.08 4.33 13.38 5.31C10.68 6.29 8.37 8.11 6.77 10.5H10.5L10.65 10.50C11.03 10.54 11.38 10.73 11.63 11.02C11.88 11.31 12.01 11.69 11.99 12.07C11.97 12.46 11.81 12.82 11.53 13.08C11.25 13.35 10.88 13.49 10.5 13.5H1.5V4.5L1.50 4.34C1.54 3.97 1.71 3.63 1.99 3.38C2.27 3.13 2.62 3.00 3 3.00C3.37 3.00 3.72 3.13 4.00 3.38C4.28 3.63 4.45 3.97 4.49 4.34L4.5 4.5V8.51C6.21 6.07 8.56 4.13 11.29 2.92ZM24 18L15 12.75V23.25L24 18ZM3.02 19.73C2.63 19.82 2.29 20.05 2.08 20.39C1.86 20.72 1.79 21.13 1.88 21.52L4.81 20.88C4.77 20.69 4.69 20.50 4.57 20.34C4.46 20.18 4.32 20.04 4.15 19.94C3.99 19.83 3.80 19.76 3.61 19.72C3.41 19.69 3.21 19.69 3.02 19.73Z" fill="white">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
41
public/rewind.svg
Normal file
41
public/rewind.svg
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="50"
|
||||||
|
viewBox="0 0 50 50"
|
||||||
|
width="50"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
sodipodi:docname="rewind.svg"
|
||||||
|
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#505050"
|
||||||
|
bordercolor="#eeeeee"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#505050"
|
||||||
|
inkscape:zoom="8.9665068"
|
||||||
|
inkscape:cx="15.334846"
|
||||||
|
inkscape:cy="20.018944"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="1132"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg1" />
|
||||||
|
<path
|
||||||
|
d="M 15.44514,3.5259965 C 20.516099,1.2611577 26.242296,0.87656242 31.569652,2.4291878 l 0.49855,0.1566869 c 5.127937,1.6238467 9.557904,4.9570057 12.520712,9.4439503 l 0.284886,0.427328 c 2.777632,4.401479 4.016884,9.600638 3.518334,14.771307 l -0.05698,0.527038 c -0.484305,4.073861 -2.022686,7.948303 -4.458456,11.252973 -2.450014,3.290426 -5.726196,5.897127 -9.486683,7.535218 -3.760488,1.638091 -7.891326,2.264839 -11.979431,1.823267 -4.073861,-0.455817 -7.962546,-1.965709 -11.281461,-4.387235 L 10.716042,43.666347 C 6.3003187,40.290455 3.2235566,35.447404 2.0412822,30.020337 l 2.0796633,-0.455816 2.0939076,-0.455817 c 0.9686103,4.444212 3.4756017,8.389875 7.0936459,11.167507 l 0.683725,0.498549 c 2.7349,1.894488 5.897128,3.062518 9.216042,3.375892 3.304671,0.29913 6.63783,-0.242152 9.671859,-1.609602 3.034029,-1.36745 5.654975,-3.489846 7.620684,-6.167769 1.965709,-2.677922 3.204961,-5.825906 3.589556,-9.116332 l 0.08546,-0.854656 C 44.489204,22.157501 43.378151,17.926953 41.027847,14.38013 38.677543,10.833307 35.216185,8.1696282 31.185057,6.8021784 L 30.373134,6.5457816 C 26.45596,5.3919958 22.268144,5.5344384 18.422192,6.9303768 14.576239,8.3263152 11.285813,10.918772 9.0067299,14.323153 h 5.3131121 0.213664 c 0.541283,0.05698 1.039832,0.327618 1.395939,0.740702 0.356106,0.413083 0.541282,0.954366 0.512793,1.495648 -0.02849,0.555526 -0.256396,1.06832 -0.655236,1.438671 -0.39884,0.384595 -0.925877,0.584015 -1.46716,0.598259 H 1.5 V 5.776591 5.5486827 C 1.5569771,5.0216448 1.7991297,4.5373396 2.1979692,4.1812329 2.5968087,3.8251261 3.0953581,3.6399506 3.6366404,3.6399506 c 0.5270379,0 1.0255874,0.1851755 1.4244269,0.5412823 0.3988395,0.3561067 0.6409921,0.8404119 0.6979692,1.3674498 l 0.014244,0.2279083 v 5.711952 C 8.2090508,8.0129413 11.556454,5.2495531 15.44514,3.5259965 Z M 3.6651289,27.470613 C 3.1096024,27.598811 2.6252973,27.92643 2.3261676,28.410735 2.0127937,28.880796 1.9130838,29.464811 2.0412822,30.020337 L 6.2148531,29.108704 C 6.157876,28.838063 6.0439219,28.567422 5.8729906,28.339513 5.7163037,28.111605 5.5168839,27.912185 5.2747313,27.769743 5.046823,27.613056 4.7761819,27.513346 4.5055408,27.456369 4.2206554,27.413636 3.93577,27.413636 3.6651289,27.470613 Z"
|
||||||
|
fill="#ffffff"
|
||||||
|
id="path1"
|
||||||
|
sodipodi:nodetypes="ccccccccscccccccccssccsccscccsccccccssscccccccccccc"
|
||||||
|
style="stroke-width:1.42443" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
65
scripts/update-esid.ts
Normal file
65
scripts/update-esid.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { db } from '../server/services/db'
|
||||||
|
import { tracks, compilations, playlists, sql } from '../server/schema'
|
||||||
|
import { eq, and } from 'drizzle-orm'
|
||||||
|
|
||||||
|
async function updateEsid() {
|
||||||
|
console.log('🚀 Démarrage de la mise à jour des esid')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Mettre à jour les esid pour les compilations
|
||||||
|
console.log('📋 Mise à jour des esid pour les compilations...')
|
||||||
|
await db.run(sql`
|
||||||
|
UPDATE tracks
|
||||||
|
SET esid = (
|
||||||
|
SELECT c.box_id || c.side || t."order"
|
||||||
|
FROM compilations c
|
||||||
|
WHERE c.id = tracks.source_id AND tracks.source_type = 'compilation'
|
||||||
|
)
|
||||||
|
WHERE source_type = 'compilation';
|
||||||
|
`)
|
||||||
|
|
||||||
|
// 2. Mettre à jour les esid pour les playlists
|
||||||
|
console.log('📋 Mise à jour des esid pour les playlists...')
|
||||||
|
|
||||||
|
// Récupérer toutes les playlists
|
||||||
|
const allPlaylists = await db.select().from(playlists).all()
|
||||||
|
|
||||||
|
for (const playlist of allPlaylists) {
|
||||||
|
// Récupérer les tracks de la playlist triés par ordre
|
||||||
|
const playlistTracks = await db
|
||||||
|
.select()
|
||||||
|
.from(tracks)
|
||||||
|
.where(and(eq(tracks.sourceType, 'playlist'), eq(tracks.sourceId, playlist.id)))
|
||||||
|
.orderBy(tracks.order)
|
||||||
|
|
||||||
|
// Mettre à jour chaque track avec le bon esid
|
||||||
|
for (let i = 0; i < playlistTracks.length; i++) {
|
||||||
|
const track = playlistTracks[i]
|
||||||
|
const esidSuffix =
|
||||||
|
i < 26
|
||||||
|
? String.fromCharCode(65 + i) // A-Z pour les 26 premières pistes
|
||||||
|
: (i + 1).toString() // Numéros pour les suivantes
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(tracks)
|
||||||
|
.set({ esid: `${playlist.name}${esidSuffix}` })
|
||||||
|
.where(eq(tracks.id, track.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Playlist ${playlist.name} mise à jour (${playlistTracks.length} pistes)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✅ Tous les esid ont été mis à jour avec succès !')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur lors de la mise à jour des esid:', error)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exécuter le script
|
||||||
|
updateEsid()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Erreur inattendue:', error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
# Migration vers SQLite - Guide d'utilisation
|
|
||||||
|
|
||||||
## 📦 Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm add better-sqlite3
|
|
||||||
pnpm add -D @types/better-sqlite3
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Migration des données
|
|
||||||
|
|
||||||
### 1. Exécuter la migration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm tsx server/database/migrate.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Cette commande va :
|
|
||||||
|
|
||||||
- Créer la base de données SQLite dans `server/database/evilspins.db`
|
|
||||||
- Créer les tables (boxes, sides, artists, tracks)
|
|
||||||
- Importer toutes vos données existantes
|
|
||||||
|
|
||||||
### 2. Vérifier la migration
|
|
||||||
|
|
||||||
Lancez votre serveur Nuxt :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Testez les nouveaux endpoints :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Récupérer toutes les boxes
|
|
||||||
curl http://localhost:3000/api/boxes
|
|
||||||
|
|
||||||
# Récupérer tous les artistes
|
|
||||||
curl http://localhost:3000/api/artists
|
|
||||||
|
|
||||||
# Récupérer les tracks de compilation (première page)
|
|
||||||
curl http://localhost:3000/api/tracks/compilation
|
|
||||||
|
|
||||||
# Filtrer par boxId
|
|
||||||
curl http://localhost:3000/api/tracks/compilation?boxId=ES00
|
|
||||||
|
|
||||||
# Pagination
|
|
||||||
curl http://localhost:3000/api/tracks/compilation?page=2&limit=10
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📁 Structure créée
|
|
||||||
|
|
||||||
```
|
|
||||||
server/
|
|
||||||
├── database/
|
|
||||||
│ ├── evilspins.db # Base SQLite (créée automatiquement)
|
|
||||||
│ ├── schema.sql # Schéma de la base
|
|
||||||
│ └── migrate.ts # Script de migration
|
|
||||||
├── utils/
|
|
||||||
│ └── database.ts # Utilitaire de connexion
|
|
||||||
└── api/
|
|
||||||
├── boxes.ts # ✅ Nouveau (SQLite)
|
|
||||||
├── artists.ts # ✅ Nouveau (SQLite)
|
|
||||||
└── tracks/
|
|
||||||
├── compilation.ts # ✅ Nouveau (SQLite avec pagination)
|
|
||||||
└── playlist.ts # ⚠️ À adapter
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔄 Côté client : utiliser la pagination
|
|
||||||
|
|
||||||
Exemple pour charger les tracks progressivement :
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Au lieu de charger tout d'un coup
|
|
||||||
const { data } = await useFetch('/api/tracks/compilation')
|
|
||||||
|
|
||||||
// Maintenant avec pagination
|
|
||||||
const { data } = await useFetch('/api/tracks/compilation', {
|
|
||||||
query: {
|
|
||||||
page: 1,
|
|
||||||
limit: 50,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'A'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// data.tracks -> tableau de tracks
|
|
||||||
// data.pagination -> { page, limit, total, totalPages }
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📊 Avantages obtenus
|
|
||||||
|
|
||||||
✅ **Performances** : Plus de chargement massif, pagination efficace
|
|
||||||
✅ **Scalabilité** : Peut gérer des milliers de tracks sans ralentir
|
|
||||||
✅ **Filtrage** : Recherche et filtres côté serveur (ultra rapide)
|
|
||||||
✅ **Déploiement** : Un seul fichier `.db` à déployer
|
|
||||||
|
|
||||||
## 🔧 À faire ensuite
|
|
||||||
|
|
||||||
### 1. Adapter l'endpoint playlist
|
|
||||||
|
|
||||||
L'endpoint `tracks/playlist.ts` lit des fichiers sur disque. Options :
|
|
||||||
|
|
||||||
**Option A** : Scanner le dossier au démarrage et insérer dans SQLite
|
|
||||||
**Option B** : Garder la lecture filesystem mais optimiser avec un cache
|
|
||||||
|
|
||||||
### 2. Modifier le frontend
|
|
||||||
|
|
||||||
Mettre à jour vos composants Vue pour utiliser la pagination :
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup>
|
|
||||||
const page = ref(1)
|
|
||||||
const { data, refresh } = await useFetch('/api/tracks/compilation', {
|
|
||||||
query: { page, limit: 20 }
|
|
||||||
})
|
|
||||||
|
|
||||||
function loadMore() {
|
|
||||||
page.value++
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Ajouter des fonctionnalités
|
|
||||||
|
|
||||||
Exemples de requêtes possibles maintenant :
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Recherche par titre
|
|
||||||
GET /api/tracks/compilation?search=love
|
|
||||||
|
|
||||||
// Tracks d'un artiste
|
|
||||||
GET /api/tracks/compilation?artistId=5
|
|
||||||
|
|
||||||
// Tri personnalisé
|
|
||||||
GET /api/tracks/compilation?sortBy=title&order=asc
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🐛 Dépannage
|
|
||||||
|
|
||||||
### Erreur "Cannot find module 'better-sqlite3'"
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm add better-sqlite3
|
|
||||||
```
|
|
||||||
|
|
||||||
### La base ne se crée pas
|
|
||||||
|
|
||||||
Vérifiez les permissions :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod -R 755 server/database
|
|
||||||
```
|
|
||||||
|
|
||||||
### Données manquantes après migration
|
|
||||||
|
|
||||||
Re-exécutez la migration :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm tsx scripts/migrate.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📝 Notes
|
|
||||||
|
|
||||||
- La base SQLite est créée automatiquement au premier lancement
|
|
||||||
- Elle est incluse dans `.gitignore` par défaut (à ajuster selon vos besoins)
|
|
||||||
- Pour un déploiement, commitez le fichier `.db` OU re-exécutez la migration en production
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { eventHandler } from 'h3'
|
|
||||||
import { getDatabase } from '../utils/database'
|
|
||||||
|
|
||||||
export default eventHandler(() => {
|
|
||||||
const db = getDatabase()
|
|
||||||
|
|
||||||
const artists = db
|
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT id, name, url, cover_id
|
|
||||||
FROM artists
|
|
||||||
ORDER BY id
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
|
|
||||||
return artists.map((artist: any) => ({
|
|
||||||
id: artist.id,
|
|
||||||
name: artist.name,
|
|
||||||
url: artist.url,
|
|
||||||
coverId: `https://f4.bcbits.com/img/${artist.cover_id}_4.jpg`
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { eventHandler } from 'h3'
|
|
||||||
import { getDatabase } from '../utils/database'
|
|
||||||
|
|
||||||
export default eventHandler(() => {
|
|
||||||
const db = getDatabase()
|
|
||||||
|
|
||||||
// Récupérer les boxes
|
|
||||||
const boxes = db
|
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT id, type, name, description, state, duration, active_side, color1, color2
|
|
||||||
FROM boxes
|
|
||||||
ORDER BY id
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
|
|
||||||
// Récupérer les sides pour chaque box
|
|
||||||
const sides = db
|
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT box_id, side, name, description, duration, color1, color2
|
|
||||||
FROM sides
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
|
|
||||||
// Grouper les sides par box_id
|
|
||||||
const sidesByBoxId: Record<string, any> = {}
|
|
||||||
for (const side of sides) {
|
|
||||||
if (!sidesByBoxId[side.box_id]) {
|
|
||||||
sidesByBoxId[side.box_id] = {}
|
|
||||||
}
|
|
||||||
sidesByBoxId[side.box_id][side.side] = {
|
|
||||||
name: side.name,
|
|
||||||
description: side.description,
|
|
||||||
duration: side.duration,
|
|
||||||
color1: side.color1,
|
|
||||||
color2: side.color2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Formater les résultats
|
|
||||||
return boxes.map((box: any) => ({
|
|
||||||
id: box.id,
|
|
||||||
type: box.type,
|
|
||||||
name: box.name,
|
|
||||||
description: box.description,
|
|
||||||
state: box.state,
|
|
||||||
duration: box.duration,
|
|
||||||
activeSide: box.active_side,
|
|
||||||
...(box.type === 'compilation'
|
|
||||||
? {
|
|
||||||
sides: sidesByBoxId[box.id] || {}
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
color1: box.color1,
|
|
||||||
color2: box.color2
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
41
server/api/card/[slug].ts
Normal file
41
server/api/card/[slug].ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { useDB, schema } from '../../db'
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Slug manquant dans la requête'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = useDB()
|
||||||
|
const card = await db.select().from(schema.cards).where(eq(schema.cards.slug, slug)).get()
|
||||||
|
|
||||||
|
if (!card) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Morceau non trouvé'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: card.id,
|
||||||
|
esid: card.esid,
|
||||||
|
title: card.title,
|
||||||
|
artist: card.artist,
|
||||||
|
url_audio: card.url_audio,
|
||||||
|
url_image: card.url_image,
|
||||||
|
year: card.year,
|
||||||
|
month: card.month,
|
||||||
|
day: card.day,
|
||||||
|
hour: card.hour,
|
||||||
|
slug: card.slug,
|
||||||
|
suit: card.suit,
|
||||||
|
rank: card.rank,
|
||||||
|
createdAt: card.createdAt,
|
||||||
|
updatedAt: card.updatedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
15
server/api/card/random.ts
Normal file
15
server/api/card/random.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { useDB, schema } from '../../db'
|
||||||
|
import { sql } from 'drizzle-orm'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const db = useDB()
|
||||||
|
|
||||||
|
const count = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(schema.cards)
|
||||||
|
.get()
|
||||||
|
const randomOffset = Math.floor(Math.random() * count.count)
|
||||||
|
const randomCard = await db.select().from(schema.cards).limit(1).offset(randomOffset).get()
|
||||||
|
|
||||||
|
return randomCard
|
||||||
|
})
|
||||||
41
server/api/cards/[esid].ts
Normal file
41
server/api/cards/[esid].ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { useDB, schema } from '../../db'
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const esid = getRouterParam(event, 'esid')
|
||||||
|
|
||||||
|
if (!esid) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'ESID manquant dans la requête'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = useDB()
|
||||||
|
const card = await db.select().from(schema.cards).where(eq(schema.cards.esid, esid)).get()
|
||||||
|
|
||||||
|
if (!card) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Morceau non trouvé'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: card.id,
|
||||||
|
esid: card.esid,
|
||||||
|
title: card.title,
|
||||||
|
artist: card.artist,
|
||||||
|
url_audio: card.url_audio,
|
||||||
|
url_image: card.url_image,
|
||||||
|
year: card.year,
|
||||||
|
month: card.month,
|
||||||
|
day: card.day,
|
||||||
|
hour: card.hour,
|
||||||
|
slug: card.slug,
|
||||||
|
suit: card.suit,
|
||||||
|
rank: card.rank,
|
||||||
|
createdAt: card.createdAt,
|
||||||
|
updatedAt: card.updatedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
84
server/api/cards/index.ts
Normal file
84
server/api/cards/index.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { and, eq, ilike, or, sql } from 'drizzle-orm'
|
||||||
|
import { useDB, schema } from '../../db'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20 // Nombre d'éléments par page
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const page = Number(query.page) || 1
|
||||||
|
const search = query.search?.toString()
|
||||||
|
const cardRank = query.rank?.toString()
|
||||||
|
const cardSuit = query.suit?.toString()
|
||||||
|
const year = query.year?.toString()
|
||||||
|
|
||||||
|
const db = useDB()
|
||||||
|
const offset = (page - 1) * PAGE_SIZE
|
||||||
|
|
||||||
|
// Log pour débogage
|
||||||
|
console.log('Requête avec paramètres:', { search, cardRank, cardSuit, year })
|
||||||
|
console.log('Schéma des cards:', Object.keys(schema.cards))
|
||||||
|
|
||||||
|
// Construction des conditions de filtrage
|
||||||
|
const conditions = []
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const searchTerm = `%${search}%`
|
||||||
|
conditions.push(
|
||||||
|
or(ilike(schema.cards.title, searchTerm), ilike(schema.cards.artist, searchTerm))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardRank) {
|
||||||
|
conditions.push(eq(schema.cards.rank, cardRank))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardSuit) {
|
||||||
|
conditions.push(eq(schema.cards.suit, cardSuit))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (year) {
|
||||||
|
conditions.push(eq(schema.cards.year, year))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requête pour le comptage total
|
||||||
|
const countQuery = db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(schema.cards)
|
||||||
|
.$dynamic()
|
||||||
|
|
||||||
|
// Log pour débogage SQL
|
||||||
|
console.log('Requête de comptage SQL:', countQuery.toSQL())
|
||||||
|
|
||||||
|
// Requête pour les données paginées
|
||||||
|
const cardsQuery = db
|
||||||
|
.select()
|
||||||
|
.from(schema.cards)
|
||||||
|
.$dynamic()
|
||||||
|
.limit(PAGE_SIZE)
|
||||||
|
.offset(offset)
|
||||||
|
.orderBy(schema.cards.title)
|
||||||
|
|
||||||
|
// Application des conditions si elles existent
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
const where = and(...conditions)
|
||||||
|
countQuery.where(where)
|
||||||
|
cardsQuery.where(where)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [countResult, cards] = await Promise.all([countQuery, cardsQuery])
|
||||||
|
|
||||||
|
const totalItems = countResult[0]?.count || 0
|
||||||
|
const totalPages = Math.ceil(totalItems / PAGE_SIZE)
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: cards,
|
||||||
|
pagination: {
|
||||||
|
currentPage: page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
totalItems,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPreviousPage: page > 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
27
server/api/sync-cards.post.ts
Normal file
27
server/api/sync-cards.post.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { syncCardsWithDatabase } from '../services/cardSync.service'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
const folderPath = runtimeConfig.NUXT_PATH_FILES
|
||||||
|
|
||||||
|
if (!folderPath) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'NUXT_PATH_FILES not configured'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncCardsWithDatabase(folderPath)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
...result
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
21
server/api/test/test-db-sync.post.ts
Normal file
21
server/api/test/test-db-sync.post.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { syncCardsWithDatabase } from '../../services/cardSync.service'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
const folderPath = runtimeConfig.NUXT_PATH_FILES
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncCardsWithDatabase(folderPath)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
...result
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
29
server/api/test/test-scanner.get.ts
Normal file
29
server/api/test/test-scanner.get.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { scanMusicFolder } from '../../utils/fileScanner'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
const folderPath = runtimeConfig.NUXT_PATH_FILES
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: Vérifier que le dossier existe
|
||||||
|
const { access } = await import('node:fs/promises')
|
||||||
|
await access(folderPath)
|
||||||
|
|
||||||
|
// Test 2: Scanner le dossier
|
||||||
|
const cards = await scanMusicFolder(folderPath)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
folderPath,
|
||||||
|
cardsFound: cards.length,
|
||||||
|
cards: cards.slice(0, 5), // Afficher seulement les 5 premiers
|
||||||
|
sample: cards[0] // Un exemple complet
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
folderPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { eventHandler, getQuery } from 'h3'
|
|
||||||
import { getDatabase } from '../../utils/database'
|
|
||||||
|
|
||||||
export default eventHandler((event) => {
|
|
||||||
const db = getDatabase()
|
|
||||||
const query = getQuery(event)
|
|
||||||
|
|
||||||
// Paramètres de pagination
|
|
||||||
const page = parseInt(query.page as string) || 1
|
|
||||||
const limit = parseInt(query.limit as string) || 50
|
|
||||||
const offset = (page - 1) * limit
|
|
||||||
|
|
||||||
// Filtres optionnels
|
|
||||||
const boxId = query.boxId as string | undefined
|
|
||||||
const side = query.side as string | undefined
|
|
||||||
|
|
||||||
// Construction de la requête
|
|
||||||
let sql = `
|
|
||||||
SELECT
|
|
||||||
t.id,
|
|
||||||
t.box_id,
|
|
||||||
t.side,
|
|
||||||
t.track_order,
|
|
||||||
t.title,
|
|
||||||
t.artist_id,
|
|
||||||
t.start,
|
|
||||||
t.link,
|
|
||||||
t.cover_id,
|
|
||||||
t.url,
|
|
||||||
t.type,
|
|
||||||
a.name as artist_name
|
|
||||||
FROM tracks t
|
|
||||||
LEFT JOIN artists a ON t.artist_id = a.id
|
|
||||||
WHERE t.type = 'compilation'
|
|
||||||
`
|
|
||||||
|
|
||||||
const params: any[] = []
|
|
||||||
|
|
||||||
if (boxId) {
|
|
||||||
sql += ' AND t.box_id = ?'
|
|
||||||
params.push(boxId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (side) {
|
|
||||||
sql += ' AND t.side = ?'
|
|
||||||
params.push(side)
|
|
||||||
}
|
|
||||||
|
|
||||||
sql += ' ORDER BY t.box_id, t.side, t.track_order'
|
|
||||||
sql += ' LIMIT ? OFFSET ?'
|
|
||||||
params.push(limit, offset)
|
|
||||||
|
|
||||||
const tracks = db.prepare(sql).all(...params)
|
|
||||||
|
|
||||||
// Compter le total pour la pagination
|
|
||||||
let countSql = "SELECT COUNT(*) as total FROM tracks WHERE type = 'compilation'"
|
|
||||||
const countParams: any[] = []
|
|
||||||
|
|
||||||
if (boxId) {
|
|
||||||
countSql += ' AND box_id = ?'
|
|
||||||
countParams.push(boxId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (side) {
|
|
||||||
countSql += ' AND side = ?'
|
|
||||||
countParams.push(side)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { total } = db.prepare(countSql).get(...countParams) as { total: number }
|
|
||||||
|
|
||||||
return {
|
|
||||||
tracks: tracks.map((track: any) => ({
|
|
||||||
id: track.id,
|
|
||||||
boxId: track.box_id,
|
|
||||||
side: track.side,
|
|
||||||
order: track.track_order,
|
|
||||||
title: track.title,
|
|
||||||
artist: track.artist_id,
|
|
||||||
artistName: track.artist_name,
|
|
||||||
start: track.start,
|
|
||||||
link: track.link,
|
|
||||||
coverId: track.cover_id,
|
|
||||||
url: track.url,
|
|
||||||
type: track.type
|
|
||||||
})),
|
|
||||||
pagination: {
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
total,
|
|
||||||
totalPages: Math.ceil(total / limit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import fs from 'fs'
|
|
||||||
import path from 'path'
|
|
||||||
import { eventHandler } from 'h3'
|
|
||||||
import { getCardFromDate } from '../../../utils/cards'
|
|
||||||
|
|
||||||
export default eventHandler(async (event) => {
|
|
||||||
const dirPath = path.join(process.cwd(), '/mnt/media/files/music')
|
|
||||||
const urlPrefix = `https://files.erudi.fr/music`
|
|
||||||
|
|
||||||
try {
|
|
||||||
let allTracks: any[] = []
|
|
||||||
|
|
||||||
const items = await fs.promises.readdir(dirPath, { withFileTypes: true })
|
|
||||||
|
|
||||||
// Process files
|
|
||||||
const files = items
|
|
||||||
.filter((item) => item.isFile() && !item.name.startsWith('.') && !item.name.endsWith('.jpg'))
|
|
||||||
.map((item) => item.name)
|
|
||||||
|
|
||||||
// Process folders
|
|
||||||
const folders = items
|
|
||||||
.filter((item) => item.isDirectory() && !item.name.startsWith('.'))
|
|
||||||
.map((folder, index) => ({
|
|
||||||
id: `folder-${index}`,
|
|
||||||
boxId: 'ESFOLDER',
|
|
||||||
title: folder.name.replace(/_/g, ' ').trim(),
|
|
||||||
type: 'folder',
|
|
||||||
order: 0,
|
|
||||||
date: new Date(),
|
|
||||||
card: getCardFromDate(new Date())
|
|
||||||
}))
|
|
||||||
|
|
||||||
const tracks = files.map((file, index) => {
|
|
||||||
const EXT_RE = /\.(mp3|flac|wav|opus)$/i
|
|
||||||
const nameWithoutExt = file.replace(EXT_RE, '')
|
|
||||||
|
|
||||||
// On split sur __
|
|
||||||
const parts = nameWithoutExt.split('__')
|
|
||||||
let stamp = parts[0] || ''
|
|
||||||
let artist = parts[1] || ''
|
|
||||||
let title = parts[2] || ''
|
|
||||||
|
|
||||||
title = title.replaceAll('_', ' ')
|
|
||||||
artist = artist.replaceAll('_', ' ')
|
|
||||||
|
|
||||||
// Parser la date depuis le stamp
|
|
||||||
let year = 2020,
|
|
||||||
month = 1,
|
|
||||||
day = 1,
|
|
||||||
hour = 0
|
|
||||||
if (stamp.length === 10) {
|
|
||||||
year = Number(stamp.slice(0, 4))
|
|
||||||
month = Number(stamp.slice(4, 6))
|
|
||||||
day = Number(stamp.slice(6, 8))
|
|
||||||
hour = Number(stamp.slice(8, 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(year, month - 1, day, hour)
|
|
||||||
const card = getCardFromDate(date)
|
|
||||||
const url = `${urlPrefix}/${encodeURIComponent(file)}`
|
|
||||||
const coverId = `${urlPrefix}/cover/${encodeURIComponent(file).replace(EXT_RE, '.jpg')}`
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: Number(`${year}${index + 1}`),
|
|
||||||
boxId: `ESPLAYLIST`,
|
|
||||||
year,
|
|
||||||
date,
|
|
||||||
title: title.trim(),
|
|
||||||
artist: artist.trim(),
|
|
||||||
url,
|
|
||||||
coverId,
|
|
||||||
card,
|
|
||||||
order: 0,
|
|
||||||
type: 'playlist'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
tracks.sort((a, b) => b.date.getTime() - a.date.getTime())
|
|
||||||
// Combine folders and tracks
|
|
||||||
const allItems = [...folders, ...tracks]
|
|
||||||
|
|
||||||
// Sort by date (newest first) and assign order
|
|
||||||
allItems.sort((a, b) => b.date.getTime() - a.date.getTime())
|
|
||||||
allItems.forEach((item, i) => (item.order = i + 1))
|
|
||||||
|
|
||||||
return allItems
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: (error as Error).message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { eventHandler, getQuery } from 'h3'
|
|
||||||
import { getDatabase } from '../../utils/database'
|
|
||||||
|
|
||||||
export default eventHandler((event) => {
|
|
||||||
const db = getDatabase()
|
|
||||||
const query = getQuery(event)
|
|
||||||
|
|
||||||
const search = (query.search as string) || ''
|
|
||||||
const page = parseInt(query.page as string) || 1
|
|
||||||
const limit = parseInt(query.limit as string) || 50
|
|
||||||
const offset = (page - 1) * limit
|
|
||||||
|
|
||||||
// Construction de la requête de recherche
|
|
||||||
let sql = `
|
|
||||||
SELECT
|
|
||||||
t.id,
|
|
||||||
t.box_id,
|
|
||||||
t.side,
|
|
||||||
t.track_order,
|
|
||||||
t.title,
|
|
||||||
t.artist_id,
|
|
||||||
t.start,
|
|
||||||
t.link,
|
|
||||||
t.cover_id,
|
|
||||||
t.url,
|
|
||||||
t.type,
|
|
||||||
a.name as artist_name,
|
|
||||||
a.url as artist_url
|
|
||||||
FROM tracks t
|
|
||||||
LEFT JOIN artists a ON t.artist_id = a.id
|
|
||||||
WHERE 1=1
|
|
||||||
`
|
|
||||||
|
|
||||||
const params: any[] = []
|
|
||||||
|
|
||||||
// Recherche par titre ou artiste
|
|
||||||
if (search) {
|
|
||||||
sql += ` AND (t.title LIKE ? OR a.name LIKE ?)`
|
|
||||||
const searchPattern = `%${search}%`
|
|
||||||
params.push(searchPattern, searchPattern)
|
|
||||||
}
|
|
||||||
|
|
||||||
sql += ' ORDER BY t.box_id, t.track_order'
|
|
||||||
sql += ' LIMIT ? OFFSET ?'
|
|
||||||
params.push(limit, offset)
|
|
||||||
|
|
||||||
const tracks = db.prepare(sql).all(...params)
|
|
||||||
|
|
||||||
// Compter le total
|
|
||||||
let countSql = `
|
|
||||||
SELECT COUNT(*) as total
|
|
||||||
FROM tracks t
|
|
||||||
LEFT JOIN artists a ON t.artist_id = a.id
|
|
||||||
WHERE 1=1
|
|
||||||
`
|
|
||||||
|
|
||||||
const countParams: any[] = []
|
|
||||||
|
|
||||||
if (search) {
|
|
||||||
countSql += ` AND (t.title LIKE ? OR a.name LIKE ?)`
|
|
||||||
const searchPattern = `%${search}%`
|
|
||||||
countParams.push(searchPattern, searchPattern)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { total } = db.prepare(countSql).get(...countParams) as { total: number }
|
|
||||||
|
|
||||||
return {
|
|
||||||
tracks: tracks.map((track: any) => ({
|
|
||||||
id: track.id,
|
|
||||||
boxId: track.box_id,
|
|
||||||
side: track.side,
|
|
||||||
order: track.track_order,
|
|
||||||
title: track.title,
|
|
||||||
artist: track.artist_id,
|
|
||||||
artistName: track.artist_name,
|
|
||||||
artistUrl: track.artist_url,
|
|
||||||
start: track.start,
|
|
||||||
link: track.link,
|
|
||||||
coverId: track.cover_id,
|
|
||||||
url: track.url,
|
|
||||||
type: track.type
|
|
||||||
})),
|
|
||||||
pagination: {
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
total,
|
|
||||||
totalPages: Math.ceil(total / limit)
|
|
||||||
},
|
|
||||||
search
|
|
||||||
}
|
|
||||||
})
|
|
||||||
Binary file not shown.
@@ -1,678 +0,0 @@
|
|||||||
import { getDatabase } from '../utils/database'
|
|
||||||
|
|
||||||
// Import des données depuis vos anciens fichiers
|
|
||||||
const boxes = [
|
|
||||||
{
|
|
||||||
id: 'ES01',
|
|
||||||
type: 'compilation',
|
|
||||||
name: '...',
|
|
||||||
description: '...',
|
|
||||||
state: 'box-hidden',
|
|
||||||
duration: 3487 + 3773,
|
|
||||||
sides: {
|
|
||||||
A: {
|
|
||||||
name: '...',
|
|
||||||
description: '...',
|
|
||||||
duration: 3487,
|
|
||||||
color1: '#c7b3aa',
|
|
||||||
color2: '#000100'
|
|
||||||
},
|
|
||||||
B: {
|
|
||||||
name: '... B',
|
|
||||||
description: '...',
|
|
||||||
duration: 3773,
|
|
||||||
color1: '#f7dd01',
|
|
||||||
color2: '#010103'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
activeSide: 'A'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ES00',
|
|
||||||
type: 'compilation',
|
|
||||||
name: 'manifeste',
|
|
||||||
description: 'Zero is for manifesto',
|
|
||||||
state: 'box-hidden',
|
|
||||||
duration: 2794 + 2470,
|
|
||||||
sides: {
|
|
||||||
A: {
|
|
||||||
name: 'manifeste',
|
|
||||||
description: 'Zero is for manifesto',
|
|
||||||
duration: 2794,
|
|
||||||
color1: '#ffffff',
|
|
||||||
color2: '#48959d'
|
|
||||||
},
|
|
||||||
B: {
|
|
||||||
name: 'manifeste B',
|
|
||||||
description: 'Even Zero has a b-side',
|
|
||||||
duration: 2470,
|
|
||||||
color1: '#0d01b9',
|
|
||||||
color2: '#3b7589'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
activeSide: 'A'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ESPLAYLIST',
|
|
||||||
type: 'playlist',
|
|
||||||
name: 'playlists',
|
|
||||||
duration: 0,
|
|
||||||
description: '♠♦♣♥',
|
|
||||||
state: 'box-hidden',
|
|
||||||
activeSide: 'A',
|
|
||||||
color1: '#fdec50ff',
|
|
||||||
color2: '#fdec50ff'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const artists = [
|
|
||||||
{ id: 0, name: "L'efondras", url: 'https://leffondras.bandcamp.com/music', coverId: '0024705317' },
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'The kundalini genie',
|
|
||||||
url: 'https://the-kundalini-genie.bandcamp.com',
|
|
||||||
coverId: '0012045550'
|
|
||||||
},
|
|
||||||
{ id: 2, name: 'Fontaines D.C.', url: 'https://fontainesdc.bandcamp.com', coverId: '0027327090' },
|
|
||||||
{ id: 3, name: 'Fontanarosa', url: 'https://fontanarosa.bandcamp.com', coverId: '0035380235' },
|
|
||||||
{ id: 4, name: 'Johnny mafia', url: 'https://johnnymafia.bandcamp.com', coverId: '0035009392' },
|
|
||||||
{ id: 5, name: 'New candys', url: 'https://newcandys.bandcamp.com', coverId: '0039963261' },
|
|
||||||
{ id: 6, name: 'Magic shoppe', url: 'https://magicshoppe.bandcamp.com', coverId: '0030748374' },
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
name: 'Les jaguars',
|
|
||||||
url: 'https://radiomartiko.bandcamp.com/album/surf-qu-b-cois',
|
|
||||||
coverId: '0016551336'
|
|
||||||
},
|
|
||||||
{ id: 8, name: 'TRAAMS', url: 'https://traams.bandcamp.com', coverId: '0028348410' },
|
|
||||||
{ id: 9, name: 'Blue orchid', url: 'https://blue-orchid.bandcamp.com', coverId: '0034796193' },
|
|
||||||
{ id: 10, name: 'I love UFO', url: 'https://bruitblanc.bandcamp.com', coverId: 'a2203158939' },
|
|
||||||
{
|
|
||||||
id: 11,
|
|
||||||
name: 'Kid Congo & The Pink Monkey Birds',
|
|
||||||
url: 'https://kidcongothepinkmonkeybirds.bandcamp.com/',
|
|
||||||
coverId: '0017196290'
|
|
||||||
},
|
|
||||||
{ id: 12, name: 'Firefriend', url: 'https://firefriend.bandcamp.com/', coverId: '0031072203' },
|
|
||||||
{ id: 13, name: 'Squid', url: 'https://squiduk.bandcamp.com/', coverId: '0037649385' },
|
|
||||||
{ id: 14, name: 'Lysistrata', url: 'https://lysistrata.bandcamp.com/', coverId: '0033900158' },
|
|
||||||
{
|
|
||||||
id: 15,
|
|
||||||
name: 'Pablo X Broadcasting Services',
|
|
||||||
url: 'https://pabloxbroadcastingservices.bandcamp.com/',
|
|
||||||
coverId: '0036956486'
|
|
||||||
},
|
|
||||||
{ id: 16, name: 'Night Beats', url: 'https://nightbeats.bandcamp.com/', coverId: '0036987720' },
|
|
||||||
{ id: 17, name: 'Deltron 3030', url: 'https://delthefunkyhomosapien.bandcamp.com/', coverId: '0005254781' },
|
|
||||||
{
|
|
||||||
id: 18,
|
|
||||||
name: 'The Amorphous Androgynous',
|
|
||||||
url: 'https://theaa.bandcamp.com/',
|
|
||||||
coverId: '0022226700'
|
|
||||||
},
|
|
||||||
{ id: 19, name: 'Wooden Shjips', url: 'https://woodenshjips.bandcamp.com/', coverId: '0012406678' },
|
|
||||||
{ id: 20, name: 'Silas J. Dirge', url: 'https://silasjdirge.bandcamp.com/', coverId: '0035751570' },
|
|
||||||
{ id: 21, name: 'Secret Colours', url: 'https://secretcolours.bandcamp.com/', coverId: '0010661379' },
|
|
||||||
{
|
|
||||||
id: 22,
|
|
||||||
name: 'Larry McNeil And The Blue Knights',
|
|
||||||
url: 'https://www.discogs.com/artist/6528940-Larry-McNeil-And-The-Blue-Knights',
|
|
||||||
coverId:
|
|
||||||
'https://i.discogs.com/Yr05_neEXwzPwKlDeV7dimmTG34atkAMgpxbMBhHBkI/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTEyMTEw/ODE1LTE1Mjg1NjU1/NzQtMjcyOC5qcGVn.jpeg'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 23,
|
|
||||||
name: 'Hugo Blanco',
|
|
||||||
url: 'https://elpalmasmusic.bandcamp.com/album/color-de-tr-pico-compiled-by-el-dr-gon-criollo-el-palmas',
|
|
||||||
coverId: '0016886708'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const tracks = [
|
|
||||||
{
|
|
||||||
order: 1,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'A',
|
|
||||||
title: 'The grinding wheel',
|
|
||||||
artist: 0,
|
|
||||||
start: 0,
|
|
||||||
link: 'https://arakirecords.bandcamp.com/track/the-grinding-wheel',
|
|
||||||
coverId: 'a3236746052'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 2,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'A',
|
|
||||||
title: 'Bleach',
|
|
||||||
artist: 1,
|
|
||||||
start: 392,
|
|
||||||
link: 'https://the-kundalini-genie.bandcamp.com/track/bleach-2',
|
|
||||||
coverId: 'a1714786533'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 3,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'A',
|
|
||||||
title: 'Televised mind',
|
|
||||||
artist: 2,
|
|
||||||
start: 896,
|
|
||||||
link: 'https://fontainesdc.bandcamp.com/track/televised-mind',
|
|
||||||
coverId: 'a3772806156'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 4,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'A',
|
|
||||||
title: 'In it',
|
|
||||||
artist: 3,
|
|
||||||
start: 1139,
|
|
||||||
link: 'https://howlinbananarecords.bandcamp.com/track/in-it',
|
|
||||||
coverId: 'a1720372066'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 5,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'A',
|
|
||||||
title: 'Bad michel',
|
|
||||||
artist: 4,
|
|
||||||
start: 1245,
|
|
||||||
link: 'https://johnnymafia.bandcamp.com/track/bad-michel-3',
|
|
||||||
coverId: 'a0984622869'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 6,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'A',
|
|
||||||
title: 'Overall',
|
|
||||||
artist: 5,
|
|
||||||
start: 1394,
|
|
||||||
link: 'https://newcandys.bandcamp.com/track/overall',
|
|
||||||
coverId: 'a0559661270'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 7,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'A',
|
|
||||||
title: 'Blowup',
|
|
||||||
artist: 6,
|
|
||||||
start: 1674,
|
|
||||||
link: 'https://magicshoppe.bandcamp.com/track/blowup',
|
|
||||||
coverId: 'a1444895293'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 8,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'A',
|
|
||||||
title: 'Guitar jet',
|
|
||||||
artist: 7,
|
|
||||||
start: 1880,
|
|
||||||
link: 'https://radiomartiko.bandcamp.com/track/guitare-jet',
|
|
||||||
coverId: 'a1494681687'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 9,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'A',
|
|
||||||
title: 'Intercontinental radio waves',
|
|
||||||
artist: 8,
|
|
||||||
start: 2024,
|
|
||||||
link: 'https://traams.bandcamp.com/track/intercontinental-radio-waves',
|
|
||||||
coverId: 'a0046738552'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 10,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'A',
|
|
||||||
title: 'Here comes the sun',
|
|
||||||
artist: 9,
|
|
||||||
start: 2211,
|
|
||||||
link: 'https://blue-orchid.bandcamp.com/track/here-come-the-sun',
|
|
||||||
coverId: 'a4102567047'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 11,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'A',
|
|
||||||
title: 'Like in the movies',
|
|
||||||
artist: 10,
|
|
||||||
start: 2560,
|
|
||||||
link: 'https://bruitblanc.bandcamp.com/track/like-in-the-movies-2',
|
|
||||||
coverId: 'a2203158939'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 1,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'B',
|
|
||||||
title: "Ce que révèle l'éclipse",
|
|
||||||
artist: 0,
|
|
||||||
start: 0,
|
|
||||||
link: 'https://arakirecords.bandcamp.com/track/ce-que-r-v-le-l-clipse',
|
|
||||||
coverId: 'a3236746052'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 2,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'B',
|
|
||||||
title: "Bleedin' Gums Mushrool",
|
|
||||||
artist: 1,
|
|
||||||
start: 263,
|
|
||||||
link: 'https://the-kundalini-genie.bandcamp.com/track/bleedin-gums-mushroom',
|
|
||||||
coverId: 'a1714786533'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 3,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'B',
|
|
||||||
title: 'A lucid dream',
|
|
||||||
artist: 2,
|
|
||||||
start: 554,
|
|
||||||
link: 'https://fontainesdc.bandcamp.com/track/a-lucid-dream',
|
|
||||||
coverId: 'a3772806156'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 4,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'B',
|
|
||||||
title: 'Lights off',
|
|
||||||
artist: 3,
|
|
||||||
start: 781,
|
|
||||||
link: 'https://howlinbananarecords.bandcamp.com/track/lights-off',
|
|
||||||
coverId: 'a1720372066'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 5,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'B',
|
|
||||||
title: "I'm sentimental",
|
|
||||||
artist: 4,
|
|
||||||
start: 969,
|
|
||||||
link: 'https://johnnymafia.bandcamp.com/track/im-sentimental-2',
|
|
||||||
coverId: 'a2333676849'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 6,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'B',
|
|
||||||
title: 'Thrill or trip',
|
|
||||||
artist: 5,
|
|
||||||
start: 1128,
|
|
||||||
link: 'https://newcandys.bandcamp.com/track/thrill-or-trip',
|
|
||||||
coverId: 'a0559661270'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 7,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'B',
|
|
||||||
title: 'Redhead',
|
|
||||||
artist: 6,
|
|
||||||
start: 1303,
|
|
||||||
link: 'https://magicshoppe.bandcamp.com/track/redhead',
|
|
||||||
coverId: 'a0594426943'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 8,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'B',
|
|
||||||
title: 'Supersonic twist',
|
|
||||||
artist: 7,
|
|
||||||
start: 1584,
|
|
||||||
link: 'https://open.spotify.com/track/66voQIZAJ3zD3Eju2qtNjF',
|
|
||||||
coverId: 'a1494681687'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 9,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'B',
|
|
||||||
title: 'Flowers',
|
|
||||||
artist: 8,
|
|
||||||
start: 1749,
|
|
||||||
link: 'https://traams.bandcamp.com/track/flowers',
|
|
||||||
coverId: 'a3644668199'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 10,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'B',
|
|
||||||
title: 'The shade',
|
|
||||||
artist: 9,
|
|
||||||
start: 1924,
|
|
||||||
link: 'https://blue-orchid.bandcamp.com/track/the-shade',
|
|
||||||
coverId: 'a0804204790'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 11,
|
|
||||||
boxId: 'ES00',
|
|
||||||
side: 'B',
|
|
||||||
title: 'Like in the movies',
|
|
||||||
artist: 10,
|
|
||||||
start: 2186,
|
|
||||||
link: 'https://bruitblanc.bandcamp.com/track/like-in-the-movies',
|
|
||||||
coverId: 'a3647322740'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 1,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'A',
|
|
||||||
title: 'He Walked In',
|
|
||||||
artist: 11,
|
|
||||||
start: 0,
|
|
||||||
link: 'https://kidcongothepinkmonkeybirds.bandcamp.com/track/he-walked-in',
|
|
||||||
coverId: 'a0336300523'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 2,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'A',
|
|
||||||
title: 'The Third Wave',
|
|
||||||
artist: 12,
|
|
||||||
start: 841,
|
|
||||||
link: 'https://firefriend.bandcamp.com/track/the-third-wave',
|
|
||||||
coverId: 'a2803689859'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 3,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'A',
|
|
||||||
title: 'Broadcaster',
|
|
||||||
artist: 13,
|
|
||||||
start: 1104.5,
|
|
||||||
link: 'https://squiduk.bandcamp.com/track/broadcaster',
|
|
||||||
coverId: 'a3391719769'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 4,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'A',
|
|
||||||
title: 'Mourn',
|
|
||||||
artist: 14,
|
|
||||||
start: 1441,
|
|
||||||
link: 'https://lysistrata.bandcamp.com/track/mourn-2',
|
|
||||||
coverId: 'a0872900041'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 5,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'A',
|
|
||||||
title: 'Let it Blow',
|
|
||||||
artist: 15,
|
|
||||||
start: 1844.8,
|
|
||||||
link: 'https://pabloxbroadcastingservices.bandcamp.com/track/let-it-blow',
|
|
||||||
coverId: 'a4000148031'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 6,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'A',
|
|
||||||
title: 'Sunday Mourning',
|
|
||||||
artist: 16,
|
|
||||||
start: 2091.7,
|
|
||||||
link: 'https://nightbeats.bandcamp.com/track/sunday-mourning',
|
|
||||||
coverId: 'a0031987121'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 7,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'A',
|
|
||||||
title: '3030 Instrumental',
|
|
||||||
artist: 17,
|
|
||||||
start: 2339.3,
|
|
||||||
link: 'https://delthefunkyhomosapien.bandcamp.com/track/3030',
|
|
||||||
coverId: 'a1948146136'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 8,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'A',
|
|
||||||
title: 'Immortality Break',
|
|
||||||
artist: 18,
|
|
||||||
start: 2530.5,
|
|
||||||
link: 'https://theaa.bandcamp.com/track/immortality-break',
|
|
||||||
coverId: 'a2749250329'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 9,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'A',
|
|
||||||
title: 'Lazy Bones',
|
|
||||||
artist: 19,
|
|
||||||
start: 2718,
|
|
||||||
link: 'https://woodenshjips.bandcamp.com/track/lazy-bones',
|
|
||||||
coverId: 'a1884221104'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 10,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'A',
|
|
||||||
title: 'On the Train of Aches',
|
|
||||||
artist: 20,
|
|
||||||
start: 2948,
|
|
||||||
link: 'https://silasjdirge.bandcamp.com/track/on-the-train-of-aches',
|
|
||||||
coverId: 'a1124177379'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 11,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'A',
|
|
||||||
title: 'Me',
|
|
||||||
artist: 21,
|
|
||||||
start: 3265,
|
|
||||||
link: 'https://secretcolours.bandcamp.com/track/me',
|
|
||||||
coverId: 'a1497022499'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 1,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'B',
|
|
||||||
title: 'Lady Hawke Blues',
|
|
||||||
artist: 11,
|
|
||||||
start: 0,
|
|
||||||
link: 'https://kidcongothepinkmonkeybirds.bandcamp.com/track/lady-hawke-blues',
|
|
||||||
coverId: 'a2532623230'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 2,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'B',
|
|
||||||
title: 'Dreamscapes',
|
|
||||||
artist: 12,
|
|
||||||
start: 235,
|
|
||||||
link: 'https://littlecloudrecords.bandcamp.com/track/dreamscapes',
|
|
||||||
coverId: 'a3498981203'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 3,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'B',
|
|
||||||
title: 'Crispy Skin',
|
|
||||||
artist: 13,
|
|
||||||
start: 644.2,
|
|
||||||
link: 'https://squiduk.bandcamp.com/track/crispy-skin-2',
|
|
||||||
coverId: 'a2516727021'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 4,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'B',
|
|
||||||
title: 'The Boy Who Stood Above The Earth',
|
|
||||||
artist: 14,
|
|
||||||
start: 1018,
|
|
||||||
link: 'https://lysistrata.bandcamp.com/track/the-boy-who-stood-above-the-earth-2',
|
|
||||||
coverId: 'a0350933426'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 5,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'B',
|
|
||||||
title: 'Better Off Alone',
|
|
||||||
artist: 15,
|
|
||||||
start: 1698,
|
|
||||||
link: 'https://pabloxbroadcastingservices.bandcamp.com/track/better-off-alone',
|
|
||||||
coverId: 'a4000148031'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 6,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'B',
|
|
||||||
title: 'Celebration #1',
|
|
||||||
artist: 16,
|
|
||||||
start: 2235,
|
|
||||||
link: 'https://nightbeats.bandcamp.com/track/celebration-1',
|
|
||||||
coverId: 'a0031987121'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 7,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'B',
|
|
||||||
title: '3030 Instrumental',
|
|
||||||
artist: 17,
|
|
||||||
start: 2458.3,
|
|
||||||
link: 'https://delthefunkyhomosapien.bandcamp.com/track/3030',
|
|
||||||
coverId: 'a1948146136'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 8,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'B',
|
|
||||||
title: 'The Emptiness Of Nothingness',
|
|
||||||
artist: 18,
|
|
||||||
start: 2864.5,
|
|
||||||
link: 'https://theaa.bandcamp.com/track/the-emptiness-of-nothingness',
|
|
||||||
coverId: 'a1053923875'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 9,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'B',
|
|
||||||
title: 'Rising',
|
|
||||||
artist: 19,
|
|
||||||
start: 3145,
|
|
||||||
link: 'https://woodenshjips.bandcamp.com/track/rising',
|
|
||||||
coverId: 'a1884221104'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 10,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'B',
|
|
||||||
title: 'The Last Time',
|
|
||||||
artist: 22,
|
|
||||||
start: 3447,
|
|
||||||
link: 'https://www.discogs.com/release/12110815-Larry-McNeil-And-The-Blue-Knights-Jealous-Woman',
|
|
||||||
coverId:
|
|
||||||
'https://i.discogs.com/Yr05_neEXwzPwKlDeV7dimmTG34atkAMgpxbMBhHBkI/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTEyMTEw/ODE1LTE1Mjg1NjU1/NzQtMjcyOC5qcGVn.jpeg'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 11,
|
|
||||||
boxId: 'ES01',
|
|
||||||
side: 'B',
|
|
||||||
title: 'Guajira Con Arpa',
|
|
||||||
artist: 23,
|
|
||||||
start: 3586,
|
|
||||||
link: 'https://elpalmasmusic.bandcamp.com/track/guajira-con-arpa',
|
|
||||||
coverId: 'a3463036407'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
export async function migrate() {
|
|
||||||
console.log('🚀 Début de la migration...')
|
|
||||||
|
|
||||||
const db = getDatabase()
|
|
||||||
|
|
||||||
// Vider les tables existantes
|
|
||||||
db.exec('DELETE FROM tracks')
|
|
||||||
db.exec('DELETE FROM sides')
|
|
||||||
db.exec('DELETE FROM artists')
|
|
||||||
db.exec('DELETE FROM boxes')
|
|
||||||
|
|
||||||
console.log('🗑️ Tables vidées')
|
|
||||||
|
|
||||||
// Insérer les boxes
|
|
||||||
const insertBox = db.prepare(`
|
|
||||||
INSERT INTO boxes (id, type, name, description, state, duration, active_side, color1, color2)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`)
|
|
||||||
|
|
||||||
const insertSide = db.prepare(`
|
|
||||||
INSERT INTO sides (box_id, side, name, description, duration, color1, color2)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`)
|
|
||||||
|
|
||||||
for (const box of boxes) {
|
|
||||||
insertBox.run(
|
|
||||||
box.id,
|
|
||||||
box.type,
|
|
||||||
box.name,
|
|
||||||
box.description,
|
|
||||||
box.state,
|
|
||||||
box.duration,
|
|
||||||
box.activeSide,
|
|
||||||
box.color1 || null,
|
|
||||||
box.color2 || null
|
|
||||||
)
|
|
||||||
|
|
||||||
// Insérer les sides si c'est une compilation
|
|
||||||
if (box.sides) {
|
|
||||||
for (const [sideName, sideData] of Object.entries(box.sides)) {
|
|
||||||
insertSide.run(
|
|
||||||
box.id,
|
|
||||||
sideName,
|
|
||||||
sideData.name,
|
|
||||||
sideData.description,
|
|
||||||
sideData.duration,
|
|
||||||
sideData.color1,
|
|
||||||
sideData.color2
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✅ ${boxes.length} boxes insérées`)
|
|
||||||
|
|
||||||
// Insérer les artists
|
|
||||||
const insertArtist = db.prepare(`
|
|
||||||
INSERT INTO artists (id, name, url, cover_id)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
`)
|
|
||||||
|
|
||||||
for (const artist of artists) {
|
|
||||||
insertArtist.run(artist.id, artist.name, artist.url, artist.coverId)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✅ ${artists.length} artistes insérés`)
|
|
||||||
|
|
||||||
// Insérer les tracks
|
|
||||||
const insertTrack = db.prepare(`
|
|
||||||
INSERT INTO tracks (box_id, side, track_order, title, artist_id, start, link, cover_id, url, type)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`)
|
|
||||||
|
|
||||||
for (const track of tracks) {
|
|
||||||
const url = `https://files.erudi.fr/evilspins/${track.boxId}${track.side}.mp3`
|
|
||||||
const coverId = `https://f4.bcbits.com/img/${track.coverId}_4.jpg`
|
|
||||||
|
|
||||||
insertTrack.run(
|
|
||||||
track.boxId,
|
|
||||||
track.side,
|
|
||||||
track.order,
|
|
||||||
track.title,
|
|
||||||
track.artist,
|
|
||||||
track.start,
|
|
||||||
track.link,
|
|
||||||
coverId,
|
|
||||||
url,
|
|
||||||
'compilation'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✅ ${tracks.length} tracks insérées`)
|
|
||||||
console.log('🎉 Migration terminée avec succès !')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exécuter la migration si appelé directement
|
|
||||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
||||||
migrate()
|
|
||||||
.then(() => process.exit(0))
|
|
||||||
.catch((err) => {
|
|
||||||
console.error('❌ Erreur lors de la migration:', err)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
-- Boxes table
|
|
||||||
CREATE TABLE IF NOT EXISTS boxes (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
state TEXT,
|
|
||||||
duration INTEGER,
|
|
||||||
active_side TEXT,
|
|
||||||
color1 TEXT,
|
|
||||||
color2 TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Sides table (pour les compilations qui ont A et B)
|
|
||||||
CREATE TABLE IF NOT EXISTS sides (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
box_id TEXT NOT NULL,
|
|
||||||
side TEXT NOT NULL,
|
|
||||||
name TEXT,
|
|
||||||
description TEXT,
|
|
||||||
duration INTEGER,
|
|
||||||
color1 TEXT,
|
|
||||||
color2 TEXT,
|
|
||||||
FOREIGN KEY (box_id) REFERENCES boxes(id) ON DELETE CASCADE,
|
|
||||||
UNIQUE(box_id, side)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Artists table
|
|
||||||
CREATE TABLE IF NOT EXISTS artists (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
url TEXT,
|
|
||||||
cover_id TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tracks table
|
|
||||||
CREATE TABLE IF NOT EXISTS tracks (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
box_id TEXT NOT NULL,
|
|
||||||
side TEXT,
|
|
||||||
track_order INTEGER,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
artist_id INTEGER,
|
|
||||||
start INTEGER,
|
|
||||||
link TEXT,
|
|
||||||
cover_id TEXT,
|
|
||||||
url TEXT,
|
|
||||||
type TEXT,
|
|
||||||
year INTEGER,
|
|
||||||
date DATETIME,
|
|
||||||
card TEXT,
|
|
||||||
FOREIGN KEY (box_id) REFERENCES boxes(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (artist_id) REFERENCES artists(id) ON DELETE SET NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Index pour les requêtes fréquentes
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tracks_box_id ON tracks(box_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tracks_artist_id ON tracks(artist_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tracks_type ON tracks(type);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tracks_year ON tracks(year);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sides_box_id ON sides(box_id);
|
|
||||||
27
server/db/index.ts
Normal file
27
server/db/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/libsql'
|
||||||
|
import * as schema from './schema'
|
||||||
|
|
||||||
|
let _db: ReturnType<typeof drizzle> | null = null
|
||||||
|
|
||||||
|
export function useDB() {
|
||||||
|
if (_db) return _db
|
||||||
|
|
||||||
|
let dbPath = 'data/music.db'
|
||||||
|
|
||||||
|
// Convertir le chemin en URL file:// si ce n'est pas déjà une URL
|
||||||
|
if (!dbPath.startsWith('file:') && !dbPath.startsWith('libsql:') && !dbPath.startsWith('http')) {
|
||||||
|
// Si c'est un chemin relatif, le rendre absolu
|
||||||
|
if (!dbPath.startsWith('/')) {
|
||||||
|
dbPath = `file:${process.cwd()}/${dbPath}`
|
||||||
|
} else {
|
||||||
|
dbPath = `file:${dbPath}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🗄️ Connexion à la DB:', dbPath)
|
||||||
|
|
||||||
|
_db = drizzle(dbPath, { schema })
|
||||||
|
return _db
|
||||||
|
}
|
||||||
|
|
||||||
|
export { schema }
|
||||||
24
server/db/schema.ts
Normal file
24
server/db/schema.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { sqliteTable, text, int } from 'drizzle-orm/sqlite-core'
|
||||||
|
|
||||||
|
export const cards = sqliteTable('cards', {
|
||||||
|
id: int('id').primaryKey({ autoIncrement: true }),
|
||||||
|
esid: text('esid').notNull(),
|
||||||
|
url_audio: text('url_audio').notNull(),
|
||||||
|
url_image: text('url_image').notNull(),
|
||||||
|
year: text('year').notNull(),
|
||||||
|
month: text('month').notNull(),
|
||||||
|
day: text('day').notNull(),
|
||||||
|
hour: text('hour').notNull(),
|
||||||
|
artist: text('artist').notNull(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
slug: text('slug').notNull(),
|
||||||
|
suit: text('suit').notNull(),
|
||||||
|
rank: text('rank').notNull(),
|
||||||
|
blurhash: text('blurhash').notNull(), // blurhash of the image
|
||||||
|
createdAt: int('created_at', { mode: 'timestamp' })
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date()),
|
||||||
|
updatedAt: int('updated_at', { mode: 'timestamp' })
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date())
|
||||||
|
})
|
||||||
586
server/db/start.prompt
Normal file
586
server/db/start.prompt
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
Je développe une application musicale en Node.js qui gère des morceaux de musique (tracks). Voici l'architecture actuelle des données :Tracks : Morceaux de musique stockés sous forme de fichiers audio.
|
||||||
|
Playlists : Ensembles de tracks regroupés dans un dossier spécifique sur le disque (ex. : un dossier par playlist). Actuellement, à chaque requête, le serveur scanne récursivement le dossier pour lister les tracks, ce qui génère la playlist dynamiquement.
|
||||||
|
Compilations : Ensembles de tracks mixés ensemble, représentant un seul fichier audio unifié. Actuellement, les compilations sont hardcodées dans le code (pas de scan dynamique).
|
||||||
|
|
||||||
|
Problèmes actuels :Performances : Le scan des dossiers pour les playlists prend ~6 secondes par requête, ce qui est inacceptable pour une bonne UX.
|
||||||
|
Manque d'uniformité : Les playlists sont générées dynamiquement (lourd), tandis que les compilations sont statiques (hardcodées), rendant la maintenance difficile.
|
||||||
|
Pas de persistance : Aucune base de données, donc pas de cache ni de requêtes rapides.
|
||||||
|
|
||||||
|
Objectifs :Utiliser une base de données SQLite comme cache pour stocker les métadonnées des tracks, playlists et compilations, afin d'éviter les scans disque à chaque requête.
|
||||||
|
Uniformiser l'architecture : Stocker à la fois les playlists (issues de scans de dossiers) et les compilations en base.
|
||||||
|
Améliorer les performances : Les requêtes doivent renvoyer les données depuis la DB en <1 seconde, avec un scan disque initial ou périodique pour mise à jour.
|
||||||
|
ORM : Utiliser Drizzle ORM pour interagir avec SQLite (facile à setup en Node.js).
|
||||||
|
Schéma DB suggéré (à affiner si needed) :
|
||||||
|
Table tracks : id, title, artist, duration, file_path (chemin du fichier), source_type ('playlist' ou 'compilation'), source_id (FK vers playlist ou compilation).
|
||||||
|
Table compilations : id, name, file_path (chemin du fichier mixé), tracks_list (JSON ou relation many-to-many si needed).
|
||||||
|
|
||||||
|
Contraintes techniques :DB : SQLite uniquement (fichier local, pas de serveur).
|
||||||
|
Environnement : Node.js (version récente, ex. 18+). Pas d'installation de paquets incompatibles. Deployable dans une app Nuxt 4, la partie typescript sera écrite dans le dossier server prevu dans nuxt avec h3.
|
||||||
|
Gestion des mises à jour : Implémenter un mécanisme pour re-scanner les dossiers playlists seulement si des changements sont détectés (ex. : via fs.watch ou comparaison de timestamps). Pour les compilations, permettre un import manuel ou initial.
|
||||||
|
Population initiale : Au démarrage de l'app, scanner les dossiers playlists et importer les compilations hardcodées actuelles en DB.
|
||||||
|
|
||||||
|
Output attendu :Étapes détaillées pour implémenter cela (setup DB, schéma avec Drizzle, code pour population initiale et mises à jour).
|
||||||
|
Exemples de code Node.js : Setup de Drizzle avec SQLite.
|
||||||
|
Fonctions pour scanner le dossier playlists
|
||||||
|
Exemples de requêtes (ex. : getPlaylistById depuis DB, avec fallback sur scan si cache invalide).
|
||||||
|
Gestion des erreurs et optimisations perf.
|
||||||
|
|
||||||
|
Si possible, un schéma DB en SQL ou Drizzle schema.ts.
|
||||||
|
Assure-toi que la solution est scalable pour ~1000 tracks initiaux, et teste les perfs dans tes exemples.
|
||||||
|
|
||||||
|
|
||||||
|
voici ma structure de projet actuelle :
|
||||||
|
|
||||||
|
compilation :
|
||||||
|
import { eventHandler } from 'h3'
|
||||||
|
|
||||||
|
export default eventHandler(() => {
|
||||||
|
const tracks = [
|
||||||
|
{
|
||||||
|
order: 1,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'A',
|
||||||
|
title: 'The grinding wheel',
|
||||||
|
artist: 0,
|
||||||
|
start: 0,
|
||||||
|
link: 'https://arakirecords.bandcamp.com/track/the-grinding-wheel',
|
||||||
|
coverId: 'a3236746052'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 2,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'A',
|
||||||
|
title: 'Bleach',
|
||||||
|
artist: 1,
|
||||||
|
start: 392,
|
||||||
|
link: 'https://the-kundalini-genie.bandcamp.com/track/bleach-2',
|
||||||
|
coverId: 'a1714786533'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 3,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'A',
|
||||||
|
title: 'Televised mind',
|
||||||
|
artist: 2,
|
||||||
|
start: 896,
|
||||||
|
link: 'https://fontainesdc.bandcamp.com/track/televised-mind',
|
||||||
|
coverId: 'a3772806156'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 4,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'A',
|
||||||
|
title: 'In it',
|
||||||
|
artist: 3,
|
||||||
|
start: 1139,
|
||||||
|
link: 'https://howlinbananarecords.bandcamp.com/track/in-it',
|
||||||
|
coverId: 'a1720372066'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 5,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'A',
|
||||||
|
title: 'Bad michel',
|
||||||
|
artist: 4,
|
||||||
|
start: 1245,
|
||||||
|
link: 'https://johnnymafia.bandcamp.com/track/bad-michel-3',
|
||||||
|
coverId: 'a0984622869'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 6,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'A',
|
||||||
|
title: 'Overall',
|
||||||
|
artist: 5,
|
||||||
|
start: 1394,
|
||||||
|
link: 'https://newcandys.bandcamp.com/track/overall',
|
||||||
|
coverId: 'a0559661270'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 7,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'A',
|
||||||
|
title: 'Blowup',
|
||||||
|
artist: 6,
|
||||||
|
start: 1674,
|
||||||
|
link: 'https://magicshoppe.bandcamp.com/track/blowup',
|
||||||
|
coverId: 'a1444895293'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 8,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'A',
|
||||||
|
title: 'Guitar jet',
|
||||||
|
artist: 7,
|
||||||
|
start: 1880,
|
||||||
|
link: 'https://radiomartiko.bandcamp.com/track/guitare-jet',
|
||||||
|
coverId: 'a1494681687'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 9,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'A',
|
||||||
|
title: 'Intercontinental radio waves',
|
||||||
|
artist: 8,
|
||||||
|
start: 2024,
|
||||||
|
link: 'https://traams.bandcamp.com/track/intercontinental-radio-waves',
|
||||||
|
coverId: 'a0046738552'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 10,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'A',
|
||||||
|
title: 'Here comes the sun',
|
||||||
|
artist: 9,
|
||||||
|
start: 2211,
|
||||||
|
link: 'https://blue-orchid.bandcamp.com/track/here-come-the-sun',
|
||||||
|
coverId: 'a4102567047'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 11,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'A',
|
||||||
|
title: 'Like in the movies',
|
||||||
|
artist: 10,
|
||||||
|
start: 2560,
|
||||||
|
link: 'https://bruitblanc.bandcamp.com/track/like-in-the-movies-2',
|
||||||
|
coverId: 'a2203158939'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 1,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'B',
|
||||||
|
title: "Ce que révèle l'éclipse",
|
||||||
|
artist: 0,
|
||||||
|
start: 0,
|
||||||
|
link: 'https://arakirecords.bandcamp.com/track/ce-que-r-v-le-l-clipse',
|
||||||
|
coverId: 'a3236746052'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 2,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'B',
|
||||||
|
title: "Bleedin' Gums Mushrool",
|
||||||
|
artist: 1,
|
||||||
|
start: 263,
|
||||||
|
link: 'https://the-kundalini-genie.bandcamp.com/track/bleedin-gums-mushroom',
|
||||||
|
coverId: 'a1714786533'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 3,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'B',
|
||||||
|
title: 'A lucid dream',
|
||||||
|
artist: 2,
|
||||||
|
start: 554,
|
||||||
|
link: 'https://fontainesdc.bandcamp.com/track/a-lucid-dream',
|
||||||
|
coverId: 'a3772806156'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 4,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'B',
|
||||||
|
title: 'Lights off',
|
||||||
|
artist: 3,
|
||||||
|
start: 781,
|
||||||
|
link: 'https://howlinbananarecords.bandcamp.com/track/lights-off',
|
||||||
|
coverId: 'a1720372066'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 5,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'B',
|
||||||
|
title: "I'm sentimental",
|
||||||
|
artist: 4,
|
||||||
|
start: 969,
|
||||||
|
link: 'https://johnnymafia.bandcamp.com/track/im-sentimental-2',
|
||||||
|
coverId: 'a2333676849'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 6,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'B',
|
||||||
|
title: 'Thrill or trip',
|
||||||
|
artist: 5,
|
||||||
|
start: 1128,
|
||||||
|
link: 'https://newcandys.bandcamp.com/track/thrill-or-trip',
|
||||||
|
coverId: 'a0559661270'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 7,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'B',
|
||||||
|
title: 'Redhead',
|
||||||
|
artist: 6,
|
||||||
|
start: 1303,
|
||||||
|
link: 'https://magicshoppe.bandcamp.com/track/redhead',
|
||||||
|
coverId: 'a0594426943'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 8,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'B',
|
||||||
|
title: 'Supersonic twist',
|
||||||
|
artist: 7,
|
||||||
|
start: 1584,
|
||||||
|
link: 'https://open.spotify.com/track/66voQIZAJ3zD3Eju2qtNjF',
|
||||||
|
coverId: 'a1494681687'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 9,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'B',
|
||||||
|
title: 'Flowers',
|
||||||
|
artist: 8,
|
||||||
|
start: 1749,
|
||||||
|
link: 'https://traams.bandcamp.com/track/flowers',
|
||||||
|
coverId: 'a3644668199'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 10,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'B',
|
||||||
|
title: 'The shade',
|
||||||
|
artist: 9,
|
||||||
|
start: 1924,
|
||||||
|
link: 'https://blue-orchid.bandcamp.com/track/the-shade',
|
||||||
|
coverId: 'a0804204790'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 11,
|
||||||
|
boxId: 'ES00',
|
||||||
|
side: 'B',
|
||||||
|
title: 'Like in the movies',
|
||||||
|
artist: 10,
|
||||||
|
start: 2186,
|
||||||
|
link: 'https://bruitblanc.bandcamp.com/track/like-in-the-movies',
|
||||||
|
coverId: 'a3647322740'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 1,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'A',
|
||||||
|
title: 'He Walked In',
|
||||||
|
artist: 11,
|
||||||
|
start: 0,
|
||||||
|
link: 'https://kidcongothepinkmonkeybirds.bandcamp.com/track/he-walked-in',
|
||||||
|
coverId: 'a0336300523'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 2,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'A',
|
||||||
|
title: 'The Third Wave',
|
||||||
|
artist: 12,
|
||||||
|
start: 841,
|
||||||
|
link: 'https://firefriend.bandcamp.com/track/the-third-wave',
|
||||||
|
coverId: 'a2803689859'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 3,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'A',
|
||||||
|
title: 'Broadcaster',
|
||||||
|
artist: 13,
|
||||||
|
start: 1104.5,
|
||||||
|
link: 'https://squiduk.bandcamp.com/track/broadcaster',
|
||||||
|
coverId: 'a3391719769'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 4,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'A',
|
||||||
|
title: 'Mourn',
|
||||||
|
artist: 14,
|
||||||
|
start: 1441,
|
||||||
|
link: 'https://lysistrata.bandcamp.com/track/mourn-2',
|
||||||
|
coverId: 'a0872900041'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 5,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'A',
|
||||||
|
title: 'Let it Blow',
|
||||||
|
artist: 15,
|
||||||
|
start: 1844.8,
|
||||||
|
link: 'https://pabloxbroadcastingservices.bandcamp.com/track/let-it-blow',
|
||||||
|
coverId: 'a4000148031'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 6,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'A',
|
||||||
|
title: 'Sunday Mourning',
|
||||||
|
artist: 16,
|
||||||
|
start: 2091.7,
|
||||||
|
link: 'https://nightbeats.bandcamp.com/track/sunday-mourning',
|
||||||
|
coverId: 'a0031987121'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 7,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'A',
|
||||||
|
title: '3030 Instrumental',
|
||||||
|
artist: 17,
|
||||||
|
start: 2339.3,
|
||||||
|
link: 'https://delthefunkyhomosapien.bandcamp.com/track/3030',
|
||||||
|
coverId: 'a1948146136'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 8,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'A',
|
||||||
|
title: 'Immortality Break',
|
||||||
|
artist: 18,
|
||||||
|
start: 2530.5,
|
||||||
|
link: 'https://theaa.bandcamp.com/track/immortality-break',
|
||||||
|
coverId: 'a2749250329'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 9,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'A',
|
||||||
|
title: 'Lazy Bones',
|
||||||
|
artist: 19,
|
||||||
|
start: 2718,
|
||||||
|
link: 'https://woodenshjips.bandcamp.com/track/lazy-bones',
|
||||||
|
coverId: 'a1884221104'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 10,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'A',
|
||||||
|
title: 'On the Train of Aches',
|
||||||
|
artist: 20,
|
||||||
|
start: 2948,
|
||||||
|
link: 'https://silasjdirge.bandcamp.com/track/on-the-train-of-aches',
|
||||||
|
coverId: 'a1124177379'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 11,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'A',
|
||||||
|
title: 'Me',
|
||||||
|
artist: 21,
|
||||||
|
start: 3265,
|
||||||
|
link: 'https://secretcolours.bandcamp.com/track/me',
|
||||||
|
coverId: 'a1497022499'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 1,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'B',
|
||||||
|
title: 'Lady Hawke Blues',
|
||||||
|
artist: 11,
|
||||||
|
start: 0,
|
||||||
|
link: 'https://kidcongothepinkmonkeybirds.bandcamp.com/track/lady-hawke-blues',
|
||||||
|
coverId: 'a2532623230'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 2,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'B',
|
||||||
|
title: 'Dreamscapes',
|
||||||
|
artist: 12,
|
||||||
|
start: 235,
|
||||||
|
link: 'https://littlecloudrecords.bandcamp.com/track/dreamscapes',
|
||||||
|
coverId: 'a3498981203'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 3,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'B',
|
||||||
|
title: 'Crispy Skin',
|
||||||
|
artist: 13,
|
||||||
|
start: 644.2,
|
||||||
|
link: 'https://squiduk.bandcamp.com/track/crispy-skin-2',
|
||||||
|
coverId: 'a2516727021'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 4,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'B',
|
||||||
|
title: 'The Boy Who Stood Above The Earth',
|
||||||
|
artist: 14,
|
||||||
|
start: 1018,
|
||||||
|
link: 'https://lysistrata.bandcamp.com/track/the-boy-who-stood-above-the-earth-2',
|
||||||
|
coverId: 'a0350933426'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 5,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'B',
|
||||||
|
title: 'Better Off Alone',
|
||||||
|
artist: 15,
|
||||||
|
start: 1698,
|
||||||
|
link: 'https://pabloxbroadcastingservices.bandcamp.com/track/better-off-alone',
|
||||||
|
coverId: 'a4000148031'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 6,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'B',
|
||||||
|
title: 'Celebration #1',
|
||||||
|
artist: 16,
|
||||||
|
start: 2235,
|
||||||
|
link: 'https://nightbeats.bandcamp.com/track/celebration-1',
|
||||||
|
coverId: 'a0031987121'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 7,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'B',
|
||||||
|
title: '3030 Instrumental',
|
||||||
|
artist: 17,
|
||||||
|
start: 2458.3,
|
||||||
|
link: 'https://delthefunkyhomosapien.bandcamp.com/track/3030',
|
||||||
|
coverId: 'a1948146136'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 8,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'B',
|
||||||
|
title: 'The Emptiness Of Nothingness',
|
||||||
|
artist: 18,
|
||||||
|
start: 2864.5,
|
||||||
|
link: 'https://theaa.bandcamp.com/track/the-emptiness-of-nothingness',
|
||||||
|
coverId: 'a1053923875'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 9,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'B',
|
||||||
|
title: 'Rising',
|
||||||
|
artist: 19,
|
||||||
|
start: 3145,
|
||||||
|
link: 'https://woodenshjips.bandcamp.com/track/rising',
|
||||||
|
coverId: 'a1884221104'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 10,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'B',
|
||||||
|
title: 'The Last Time',
|
||||||
|
artist: 22,
|
||||||
|
start: 3447,
|
||||||
|
link: 'https://www.discogs.com/release/12110815-Larry-McNeil-And-The-Blue-Knights-Jealous-Woman',
|
||||||
|
coverId:
|
||||||
|
'https://i.discogs.com/Yr05_neEXwzPwKlDeV7dimmTG34atkAMgpxbMBhHBkI/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTEyMTEw/ODE1LTE1Mjg1NjU1/NzQtMjcyOC5qcGVn.jpeg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 11,
|
||||||
|
boxId: 'ES01',
|
||||||
|
side: 'B',
|
||||||
|
title: 'Guajira Con Arpa',
|
||||||
|
artist: 23,
|
||||||
|
start: 3586,
|
||||||
|
link: 'https://elpalmasmusic.bandcamp.com/track/guajira-con-arpa',
|
||||||
|
coverId: 'a3463036407'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return tracks.map((track, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
...track,
|
||||||
|
filePath: `https://files.erudi.fr/evilspins/${track.boxId}${track.side}.mp3`,
|
||||||
|
coverId: `https://f4.bcbits.com/img/${track.coverId}_4.jpg`,
|
||||||
|
type: 'compilation'
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
playlist:
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import 'dotenv/config'
|
||||||
|
import { eventHandler } from 'h3'
|
||||||
|
import { getCardFromDate } from '../../../utils/cards'
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const dirPath = path.join(process.env.AUDIO_FILES_BASE_PATH)
|
||||||
|
const urlPrefix = `https://files.erudi.fr/music`
|
||||||
|
|
||||||
|
try {
|
||||||
|
let allTracks: any[] = []
|
||||||
|
|
||||||
|
const items = await fs.promises.readdir(dirPath, { withFileTypes: true })
|
||||||
|
|
||||||
|
// Process files
|
||||||
|
const files = items
|
||||||
|
.filter((item) => item.isFile() && !item.name.startsWith('.') && !item.name.endsWith('.jpg'))
|
||||||
|
.map((item) => item.name)
|
||||||
|
|
||||||
|
// Process folders
|
||||||
|
const folders = items
|
||||||
|
.filter((item) => item.isDirectory() && !item.name.startsWith('.'))
|
||||||
|
.map((folder, index) => ({
|
||||||
|
id: `folder-${index}`,
|
||||||
|
boxId: 'ESFOLDER',
|
||||||
|
title: folder.name.replace(/_/g, ' ').trim(),
|
||||||
|
type: 'folder',
|
||||||
|
order: 0,
|
||||||
|
date: new Date(),
|
||||||
|
card: getCardFromDate(new Date())
|
||||||
|
}))
|
||||||
|
|
||||||
|
const tracks = files.map((file, index) => {
|
||||||
|
const EXT_RE = /\.(mp3|flac|wav|opus)$/i
|
||||||
|
const nameWithoutExt = file.replace(EXT_RE, '')
|
||||||
|
|
||||||
|
// On split sur __
|
||||||
|
const parts = nameWithoutExt.split('__')
|
||||||
|
let stamp = parts[0] || ''
|
||||||
|
let artist = parts[1] || ''
|
||||||
|
let title = parts[2] || ''
|
||||||
|
|
||||||
|
title = title.replaceAll('_', ' ')
|
||||||
|
artist = artist.replaceAll('_', ' ')
|
||||||
|
|
||||||
|
// Parser la date depuis le stamp
|
||||||
|
let year = 2020,
|
||||||
|
month = 1,
|
||||||
|
day = 1,
|
||||||
|
hour = 0
|
||||||
|
if (stamp.length === 10) {
|
||||||
|
year = Number(stamp.slice(0, 4))
|
||||||
|
month = Number(stamp.slice(4, 6))
|
||||||
|
day = Number(stamp.slice(6, 8))
|
||||||
|
hour = Number(stamp.slice(8, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(year, month - 1, day, hour)
|
||||||
|
const card = getCardFromDate(date)
|
||||||
|
const filePath = `${urlPrefix}/${encodeURIComponent(file)}`
|
||||||
|
const coverId = `${urlPrefix}/cover/${encodeURIComponent(file).replace(EXT_RE, '.jpg')}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(`${year}${index + 1}`),
|
||||||
|
boxId: `ESPLAYLIST`,
|
||||||
|
year,
|
||||||
|
date,
|
||||||
|
title: title.trim(),
|
||||||
|
artist: artist.trim(),
|
||||||
|
filePath,
|
||||||
|
coverId,
|
||||||
|
card,
|
||||||
|
order: 0,
|
||||||
|
type: 'playlist'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
tracks.sort((a, b) => b.date.getTime() - a.date.getTime())
|
||||||
|
// Combine folders and tracks
|
||||||
|
const allItems = [...folders, ...tracks]
|
||||||
|
|
||||||
|
// Sort by date (newest first) and assign order
|
||||||
|
allItems.sort((a, b) => b.date.getTime() - a.date.getTime())
|
||||||
|
allItems.forEach((item, i) => (item.order = i + 1))
|
||||||
|
|
||||||
|
return allItems
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: (error as Error).message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
21
server/plugins/initialSync.ts
Normal file
21
server/plugins/initialSync.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { syncCardsWithDatabase } from '../services/cardSync.service'
|
||||||
|
|
||||||
|
export default defineNitroPlugin(async (nitroApp) => {
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
const folderPath = runtimeConfig.NUXT_PATH_FILES
|
||||||
|
|
||||||
|
if (!folderPath) {
|
||||||
|
console.warn('⚠️ NUXT_PATH_FILES non configuré')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync au démarrage
|
||||||
|
console.log('🚀 Synchronisation initiale au démarrage...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncCardsWithDatabase(folderPath)
|
||||||
|
console.log('✅ Synchronisation initiale terminée:', result)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Erreur lors de la sync initiale:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user