Compare commits
32 Commits
afb20fe75f
...
sql
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8cc3d277d | ||
|
|
19d19edb1c | ||
|
|
98e6213fa1 | ||
|
|
8e78511738 | ||
|
|
dbd992854b | ||
|
|
b9a3d0184f | ||
|
|
e257e076c4 | ||
|
|
57df3dbd5b | ||
|
|
4ff3da8380 | ||
|
|
c5e5f1abf5 | ||
|
|
a0fce1a8d3 | ||
|
|
0cbdfdeff1 | ||
|
|
a48cd4049d | ||
|
|
0b709ff0dc | ||
|
|
75507452ac | ||
|
|
8337eb9e4c | ||
|
|
1310210ac9 | ||
|
|
deeff36440 | ||
|
|
f896a8a828 | ||
|
|
b769eac9cc | ||
|
|
64eb4d09b9 | ||
|
|
0a587b5e69 | ||
|
|
7a9f4d369c | ||
|
|
d40ca3b1d1 | ||
|
|
8573165e4b | ||
|
|
c586cc3932 | ||
|
|
11694d36dd | ||
|
|
3b05938162 | ||
|
|
f75a1481bd | ||
|
|
bb791e35d1 | ||
|
|
a5fe876e3f | ||
|
|
9001025837 |
10
.env
Executable file
10
.env
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
DOMAIN=evilspins.com
|
||||||
|
PORT=7901
|
||||||
|
PORT_EXPOSED=3000
|
||||||
|
PATH_FILES=mnt/media/files/music
|
||||||
|
PATH_DB=data/music.db
|
||||||
|
EXT_TRACK=mp3
|
||||||
|
EXT_COVER=jpg
|
||||||
|
URL_PREFIX=https://files.erudi.fr/music/
|
||||||
|
NODE_ENV=production
|
||||||
|
ENABLE_WATCHER=true
|
||||||
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
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# Builder
|
# Builder
|
||||||
FROM node:20-bookworm AS builder
|
FROM node:20 AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install --legacy-peer-deps
|
RUN npm install --legacy-peer-deps
|
||||||
@@ -8,9 +9,12 @@ COPY . .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Runtime
|
# Runtime
|
||||||
FROM node:20-slim AS runner
|
FROM node:20 AS runner
|
||||||
|
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 ./data ./data
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", ".output/server/index.mjs"]
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
|
|||||||
76
app/app.vue
76
app/app.vue
@@ -1,82 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="min-h-screen bg-gray-100">
|
||||||
<NuxtRouteAnnouncer />
|
<NuxtRouteAnnouncer />
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { useUiStore } from '~/store/ui'
|
|
||||||
import { usePlayerStore } from '~/store/player'
|
|
||||||
import { watch, computed } from 'vue'
|
|
||||||
|
|
||||||
const ui = useUiStore()
|
|
||||||
const player = usePlayerStore()
|
|
||||||
useHead({
|
|
||||||
bodyAttrs: {
|
|
||||||
class: 'bg-slate-100'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => player.currentTrack?.id,
|
|
||||||
(id) => {
|
|
||||||
if (!id) {
|
|
||||||
if (route.name === 'track-id') router.replace({ path: '/' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const currentParam = Number(
|
|
||||||
Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
|
||||||
)
|
|
||||||
if (route.name === 'track-id' && currentParam === id) return
|
|
||||||
router.replace({ name: 'track-id', params: { id } })
|
|
||||||
},
|
|
||||||
{ flush: 'post' }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Keep URL in sync with selected box: /box/:id when a box is selected, back to / when none
|
|
||||||
const selectedBoxId = computed(() => ui.getSelectedBox?.id)
|
|
||||||
watch(
|
|
||||||
() => selectedBoxId.value,
|
|
||||||
(id) => {
|
|
||||||
if (import.meta.client) {
|
|
||||||
if (!id) {
|
|
||||||
// Back to root path without navigation to preserve UI state/animations
|
|
||||||
if (location.pathname.startsWith('/box/')) {
|
|
||||||
history.replaceState(null, '', '/')
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const currentId = location.pathname.startsWith('/box/')
|
|
||||||
? location.pathname.split('/').pop()
|
|
||||||
: null
|
|
||||||
if (currentId === id) return
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
history.replaceState(null, '', `/box/${id}`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ flush: 'post' }
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
button,
|
|
||||||
input {
|
|
||||||
@apply px-4 py-2 m-4 rounded-md text-center font-bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type='email'] {
|
|
||||||
@apply bg-slate-900 text-esyellow;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
user-drag: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,56 +1,39 @@
|
|||||||
<template>
|
<template>
|
||||||
<article v-bind="attrs" :role="props.role" :draggable="isFaceUp" :class="[
|
<article :role="props.role" :class="[
|
||||||
'card cursor-pointer',
|
'card cursor-pointer',
|
||||||
isFaceUp ? 'face-up' : 'face-down',
|
isFaceUp ? 'face-up' : 'face-down'
|
||||||
{ 'current-track': playerStore.currentTrack?.id === track.id },
|
]" :tabindex="props.tabindex" :aria-disabled="false" @click="$emit('click', $event)"
|
||||||
{ 'is-dragging': isDragging }
|
@keydown.enter="$emit('click', $event)" @keydown.space.prevent="$emit('click', $event)">
|
||||||
]" :tabindex="props.tabindex" :aria-disabled="false" @click.stop="handleClick" @keydown.enter.stop="handleClick"
|
|
||||||
@keydown.space.prevent.stop="handleClick" @dragstart="handleDragStart" @dragend="handleDragEnd"
|
|
||||||
@drag="handleDragMove" @touchstart.passive="!isFaceUp" @touchmove.passive="!isFaceUp">
|
|
||||||
<div class="flip-inner" ref="cardElement">
|
<div class="flip-inner" ref="cardElement">
|
||||||
<!-- Face-Up -->
|
<!-- Face-Up -->
|
||||||
<main
|
<main
|
||||||
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 hover:shadow-xl hover:scale-110 transition-all rounded-2xl shadow-lg flex flex-col overflow-hidden">
|
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 hover:shadow-xl hover:scale-110 transition-all rounded-2xl shadow-lg flex flex-col overflow-hidden">
|
||||||
|
|
||||||
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 right-7"
|
<div class="flex items-center justify-center size-7 absolute top-7 right-7">
|
||||||
@click.stop="clickCardSymbol">
|
|
||||||
<div class="suit text-7xl absolute"
|
<div class="suit text-7xl absolute"
|
||||||
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
|
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.card?.suit]">
|
||||||
<img draggable="false" :src="`/${props.track.card?.suit}.svg`" />
|
<img :src="`/${props.card?.suit}.svg`" />
|
||||||
</div>
|
</div>
|
||||||
<div class="rank text-white font-bold absolute -mt-1">
|
<div class="rank text-white font-bold absolute -mt-1">
|
||||||
{{ props.track.card?.rank }}
|
{{ props.card?.rank }}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="flex items-center justify-center size-7 absolute top-6 left-6">
|
|
||||||
<div class="rank text-white font-bold absolute -mt-1">
|
|
||||||
{{ props.track.order }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cover -->
|
<!-- Cover -->
|
||||||
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer">
|
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer">
|
||||||
<playButton :objectToPlay="track" />
|
<playButton :objectToPlay="card" />
|
||||||
<img draggable="false" v-if="isFaceUp" :src="coverUrl" alt="Pochette de l'album" loading="lazy"
|
<img v-if="isFaceUp" :src="props.card.url_image" alt="Pochette de l'album" loading="lazy"
|
||||||
class="w-full h-full object-cover object-center" />
|
class="w-full h-full object-cover object-center" />
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div
|
<div
|
||||||
class="card-body p-3 text-center bg-white rounded-b-xl opacity-0 -mt-16 hover:opacity-100 hover:-mt-0 transition-all duration-300">
|
class="card-body p-3 text-center bg-white rounded-b-xl opacity-0 -mt-16 hover:opacity-100 hover:-mt-0 transition-all duration-300">
|
||||||
<div v-if="isOrder" class="label">
|
|
||||||
{{ props.track.order }}
|
|
||||||
</div>
|
|
||||||
<h2 class="select-text text-sm text-neutral-500 first-letter:uppercase truncate">
|
<h2 class="select-text text-sm text-neutral-500 first-letter:uppercase truncate">
|
||||||
{{ props.track.title || 'title' }}
|
{{ props.card.title || 'title' }}
|
||||||
</h2>
|
</h2>
|
||||||
<p v-if="isPlaylistTrack && track.artist && typeof track.artist === 'object'"
|
<p class="select-text text-base text-neutral-800 font-bold capitalize truncate">
|
||||||
class="select-text text-base text-neutral-800 font-bold capitalize truncate">
|
{{ props.card.artist || 'artist' }}
|
||||||
{{ track.artist.name || 'artist' }}
|
|
||||||
</p>
|
|
||||||
<p v-else-if="isPlaylistTrack" class="select-text text-base text-neutral-800 font-bold capitalize truncate">
|
|
||||||
{{ typeof track.artist === 'string' ? track.artist : 'artist' }}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -60,317 +43,35 @@
|
|||||||
class="face-down backdrop-blur-sm z-10 card w-56 h-80 p-3 bg-opacity-10 bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden select-none">
|
class="face-down backdrop-blur-sm z-10 card w-56 h-80 p-3 bg-opacity-10 bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden select-none">
|
||||||
<figure class="h-full flex text-center rounded-xl justify-center items-center"
|
<figure class="h-full flex text-center rounded-xl justify-center items-center"
|
||||||
:style="{ backgroundColor: cardColor }">
|
:style="{ backgroundColor: cardColor }">
|
||||||
<playButton :objectToPlay="track" />
|
<playButton :objectToPlay="card" />
|
||||||
<img draggable="false" src="/face-down.svg" />
|
<img src="/face-down.svg" />
|
||||||
</figure>
|
</figure>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<!-- Clone fantôme unifié pour drag souris ET tactile -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="isDragging && touchClone" ref="ghostElement"
|
|
||||||
class="ghost-card fixed pointer-events-none z-[9999] w-56 h-80" :style="{
|
|
||||||
left: touchClone.x + 'px',
|
|
||||||
top: touchClone.y + 'px',
|
|
||||||
transform: 'translate(-50%, -50%)'
|
|
||||||
}">
|
|
||||||
<div class="flip-inner">
|
|
||||||
<main
|
|
||||||
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 rounded-2xl shadow-2xl flex flex-col overflow-hidden bg-white bg-opacity-90">
|
|
||||||
|
|
||||||
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 left-7">
|
|
||||||
<div class="suit text-7xl absolute"
|
|
||||||
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
|
|
||||||
<img draggable="false" :src="`/${props.track.card?.suit}.svg`" />
|
|
||||||
</div>
|
|
||||||
<div class="rank text-white font-bold absolute -mt-1">
|
|
||||||
{{ props.track.card?.rank }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl">
|
|
||||||
<img draggable="false" :src="coverUrl" alt="Pochette de l'album"
|
|
||||||
class="w-full h-full object-cover object-center" />
|
|
||||||
</figure>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<!-- Modal de partage -->
|
|
||||||
<ModalSharer v-if="showModalSharer" ref="modalSharer" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Track } from '~~/types/types'
|
import type { Card } from '~~/types/types'
|
||||||
import { usePlayerStore } from '~/store/player'
|
|
||||||
import { useDataStore } from '~/store/data'
|
const emit = defineEmits(['click'])
|
||||||
import { useNuxtApp } from '#app'
|
|
||||||
import ModalSharer from '~/components/ui/ModalSharer.vue'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
track: Track;
|
card: Card;
|
||||||
isFaceUp?: boolean;
|
isFaceUp?: boolean;
|
||||||
role?: string;
|
role?: string;
|
||||||
tabindex?: string | number;
|
tabindex?: string | number;
|
||||||
'onUpdate:isFaceUp'?: (value: boolean) => void;
|
|
||||||
}>(), {
|
}>(), {
|
||||||
isFaceUp: true,
|
isFaceUp: false,
|
||||||
role: 'button',
|
role: 'button',
|
||||||
tabindex: '0'
|
tabindex: '0'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Use useAttrs to get all other attributes
|
import { getYearColor } from '~/utils/colors'
|
||||||
const attrs = useAttrs()
|
|
||||||
|
|
||||||
const modalSharer = ref<InstanceType<typeof ModalSharer> | null>(null)
|
const cardColor = computed(() => getYearColor(props.card.year || 0))
|
||||||
const showModalSharer = ref(false)
|
const isRedCard = computed(() => (props.card?.suit === '♥' || props.card?.suit === '♦'))
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:isFaceUp', value: boolean): void;
|
|
||||||
(e: 'cardClick', track: Track): void;
|
|
||||||
(e: 'clickCardSymbol', track: Track): void;
|
|
||||||
(e: 'dragstart', event: DragEvent): void;
|
|
||||||
(e: 'dragend', event: DragEvent): void;
|
|
||||||
(e: 'drag', event: DragEvent): void;
|
|
||||||
(e: 'click', event: MouseEvent): void;
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// Handle click events (mouse and keyboard)
|
|
||||||
const handleClick = (event: MouseEvent | KeyboardEvent) => {
|
|
||||||
if (!isDragging.value && !hasMovedDuringPress.value) {
|
|
||||||
emit('cardClick', props.track);
|
|
||||||
emit('click', event as MouseEvent);
|
|
||||||
}
|
|
||||||
hasMovedDuringPress.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clickCardSymbol = (event: MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
// Afficher la modale
|
|
||||||
showModalSharer.value = true;
|
|
||||||
|
|
||||||
// Donner le focus à la modale après le rendu
|
|
||||||
nextTick(() => {
|
|
||||||
if (modalSharer.value) {
|
|
||||||
modalSharer.value.open(props.track);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
emit('clickCardSymbol', props.track);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle drag start with proper event emission
|
|
||||||
const handleDragStart = (event: DragEvent) => {
|
|
||||||
if (!props.isFaceUp) {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { $bodyClass } = useNuxtApp()
|
|
||||||
$bodyClass.add('card-dragging')
|
|
||||||
dragStart(event);
|
|
||||||
emit('dragstart', event);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle drag end with proper event emission
|
|
||||||
const handleDragEnd = (event: DragEvent) => {
|
|
||||||
if (!props.isFaceUp) {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { $bodyClass } = useNuxtApp()
|
|
||||||
$bodyClass.remove('card-dragging')
|
|
||||||
dragEnd(event);
|
|
||||||
emit('dragend', event);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle drag move with proper event emission
|
|
||||||
const handleDragMove = (event: DragEvent) => {
|
|
||||||
if (!props.isFaceUp) {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dragMove(event);
|
|
||||||
emit('drag', event);
|
|
||||||
}
|
|
||||||
|
|
||||||
const playerStore = usePlayerStore()
|
|
||||||
const isManifesto = computed(() => props.track.boxId.startsWith('ES00'))
|
|
||||||
const isOrder = computed(() => props.track.order && !isManifesto)
|
|
||||||
const isPlaylistTrack = computed(() => props.track.type === 'playlist')
|
|
||||||
const isRedCard = computed(() => (props.track.card?.suit === '♥' || props.track.card?.suit === '♦'))
|
|
||||||
const dataStore = useDataStore()
|
|
||||||
const cardColor = computed(() => dataStore.getYearColor(props.track.year || 0))
|
|
||||||
const coverUrl = computed(() => props.track.coverId || '/card-dock.svg')
|
|
||||||
|
|
||||||
const isDragging = ref(false)
|
|
||||||
const cardElement = ref<HTMLElement | null>(null)
|
|
||||||
const ghostElement = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
// État unifié pour souris et tactile
|
|
||||||
const touchClone = ref<{ x: number, y: number } | null>(null)
|
|
||||||
const touchStartPos = ref<{ x: number, y: number } | null>(null)
|
|
||||||
const longPressTimer = ref<number | null>(null)
|
|
||||||
const LONG_PRESS_DURATION = 200 // ms
|
|
||||||
const hasMovedDuringPress = ref(false)
|
|
||||||
|
|
||||||
|
|
||||||
// Drag desktop - utilise maintenant ghostElement
|
|
||||||
const dragStart = (event: DragEvent) => {
|
|
||||||
if (event.dataTransfer && cardElement.value) {
|
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
|
||||||
event.dataTransfer.setData('application/json', JSON.stringify(props.track));
|
|
||||||
|
|
||||||
// Créer une image transparente pour masquer l'image par défaut du navigateur
|
|
||||||
const img = new Image();
|
|
||||||
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
||||||
event.dataTransfer.setDragImage(img, 0, 0);
|
|
||||||
|
|
||||||
// Activer le clone fantôme
|
|
||||||
isDragging.value = true
|
|
||||||
touchClone.value = {
|
|
||||||
x: event.clientX,
|
|
||||||
y: event.clientY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Nouveau: suivre le mouvement de la souris pendant le drag
|
|
||||||
const dragMove = (event: DragEvent) => {
|
|
||||||
if (isDragging.value && touchClone.value && event.clientX !== 0 && event.clientY !== 0) {
|
|
||||||
touchClone.value = {
|
|
||||||
x: event.clientX,
|
|
||||||
y: event.clientY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const instance = getCurrentInstance();
|
|
||||||
const dragEnd = (event: DragEvent) => {
|
|
||||||
isDragging.value = false
|
|
||||||
touchClone.value = null
|
|
||||||
|
|
||||||
if (event.dataTransfer?.dropEffect === 'move' && instance?.vnode?.el?.parentNode) {
|
|
||||||
instance.vnode.el.parentNode.removeChild(instance.vnode.el);
|
|
||||||
const parent = instance.parent;
|
|
||||||
if (parent?.update) {
|
|
||||||
parent.update();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Touch events
|
|
||||||
const touchStart = (event: TouchEvent) => {
|
|
||||||
const touch = event.touches[0];
|
|
||||||
if (!touch) return;
|
|
||||||
|
|
||||||
touchStartPos.value = { x: touch.clientX, y: touch.clientY };
|
|
||||||
hasMovedDuringPress.value = false;
|
|
||||||
|
|
||||||
// Démarrer un timer pour le long press
|
|
||||||
longPressTimer.value = window.setTimeout(() => {
|
|
||||||
startTouchDrag(touch);
|
|
||||||
}, LONG_PRESS_DURATION);
|
|
||||||
}
|
|
||||||
|
|
||||||
const startTouchDrag = (touch: Touch) => {
|
|
||||||
if (!touch) return;
|
|
||||||
|
|
||||||
isDragging.value = true;
|
|
||||||
touchClone.value = {
|
|
||||||
x: touch.clientX,
|
|
||||||
y: touch.clientY
|
|
||||||
};
|
|
||||||
|
|
||||||
// Vibration feedback si disponible
|
|
||||||
if (navigator.vibrate) {
|
|
||||||
navigator.vibrate(50);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const touchMove = (event: TouchEvent) => {
|
|
||||||
const touch = event.touches[0];
|
|
||||||
if (!touch || !longPressTimer.value) return;
|
|
||||||
|
|
||||||
// Annuler le long press si l'utilisateur bouge trop
|
|
||||||
const dx = touch.clientX - (touchStartPos.value?.x || 0);
|
|
||||||
const dy = touch.clientY - (touchStartPos.value?.y || 0);
|
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
|
|
||||||
if (distance > 10) { // Seuil de tolérance pour un tap
|
|
||||||
clearTimeout(longPressTimer.value)
|
|
||||||
longPressTimer.value = null
|
|
||||||
hasMovedDuringPress.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDragging.value && touchClone.value) {
|
|
||||||
event.preventDefault()
|
|
||||||
const touch = event.touches[0]
|
|
||||||
touchClone.value = {
|
|
||||||
x: touch.clientX,
|
|
||||||
y: touch.clientY
|
|
||||||
}
|
|
||||||
|
|
||||||
// Déterminer l'élément sous le doigt
|
|
||||||
checkDropTarget(touch.clientX, touch.clientY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const touchEnd = (event: TouchEvent) => {
|
|
||||||
// Annuler le timer de long press
|
|
||||||
if (longPressTimer.value) {
|
|
||||||
clearTimeout(longPressTimer.value);
|
|
||||||
longPressTimer.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier si c'était un tap simple (pas de déplacement)
|
|
||||||
if (!hasMovedDuringPress.value && touchStartPos.value) {
|
|
||||||
const touch = event.changedTouches[0];
|
|
||||||
if (touch) {
|
|
||||||
const dx = touch.clientX - touchStartPos.value.x;
|
|
||||||
const dy = touch.clientY - touchStartPos.value.y;
|
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
|
|
||||||
if (distance < 10) { // Seuil de tolérance pour un tap
|
|
||||||
handleClick(new MouseEvent('click'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Réinitialiser l'état de glisser-déposer
|
|
||||||
if (isDragging.value) {
|
|
||||||
// Vérifier si on est au-dessus d'une cible de dépôt
|
|
||||||
const touch = event.changedTouches[0];
|
|
||||||
if (touch) {
|
|
||||||
checkDropTarget(touch.clientX, touch.clientY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nettoyer
|
|
||||||
isDragging.value = false;
|
|
||||||
touchClone.value = null;
|
|
||||||
touchStartPos.value = null;
|
|
||||||
hasMovedDuringPress.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkDropTarget = (x: number, y: number): HTMLElement | null => {
|
|
||||||
const element = document.elementFromPoint(x, y);
|
|
||||||
if (element) {
|
|
||||||
const dropZone = element.closest('[data-drop-zone]');
|
|
||||||
if (dropZone) {
|
|
||||||
return dropZone as HTMLElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (longPressTimer.value) {
|
|
||||||
clearTimeout(longPressTimer.value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@@ -391,7 +92,6 @@ onUnmounted(() => {
|
|||||||
.card {
|
.card {
|
||||||
perspective: 1000px;
|
perspective: 1000px;
|
||||||
@apply transition-all scale-100 w-56 h-80 min-w-56 min-h-80;
|
@apply transition-all scale-100 w-56 h-80 min-w-56 min-h-80;
|
||||||
touch-action: none;
|
|
||||||
|
|
||||||
.flip-inner {
|
.flip-inner {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -440,7 +140,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:focus,
|
&:focus,
|
||||||
&.current-track {
|
&.current-card {
|
||||||
@apply z-50 scale-110;
|
@apply z-50 scale-110;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
@@ -453,7 +153,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:focus,
|
&:focus,
|
||||||
&.current-track {
|
&.current-card {
|
||||||
.play-button {
|
.play-button {
|
||||||
@apply opacity-100;
|
@apply opacity-100;
|
||||||
}
|
}
|
||||||
@@ -474,7 +174,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.play-button {
|
.play-button {
|
||||||
@apply absolute bottom-1/2 top-24 opacity-0 hover:opacity-100;
|
@apply absolute bottom-1/2 top-28 opacity-0 hover:opacity-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pochette:active,
|
.pochette:active,
|
||||||
@@ -498,22 +198,4 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ghost card styles - maintenant unifié pour souris et tactile */
|
|
||||||
.ghost-card {
|
|
||||||
transition: none;
|
|
||||||
|
|
||||||
.card {
|
|
||||||
@apply shadow-2xl scale-95 rotate-6;
|
|
||||||
|
|
||||||
.play-button,
|
|
||||||
.card-body {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.flip-inner {
|
|
||||||
perspective: 1000px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -12,25 +12,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { usePlatineStore } from '~/store/platine'
|
const props = withDefaults(defineProps<{
|
||||||
import type { Box, Track } from '~/../types/types'
|
isLoading?: boolean;
|
||||||
|
isPlaying?: boolean;
|
||||||
const platineStore = usePlatineStore()
|
}>(), {
|
||||||
const props = defineProps<{ objectToPlay: Box | Track }>()
|
isLoading: false,
|
||||||
|
isPlaying: false
|
||||||
const isCurrentTrack = computed(() => {
|
|
||||||
if (!('activeSide' in props.objectToPlay)) {
|
|
||||||
return platineStore.currentTrack?.id === props.objectToPlay.id
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
const isPlaying = computed(() => {
|
|
||||||
return platineStore.isPlaying && isCurrentTrack.value
|
|
||||||
})
|
|
||||||
|
|
||||||
const isLoading = computed(() => {
|
|
||||||
return platineStore.isLoadingTrack && isCurrentTrack.value
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1
|
error
|
||||||
class="text-white pt-6 text-lg md:text-xl lg:text-2xl text-center font-bold tracking-widest text-shadow"
|
|
||||||
>
|
|
||||||
{{ error?.statusCode }}
|
|
||||||
</h1>
|
|
||||||
<NuxtLink to="/">Go back home</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { NuxtError } from '#app'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
error: Object as () => NuxtError
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -1,46 +1,3 @@
|
|||||||
<template>
|
<template>
|
||||||
<slot />
|
<slot />
|
||||||
<Bucket />
|
|
||||||
<Platine />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Track } from '~~/types/types'
|
|
||||||
|
|
||||||
const onCardDropped = (card: Track) => {
|
|
||||||
console.log('Carte déposée dans le bucket:', card)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.bucket,
|
|
||||||
.platine {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bucket {
|
|
||||||
z-index: 70;
|
|
||||||
bottom: -260px;
|
|
||||||
transition: bottom 0.3s ease;
|
|
||||||
width: 100%;
|
|
||||||
overflow-x: scroll;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
.card-dragging & {
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bucket-card-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.platine {
|
|
||||||
z-index: 60;
|
|
||||||
bottom: -70%;
|
|
||||||
transition: bottom 0.3s ease;
|
|
||||||
/* width: 25%; */
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
43
app/pages/card/[slug].vue
Normal file
43
app/pages/card/[slug].vue
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<section class="screen-centered">
|
||||||
|
<Card :card="card" :isFaceUp="isFaceUp" @click="clickOnSlugCard" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Card } from '~~/types/types'
|
||||||
|
const route = useRoute()
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
const isFaceUp = ref(false)
|
||||||
|
|
||||||
|
const { data: card, pending, error } = await useFetch<Card>(`/api/card/${slug}`)
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: computed(() =>
|
||||||
|
card.value ? `${card.value.artist} - ${card.value.title}` : 'Loading...'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const clickOnSlugCard = () => {
|
||||||
|
isFaceUp.value = true
|
||||||
|
const audio = new Audio(card.value?.url_audio)
|
||||||
|
audio.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
clickOnSlugCard()
|
||||||
|
}, 700)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.screen-centered {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
height: 100dvh;
|
||||||
|
width: 100dvw;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,27 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<boxes />
|
<div>
|
||||||
|
here is the New New front
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { useUiStore } from '~/store/ui'
|
|
||||||
import { useDataStore } from '~/store/data'
|
|
||||||
|
|
||||||
// Configuration du layout
|
|
||||||
definePageMeta({
|
|
||||||
layout: 'default'
|
|
||||||
})
|
|
||||||
|
|
||||||
const uiStore = useUiStore()
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
const dataStore = useDataStore()
|
|
||||||
await dataStore.loadData()
|
|
||||||
uiStore.listBoxes()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.logo {
|
|
||||||
filter: drop-shadow(3px 3px 0 rgb(0 0 0 / 0.7));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
39
app/utils/colors.ts
Normal file
39
app/utils/colors.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export const getYearColor = (year: number): string => {
|
||||||
|
// Palette élargie avec des différences plus marquées
|
||||||
|
const colorMap: Record<number, string> = {
|
||||||
|
// Années récentes - teintes froides et claires
|
||||||
|
2025: '#3a4a6c', // bleu-gris clair
|
||||||
|
2024: '#1e3a7a', // bleu vif
|
||||||
|
2023: '#1a4d5c', // bleu-vert émeraude
|
||||||
|
2022: '#3a4a6a', // bleu-gris moyen
|
||||||
|
|
||||||
|
// Années 2020-2021 - transition
|
||||||
|
2021: '#3a2e6a', // bleu-violet
|
||||||
|
2020: '#2a467a', // bleu-gris chaud
|
||||||
|
|
||||||
|
// Années 2010-2019 - teintes moyennes
|
||||||
|
2019: '#2a2a7a', // bleu nuit profond
|
||||||
|
2018: '#1e2a8a', // bleu roi
|
||||||
|
2017: '#1a5a6a', // bleu canard vif
|
||||||
|
2016: '#1a5a4a', // vert bleuté
|
||||||
|
2015: '#1a3a7a', // bleu marine
|
||||||
|
2014: '#4a1e7a', // violet profond
|
||||||
|
2013: '#1a5a4a', // vert émeraude
|
||||||
|
2012: '#1e3a9a', // bleu ciel profond
|
||||||
|
|
||||||
|
// Années 2000-2011 - teintes chaudes et foncées
|
||||||
|
2011: '#1e293b', // slate-800 de base
|
||||||
|
2010: '#2a467a', // bleu-gris chaud
|
||||||
|
2009: '#3a4a6a', // bleu-gris moyen
|
||||||
|
2008: '#1a3a8a', // bleu nuit clair
|
||||||
|
2007: '#5a2a4a', // bordeaux
|
||||||
|
2006: '#5a1e6a', // violet profond
|
||||||
|
2005: '#3a1a7a', // bleu-violet foncé
|
||||||
|
2004: '#2a1a5a', // bleu nuit profond
|
||||||
|
2003: '#3a3a5a', // bleu-gris foncé
|
||||||
|
2002: '#1a5a4a', // vert foncé
|
||||||
|
2001: '#5a3a2a', // marron chaud
|
||||||
|
2000: '#3a3a5a' // bleu-gris foncé
|
||||||
|
}
|
||||||
|
return colorMap[year] || '#1e293b' // slate-800 par défaut
|
||||||
|
}
|
||||||
82
appOLDD/app.vue
Normal file
82
appOLDD/app.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NuxtRouteAnnouncer />
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useUiStore } from '~/store/ui'
|
||||||
|
import { usePlayerStore } from '~/store/player'
|
||||||
|
import { watch, computed } from 'vue'
|
||||||
|
|
||||||
|
const ui = useUiStore()
|
||||||
|
const player = usePlayerStore()
|
||||||
|
useHead({
|
||||||
|
bodyAttrs: {
|
||||||
|
class: 'bg-slate-100'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => player.currentTrack?.id,
|
||||||
|
(id) => {
|
||||||
|
if (!id) {
|
||||||
|
if (route.name === 'track-id') router.replace({ path: '/' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentParam = Number(
|
||||||
|
Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||||
|
)
|
||||||
|
if (route.name === 'track-id' && currentParam === id) return
|
||||||
|
router.replace({ name: 'track-id', params: { id } })
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keep URL in sync with selected box: /box/:id when a box is selected, back to / when none
|
||||||
|
const selectedBoxId = computed(() => ui.getSelectedBox?.id)
|
||||||
|
watch(
|
||||||
|
() => selectedBoxId.value,
|
||||||
|
(id) => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
if (!id) {
|
||||||
|
// Back to root path without navigation to preserve UI state/animations
|
||||||
|
if (location.pathname.startsWith('/box/')) {
|
||||||
|
history.replaceState(null, '', '/')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentId = location.pathname.startsWith('/box/')
|
||||||
|
? location.pathname.split('/').pop()
|
||||||
|
: null
|
||||||
|
if (currentId === id) return
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
history.replaceState(null, '', `/box/${id}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
@apply px-4 py-2 m-4 rounded-md text-center font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='email'] {
|
||||||
|
@apply bg-slate-900 text-esyellow;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
user-drag: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="face back flex flex-row flex-wrap items-start p-4 overflow-hidden"
|
<div class="face back flex flex-row flex-wrap items-start p-4 overflow-hidden"
|
||||||
:class="{ 'overflow-y-scroll': !isCompilation }" ref="backFace">
|
:class="{ 'overflow-y-scroll': !isCompilation }" ref="backFace">
|
||||||
<li class="list-none text-xxs w-1/2 flex flex-row"
|
<!-- <li class="list-none text-xxs w-1/2 flex flex-row"
|
||||||
v-for="track in dataStore.getTracksByboxId(box.id, box.activeSide)" :key="track.id" :track="track">
|
v-for="track in dataStore.getTracksByboxId(box.id, box.activeSide)" :key="track.id" :track="track">
|
||||||
<span class="text-slate-700" v-if="isNotManifesto"> {{ track.order }}. </span>
|
<span class="text-slate-700" v-if="isNotManifesto"> {{ track.order }}. </span>
|
||||||
<p class="text-left text-slate-700">
|
<p class="text-left text-slate-700">
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<br />
|
<br />
|
||||||
{{ track.artist.name }}
|
{{ track.artist.name }}
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="face right" ref="rightFace" />
|
<div class="face right" ref="rightFace" />
|
||||||
<div class="face left" ref="leftFace" />
|
<div class="face left" ref="leftFace" />
|
||||||
@@ -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'
|
||||||
@@ -60,6 +60,8 @@ onMounted(() => {
|
|||||||
// Gestion du drag and drop desktop
|
// Gestion du drag and drop desktop
|
||||||
const handleDragStart = (event: { item: HTMLElement }) => {
|
const handleDragStart = (event: { item: HTMLElement }) => {
|
||||||
drag.value = true
|
drag.value = true
|
||||||
|
// Émettre un événement personnalisé pour indiquer qu'un glisser a commencé depuis le bucket
|
||||||
|
document.dispatchEvent(new CustomEvent('bucket-drag-start'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragEnd = (event: { item: HTMLElement; newIndex: number; oldIndex: number }) => {
|
const handleDragEnd = (event: { item: HTMLElement; newIndex: number; oldIndex: number }) => {
|
||||||
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>
|
||||||
@@ -1,72 +1,43 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="platine pointer-events-none" :class="{ 'drag-over': isDragOver }" @dragenter.prevent="onDragEnter"
|
<div class="platine pointer-events-none" :class="{ 'loading': platineStore.isLoadingTrack, 'mounted': isMounted }"
|
||||||
@dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop.prevent="onDrop">
|
ref="platine">
|
||||||
<div class="disc pointer-events-auto fixed" ref="discRef" :style="'background-image: url(/card-dock.svg)'"
|
<img class="cover" :src="platineStore.currentTrack?.coverId" />
|
||||||
id="disc">
|
<div class="disc pointer-events-auto fixed bg-transparent" ref="discRef" id="disc">
|
||||||
<div
|
<div class="bobine"
|
||||||
class="bobine bg-slate-900 bg-opacity-50 absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 rounded-full"
|
|
||||||
:style="{ height: platineStore.progressPercentage + '%', width: platineStore.progressPercentage + '%' }"></div>
|
:style="{ height: platineStore.progressPercentage + '%', width: platineStore.progressPercentage + '%' }"></div>
|
||||||
|
|
||||||
|
|
||||||
<div class="disc-label rounded-full bg-cover bg-center">
|
<div class="disc-label rounded-full bg-cover bg-center">
|
||||||
<img src="/favicon.svg" class="size-1/3">
|
<img src="/favicon.svg" class="size-1/2 bg-black rounded-full p-5">
|
||||||
<div v-if="platineStore.isLoadingTrack" class="loading-indicator">
|
<div v-if="platineStore.isLoadingTrack" class="loading-indicator">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full h-1/5 flex justify-center items-center text-8xl text-white absolute pointer-events-none">
|
|
||||||
{{ platineStore.currentTrack?.title }}
|
|
||||||
<br>
|
|
||||||
{{ platineStore.currentTrack?.artist.name }}
|
|
||||||
</div>
|
|
||||||
<div v-if="!platineStore.isLoadingTrack" class="absolute top-1/2 right-8 size-1/12 rounded-full bg-esyellow">
|
<div v-if="!platineStore.isLoadingTrack" class="absolute top-1/2 right-8 size-1/12 rounded-full bg-esyellow">
|
||||||
</div>
|
</div>
|
||||||
</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 { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { usePlatineStore } from '~/store/platine'
|
import { usePlatineStore } from '~/store/platine'
|
||||||
import type { Track } from '~~/types/types'
|
import type { Track } from '~~/types'
|
||||||
|
|
||||||
const props = defineProps<{ track?: Track }>()
|
const props = defineProps<{ track?: Track }>()
|
||||||
const platineStore = usePlatineStore()
|
const platineStore = usePlatineStore()
|
||||||
const discRef = ref<HTMLElement>()
|
const discRef = ref<HTMLElement>()
|
||||||
const isDragOver = ref(false)
|
const platine = ref<HTMLElement>()
|
||||||
|
const isMounted = ref(false)
|
||||||
// Gestion du drag and drop
|
|
||||||
const onDragEnter = (e: DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
isDragOver.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDragOver = (e: DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
isDragOver.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDragLeave = () => {
|
|
||||||
isDragOver.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDrop = (e: DragEvent) => {
|
|
||||||
isDragOver.value = false
|
|
||||||
const cardData = e.dataTransfer?.getData('application/json')
|
|
||||||
|
|
||||||
if (cardData) {
|
|
||||||
try {
|
|
||||||
const newTrack = JSON.parse(cardData)
|
|
||||||
if (newTrack && newTrack.url) {
|
|
||||||
platineStore.loadTrack(newTrack)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors du traitement de la carte déposée', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialisation du lecteur
|
// Initialisation du lecteur
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
isMounted.value = true
|
||||||
if (discRef.value) {
|
if (discRef.value) {
|
||||||
platineStore.initPlatine(discRef.value)
|
platineStore.initPlatine(discRef.value)
|
||||||
}
|
}
|
||||||
@@ -74,6 +45,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
// Nettoyage
|
// Nettoyage
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
isMounted.value = false
|
||||||
platineStore.cleanup()
|
platineStore.cleanup()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -92,11 +64,9 @@ watch(() => props.track, (newTrack) => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 20px;
|
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
z-index: 99;
|
|
||||||
top: -20%;
|
top: -20%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -106,7 +76,6 @@ watch(() => props.track, (newTrack) => {
|
|||||||
|
|
||||||
.disc {
|
.disc {
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: white;
|
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -114,10 +83,10 @@ 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.1);
|
box-shadow: 0 0 20px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
.dragoOver & {
|
.loading & {
|
||||||
background-color: #4CAF50;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +159,22 @@ watch(() => props.track, (newTrack) => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
z-index: 10;
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
@@ -209,15 +193,6 @@ watch(() => props.track, (newTrack) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bobine {
|
.bobine {
|
||||||
&::before {
|
@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;
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-size: cover;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
42
appOLDD/components/PlayButton.vue
Normal file
42
appOLDD/components/PlayButton.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<button tabindex="-1"
|
||||||
|
class="play-button pointer-events-none rounded-full size-24 flex items-center justify-center text-esyellow backdrop-blur-sm bg-black/25 transition-all duration-200 ease-in-out transform active:scale-90 scale-110 text-4xl font-bold"
|
||||||
|
:class="{ loading: isLoading }" :disabled="isLoading">
|
||||||
|
<template v-if="isLoading">
|
||||||
|
<img src="/loader.svg" alt="Chargement" class="size-16" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ isPlaying ? 'I I' : '▶' }}
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { usePlatineStore } from '~/store/platine'
|
||||||
|
import type { Box, Track } from '~/../types/types'
|
||||||
|
|
||||||
|
const platineStore = usePlatineStore()
|
||||||
|
const props = defineProps<{ objectToPlay: Box | Track }>()
|
||||||
|
|
||||||
|
const isCurrentTrack = computed(() => {
|
||||||
|
if (!('activeSide' in props.objectToPlay)) {
|
||||||
|
return platineStore.currentTrack?.id === props.objectToPlay.id
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
const isPlaying = computed(() => {
|
||||||
|
return platineStore.isPlaying && isCurrentTrack.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = computed(() => {
|
||||||
|
return platineStore.isLoadingTrack && isCurrentTrack.value
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loading,
|
||||||
|
.play-button-changed {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -24,7 +24,7 @@ import { useDataStore } from '~/store/data'
|
|||||||
import { useCardStore } from '~/store/card'
|
import { useCardStore } from '~/store/card'
|
||||||
import { usePlayerStore } from '~/store/player'
|
import { usePlayerStore } from '~/store/player'
|
||||||
import { useUiStore } from '~/store/ui'
|
import { useUiStore } from '~/store/ui'
|
||||||
import type { Box } from '~~/types/types'
|
import type { Box } from '~~/types'
|
||||||
|
|
||||||
const uiStore = useUiStore()
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
<SelectCardRank @change="onRankChange" />
|
<SelectCardRank @change="onRankChange" />
|
||||||
<SelectCardSuit @change="onSuitChange" />
|
<SelectCardSuit @change="onSuitChange" />
|
||||||
</div>
|
</div>
|
||||||
<div ref="deck" class="deck flex flex-wrap justify-center gap-4" :class="{ 'pb-36': playerStore.currentTrack }">
|
<div ref="deck" class="deck flex flex-wrap justify-center gap-4" :class="{ 'pb-36': playerStore.currentTrack }"
|
||||||
|
@dragover.prevent @drop.prevent="handleGlobalDrop">
|
||||||
<card v-for="(track, i) in filteredTracks" :key="track.id" :track="track" :tabindex="i"
|
<card v-for="(track, i) in filteredTracks" :key="track.id" :track="track" :tabindex="i"
|
||||||
@card-click="playerStore.playPlaylistTrack(track)" :is-face-up="isCardRevealed(track.id)"
|
@card-click="playerStore.playPlaylistTrack(track)" :is-face-up="isCardRevealed(track.id)"
|
||||||
@click-card-symbol="openCardSharer()" />
|
@click-card-symbol="openCardSharer()" />
|
||||||
@@ -18,12 +19,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useDataStore } from '~/store/data'
|
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'
|
||||||
@@ -48,7 +49,82 @@ const uiStore = useUiStore()
|
|||||||
|
|
||||||
const deck = ref()
|
const deck = ref()
|
||||||
const tracks = computed(() => dataStore.getTracksByboxId(props.box.id))
|
const tracks = computed(() => dataStore.getTracksByboxId(props.box.id))
|
||||||
const filteredTracks = ref(tracks.value)
|
|
||||||
|
// Suivre si un glisser est en cours depuis le bucket
|
||||||
|
const isDraggingFromBucket = ref(false)
|
||||||
|
|
||||||
|
// Gérer le dépôt d'une carte en dehors du bucket
|
||||||
|
const handleGlobalDrop = (e: DragEvent) => {
|
||||||
|
if (isDraggingFromBucket.value) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
// Récupérer les données de la carte glissée
|
||||||
|
const cardData = e.dataTransfer?.getData('application/json')
|
||||||
|
if (cardData) {
|
||||||
|
try {
|
||||||
|
const track = JSON.parse(cardData)
|
||||||
|
// Retirer la carte du panier
|
||||||
|
cardStore.removeFromBucket(track.id)
|
||||||
|
// La carte réapparaîtra automatiquement dans la playlist
|
||||||
|
// grâce à la computed property filteredTracks
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erreur lors du traitement de la carte glissée', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isDraggingFromBucket.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gérer le début du glisser depuis le bucket
|
||||||
|
const handleBucketDragStart = () => {
|
||||||
|
isDraggingFromBucket.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configurer les écouteurs d'événements
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('drop', handleGlobalDrop)
|
||||||
|
document.addEventListener('dragover', (e) => e.preventDefault()) // Nécessaire pour permettre le drop
|
||||||
|
document.addEventListener('bucket-drag-start', handleBucketDragStart)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Nettoyer les écouteurs d'événements
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('drop', handleGlobalDrop)
|
||||||
|
document.removeEventListener('dragover', (e) => e.preventDefault())
|
||||||
|
document.removeEventListener('bucket-drag-start', handleBucketDragStart)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Utiliser une computed property pour filteredTracks qui réagit aux changements
|
||||||
|
const filteredTracks = computed(() => {
|
||||||
|
let result = [...tracks.value]
|
||||||
|
|
||||||
|
// Exclure les pistes déjà dans le panier
|
||||||
|
result = result.filter(track => !cardStore.isInBucket(track.id))
|
||||||
|
|
||||||
|
// Appliquer les autres filtres
|
||||||
|
if (selectedSuit.value) {
|
||||||
|
result = result.filter(track => track.card?.suit === selectedSuit.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedRank.value) {
|
||||||
|
result = result.filter(track => track.card?.rank === selectedRank.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
result = result.filter(track => {
|
||||||
|
const artistName = typeof track.artist === 'object' ? track.artist?.name : String(track.artist || '')
|
||||||
|
return (
|
||||||
|
track.title?.toLowerCase().includes(query) ||
|
||||||
|
artistName.toLowerCase().includes(query) ||
|
||||||
|
String(track.year || '').includes(query)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
// Variables réactives pour les filtres
|
// Variables réactives pour les filtres
|
||||||
const selectedSuit = ref('')
|
const selectedSuit = ref('')
|
||||||
@@ -86,35 +162,11 @@ const onSearch = (query: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Applique tous les filtres (couleur, rang et recherche)
|
// Applique tous les filtres (couleur, rang et recherche)
|
||||||
|
// La computed property filteredTracks se mettra automatiquement à jour
|
||||||
|
// car elle dépend des mêmes réactifs que cette fonction
|
||||||
const applyFilters = () => {
|
const applyFilters = () => {
|
||||||
let result = [...tracks.value]
|
// Cette fonction ne fait plus que déclencher la réévaluation des dépendances
|
||||||
|
// La computed property filteredTracks fera le reste
|
||||||
// Filtre par couleur
|
|
||||||
if (selectedSuit.value) {
|
|
||||||
result = result.filter(track => track.card?.suit === selectedSuit.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtre par rang
|
|
||||||
if (selectedRank.value) {
|
|
||||||
result = result.filter(track => track.card?.rank === selectedRank.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtre par recherche textuelle
|
|
||||||
if (searchQuery.value) {
|
|
||||||
const query = searchQuery.value.toLowerCase()
|
|
||||||
result = result.filter(track => {
|
|
||||||
// Gestion du nom d'artiste (peut être un objet ou une chaîne)
|
|
||||||
const artistName = typeof track.artist === 'object' ? track.artist?.name : String(track.artist || '')
|
|
||||||
// Recherche dans le titre, l'artiste et l'année
|
|
||||||
return (
|
|
||||||
track.title?.toLowerCase().includes(query) ||
|
|
||||||
artistName.toLowerCase().includes(query) ||
|
|
||||||
String(track.year || '').includes(query)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredTracks.value = result
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useUiStore } from '~/store/ui'
|
import { useUiStore } from '~/store/ui'
|
||||||
import type { Track } from '~~/types/types'
|
import type { Track } from '~~/types'
|
||||||
|
|
||||||
const uiStore = useUiStore()
|
const uiStore = useUiStore()
|
||||||
const currentTrack = ref<Track | null>(null)
|
const currentTrack = ref<Track | null>(null)
|
||||||
18
appOLDD/error.vue
Normal file
18
appOLDD/error.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
class="text-white pt-6 text-lg md:text-xl lg:text-2xl text-center font-bold tracking-widest text-shadow"
|
||||||
|
>
|
||||||
|
{{ error?.statusCode }}
|
||||||
|
</h1>
|
||||||
|
<NuxtLink to="/">Go back home</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { NuxtError } from '#app'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
error: Object as () => NuxtError
|
||||||
|
})
|
||||||
|
</script>
|
||||||
55
appOLDD/layouts/default.vue
Normal file
55
appOLDD/layouts/default.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<slot />
|
||||||
|
<Bucket />
|
||||||
|
<Platine v-if="playerStore.currentTrack" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Track } from '~~/types'
|
||||||
|
import { usePlayerStore } from '~/store/player'
|
||||||
|
|
||||||
|
const playerStore = usePlayerStore()
|
||||||
|
const onCardDropped = (card: Track) => {
|
||||||
|
console.log('Carte déposée dans le bucket:', card)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bucket,
|
||||||
|
.platine {
|
||||||
|
position: fixed;
|
||||||
|
bottom: -100%;
|
||||||
|
right: 0;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bucket {
|
||||||
|
z-index: 70;
|
||||||
|
bottom: -260px;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: scroll;
|
||||||
|
transition: bottom .3s ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
.card-dragging & {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bucket-card-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.platine {
|
||||||
|
bottom: -100%;
|
||||||
|
transition: bottom 2s ease;
|
||||||
|
|
||||||
|
&.mounted {
|
||||||
|
z-index: 80;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -22,7 +22,7 @@ import { useRoute } from 'vue-router'
|
|||||||
import { usePlayerStore } from '~/store/player'
|
import { usePlayerStore } from '~/store/player'
|
||||||
import { useCardStore } from '~/store/card'
|
import { useCardStore } from '~/store/card'
|
||||||
import { useDataStore } from '~/store/data'
|
import { useDataStore } from '~/store/data'
|
||||||
import type { Track } from '~~/types/types'
|
import type { Track } from '~~/types'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const playerStore = usePlayerStore()
|
const playerStore = usePlayerStore()
|
||||||
27
appOLDD/pages/index.vue
Normal file
27
appOLDD/pages/index.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<boxes />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useUiStore } from '~/store/ui'
|
||||||
|
import { useDataStore } from '~/store/data'
|
||||||
|
|
||||||
|
// Configuration du layout
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'default'
|
||||||
|
})
|
||||||
|
|
||||||
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const dataStore = useDataStore()
|
||||||
|
await dataStore.loadData()
|
||||||
|
uiStore.listBoxes()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.logo {
|
||||||
|
filter: drop-shadow(3px 3px 0 rgb(0 0 0 / 0.7));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { Track } from '~~/types/types'
|
import type { Track } from '~~/types'
|
||||||
|
|
||||||
export const useCardStore = defineStore('card', {
|
export const useCardStore = defineStore('card', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { Track } from '~~/types/types'
|
import type { Track } from '~~/types'
|
||||||
import Disc from '~/platine-tools/disc'
|
import Disc from '~/platine-tools/disc'
|
||||||
import Sampler from '~/platine-tools/sampler'
|
import Sampler from '~/platine-tools/sampler'
|
||||||
import { useCardStore } from '~/store/card'
|
import { useCardStore } from '~/store/card'
|
||||||
@@ -74,7 +74,7 @@ export const usePlatineStore = defineStore('platine', () => {
|
|||||||
isLoadingTrack.value = true
|
isLoadingTrack.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sampler.value.loadTrack(track.url)
|
await sampler.value.loadTrack(track.filePath)
|
||||||
if (disc.value) {
|
if (disc.value) {
|
||||||
disc.value.setDuration(sampler.value.duration)
|
disc.value.setDuration(sampler.value.duration)
|
||||||
updateTurns()
|
updateTurns()
|
||||||
146
data/Artists.json
Normal file
146
data/Artists.json
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "L'efondras",
|
||||||
|
"url": "https://leffondras.bandcamp.com/music",
|
||||||
|
"coverId": "0024705317"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "The kundalini genie",
|
||||||
|
"url": "https://the-kundalini-genie.bandcamp.com",
|
||||||
|
"coverId": "0012045550"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "Fontaines D.C.",
|
||||||
|
"url": "https://fontainesdc.bandcamp.com",
|
||||||
|
"coverId": "0027327090"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "Fontanarosa",
|
||||||
|
"url": "https://fontanarosa.bandcamp.com",
|
||||||
|
"coverId": "0035380235"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"name": "Johnny mafia",
|
||||||
|
"url": "https://johnnymafia.bandcamp.com",
|
||||||
|
"coverId": "0035009392"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"name": "New candys",
|
||||||
|
"url": "https://newcandys.bandcamp.com",
|
||||||
|
"coverId": "0039963261"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"name": "Magic shoppe",
|
||||||
|
"url": "https://magicshoppe.bandcamp.com",
|
||||||
|
"coverId": "0030748374"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"name": "Les jaguars",
|
||||||
|
"url": "https://radiomartiko.bandcamp.com/album/surf-qu-b-cois",
|
||||||
|
"coverId": "0016551336"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"name": "TRAAMS",
|
||||||
|
"url": "https://traams.bandcamp.com",
|
||||||
|
"coverId": "0028348410"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"name": "Blue orchid",
|
||||||
|
"url": "https://blue-orchid.bandcamp.com",
|
||||||
|
"coverId": "0034796193"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"name": "I love UFO",
|
||||||
|
"url": "https://bruitblanc.bandcamp.com",
|
||||||
|
"coverId": "a2203158939"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"name": "Kid Congo & The Pink Monkey Birds",
|
||||||
|
"url": "https://kidcongothepinkmonkeybirds.bandcamp.com/",
|
||||||
|
"coverId": "0017196290"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"name": "Firefriend",
|
||||||
|
"url": "https://firefriend.bandcamp.com/",
|
||||||
|
"coverId": "0031072203"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"name": "Squid",
|
||||||
|
"url": "https://squiduk.bandcamp.com/",
|
||||||
|
"coverId": "0037649385"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"name": "Lysistrata",
|
||||||
|
"url": "https://lysistrata.bandcamp.com/",
|
||||||
|
"coverId": "0033900158"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"name": "Pablo X Broadcasting Services",
|
||||||
|
"url": "https://pabloxbroadcastingservices.bandcamp.com/",
|
||||||
|
"coverId": "0036956486"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"name": "Night Beats",
|
||||||
|
"url": "https://nightbeats.bandcamp.com/",
|
||||||
|
"coverId": "0036987720"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"name": "Deltron 3030",
|
||||||
|
"url": "https://delthefunkyhomosapien.bandcamp.com/",
|
||||||
|
"coverId": "0005254781"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"name": "The Amorphous Androgynous",
|
||||||
|
"url": "https://theaa.bandcamp.com/",
|
||||||
|
"coverId": "0022226700"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"name": "Wooden Shjips",
|
||||||
|
"url": "https://woodenshjips.bandcamp.com/",
|
||||||
|
"coverId": "0012406678"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"name": "Silas J. Dirge",
|
||||||
|
"url": "https://silasjdirge.bandcamp.com/",
|
||||||
|
"coverId": "0035751570"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"name": "Secret Colours",
|
||||||
|
"url": "https://secretcolours.bandcamp.com/",
|
||||||
|
"coverId": "0010661379"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"name": "Larry McNeil And The Blue Knights",
|
||||||
|
"url": "https://www.discogs.com/artist/6528940-Larry-McNeil-And-The-Blue-Knights",
|
||||||
|
"coverId": "https://i.discogs.com/Yr05_neEXwzPwKlDeV7dimmTG34atkAMgpxbMBhHBkI/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTEyMTEw/ODE1LTE1Mjg1NjU1/NzQtMjcyOC5qcGVn.jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"name": "Hugo Blanco",
|
||||||
|
"url": "https://elpalmasmusic.bandcamp.com/album/color-de-tr-pico-compiled-by-el-dr-gon-criollo-el-palmas",
|
||||||
|
"coverId": "0016886708"
|
||||||
|
}
|
||||||
|
]
|
||||||
44
data/Compilations.json
Normal file
44
data/Compilations.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "ES00",
|
||||||
|
"type": "compilation",
|
||||||
|
"name": "manifeste",
|
||||||
|
"description": "Zero is for manifesto",
|
||||||
|
"duration": 5264,
|
||||||
|
"sides": {
|
||||||
|
"A": {
|
||||||
|
"name": "manifeste",
|
||||||
|
"description": "Zero is for manifesto",
|
||||||
|
"duration": 2794,
|
||||||
|
"color1": "#ffffff",
|
||||||
|
"color2": "#48959d"
|
||||||
|
},
|
||||||
|
"B": {
|
||||||
|
"name": "manifeste B",
|
||||||
|
"description": "Even Zero has a b-side",
|
||||||
|
"duration": 2470,
|
||||||
|
"color1": "#0d01b9",
|
||||||
|
"color2": "#3b7589"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ES01",
|
||||||
|
"type": "compilation",
|
||||||
|
"name": "...",
|
||||||
|
"description": "...",
|
||||||
|
"duration": 7260,
|
||||||
|
"sides": {
|
||||||
|
"A": {
|
||||||
|
"duration": 3487,
|
||||||
|
"color1": "#c7b3aa",
|
||||||
|
"color2": "#000100"
|
||||||
|
},
|
||||||
|
"B": {
|
||||||
|
"duration": 3773,
|
||||||
|
"color1": "#f7dd01",
|
||||||
|
"color2": "#010103"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
486
data/Songs.json
Normal file
486
data/Songs.json
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"order": 1,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "The grinding wheel",
|
||||||
|
"artist": 0,
|
||||||
|
"start": 0,
|
||||||
|
"link": "https://arakirecords.bandcamp.com/track/the-grinding-wheel",
|
||||||
|
"coverId": "a3236746052",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 2,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Bleach",
|
||||||
|
"artist": 1,
|
||||||
|
"start": 392,
|
||||||
|
"link": "https://the-kundalini-genie.bandcamp.com/track/bleach-2",
|
||||||
|
"coverId": "a1714786533",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 3,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Televised mind",
|
||||||
|
"artist": 2,
|
||||||
|
"start": 896,
|
||||||
|
"link": "https://fontainesdc.bandcamp.com/track/televised-mind",
|
||||||
|
"coverId": "a3772806156",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 4,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "In it",
|
||||||
|
"artist": 3,
|
||||||
|
"start": 1139,
|
||||||
|
"link": "https://howlinbananarecords.bandcamp.com/track/in-it",
|
||||||
|
"coverId": "a1720372066",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 5,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Bad michel",
|
||||||
|
"artist": 4,
|
||||||
|
"start": 1245,
|
||||||
|
"link": "https://johnnymafia.bandcamp.com/track/bad-michel-3",
|
||||||
|
"coverId": "a0984622869",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 6,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Overall",
|
||||||
|
"artist": 5,
|
||||||
|
"start": 1394,
|
||||||
|
"link": "https://newcandys.bandcamp.com/track/overall",
|
||||||
|
"coverId": "a0559661270",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 7,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Blowup",
|
||||||
|
"artist": 6,
|
||||||
|
"start": 1674,
|
||||||
|
"link": "https://magicshoppe.bandcamp.com/track/blowup",
|
||||||
|
"coverId": "a1444895293",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 8,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Guitar jet",
|
||||||
|
"artist": 7,
|
||||||
|
"start": 1880,
|
||||||
|
"link": "https://radiomartiko.bandcamp.com/track/guitare-jet",
|
||||||
|
"coverId": "a1494681687",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 9,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Intercontinental radio waves",
|
||||||
|
"artist": 8,
|
||||||
|
"start": 2024,
|
||||||
|
"link": "https://traams.bandcamp.com/track/intercontinental-radio-waves",
|
||||||
|
"coverId": "a0046738552",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 10,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Here comes the sun",
|
||||||
|
"artist": 9,
|
||||||
|
"start": 2211,
|
||||||
|
"link": "https://blue-orchid.bandcamp.com/track/here-come-the-sun",
|
||||||
|
"coverId": "a4102567047",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 11,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Like in the movies",
|
||||||
|
"artist": 10,
|
||||||
|
"start": 2560,
|
||||||
|
"link": "https://bruitblanc.bandcamp.com/track/like-in-the-movies-2",
|
||||||
|
"coverId": "a2203158939",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 1,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Ce que révèle l'éclipse",
|
||||||
|
"artist": 0,
|
||||||
|
"start": 0,
|
||||||
|
"link": "https://arakirecords.bandcamp.com/track/ce-que-r-v-le-l-clipse",
|
||||||
|
"coverId": "a3236746052",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 2,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Bleedin' Gums Mushrool",
|
||||||
|
"artist": 1,
|
||||||
|
"start": 263,
|
||||||
|
"link": "https://the-kundalini-genie.bandcamp.com/track/bleedin-gums-mushroom",
|
||||||
|
"coverId": "a1714786533",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 3,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "A lucid dream",
|
||||||
|
"artist": 2,
|
||||||
|
"start": 554,
|
||||||
|
"link": "https://fontainesdc.bandcamp.com/track/a-lucid-dream",
|
||||||
|
"coverId": "a3772806156",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 4,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Lights off",
|
||||||
|
"artist": 3,
|
||||||
|
"start": 781,
|
||||||
|
"link": "https://howlinbananarecords.bandcamp.com/track/lights-off",
|
||||||
|
"coverId": "a1720372066",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 5,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "I'm sentimental",
|
||||||
|
"artist": 4,
|
||||||
|
"start": 969,
|
||||||
|
"link": "https://johnnymafia.bandcamp.com/track/im-sentimental-2",
|
||||||
|
"coverId": "a2333676849",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 6,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Thrill or trip",
|
||||||
|
"artist": 5,
|
||||||
|
"start": 1128,
|
||||||
|
"link": "https://newcandys.bandcamp.com/track/thrill-or-trip",
|
||||||
|
"coverId": "a0559661270",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 7,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Redhead",
|
||||||
|
"artist": 6,
|
||||||
|
"start": 1303,
|
||||||
|
"link": "https://magicshoppe.bandcamp.com/track/redhead",
|
||||||
|
"coverId": "a0594426943",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 8,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Supersonic twist",
|
||||||
|
"artist": 7,
|
||||||
|
"start": 1584,
|
||||||
|
"link": "https://open.spotify.com/track/66voQIZAJ3zD3Eju2qtNjF",
|
||||||
|
"coverId": "a1494681687",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 9,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Flowers",
|
||||||
|
"artist": 8,
|
||||||
|
"start": 1749,
|
||||||
|
"link": "https://traams.bandcamp.com/track/flowers",
|
||||||
|
"coverId": "a3644668199",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 10,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "The shade",
|
||||||
|
"artist": 9,
|
||||||
|
"start": 1924,
|
||||||
|
"link": "https://blue-orchid.bandcamp.com/track/the-shade",
|
||||||
|
"coverId": "a0804204790",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 11,
|
||||||
|
"boxId": "ES00",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Like in the movies",
|
||||||
|
"artist": 10,
|
||||||
|
"start": 2186,
|
||||||
|
"link": "https://bruitblanc.bandcamp.com/track/like-in-the-movies",
|
||||||
|
"coverId": "a3647322740",
|
||||||
|
"year": 2024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 1,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "He Walked In",
|
||||||
|
"artist": 11,
|
||||||
|
"start": 0,
|
||||||
|
"link": "https://kidcongothepinkmonkeybirds.bandcamp.com/track/he-walked-in",
|
||||||
|
"coverId": "a0336300523",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 2,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "The Third Wave",
|
||||||
|
"artist": 12,
|
||||||
|
"start": 841,
|
||||||
|
"link": "https://firefriend.bandcamp.com/track/the-third-wave",
|
||||||
|
"coverId": "a2803689859",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 3,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Broadcaster",
|
||||||
|
"artist": 13,
|
||||||
|
"start": 1104.5,
|
||||||
|
"link": "https://squiduk.bandcamp.com/track/broadcaster",
|
||||||
|
"coverId": "a3391719769",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 4,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Mourn",
|
||||||
|
"artist": 14,
|
||||||
|
"start": 1441,
|
||||||
|
"link": "https://lysistrata.bandcamp.com/track/mourn-2",
|
||||||
|
"coverId": "a0872900041",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 5,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Let it Blow",
|
||||||
|
"artist": 15,
|
||||||
|
"start": 1844.8,
|
||||||
|
"link": "https://pabloxbroadcastingservices.bandcamp.com/track/let-it-blow",
|
||||||
|
"coverId": "a4000148031",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 6,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Sunday Mourning",
|
||||||
|
"artist": 16,
|
||||||
|
"start": 2091.7,
|
||||||
|
"link": "https://nightbeats.bandcamp.com/track/sunday-mourning",
|
||||||
|
"coverId": "a0031987121",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 7,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "3030 Instrumental",
|
||||||
|
"artist": 17,
|
||||||
|
"start": 2339.3,
|
||||||
|
"link": "https://delthefunkyhomosapien.bandcamp.com/track/3030",
|
||||||
|
"coverId": "a1948146136",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 8,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Immortality Break",
|
||||||
|
"artist": 18,
|
||||||
|
"start": 2530.5,
|
||||||
|
"link": "https://theaa.bandcamp.com/track/immortality-break",
|
||||||
|
"coverId": "a2749250329",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 9,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Lazy Bones",
|
||||||
|
"artist": 19,
|
||||||
|
"start": 2718,
|
||||||
|
"link": "https://woodenshjips.bandcamp.com/track/lazy-bones",
|
||||||
|
"coverId": "a1884221104",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 10,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "On the Train of Aches",
|
||||||
|
"artist": 20,
|
||||||
|
"start": 2948,
|
||||||
|
"link": "https://silasjdirge.bandcamp.com/track/on-the-train-of-aches",
|
||||||
|
"coverId": "a1124177379",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 11,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "A",
|
||||||
|
"title": "Me",
|
||||||
|
"artist": 21,
|
||||||
|
"start": 3265,
|
||||||
|
"link": "https://secretcolours.bandcamp.com/track/me",
|
||||||
|
"coverId": "a1497022499",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 1,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Lady Hawke Blues",
|
||||||
|
"artist": 11,
|
||||||
|
"start": 0,
|
||||||
|
"link": "https://kidcongothepinkmonkeybirds.bandcamp.com/track/lady-hawke-blues",
|
||||||
|
"coverId": "a2532623230",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 2,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Dreamscapes",
|
||||||
|
"artist": 12,
|
||||||
|
"start": 235,
|
||||||
|
"link": "https://littlecloudrecords.bandcamp.com/track/dreamscapes",
|
||||||
|
"coverId": "a3498981203",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 3,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Crispy Skin",
|
||||||
|
"artist": 13,
|
||||||
|
"start": 644.2,
|
||||||
|
"link": "https://squiduk.bandcamp.com/track/crispy-skin-2",
|
||||||
|
"coverId": "a2516727021",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 4,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "The Boy Who Stood Above The Earth",
|
||||||
|
"artist": 14,
|
||||||
|
"start": 1018,
|
||||||
|
"link": "https://lysistrata.bandcamp.com/track/the-boy-who-stood-above-the-earth-2",
|
||||||
|
"coverId": "a0350933426",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 5,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Better Off Alone",
|
||||||
|
"artist": 15,
|
||||||
|
"start": 1698,
|
||||||
|
"link": "https://pabloxbroadcastingservices.bandcamp.com/track/better-off-alone",
|
||||||
|
"coverId": "a4000148031",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 6,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Celebration #1",
|
||||||
|
"artist": 16,
|
||||||
|
"start": 2235,
|
||||||
|
"link": "https://nightbeats.bandcamp.com/track/celebration-1",
|
||||||
|
"coverId": "a0031987121",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 7,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "3030 Instrumental",
|
||||||
|
"artist": 17,
|
||||||
|
"start": 2458.3,
|
||||||
|
"link": "https://delthefunkyhomosapien.bandcamp.com/track/3030",
|
||||||
|
"coverId": "a1948146136",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 8,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "The Emptiness Of Nothingness",
|
||||||
|
"artist": 18,
|
||||||
|
"start": 2864.5,
|
||||||
|
"link": "https://theaa.bandcamp.com/track/the-emptiness-of-nothingness",
|
||||||
|
"coverId": "a1053923875",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 9,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Rising",
|
||||||
|
"artist": 19,
|
||||||
|
"start": 3145,
|
||||||
|
"link": "https://woodenshjips.bandcamp.com/track/rising",
|
||||||
|
"coverId": "a1884221104",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 10,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "The Last Time",
|
||||||
|
"artist": 22,
|
||||||
|
"start": 3447,
|
||||||
|
"link": "https://www.discogs.com/release/12110815-Larry-McNeil-And-The-Blue-Knights-Jealous-Woman",
|
||||||
|
"coverId": "https://i.discogs.com/Yr05_neEXwzPwKlDeV7dimmTG34atkAMgpxbMBhHBkI/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTEyMTEw/ODE1LTE1Mjg1NjU1/NzQtMjcyOC5qcGVn.jpeg",
|
||||||
|
"year": 2025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 11,
|
||||||
|
"boxId": "ES01",
|
||||||
|
"side": "B",
|
||||||
|
"title": "Guajira Con Arpa",
|
||||||
|
"artist": 23,
|
||||||
|
"start": 3586,
|
||||||
|
"link": "https://elpalmasmusic.bandcamp.com/track/guajira-con-arpa",
|
||||||
|
"coverId": "a3463036407",
|
||||||
|
"year": 2025
|
||||||
|
}
|
||||||
|
]
|
||||||
BIN
data/music.db
Normal file
BIN
data/music.db
Normal file
Binary file not shown.
@@ -8,14 +8,18 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
ports:
|
ports:
|
||||||
- "${PORT}:${PORT_EXPOSED}"
|
- '${PORT}:${PORT_EXPOSED}'
|
||||||
volumes:
|
volumes:
|
||||||
- $MEDIA_DIR:/app/mnt/media
|
- $MEDIA_DIR:/app/mnt/media
|
||||||
|
- evilspins:/app/data
|
||||||
environment:
|
environment:
|
||||||
VIRTUAL_HOST: "${DOMAIN}"
|
VIRTUAL_HOST: '${DOMAIN}'
|
||||||
LETSENCRYPT_HOST: "${DOMAIN}"
|
LETSENCRYPT_HOST: '${DOMAIN}'
|
||||||
PUID: "${PUID}"
|
PUID: '${PUID}'
|
||||||
PGID: "${PGID}"
|
PGID: '${PGID}'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
evilspins:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
|
|||||||
11
drizzle.config.ts
Normal file
11
drizzle.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
|
import { defineConfig } from 'drizzle-kit'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
out: './drizzle',
|
||||||
|
schema: './server/db/schema.ts',
|
||||||
|
dialect: 'sqlite',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.PATH_DB!
|
||||||
|
}
|
||||||
|
})
|
||||||
3
env.sh
3
env.sh
@@ -1,3 +0,0 @@
|
|||||||
export DOMAIN="evilspins.com"
|
|
||||||
export PORT="7901"
|
|
||||||
export PORT_EXPOSED="3000"
|
|
||||||
@@ -4,9 +4,26 @@ import tsconfigPaths from 'vite-tsconfig-paths'
|
|||||||
const isProd = process.env.NODE_ENV === 'production'
|
const isProd = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
|
runtimeConfig: {
|
||||||
|
pathFiles: process.env.PATH_FILES,
|
||||||
|
pathDb: process.env.PATH_DB
|
||||||
|
},
|
||||||
|
nitro: {
|
||||||
|
experimental: {
|
||||||
|
tasks: true
|
||||||
|
},
|
||||||
|
scheduledTasks: {
|
||||||
|
'*/5 * * * *': ['syncTracks']
|
||||||
|
}
|
||||||
|
},
|
||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
modules: ['@nuxt/eslint', '@nuxtjs/tailwindcss', '@pinia/nuxt'],
|
modules: ['@nuxt/eslint', '@nuxtjs/tailwindcss', '@pinia/nuxt'],
|
||||||
|
typescript: {
|
||||||
|
tsConfig: {
|
||||||
|
include: ['types/**/*.ts']
|
||||||
|
}
|
||||||
|
},
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tsconfigPaths()]
|
plugins: [tsconfigPaths()]
|
||||||
},
|
},
|
||||||
|
|||||||
18057
package-lock.json
generated
18057
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
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",
|
||||||
@@ -11,14 +12,19 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"format": "prettier --check .",
|
"format": "prettier --check .",
|
||||||
"format:fix": "prettier --write ."
|
"format:fix": "prettier --write .",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"db:reset": "rm -rf drizzle data/music.db && drizzle-kit push",
|
||||||
|
"db:sync": "curl -X POST -H \"Content-Type: application/json\" -d '{}' http://localhost:7901/api/test/test-db-sync"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
||||||
"atropos": "^2.0.2",
|
"atropos": "^2.0.2",
|
||||||
"nuxt": "^4.2.0",
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"nuxt": "^4.3.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
@@ -27,7 +33,7 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"pnpm": ">=10 <11"
|
"pnpm": ">=10 <11"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748",
|
"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",
|
||||||
@@ -35,7 +41,9 @@
|
|||||||
"@nuxtjs/eslint-config-typescript": "^12.1.0",
|
"@nuxtjs/eslint-config-typescript": "^12.1.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||||
"@typescript-eslint/parser": "^8.46.3",
|
"@typescript-eslint/parser": "^8.46.3",
|
||||||
"eslint": "^8.57.1",
|
"dotenv": "^16.4.7",
|
||||||
|
"drizzle-kit": "^0.31.8",
|
||||||
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"eslint-plugin-vue": "9.3.0",
|
"eslint-plugin-vue": "9.3.0",
|
||||||
@@ -43,6 +51,8 @@
|
|||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"sass-embedded": "^1.93.2",
|
"sass-embedded": "^1.93.2",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4997
pnpm-lock.yaml
generated
4997
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
7
pnpm-workspace.yaml
Normal file
7
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
ignoredBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
|
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@parcel/watcher'
|
||||||
|
- better-sqlite3
|
||||||
|
- unrs-resolver
|
||||||
1
public/cassette.svg
Normal file
1
public/cassette.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg data-v-161a534a="" data-v-ef1138a9="" width="20" height="20" viewBox="0 0 20 20" fill="none" role="img" class="icon cassette-tape-outline-icon" aria-hidden="true" style="--v7dd280ad: var(--gray700);"><g data-v-161a534a=""><path data-v-161a534a="" fill-rule="evenodd" clip-rule="evenodd" d="M17.7 4.3H2.3L2.3 15.7H17.7V4.3ZM2.3 3.5C1.85817 3.5 1.5 3.85817 1.5 4.3V15.7C1.5 16.1418 1.85817 16.5 2.3 16.5H17.7C18.1418 16.5 18.5 16.1418 18.5 15.7V4.3C18.5 3.85817 18.1418 3.5 17.7 3.5H2.3Z" stroke="none"></path><path data-v-161a534a="" fill-rule="evenodd" clip-rule="evenodd" d="M8.5 9C8.5 10.2703 7.47025 11.3 6.2 11.3C4.92974 11.3 3.9 10.2703 3.9 9C3.9 7.72974 4.92974 6.7 6.2 6.7C7.47025 6.7 8.5 7.72974 8.5 9ZM6.2 10.5C7.02842 10.5 7.7 9.82843 7.7 9C7.7 8.17157 7.02842 7.5 6.2 7.5C5.37157 7.5 4.7 8.17157 4.7 9C4.7 9.82843 5.37157 10.5 6.2 10.5Z" stroke="none"></path><path data-v-161a534a="" fill-rule="evenodd" clip-rule="evenodd" d="M16.2 9C16.2 10.2703 15.1702 11.3 13.9 11.3C12.6297 11.3 11.6 10.2703 11.6 9C11.6 7.72974 12.6297 6.7 13.9 6.7C15.1702 6.7 16.2 7.72974 16.2 9ZM13.9 10.5C14.7284 10.5 15.4 9.82843 15.4 9C15.4 8.17157 14.7284 7.5 13.9 7.5C13.0716 7.5 12.4 8.17157 12.4 9C12.4 9.82843 13.0716 10.5 13.9 10.5Z" stroke="none"></path><path data-v-161a534a="" fill-rule="evenodd" clip-rule="evenodd" d="M6.36642 14C6.22966 14 6.10238 14.0698 6.02896 14.1852L4.73746 16.2147L4.06253 15.7852L5.35403 13.7557C5.5743 13.4096 5.95614 13.2 6.36642 13.2H13.6336C14.0438 13.2 14.4257 13.4096 14.646 13.7557L15.9375 15.7852L15.2625 16.2147L13.971 14.1852C13.8976 14.0698 13.7703 14 13.6336 14H6.36642Z" stroke="none"></path></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
17
public/disc.svg
Normal file
17
public/disc.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<svg data-v-daf08565="" data-v-ef1138a9="" width="20" height="20" viewBox="0 0 20 20" fill="none" role="img"
|
||||||
|
class="icon vinyl-record-outline-icon" aria-hidden="true" style="--v0954d04a: var(--gray700);">
|
||||||
|
<g data-v-daf08565="">
|
||||||
|
<path data-v-daf08565=""
|
||||||
|
d="M10 11C10.5523 11 11 10.5523 11 10C11 9.44772 10.5523 9 10 9C9.44772 9 9 9.44772 9 10C9 10.5523 9.44772 11 10 11Z"
|
||||||
|
stroke="none"></path>
|
||||||
|
<path data-v-daf08565="" fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
d="M13 10C13 11.6569 11.6569 13 10 13C8.34315 13 7 11.6569 7 10C7 8.34315 8.34315 7 10 7C11.6569 7 13 8.34315 13 10ZM10 12.1892C11.2091 12.1892 12.1892 11.2091 12.1892 10C12.1892 8.79094 11.2091 7.81081 10 7.81081C8.79094 7.81081 7.81081 8.79094 7.81081 10C7.81081 11.2091 8.79094 12.1892 10 12.1892Z"
|
||||||
|
stroke="none"></path>
|
||||||
|
<path data-v-daf08565=""
|
||||||
|
d="M12.3606 13.4425L12.0793 12.9862L9.80855 14.512L10.9231 16.2177L13.1938 14.6919L13.0425 14.3444L14.7376 13.1105C15.1903 12.7894 17.0431 11.2251 17.2951 9.62497L17.5002 8.39218L16.6563 6.35491L16.5832 7.60639C16.5764 7.75055 16.5615 7.92059 16.5332 8.11074C16.3877 9.08943 15.8891 10.6011 14.3388 11.8603L13.8788 12.234C13.5086 12.5349 13.2133 12.7749 12.9854 12.9589C12.686 13.2007 12.5025 13.3462 12.4167 13.4079C12.4012 13.419 12.3888 13.4274 12.3796 13.4331C12.37 13.4391 12.3636 13.4422 12.3606 13.4425Z"
|
||||||
|
stroke="none"></path>
|
||||||
|
<path data-v-daf08565="" fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
d="M10 17.2C13.9765 17.2 17.2 13.9765 17.2 10C17.2 6.02355 13.9765 2.8 10 2.8C6.02355 2.8 2.8 6.02355 2.8 10C2.8 13.9765 6.02355 17.2 10 17.2ZM10 18C14.4183 18 18 14.4183 18 10C18 5.58172 14.4183 2 10 2C5.58172 2 2 5.58172 2 10C2 14.4183 5.58172 18 10 18Z"
|
||||||
|
stroke="none"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
65
scripts/update-esid.ts
Normal file
65
scripts/update-esid.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { db } from '../server/services/db'
|
||||||
|
import { tracks, compilations, playlists, sql } from '../server/schema'
|
||||||
|
import { eq, and } from 'drizzle-orm'
|
||||||
|
|
||||||
|
async function updateEsid() {
|
||||||
|
console.log('🚀 Démarrage de la mise à jour des esid')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Mettre à jour les esid pour les compilations
|
||||||
|
console.log('📋 Mise à jour des esid pour les compilations...')
|
||||||
|
await db.run(sql`
|
||||||
|
UPDATE tracks
|
||||||
|
SET esid = (
|
||||||
|
SELECT c.box_id || c.side || t."order"
|
||||||
|
FROM compilations c
|
||||||
|
WHERE c.id = tracks.source_id AND tracks.source_type = 'compilation'
|
||||||
|
)
|
||||||
|
WHERE source_type = 'compilation';
|
||||||
|
`)
|
||||||
|
|
||||||
|
// 2. Mettre à jour les esid pour les playlists
|
||||||
|
console.log('📋 Mise à jour des esid pour les playlists...')
|
||||||
|
|
||||||
|
// Récupérer toutes les playlists
|
||||||
|
const allPlaylists = await db.select().from(playlists).all()
|
||||||
|
|
||||||
|
for (const playlist of allPlaylists) {
|
||||||
|
// Récupérer les tracks de la playlist triés par ordre
|
||||||
|
const playlistTracks = await db
|
||||||
|
.select()
|
||||||
|
.from(tracks)
|
||||||
|
.where(and(eq(tracks.sourceType, 'playlist'), eq(tracks.sourceId, playlist.id)))
|
||||||
|
.orderBy(tracks.order)
|
||||||
|
|
||||||
|
// Mettre à jour chaque track avec le bon esid
|
||||||
|
for (let i = 0; i < playlistTracks.length; i++) {
|
||||||
|
const track = playlistTracks[i]
|
||||||
|
const esidSuffix =
|
||||||
|
i < 26
|
||||||
|
? String.fromCharCode(65 + i) // A-Z pour les 26 premières pistes
|
||||||
|
: (i + 1).toString() // Numéros pour les suivantes
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(tracks)
|
||||||
|
.set({ esid: `${playlist.name}${esidSuffix}` })
|
||||||
|
.where(eq(tracks.id, track.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Playlist ${playlist.name} mise à jour (${playlistTracks.length} pistes)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✅ Tous les esid ont été mis à jour avec succès !')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur lors de la mise à jour des esid:', error)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exécuter le script
|
||||||
|
updateEsid()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Erreur inattendue:', error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import { eventHandler } from 'h3'
|
|
||||||
|
|
||||||
export default eventHandler(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 0,
|
|
||||||
name: "L'efondras",
|
|
||||||
url: 'https://leffondras.bandcamp.com/music',
|
|
||||||
coverId: '0024705317'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'The kundalini genie',
|
|
||||||
url: 'https://the-kundalini-genie.bandcamp.com',
|
|
||||||
coverId: '0012045550'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'Fontaines D.C.',
|
|
||||||
url: 'https://fontainesdc.bandcamp.com',
|
|
||||||
coverId: '0027327090'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'Fontanarosa',
|
|
||||||
url: 'https://fontanarosa.bandcamp.com',
|
|
||||||
coverId: '0035380235'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: 'Johnny mafia',
|
|
||||||
url: 'https://johnnymafia.bandcamp.com',
|
|
||||||
coverId: '0035009392'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
name: 'New candys',
|
|
||||||
url: 'https://newcandys.bandcamp.com',
|
|
||||||
coverId: '0039963261'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
name: 'Magic shoppe',
|
|
||||||
url: 'https://magicshoppe.bandcamp.com',
|
|
||||||
coverId: '0030748374'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
name: 'Les jaguars',
|
|
||||||
url: 'https://radiomartiko.bandcamp.com/album/surf-qu-b-cois',
|
|
||||||
coverId: '0016551336'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 8,
|
|
||||||
name: 'TRAAMS',
|
|
||||||
url: 'https://traams.bandcamp.com',
|
|
||||||
coverId: '0028348410'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
name: 'Blue orchid',
|
|
||||||
url: 'https://blue-orchid.bandcamp.com',
|
|
||||||
coverId: '0034796193'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 10,
|
|
||||||
name: 'I love UFO',
|
|
||||||
url: 'https://bruitblanc.bandcamp.com',
|
|
||||||
coverId: 'a2203158939'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 11,
|
|
||||||
name: 'Kid Congo & The Pink Monkey Birds',
|
|
||||||
url: 'https://kidcongothepinkmonkeybirds.bandcamp.com/',
|
|
||||||
coverId: '0017196290'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 12,
|
|
||||||
name: 'Firefriend',
|
|
||||||
url: 'https://firefriend.bandcamp.com/',
|
|
||||||
coverId: '0031072203'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 13,
|
|
||||||
name: 'Squid',
|
|
||||||
url: 'https://squiduk.bandcamp.com/',
|
|
||||||
coverId: '0037649385'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 14,
|
|
||||||
name: 'Lysistrata',
|
|
||||||
url: 'https://lysistrata.bandcamp.com/',
|
|
||||||
coverId: '0033900158'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 15,
|
|
||||||
name: 'Pablo X Broadcasting Services',
|
|
||||||
url: 'https://pabloxbroadcastingservices.bandcamp.com/',
|
|
||||||
coverId: '0036956486'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 16,
|
|
||||||
name: 'Night Beats',
|
|
||||||
url: 'https://nightbeats.bandcamp.com/',
|
|
||||||
coverId: '0036987720'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 17,
|
|
||||||
name: 'Deltron 3030',
|
|
||||||
url: 'https://delthefunkyhomosapien.bandcamp.com/',
|
|
||||||
coverId: '0005254781'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 18,
|
|
||||||
name: 'The Amorphous Androgynous',
|
|
||||||
url: 'https://theaa.bandcamp.com/',
|
|
||||||
coverId: '0022226700'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 19,
|
|
||||||
name: 'Wooden Shjips',
|
|
||||||
url: 'https://woodenshjips.bandcamp.com/',
|
|
||||||
coverId: '0012406678'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 20,
|
|
||||||
name: 'Silas J. Dirge',
|
|
||||||
url: 'https://silasjdirge.bandcamp.com/',
|
|
||||||
coverId: '0035751570'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 21,
|
|
||||||
name: 'Secret Colours',
|
|
||||||
url: 'https://secretcolours.bandcamp.com/',
|
|
||||||
coverId: '0010661379'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 22,
|
|
||||||
name: 'Larry McNeil And The Blue Knights',
|
|
||||||
url: 'https://www.discogs.com/artist/6528940-Larry-McNeil-And-The-Blue-Knights',
|
|
||||||
coverId:
|
|
||||||
'https://i.discogs.com/Yr05_neEXwzPwKlDeV7dimmTG34atkAMgpxbMBhHBkI/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTEyMTEw/ODE1LTE1Mjg1NjU1/NzQtMjcyOC5qcGVn.jpeg'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 23,
|
|
||||||
name: 'Hugo Blanco',
|
|
||||||
url: 'https://elpalmasmusic.bandcamp.com/album/color-de-tr-pico-compiled-by-el-dr-gon-criollo-el-palmas',
|
|
||||||
coverId: '0016886708'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { eventHandler } from 'h3'
|
|
||||||
import type { Box } from '~~/types/types'
|
|
||||||
|
|
||||||
export default eventHandler<Box[]>(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'ES01',
|
|
||||||
type: 'compilation',
|
|
||||||
name: '...',
|
|
||||||
description: '...',
|
|
||||||
state: 'box-hidden',
|
|
||||||
duration: 3487 + 3773, // Somme des durées A et B
|
|
||||||
sides: {
|
|
||||||
A: {
|
|
||||||
name: '...',
|
|
||||||
description: '...',
|
|
||||||
duration: 3487,
|
|
||||||
color1: '#c7b3aa',
|
|
||||||
color2: '#000100'
|
|
||||||
},
|
|
||||||
B: {
|
|
||||||
name: '... B',
|
|
||||||
description: '...',
|
|
||||||
duration: 3773,
|
|
||||||
color1: '#f7dd01',
|
|
||||||
color2: '#010103'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
activeSide: 'A'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ES00',
|
|
||||||
type: 'compilation',
|
|
||||||
name: 'manifeste',
|
|
||||||
description: 'Zero is for manifesto',
|
|
||||||
state: 'box-hidden',
|
|
||||||
duration: 2794 + 2470, // Somme des durées A et B
|
|
||||||
sides: {
|
|
||||||
A: {
|
|
||||||
name: 'manifeste',
|
|
||||||
description: 'Zero is for manifesto',
|
|
||||||
duration: 2794,
|
|
||||||
color1: '#ffffff',
|
|
||||||
color2: '#48959d'
|
|
||||||
},
|
|
||||||
B: {
|
|
||||||
name: 'manifeste B',
|
|
||||||
description: 'Even Zero has a b-side',
|
|
||||||
duration: 2470,
|
|
||||||
color1: '#0d01b9',
|
|
||||||
color2: '#3b7589'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
activeSide: 'A'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ESPLAYLIST',
|
|
||||||
type: 'playlist',
|
|
||||||
name: 'playlists',
|
|
||||||
duration: 0,
|
|
||||||
description: '♠♦♣♥',
|
|
||||||
state: 'box-hidden',
|
|
||||||
activeSide: 'A',
|
|
||||||
color1: '#fdec50ff',
|
|
||||||
color2: '#fdec50ff'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
41
server/api/card/[slug].ts
Normal file
41
server/api/card/[slug].ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { useDB, schema } from '../../db'
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'ESID manquant dans la requête'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = useDB()
|
||||||
|
const card = await db.select().from(schema.cards).where(eq(schema.cards.slug, slug)).get()
|
||||||
|
|
||||||
|
if (!card) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Morceau non trouvé'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: card.id,
|
||||||
|
esid: card.esid,
|
||||||
|
title: card.title,
|
||||||
|
artist: card.artist,
|
||||||
|
url_audio: card.url_audio,
|
||||||
|
url_image: card.url_image,
|
||||||
|
year: card.year,
|
||||||
|
month: card.month,
|
||||||
|
day: card.day,
|
||||||
|
hour: card.hour,
|
||||||
|
slug: card.slug,
|
||||||
|
suit: card.suit,
|
||||||
|
rank: card.rank,
|
||||||
|
createdAt: card.createdAt,
|
||||||
|
updatedAt: card.updatedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
41
server/api/cards/[esid].ts
Normal file
41
server/api/cards/[esid].ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { useDB, schema } from '../../db'
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const esid = getRouterParam(event, 'esid')
|
||||||
|
|
||||||
|
if (!esid) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'ESID manquant dans la requête'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = useDB()
|
||||||
|
const card = await db.select().from(schema.cards).where(eq(schema.cards.esid, esid)).get()
|
||||||
|
|
||||||
|
if (!card) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Morceau non trouvé'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: card.id,
|
||||||
|
esid: card.esid,
|
||||||
|
title: card.title,
|
||||||
|
artist: card.artist,
|
||||||
|
url_audio: card.url_audio,
|
||||||
|
url_image: card.url_image,
|
||||||
|
year: card.year,
|
||||||
|
month: card.month,
|
||||||
|
day: card.day,
|
||||||
|
hour: card.hour,
|
||||||
|
slug: card.slug,
|
||||||
|
suit: card.suit,
|
||||||
|
rank: card.rank,
|
||||||
|
createdAt: card.createdAt,
|
||||||
|
updatedAt: card.updatedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
84
server/api/cards/index.ts
Normal file
84
server/api/cards/index.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { and, eq, ilike, or, sql } from 'drizzle-orm'
|
||||||
|
import { useDB, schema } from '../../db'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20 // Nombre d'éléments par page
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const page = Number(query.page) || 1
|
||||||
|
const search = query.search?.toString()
|
||||||
|
const cardRank = query.rank?.toString()
|
||||||
|
const cardSuit = query.suit?.toString()
|
||||||
|
const year = query.year?.toString()
|
||||||
|
|
||||||
|
const db = useDB()
|
||||||
|
const offset = (page - 1) * PAGE_SIZE
|
||||||
|
|
||||||
|
// Log pour débogage
|
||||||
|
console.log('Requête avec paramètres:', { search, cardRank, cardSuit, year })
|
||||||
|
console.log('Schéma des cards:', Object.keys(schema.cards))
|
||||||
|
|
||||||
|
// Construction des conditions de filtrage
|
||||||
|
const conditions = []
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const searchTerm = `%${search}%`
|
||||||
|
conditions.push(
|
||||||
|
or(ilike(schema.cards.title, searchTerm), ilike(schema.cards.artist, searchTerm))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardRank) {
|
||||||
|
conditions.push(eq(schema.cards.rank, cardRank))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardSuit) {
|
||||||
|
conditions.push(eq(schema.cards.suit, cardSuit))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (year) {
|
||||||
|
conditions.push(eq(schema.cards.year, year))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requête pour le comptage total
|
||||||
|
const countQuery = db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(schema.cards)
|
||||||
|
.$dynamic()
|
||||||
|
|
||||||
|
// Log pour débogage SQL
|
||||||
|
console.log('Requête de comptage SQL:', countQuery.toSQL())
|
||||||
|
|
||||||
|
// Requête pour les données paginées
|
||||||
|
const cardsQuery = db
|
||||||
|
.select()
|
||||||
|
.from(schema.cards)
|
||||||
|
.$dynamic()
|
||||||
|
.limit(PAGE_SIZE)
|
||||||
|
.offset(offset)
|
||||||
|
.orderBy(schema.cards.title)
|
||||||
|
|
||||||
|
// Application des conditions si elles existent
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
const where = and(...conditions)
|
||||||
|
countQuery.where(where)
|
||||||
|
cardsQuery.where(where)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [countResult, cards] = await Promise.all([countQuery, cardsQuery])
|
||||||
|
|
||||||
|
const totalItems = countResult[0]?.count || 0
|
||||||
|
const totalPages = Math.ceil(totalItems / PAGE_SIZE)
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: cards,
|
||||||
|
pagination: {
|
||||||
|
currentPage: page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
totalItems,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPreviousPage: page > 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
27
server/api/sync-cards.post.ts
Normal file
27
server/api/sync-cards.post.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { syncCardsWithDatabase } from '../services/cardSync.service'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const folderPath = config.pathFiles || process.env.PATH_FILES
|
||||||
|
|
||||||
|
if (!folderPath) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'PATH_FILES not configured'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncCardsWithDatabase(folderPath)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
...result
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
21
server/api/test/test-db-sync.post.ts
Normal file
21
server/api/test/test-db-sync.post.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { syncCardsWithDatabase } from '../../services/cardSync.service'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const folderPath = config.pathFiles || process.env.PATH_FILES || 'mnt/media/files/music'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncCardsWithDatabase(folderPath)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
...result
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
29
server/api/test/test-scanner.get.ts
Normal file
29
server/api/test/test-scanner.get.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { scanMusicFolder } from '../../utils/fileScanner'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const folderPath = config.pathFiles || process.env.PATH_FILES || 'mnt/media/files/music'
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: Vérifier que le dossier existe
|
||||||
|
const { access } = await import('node:fs/promises')
|
||||||
|
await access(folderPath)
|
||||||
|
|
||||||
|
// Test 2: Scanner le dossier
|
||||||
|
const cards = await scanMusicFolder(folderPath)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
folderPath,
|
||||||
|
cardsFound: cards.length,
|
||||||
|
cards: cards.slice(0, 5), // Afficher seulement les 5 premiers
|
||||||
|
sample: cards[0] // Un exemple complet
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
folderPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import fs from 'fs'
|
|
||||||
import path from 'path'
|
|
||||||
import { eventHandler } from 'h3'
|
|
||||||
import { getCardFromDate } from '../../../utils/cards'
|
|
||||||
|
|
||||||
export default eventHandler(async (event) => {
|
|
||||||
const dirPath = path.join(process.cwd(), '/mnt/media/files/music')
|
|
||||||
const urlPrefix = `https://files.erudi.fr/music`
|
|
||||||
|
|
||||||
try {
|
|
||||||
let allTracks: any[] = []
|
|
||||||
|
|
||||||
let files = await fs.promises.readdir(dirPath)
|
|
||||||
files = files.filter((f) => !f.startsWith('.') && !f.endsWith('.jpg'))
|
|
||||||
|
|
||||||
const tracks = files.map((file, index) => {
|
|
||||||
const EXT_RE = /\.(mp3|flac|wav|opus)$/i
|
|
||||||
const nameWithoutExt = file.replace(EXT_RE, '')
|
|
||||||
|
|
||||||
// On split sur __
|
|
||||||
const parts = nameWithoutExt.split('__')
|
|
||||||
let stamp = parts[0] || ''
|
|
||||||
let artist = parts[1] || ''
|
|
||||||
let title = parts[2] || ''
|
|
||||||
|
|
||||||
title = title.replaceAll('_', ' ')
|
|
||||||
artist = artist.replaceAll('_', ' ')
|
|
||||||
|
|
||||||
// Parser la date depuis le stamp
|
|
||||||
let year = 2020,
|
|
||||||
month = 1,
|
|
||||||
day = 1,
|
|
||||||
hour = 0
|
|
||||||
if (stamp.length === 10) {
|
|
||||||
year = Number(stamp.slice(0, 4))
|
|
||||||
month = Number(stamp.slice(4, 6))
|
|
||||||
day = Number(stamp.slice(6, 8))
|
|
||||||
hour = Number(stamp.slice(8, 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(year, month - 1, day, hour)
|
|
||||||
const card = getCardFromDate(date)
|
|
||||||
const url = `${urlPrefix}/${encodeURIComponent(file)}`
|
|
||||||
const coverId = `${urlPrefix}/cover/${encodeURIComponent(file).replace(EXT_RE, '.jpg')}`
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: Number(`${year}${index + 1}`),
|
|
||||||
boxId: `ESPLAYLIST`,
|
|
||||||
year,
|
|
||||||
date,
|
|
||||||
title: title.trim(),
|
|
||||||
artist: artist.trim(),
|
|
||||||
url,
|
|
||||||
coverId,
|
|
||||||
card,
|
|
||||||
order: 0,
|
|
||||||
type: 'playlist'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
tracks.sort((a, b) => b.date.getTime() - a.date.getTime())
|
|
||||||
// assign a stable order after sort (1..N)
|
|
||||||
tracks.forEach((t, i) => (t.order = i + 1))
|
|
||||||
allTracks.push(...tracks)
|
|
||||||
|
|
||||||
// Récupérer l'ID depuis les paramètres de la requête
|
|
||||||
const trackId = event.context.params?.id
|
|
||||||
|
|
||||||
// Si un ID est fourni dans l'URL, filtrer pour ne retourner que ce morceau
|
|
||||||
if (trackId) {
|
|
||||||
const track = allTracks.find((track) => track.id === Number(trackId))
|
|
||||||
return track || { error: 'Track not found' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return allTracks
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: (error as Error).message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import fs from 'fs'
|
|
||||||
import path from 'path'
|
|
||||||
import { eventHandler } from 'h3'
|
|
||||||
import { getCardFromDate } from '../../../utils/cards'
|
|
||||||
|
|
||||||
export default eventHandler(async (event) => {
|
|
||||||
const dirPath = path.join(process.cwd(), '/mnt/media/files/music')
|
|
||||||
const urlPrefix = `https://files.erudi.fr/music`
|
|
||||||
|
|
||||||
try {
|
|
||||||
let allTracks: any[] = []
|
|
||||||
|
|
||||||
let files = await fs.promises.readdir(dirPath)
|
|
||||||
files = files.filter((f) => !f.startsWith('.') && !f.endsWith('.jpg'))
|
|
||||||
|
|
||||||
const tracks = files.map((file, index) => {
|
|
||||||
const EXT_RE = /\.(mp3|flac|wav|opus)$/i
|
|
||||||
const nameWithoutExt = file.replace(EXT_RE, '')
|
|
||||||
|
|
||||||
// On split sur __
|
|
||||||
const parts = nameWithoutExt.split('__')
|
|
||||||
let stamp = parts[0] || ''
|
|
||||||
let artist = parts[1] || ''
|
|
||||||
let title = parts[2] || ''
|
|
||||||
|
|
||||||
title = title.replaceAll('_', ' ')
|
|
||||||
artist = artist.replaceAll('_', ' ')
|
|
||||||
|
|
||||||
// Parser la date depuis le stamp
|
|
||||||
let year = 2020,
|
|
||||||
month = 1,
|
|
||||||
day = 1,
|
|
||||||
hour = 0
|
|
||||||
if (stamp.length === 10) {
|
|
||||||
year = Number(stamp.slice(0, 4))
|
|
||||||
month = Number(stamp.slice(4, 6))
|
|
||||||
day = Number(stamp.slice(6, 8))
|
|
||||||
hour = Number(stamp.slice(8, 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(year, month - 1, day, hour)
|
|
||||||
const card = getCardFromDate(date)
|
|
||||||
const url = `${urlPrefix}/${encodeURIComponent(file)}`
|
|
||||||
const coverId = `${urlPrefix}/cover/${encodeURIComponent(file).replace(EXT_RE, '.jpg')}`
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: Number(`${year}${index + 1}`),
|
|
||||||
boxId: `ESPLAYLIST`,
|
|
||||||
year,
|
|
||||||
date,
|
|
||||||
title: title.trim(),
|
|
||||||
artist: artist.trim(),
|
|
||||||
url,
|
|
||||||
coverId,
|
|
||||||
card,
|
|
||||||
order: 0,
|
|
||||||
type: 'playlist'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
tracks.sort((a, b) => b.date.getTime() - a.date.getTime())
|
|
||||||
// assign a stable order after sort (1..N)
|
|
||||||
tracks.forEach((t, i) => (t.order = i + 1))
|
|
||||||
allTracks.push(...tracks)
|
|
||||||
|
|
||||||
return allTracks
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: (error as Error).message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
32
server/db/index.ts
Normal file
32
server/db/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/libsql'
|
||||||
|
import * as schema from './schema'
|
||||||
|
|
||||||
|
let _db: ReturnType<typeof drizzle> | null = null
|
||||||
|
|
||||||
|
export function useDB() {
|
||||||
|
if (_db) return _db
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
let dbPath = config.pathDb || process.env.PATH_DB
|
||||||
|
|
||||||
|
if (!dbPath) {
|
||||||
|
throw new Error('PATH_DB is not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir le chemin en URL file:// si ce n'est pas déjà une URL
|
||||||
|
if (!dbPath.startsWith('file:') && !dbPath.startsWith('libsql:') && !dbPath.startsWith('http')) {
|
||||||
|
// Si c'est un chemin relatif, le rendre absolu
|
||||||
|
if (!dbPath.startsWith('/')) {
|
||||||
|
dbPath = `file:${process.cwd()}/${dbPath}`
|
||||||
|
} else {
|
||||||
|
dbPath = `file:${dbPath}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🗄️ Connexion à la DB:', dbPath)
|
||||||
|
|
||||||
|
_db = drizzle(dbPath, { schema })
|
||||||
|
return _db
|
||||||
|
}
|
||||||
|
|
||||||
|
export { schema }
|
||||||
23
server/db/schema.ts
Normal file
23
server/db/schema.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { sqliteTable, text, int } from 'drizzle-orm/sqlite-core'
|
||||||
|
|
||||||
|
export const cards = sqliteTable('cards', {
|
||||||
|
id: int('id').primaryKey({ autoIncrement: true }),
|
||||||
|
esid: text('esid').notNull(),
|
||||||
|
url_audio: text('url_audio').notNull(),
|
||||||
|
url_image: text('url_image').notNull(),
|
||||||
|
year: text('year').notNull(),
|
||||||
|
month: text('month').notNull(),
|
||||||
|
day: text('day').notNull(),
|
||||||
|
hour: text('hour').notNull(),
|
||||||
|
artist: text('artist').notNull(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
slug: text('slug').notNull(),
|
||||||
|
suit: text('suit').notNull(),
|
||||||
|
rank: text('rank').notNull(),
|
||||||
|
createdAt: int('created_at', { mode: 'timestamp' })
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date()),
|
||||||
|
updatedAt: int('updated_at', { mode: 'timestamp' })
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date())
|
||||||
|
})
|
||||||
@@ -1,3 +1,37 @@
|
|||||||
|
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'
|
import { eventHandler } from 'h3'
|
||||||
|
|
||||||
export default eventHandler(() => {
|
export default eventHandler(() => {
|
||||||
@@ -448,8 +482,105 @@ export default eventHandler(() => {
|
|||||||
return tracks.map((track, index) => ({
|
return tracks.map((track, index) => ({
|
||||||
id: index + 1,
|
id: index + 1,
|
||||||
...track,
|
...track,
|
||||||
url: `https://files.erudi.fr/evilspins/${track.boxId}${track.side}.mp3`,
|
filePath: `https://files.erudi.fr/evilspins/${track.boxId}${track.side}.mp3`,
|
||||||
coverId: `https://f4.bcbits.com/img/${track.coverId}_4.jpg`,
|
coverId: `https://f4.bcbits.com/img/${track.coverId}_4.jpg`,
|
||||||
type: 'compilation'
|
type: 'compilation'
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
playlist:
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import 'dotenv/config'
|
||||||
|
import { eventHandler } from 'h3'
|
||||||
|
import { getCardFromDate } from '../../../utils/cards'
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const dirPath = path.join(process.env.AUDIO_FILES_BASE_PATH)
|
||||||
|
const urlPrefix = `https://files.erudi.fr/music`
|
||||||
|
|
||||||
|
try {
|
||||||
|
let allTracks: any[] = []
|
||||||
|
|
||||||
|
const items = await fs.promises.readdir(dirPath, { withFileTypes: true })
|
||||||
|
|
||||||
|
// Process files
|
||||||
|
const files = items
|
||||||
|
.filter((item) => item.isFile() && !item.name.startsWith('.') && !item.name.endsWith('.jpg'))
|
||||||
|
.map((item) => item.name)
|
||||||
|
|
||||||
|
// Process folders
|
||||||
|
const folders = items
|
||||||
|
.filter((item) => item.isDirectory() && !item.name.startsWith('.'))
|
||||||
|
.map((folder, index) => ({
|
||||||
|
id: `folder-${index}`,
|
||||||
|
boxId: 'ESFOLDER',
|
||||||
|
title: folder.name.replace(/_/g, ' ').trim(),
|
||||||
|
type: 'folder',
|
||||||
|
order: 0,
|
||||||
|
date: new Date(),
|
||||||
|
card: getCardFromDate(new Date())
|
||||||
|
}))
|
||||||
|
|
||||||
|
const tracks = files.map((file, index) => {
|
||||||
|
const EXT_RE = /\.(mp3|flac|wav|opus)$/i
|
||||||
|
const nameWithoutExt = file.replace(EXT_RE, '')
|
||||||
|
|
||||||
|
// On split sur __
|
||||||
|
const parts = nameWithoutExt.split('__')
|
||||||
|
let stamp = parts[0] || ''
|
||||||
|
let artist = parts[1] || ''
|
||||||
|
let title = parts[2] || ''
|
||||||
|
|
||||||
|
title = title.replaceAll('_', ' ')
|
||||||
|
artist = artist.replaceAll('_', ' ')
|
||||||
|
|
||||||
|
// Parser la date depuis le stamp
|
||||||
|
let year = 2020,
|
||||||
|
month = 1,
|
||||||
|
day = 1,
|
||||||
|
hour = 0
|
||||||
|
if (stamp.length === 10) {
|
||||||
|
year = Number(stamp.slice(0, 4))
|
||||||
|
month = Number(stamp.slice(4, 6))
|
||||||
|
day = Number(stamp.slice(6, 8))
|
||||||
|
hour = Number(stamp.slice(8, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(year, month - 1, day, hour)
|
||||||
|
const card = getCardFromDate(date)
|
||||||
|
const filePath = `${urlPrefix}/${encodeURIComponent(file)}`
|
||||||
|
const coverId = `${urlPrefix}/cover/${encodeURIComponent(file).replace(EXT_RE, '.jpg')}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(`${year}${index + 1}`),
|
||||||
|
boxId: `ESPLAYLIST`,
|
||||||
|
year,
|
||||||
|
date,
|
||||||
|
title: title.trim(),
|
||||||
|
artist: artist.trim(),
|
||||||
|
filePath,
|
||||||
|
coverId,
|
||||||
|
card,
|
||||||
|
order: 0,
|
||||||
|
type: 'playlist'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
tracks.sort((a, b) => b.date.getTime() - a.date.getTime())
|
||||||
|
// Combine folders and tracks
|
||||||
|
const allItems = [...folders, ...tracks]
|
||||||
|
|
||||||
|
// Sort by date (newest first) and assign order
|
||||||
|
allItems.sort((a, b) => b.date.getTime() - a.date.getTime())
|
||||||
|
allItems.forEach((item, i) => (item.order = i + 1))
|
||||||
|
|
||||||
|
return allItems
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: (error as Error).message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
21
server/plugins/initialSync.ts
Normal file
21
server/plugins/initialSync.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { syncCardsWithDatabase } from '../services/cardSync.service'
|
||||||
|
|
||||||
|
export default defineNitroPlugin(async (nitroApp) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const folderPath = config.pathFiles || process.env.PATH_FILES
|
||||||
|
|
||||||
|
if (!folderPath) {
|
||||||
|
console.warn('⚠️ PATH_FILES non configuré')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync au démarrage
|
||||||
|
console.log('🚀 Synchronisation initiale au démarrage...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncCardsWithDatabase(folderPath)
|
||||||
|
console.log('✅ Synchronisation initiale terminée:', result)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Erreur lors de la sync initiale:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
58
server/services/cardSync.service.ts
Normal file
58
server/services/cardSync.service.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { eq, notInArray } from 'drizzle-orm'
|
||||||
|
import { useDB, schema } from '../db'
|
||||||
|
import { scanMusicFolder } from '../utils/fileScanner'
|
||||||
|
|
||||||
|
const { cards } = schema
|
||||||
|
|
||||||
|
export async function syncCardsWithDatabase(folderPath: string) {
|
||||||
|
const db = useDB()
|
||||||
|
const scannedCards = await scanMusicFolder(folderPath)
|
||||||
|
|
||||||
|
console.log(`📁 ${scannedCards.length} cards trouvées dans le dossier`)
|
||||||
|
|
||||||
|
// 1. Récupérer les cards existantes en DB
|
||||||
|
const existingCards = await db.select().from(cards).all()
|
||||||
|
const existingEsids = new Set(existingCards.map((t) => t.esid))
|
||||||
|
|
||||||
|
// 2. Identifier les nouvelles cards à ajouter
|
||||||
|
const cardsToInsert = scannedCards.filter((card) => !existingEsids.has(card.esid))
|
||||||
|
|
||||||
|
// 3. Identifier les cards à supprimer
|
||||||
|
const scannedEsids = new Set(scannedCards.map((t) => t.esid))
|
||||||
|
const cardsToDelete = existingCards.filter((t) => !scannedEsids.has(t.esid))
|
||||||
|
|
||||||
|
// 4. Insérer les nouvelles cards
|
||||||
|
if (cardsToInsert.length > 0) {
|
||||||
|
// Dans la fonction syncCardsWithDatabase
|
||||||
|
await db.insert(cards).values(
|
||||||
|
cardsToInsert.map((card) => ({
|
||||||
|
url_audio: card.url_audio,
|
||||||
|
url_image: card.url_image,
|
||||||
|
year: card.year,
|
||||||
|
month: card.month,
|
||||||
|
day: card.day,
|
||||||
|
hour: card.hour,
|
||||||
|
artist: card.artist,
|
||||||
|
title: card.title,
|
||||||
|
esid: card.esid,
|
||||||
|
slug: card.slug,
|
||||||
|
createdAt: card.createdAt,
|
||||||
|
suit: card.suit,
|
||||||
|
rank: card.rank
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
console.log(`✅ ${cardsToInsert.length} cards ajoutées`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Supprimer les cards obsolètes avec une requête distincte pour chaque esid
|
||||||
|
for (const cardToDelete of cardsToDelete) {
|
||||||
|
await db.delete(cards).where(eq(cards.esid, cardToDelete.esid))
|
||||||
|
console.log(`🗑️ ${cardsToDelete.length} cards supprimées`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
added: cardsToInsert.length,
|
||||||
|
deleted: cardsToDelete.length,
|
||||||
|
total: scannedCards.length
|
||||||
|
}
|
||||||
|
}
|
||||||
23
server/tasks/syncCards.ts
Normal file
23
server/tasks/syncCards.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { syncCardsWithDatabase } from '../services/cardSync.service'
|
||||||
|
|
||||||
|
export default defineTask({
|
||||||
|
meta: {
|
||||||
|
name: 'sync-tracks',
|
||||||
|
description: 'Synchronise les tracks avec le système de fichiers'
|
||||||
|
},
|
||||||
|
async run() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const folderPath = config.pathFiles || process.env.PATH_FILES || 'mnt/media/files/music'
|
||||||
|
|
||||||
|
console.log('⏰ [TASK] Démarrage de la synchronisation planifiée...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncCardsWithDatabase(folderPath)
|
||||||
|
console.log('✅ [TASK] Synchronisation terminée:', result)
|
||||||
|
return { result }
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ [TASK] Erreur lors de la synchronisation:', error)
|
||||||
|
return { error: error.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../.nuxt/tsconfig.server.json"
|
|
||||||
}
|
|
||||||
119
server/utils/fileScanner.ts
Normal file
119
server/utils/fileScanner.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { readdir, readFile } from 'node:fs/promises'
|
||||||
|
import { join, extname, basename } from 'node:path'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { slugify } from './slugify'
|
||||||
|
import { getCardFromDate } from './getCardFromDate'
|
||||||
|
import type { Card } from '@/types/types'
|
||||||
|
|
||||||
|
const listAudioExts = ['.mp3', '.opus', 'flac']
|
||||||
|
const listImageExts = ['.jpg', '.jpeg', '.webp']
|
||||||
|
|
||||||
|
export async function scanMusicFolder(folderPath: string): Promise<Card[]> {
|
||||||
|
try {
|
||||||
|
const files = await readdir(folderPath)
|
||||||
|
const cardMap = new Map<string, Card>()
|
||||||
|
|
||||||
|
// D'abord, on traite tous les fichiers audio
|
||||||
|
for (const file of files) {
|
||||||
|
const ext = extname(file).toLowerCase()
|
||||||
|
|
||||||
|
// On ne traite que les fichiers audio
|
||||||
|
if (!listAudioExts.includes(ext)) continue
|
||||||
|
|
||||||
|
const parsed = await parseFilename(join(folderPath, file))
|
||||||
|
if (parsed) {
|
||||||
|
// On vérifie s'il existe une image avec le même nom de base
|
||||||
|
const baseName = basename(file, ext)
|
||||||
|
let imageUrl = ''
|
||||||
|
|
||||||
|
// On cherche une image correspondante
|
||||||
|
for (const imgExt of listImageExts) {
|
||||||
|
const potentialImage = baseName + imgExt
|
||||||
|
if (files.includes(potentialImage)) {
|
||||||
|
imageUrl = process.env.URL_PREFIX + baseName + imgExt
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cardMap.set(parsed.esid, {
|
||||||
|
...parsed,
|
||||||
|
url_audio: process.env.URL_PREFIX + baseName + ext,
|
||||||
|
url_image: imageUrl,
|
||||||
|
suit: parsed.suit,
|
||||||
|
rank: parsed.rank
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(cardMap.values())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du scan du dossier:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseFilename(
|
||||||
|
filename: string
|
||||||
|
): Promise<Omit<Card, 'url_audio' | 'url_image'> | null> {
|
||||||
|
// Format: yyyymmddhh__artist__title.ext
|
||||||
|
const nameWithoutExt = basename(filename, extname(filename))
|
||||||
|
const parts = nameWithoutExt.split('__')
|
||||||
|
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
console.warn(`Nom de fichier invalide: ${filename}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [datetime, artist, title] = parts
|
||||||
|
|
||||||
|
if (!datetime || !artist || !title) {
|
||||||
|
console.warn(`Format de fichier invalide: ${filename} - manque des parties`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (datetime.length !== 10) {
|
||||||
|
console.warn(`Format de date invalide: ${filename}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utilisation d'un hash basé sur le contenu du fichier pour un ESID stable
|
||||||
|
let fileHash = ''
|
||||||
|
try {
|
||||||
|
const fileContent = await readFile(filename)
|
||||||
|
fileHash = createHash('md5').update(fileContent).digest('hex').substring(0, 8)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Impossible de lire le fichier pour générer le hash: ${filename}`)
|
||||||
|
fileHash = createHash('md5').update(filename).digest('hex').substring(0, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = datetime.substring(0, 4)
|
||||||
|
const month = datetime.substring(4, 6)
|
||||||
|
const day = datetime.substring(6, 8)
|
||||||
|
const hour = datetime.substring(8, 10)
|
||||||
|
// Créer l'ID unique pour la card
|
||||||
|
const esid = createHash('md5')
|
||||||
|
.update(`${year}${month}${day}${hour}${artist}${title}`)
|
||||||
|
.digest('hex')
|
||||||
|
|
||||||
|
const date = new Date(
|
||||||
|
parseInt(year, 10),
|
||||||
|
parseInt(month, 10) - 1,
|
||||||
|
parseInt(day, 10),
|
||||||
|
parseInt(hour, 10)
|
||||||
|
)
|
||||||
|
const card = getCardFromDate(date)
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day,
|
||||||
|
hour,
|
||||||
|
artist: artist.replace(/_/g, ' '), // Remplacer les _ par des espaces
|
||||||
|
title: title.replace(/_/g, ' '),
|
||||||
|
esid,
|
||||||
|
slug: slugify(`${artist} ${title}`),
|
||||||
|
createdAt: date,
|
||||||
|
suit: card.suit,
|
||||||
|
rank: card.rank
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { CardSuit, CardRank } from '~/types/cards'
|
import type { Suit, Rank } from '../../types/types'
|
||||||
|
|
||||||
export function getCardFromDate(date: Date): { suit: CardSuit; rank: CardRank } {
|
export function getCardFromDate(date: Date): { suit: Suit; rank: Rank } {
|
||||||
const month = date.getMonth() + 1
|
const month = date.getMonth() + 1
|
||||||
const day = date.getDate()
|
const day = date.getDate()
|
||||||
const hour = date.getHours()
|
const hour = date.getHours()
|
||||||
|
|
||||||
const suit: CardSuit =
|
const suit: Suit =
|
||||||
month >= 12 || month <= 2
|
month >= 12 || month <= 2
|
||||||
? '♠'
|
? '♠'
|
||||||
: month >= 3 && month <= 5
|
: month >= 3 && month <= 5
|
||||||
@@ -14,7 +14,7 @@ export function getCardFromDate(date: Date): { suit: CardSuit; rank: CardRank }
|
|||||||
? '♦'
|
? '♦'
|
||||||
: '♣'
|
: '♣'
|
||||||
|
|
||||||
const ranks: CardRank[] = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
|
const ranks: Rank[] = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
|
||||||
const rank = ranks[(day + hour) % ranks.length]
|
const rank = ranks[(day + hour) % ranks.length]
|
||||||
|
|
||||||
return { suit, rank }
|
return { suit, rank }
|
||||||
9
server/utils/slugify.ts
Normal file
9
server/utils/slugify.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function slugify(str: string): string {
|
||||||
|
return str
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+/, '')
|
||||||
|
.replace(/-+$/, '')
|
||||||
|
}
|
||||||
@@ -18,12 +18,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"**/*.ts",
|
"./components/**/*.js",
|
||||||
"**/*.d.ts",
|
"./components/**/*.ts",
|
||||||
"**/*.tsx",
|
"./components/**/*.jsx",
|
||||||
"**/*.vue",
|
"./components/**/*.tsx",
|
||||||
".nuxt/**/*.ts",
|
"./components/**/*.vue",
|
||||||
"./types/**/*.d.ts"
|
"./app.vue",
|
||||||
|
"./error.vue",
|
||||||
|
"./types/**/*.ts"
|
||||||
],
|
],
|
||||||
|
"typeRoots": ["./node_modules/@types", "./types"],
|
||||||
"exclude": ["node_modules", ".output", "dist"]
|
"exclude": ["node_modules", ".output", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,18 @@
|
|||||||
// types.ts
|
export interface Card {
|
||||||
export type BoxType = 'playlist' | 'compilation'
|
url_audio: string
|
||||||
|
url_image: string
|
||||||
export interface BoxSide {
|
year: number
|
||||||
duration: number
|
month: string
|
||||||
color1: string
|
day: string
|
||||||
color2: string
|
hour: string
|
||||||
name?: string
|
artist: string
|
||||||
description?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Box {
|
|
||||||
id: string
|
|
||||||
type: BoxType
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
state: BoxState
|
|
||||||
duration: number
|
|
||||||
tracks?: Track[]
|
|
||||||
sides?: {
|
|
||||||
A: BoxSide
|
|
||||||
B: BoxSide
|
|
||||||
}
|
|
||||||
activeSide: 'A' | 'B'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Artist {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
url: string
|
|
||||||
coverId: string
|
|
||||||
}
|
|
||||||
export interface Track {
|
|
||||||
id: number
|
|
||||||
side?: 'A' | 'B'
|
|
||||||
order?: number
|
|
||||||
boxId: string
|
|
||||||
title: string
|
title: string
|
||||||
artist?: Artist | number | string
|
esid: string
|
||||||
start?: number
|
slug: string
|
||||||
duration?: number
|
createdAt: Date
|
||||||
url: string
|
suit: Suit
|
||||||
coverId?: string
|
rank: Rank
|
||||||
date?: Date
|
|
||||||
card?: { suit: CardSuit; rank: CardRank }
|
|
||||||
link?: string
|
|
||||||
type: BoxType
|
|
||||||
year?: number
|
|
||||||
color: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BoxState = 'box-hidden' | 'box-list' | 'box-selected'
|
export type Suit = '♠' | '♣' | '♦' | '♥'
|
||||||
export type CardSuit = '♠' | '♣' | '♦' | '♥'
|
export type Rank = 'A' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | 'J' | 'Q' | 'K'
|
||||||
export type CardRank = 'A' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | 'J' | 'Q' | 'K'
|
|
||||||
|
|||||||
52
utils/tabManager.ts
Normal file
52
utils/tabManager.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Gestionnaire d'onglets pour les cartes NFC
|
||||||
|
export function openOrFocusCard(slug: string) {
|
||||||
|
const url = `${window.location.origin}/card/${slug}`
|
||||||
|
|
||||||
|
// Vérifier si un onglet est déjà ouvert
|
||||||
|
const isTabOpen = localStorage.getItem('nfc-active-tab') === 'true'
|
||||||
|
|
||||||
|
if (isTabOpen) {
|
||||||
|
// Si un onglet est déjà ouvert, on envoie un message pour le rafraîchir
|
||||||
|
const channel = new BroadcastChannel('nfc-channel')
|
||||||
|
channel.postMessage({
|
||||||
|
type: 'nfc-focus',
|
||||||
|
slug: slug,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
// On se concentre sur l'onglet existant
|
||||||
|
window.focus()
|
||||||
|
|
||||||
|
// On ferme le canal après un court délai
|
||||||
|
setTimeout(() => channel.close(), 1000)
|
||||||
|
|
||||||
|
// On empêche l'ouverture d'un nouvel onglet
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aucun onglet ouvert, on en ouvre un nouveau
|
||||||
|
window.open(url, '_blank')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction pour formater l'URL à utiliser dans la carte NFC
|
||||||
|
export function getNfcCardUrl(slug: string): string {
|
||||||
|
return `javascript:(function(){
|
||||||
|
const url = '${window.location.origin}/card/${slug}';
|
||||||
|
const isTabOpen = localStorage.getItem('nfc-active-tab') === 'true';
|
||||||
|
|
||||||
|
if (isTabOpen) {
|
||||||
|
const channel = new BroadcastChannel('nfc-channel');
|
||||||
|
channel.postMessage({
|
||||||
|
type: 'nfc-focus',
|
||||||
|
slug: '${slug}',
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
setTimeout(() => channel.close(), 1000);
|
||||||
|
window.focus();
|
||||||
|
window.location.href = url;
|
||||||
|
} else {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
})();`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user