Compare commits
51 Commits
b2b3b69561
...
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 | ||
|
|
afb20fe75f | ||
|
|
d8fe645e5c | ||
|
|
ad938abf79 | ||
|
|
1f4f7868ca | ||
|
|
2c826e29ea | ||
|
|
8efafc4642 | ||
|
|
ecc1c22475 | ||
|
|
5948b4efbd | ||
|
|
55cae0b9c6 | ||
|
|
c0d79591c3 | ||
|
|
1c4cbfe21c | ||
|
|
2be5724c9f | ||
|
|
dc2cba500c | ||
|
|
65aaa71a3d | ||
|
|
6176995032 | ||
|
|
9f70419ea5 | ||
|
|
a79f044096 | ||
|
|
27697ca797 | ||
|
|
ba34ecece0 |
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
|
||||
.cache
|
||||
dist
|
||||
drizzle
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
j'aimerai que tu te comporte avec moi comme un prof socratique.
|
||||
Tu ne me donnes pas la réponse directement mais tu me guides petit à petit vers la réponse pour que je puisse apprendre par moi même.
|
||||
@@ -1,5 +1,6 @@
|
||||
# Builder
|
||||
FROM node:20-bookworm AS builder
|
||||
FROM node:20 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --legacy-peer-deps
|
||||
@@ -8,9 +9,12 @@ COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# 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
|
||||
COPY --from=builder /app/.output ./.output
|
||||
COPY package*.json ./
|
||||
COPY ./data ./data
|
||||
EXPOSE 3000
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
|
||||
80
app/app.vue
80
app/app.vue
@@ -1,86 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<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;
|
||||
}
|
||||
|
||||
* {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
img {
|
||||
user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,56 +1,49 @@
|
||||
<template>
|
||||
<article class="card w-56 h-80" :class="[
|
||||
isFaceUp ? 'face-up' : 'face-down',
|
||||
{ 'current-track': playerStore.currentTrack?.id === track.id }
|
||||
]">
|
||||
<div class="flip-inner">
|
||||
<article :role="props.role" :class="[
|
||||
'card cursor-pointer',
|
||||
isFaceUp ? 'face-up' : 'face-down'
|
||||
]" :tabindex="props.tabindex" :aria-disabled="false" @click="$emit('click', $event)"
|
||||
@keydown.enter="$emit('click', $event)" @keydown.space.prevent="$emit('click', $event)">
|
||||
<div class="flip-inner" ref="cardElement">
|
||||
<!-- Face-Up -->
|
||||
<main
|
||||
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 hover:shadow-xl hover:scale-110 transition-all rounded-2xl shadow-lg flex flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-center size-7 absolute top-7 right-7" v-if="isPlaylistTrack">
|
||||
|
||||
<div class="flex items-center justify-center size-7 absolute top-7 right-7">
|
||||
<div class="suit text-7xl absolute"
|
||||
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
|
||||
<img :src="`/${props.track.card?.suit}.svg`" />
|
||||
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.card?.suit]">
|
||||
<img :src="`/${props.card?.suit}.svg`" />
|
||||
</div>
|
||||
<div class="rank text-white font-bold absolute -mt-1">
|
||||
{{ props.track.card?.rank }}
|
||||
{{ props.card?.rank }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center size-7 absolute top-6 left-6" v-else>
|
||||
<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-t-xl cursor-pointer"
|
||||
@click="playerStore.playTrack(track)">
|
||||
<playButton :objectToPlay="track" />
|
||||
<img v-if="isFaceUp" :src="coverUrl" alt="Pochette de l'album" loading="lazy"
|
||||
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer">
|
||||
<playButton :objectToPlay="card" />
|
||||
<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" />
|
||||
</figure>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="p-3 text-center bg-white rounded-b-xl">
|
||||
<div class="label" v-if="isOrder">
|
||||
{{ props.track.order }}
|
||||
</div>
|
||||
<h2 class="text-base text-neutral-800 font-bold truncate">
|
||||
{{ props.track.title }}
|
||||
<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">
|
||||
<h2 class="select-text text-sm text-neutral-500 first-letter:uppercase truncate">
|
||||
{{ props.card.title || 'title' }}
|
||||
</h2>
|
||||
<p class="text-sm text-neutral-500 truncate">
|
||||
<template v-if="isPlaylistTrack">
|
||||
{{ props.track.artist.name }}
|
||||
</template>
|
||||
<p class="select-text text-base text-neutral-800 font-bold capitalize truncate">
|
||||
{{ props.card.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">
|
||||
<figure @click.stop="playerStore.playTrack(track)"
|
||||
class="h-full flex text-center bg-slate-800 rounded-xl justify-center cursor-pointer">
|
||||
<playButton :objectToPlay="track" />
|
||||
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="card" />
|
||||
<img src="/face-down.svg" />
|
||||
</figure>
|
||||
</footer>
|
||||
@@ -59,23 +52,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Track } from '~~/types/types'
|
||||
import { usePlayerStore } from '~/store/player'
|
||||
import type { Card } from '~~/types/types'
|
||||
|
||||
const props = withDefaults(defineProps<{ track: Track; isFaceUp?: boolean }>(), {
|
||||
isFaceUp: false
|
||||
})
|
||||
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 coverUrl = computed(() => {
|
||||
if (!props.track.coverId) return ''
|
||||
return props.track.coverId.startsWith('http')
|
||||
? props.track.coverId
|
||||
: `https://f4.bcbits.com/img/${props.track.coverId}_4.jpg`
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
card: Card;
|
||||
isFaceUp?: boolean;
|
||||
role?: string;
|
||||
tabindex?: string | number;
|
||||
}>(), {
|
||||
isFaceUp: false,
|
||||
role: 'button',
|
||||
tabindex: '0'
|
||||
})
|
||||
|
||||
import { getYearColor } from '~/utils/colors'
|
||||
|
||||
const cardColor = computed(() => getYearColor(props.card.year || 0))
|
||||
const isRedCard = computed(() => (props.card?.suit === '♥' || props.card?.suit === '♦'))
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -95,7 +91,7 @@ const coverUrl = computed(() => {
|
||||
/* Flip effect */
|
||||
.card {
|
||||
perspective: 1000px;
|
||||
@apply transition-all scale-100;
|
||||
@apply transition-all scale-100 w-56 h-80 min-w-56 min-h-80;
|
||||
|
||||
.flip-inner {
|
||||
position: relative;
|
||||
@@ -143,18 +139,24 @@ const coverUrl = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
&.current-track,
|
||||
&:focus {
|
||||
@apply z-50;
|
||||
&:focus,
|
||||
&.current-card {
|
||||
@apply z-50 scale-110;
|
||||
outline: none;
|
||||
|
||||
.face-up {
|
||||
@apply shadow-none;
|
||||
@apply shadow-2xl;
|
||||
transition:
|
||||
box-shadow 0.6s,
|
||||
transform 0.6s;
|
||||
}
|
||||
}
|
||||
|
||||
@apply scale-110;
|
||||
&:focus,
|
||||
&.current-card {
|
||||
.play-button {
|
||||
@apply opacity-100;
|
||||
}
|
||||
}
|
||||
|
||||
.play-button {
|
||||
@@ -172,7 +174,7 @@ const coverUrl = computed(() => {
|
||||
}
|
||||
|
||||
.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,
|
||||
@@ -181,5 +183,19 @@ const coverUrl = computed(() => {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<video class="fixed h-full w-full object-cover" ref="video" muted autoplay src=""></video>
|
||||
</template>
|
||||
@@ -1,11 +0,0 @@
|
||||
<template>
|
||||
<form
|
||||
class="h-screen flex justify-center items-center flex-col absolute top-0 left-1/2 -translate-x-1/2"
|
||||
>
|
||||
<label for="email" class="block text-xl"> be notify when's evilSpins open : </label>
|
||||
<div>
|
||||
<input id="email" type="email" name="" placeholder="email" >
|
||||
<button>ok</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<button
|
||||
class="play-button rounded-full size-24 flex items-center justify-center text-esyellow backdrop-blur-sm bg-black/25 transition-all duration-200 ease-in-out transform active:scale-90 scale-110 text-4xl font-bold"
|
||||
<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" />
|
||||
@@ -12,40 +12,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePlayerStore } from '~/store/player'
|
||||
import type { Box, Track } from '~/../types/types'
|
||||
|
||||
const playerStore = usePlayerStore()
|
||||
const props = defineProps<{ objectToPlay: Box | Track }>()
|
||||
|
||||
const isCurrentBox = computed(() => {
|
||||
if ('activeSide' in props.objectToPlay) {
|
||||
// Vérifier si la piste courante appartient à cette box
|
||||
if (playerStore.currentTrack?.boxId === props.objectToPlay.id) {
|
||||
// Si c'est une compilation, on vérifie le side actif
|
||||
if (props.objectToPlay.type === 'compilation') {
|
||||
return playerStore.currentTrack.side === props.objectToPlay.activeSide
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const isCurrentTrack = computed(() => {
|
||||
if (!('activeSide' in props.objectToPlay)) {
|
||||
return playerStore.currentTrack?.id === props.objectToPlay.id
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const isPlaying = computed(() => {
|
||||
return playerStore.isPlaying && (isCurrentTrack.value || isCurrentBox.value)
|
||||
})
|
||||
|
||||
const isLoading = computed(() => {
|
||||
return playerStore.isLoading && (isCurrentTrack.value || isCurrentBox.value)
|
||||
const props = withDefaults(defineProps<{
|
||||
isLoading?: boolean;
|
||||
isPlaying?: boolean;
|
||||
}>(), {
|
||||
isLoading: false,
|
||||
isPlaying: false
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<template>
|
||||
<div ref="deck" class="deck flex flex-wrap justify-center gap-4" :class="{ 'pb-36': playerStore.currentTrack }">
|
||||
<button @click="closeDatBox" v-if="uiStore.isBoxSelected"
|
||||
class="absolute top-10 right-10 px-4 py-2 text-black hover:text-black bg-esyellow transition-colors z-50"
|
||||
aria-label="close the box">
|
||||
close
|
||||
</button>
|
||||
<card v-for="(track, i) in tracks" :key="track.id" :track="track" tabindex="i"
|
||||
:is-face-up="isCardRevealed(track.id)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useDataStore } from '~/store/data'
|
||||
import { useCardStore } from '~/store/card'
|
||||
import { usePlayerStore } from '~/store/player'
|
||||
import { useUiStore } from '~/store/ui'
|
||||
import type { Box } from '~~/types/types'
|
||||
|
||||
const props = defineProps<{
|
||||
box: Box
|
||||
}>()
|
||||
|
||||
const cardStore = useCardStore()
|
||||
const dataStore = useDataStore()
|
||||
const playerStore = usePlayerStore()
|
||||
const uiStore = useUiStore()
|
||||
|
||||
const deck = ref()
|
||||
const tracks = computed(() => dataStore.getTracksByboxId(props.box.id))
|
||||
|
||||
const isCardRevealed = (trackId: number) => cardStore.isCardRevealed(trackId)
|
||||
|
||||
const closeDatBox = () => {
|
||||
uiStore.closeBox()
|
||||
}
|
||||
</script>
|
||||
@@ -1,18 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1
|
||||
class="text-white pt-6 text-lg md:text-xl lg:text-2xl text-center font-bold tracking-widest text-shadow"
|
||||
>
|
||||
{{ error?.statusCode }}
|
||||
</h1>
|
||||
<NuxtLink to="/">Go back home</NuxtLink>
|
||||
error
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { NuxtError } from '#app'
|
||||
|
||||
const props = defineProps({
|
||||
error: Object as () => NuxtError
|
||||
})
|
||||
</script>
|
||||
</template>
|
||||
@@ -1,9 +1,3 @@
|
||||
<template>
|
||||
<div class="w-full flex flex-col items-center">
|
||||
<slot />
|
||||
<searchModal />
|
||||
<loader />
|
||||
<Player class="w-full border-t border-gray-200" />
|
||||
<CinemaScreen />
|
||||
</div>
|
||||
<slot />
|
||||
</template>
|
||||
|
||||
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,169 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<button
|
||||
class="fixed bottom-4 right-4 bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-full shadow-lg z-50 transition-colors"
|
||||
title="Ranger les cartes" @click="arrangeCards">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div ref="deck" class="deck">
|
||||
<card v-for="track in tracks" :key="track.id" :track="track"
|
||||
:class="['card', 'id-' + track.id, { dragging: dragging === track }]" :style="{
|
||||
top: track.y + 'px',
|
||||
left: track.x + 'px',
|
||||
zIndex: track.zIndex || 1,
|
||||
transform: `rotate(${track.rotation || 0}deg)`
|
||||
}" :is-face-up="track.isFaceUp" @mousedown="startDrag($event, track)" @click="flipCard(track)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useDataStore } from '~/store/data'
|
||||
|
||||
const tracks = ref([])
|
||||
const deck = ref(null)
|
||||
let zIndexCounter = 1
|
||||
|
||||
let dragging = null
|
||||
let offset = { x: 0, y: 0 }
|
||||
let velocity = { x: 0, y: 0 }
|
||||
let lastTime = 0
|
||||
|
||||
const dataStore = useDataStore()
|
||||
|
||||
definePageMeta({ layout: 'default' })
|
||||
|
||||
onMounted(async () => {
|
||||
await dataStore.loadData()
|
||||
tracks.value = dataStore.getTracksByboxId('ESPLAYLIST').map((t, i) => ({
|
||||
...t,
|
||||
x: t.x ?? 50 + i * 20,
|
||||
y: t.y ?? 50 + i * 20,
|
||||
zIndex: t.zIndex ?? i + 1,
|
||||
rotation: 0,
|
||||
isFaceUp: t.isFaceUp ?? false
|
||||
}))
|
||||
|
||||
window.addEventListener('mousemove', onDrag)
|
||||
window.addEventListener('mouseup', stopDrag)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('mousemove', onDrag)
|
||||
window.removeEventListener('mouseup', stopDrag)
|
||||
})
|
||||
|
||||
function startDrag(e, track) {
|
||||
dragging = track
|
||||
const rect = deck.value.getBoundingClientRect()
|
||||
offset.x = e.clientX - rect.left - track.x
|
||||
offset.y = e.clientY - rect.top - track.y
|
||||
lastTime = performance.now()
|
||||
velocity = { x: 0, y: 0 }
|
||||
|
||||
zIndexCounter += 1
|
||||
track.zIndex = zIndexCounter
|
||||
}
|
||||
|
||||
function onDrag(e) {
|
||||
if (!dragging) return
|
||||
|
||||
const rect = deck.value.getBoundingClientRect()
|
||||
const newX = e.clientX - rect.left - offset.x
|
||||
const newY = e.clientY - rect.top - offset.y
|
||||
|
||||
const now = performance.now()
|
||||
const dt = now - lastTime
|
||||
velocity.x = (newX - dragging.x) / dt
|
||||
velocity.y = (newY - dragging.y) / dt
|
||||
lastTime = now
|
||||
|
||||
dragging.x = newX
|
||||
dragging.y = newY
|
||||
|
||||
// Rotation dynamique selon la position horizontale
|
||||
const centerX = rect.width / 2
|
||||
const dx = dragging.x - centerX
|
||||
dragging.rotation = dx * 0.05
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
if (!dragging) return
|
||||
const track = dragging
|
||||
dragging = null
|
||||
|
||||
// Inertie douce
|
||||
const decay = 0.95
|
||||
function animateInertia() {
|
||||
if (Math.abs(velocity.x) < 0.02 && Math.abs(velocity.y) < 0.02) return
|
||||
track.x += velocity.x * 16
|
||||
track.y += velocity.y * 16
|
||||
velocity.x *= decay
|
||||
velocity.y *= decay
|
||||
requestAnimationFrame(animateInertia)
|
||||
}
|
||||
animateInertia()
|
||||
}
|
||||
|
||||
function flipCard(track) {
|
||||
track.isFaceUp = true
|
||||
}
|
||||
|
||||
function arrangeCards() {
|
||||
const deckRect = deck.value.getBoundingClientRect()
|
||||
const cardWidth = 224
|
||||
const cardHeight = 320
|
||||
const padding = 20
|
||||
const cardsPerRow = Math.max(
|
||||
1,
|
||||
Math.floor((deckRect.width - padding * 2) / (cardWidth + padding))
|
||||
)
|
||||
|
||||
tracks.value.forEach((track, index) => {
|
||||
const row = Math.floor(index / cardsPerRow)
|
||||
const col = index % cardsPerRow
|
||||
|
||||
track.x = padding + col * (cardWidth + padding)
|
||||
track.y = padding + row * (cardHeight / 3)
|
||||
track.zIndex = index + 1
|
||||
track.rotation = 0
|
||||
|
||||
zIndexCounter = Math.max(zIndexCounter, track.zIndex)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.deck {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 80vh;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: absolute;
|
||||
cursor: grab;
|
||||
transition:
|
||||
transform 0.1s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.card:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.card.dragging {
|
||||
z-index: 999;
|
||||
cursor: grabbing;
|
||||
transform: scale(1.05) rotate(0deg);
|
||||
/* rotation sera remplacée dynamiquement */
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -1,118 +0,0 @@
|
||||
<template>
|
||||
<div class="deck">
|
||||
<draggable v-model="tracks" item-key="id" class="draggable-container" @start="drag = true" @end="onDragEnd">
|
||||
<template #item="{ element: track }">
|
||||
<card :key="track.id" :track="track" tabindex="0" :is-face-up="track.isFaceUp" class="draggable-item"
|
||||
@click="flipCard(track)" />
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useDataStore } from '~/store/data'
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
const drag = ref(false)
|
||||
const tracks = ref([])
|
||||
// Configuration du layout
|
||||
definePageMeta({
|
||||
layout: 'default'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const dataStore = useDataStore()
|
||||
await dataStore.loadData()
|
||||
tracks.value = dataStore.getTracksByboxId('ESPLAYLIST')
|
||||
})
|
||||
|
||||
function flipCard(track) {
|
||||
track.isFaceUp = !track.isFaceUp
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
drag.value = false
|
||||
// Ici vous pouvez ajouter une logique supplémentaire après le drop si nécessaire
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.logo {
|
||||
filter: drop-shadow(3px 3px 0 rgb(0 0 0 / 0.7));
|
||||
}
|
||||
|
||||
.deck {
|
||||
position: relative;
|
||||
height: 80vh;
|
||||
|
||||
.draggable-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.draggable-item {
|
||||
cursor: grab;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&.sortable-ghost {
|
||||
opacity: 0.5;
|
||||
background: #c8ebfb;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
&.sortable-drag {
|
||||
transform: rotate(2deg);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* noise tools */
|
||||
$size: 130px;
|
||||
$scale: 1.05;
|
||||
$border-radius: calc($size / 2);
|
||||
$grad-position: 100% 0;
|
||||
$grad-start: 25%;
|
||||
$grad-stop: 65%;
|
||||
$duration: 3.5s;
|
||||
$noise: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEEAAABBCAMAAAC5KTl3AAAAgVBMVEUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABtFS1lAAAAK3RSTlMWi3QSa1uQOKBWCTwcb6V4gWInTWYOqQSGfa6XLyszmyABlFFJXySxQ0BGn2PQBgAAC4NJREFUWMMV1kWO5UAQRdFk5kwzs/33v8Cunr7ZUehKAdaRUAse99ozDjF5BqswrPKm7btzJ2tRziN3rMYXC236humIV5Our7nHWnVdFOBojW2XVnkeu1IZHNJH5OPHj9TjgVxBGBwAAmp60WoA1gBBvg3XMFhxUQ4KuLqx0CritYZPPXinsOqB7I76+OHaZlPzLEcftrqOlOwjeXvuEuH6t6emkaofgVUDIb4fEZB6CmRAeFCTq11lxbAgUyx4rXkqlH9I4bTUDRRVD1xjbqb9HyUBn7rhtr1x+x9Y0e3BdX31/loYvZaLxqnjbRuokz+pPG7WebnSNKE3yE6Tka4aDEDMVYr6Neq126c+ZR2nzzm3yyiC7PGWG/1uueqZudrVGYNdsgOMDvt1cI8CXu63QIcPvYNY8z870WwYazTS7DqpDEknZqS0AFXObWUxTaw0q5pnHlq4oQImakpLfJkmErdvAfhsc7lod0DVT4tuob25C0tQjzdiFObCz7U7eaKGP3s6yQVgQ/y+q+nY6K5dfV75iXzcNlGIP38aj22sVwtWWKMRb7B5HoHPaBvI1Ve5TSXATi66vV6utxsV+aZNFu+93VvlrG/oj8Wp67YT8l+Oq6PjwdGatFm7SEAP13kE0y9CEcf9qhtEWCMIq5AGq71moEAI9vrmFcmO8+7ZyDnmRN/VUaFkM2ce8KuBGFzDMmY6myLfQGra2ofgHhbJRXuRDZ4H+HmliWBHXQ0ysLGfv6FetbxtxzRgIZWjIsGVFl5imPXeyvVyayNek+dSWzjXd4t310YBdaF8sXeKs481PjsXbAtIru2+wHbv3GVh3sQY6Dnu6pF3pZ714VYdDi9A5GkXR/6xgaZN/tpQ8wVV3zeBuB+njoBNE4wjc+uA523ysXGd/P2sntmOb3OdHNWP5OVrxD3eJHdtH8QVkEIAqCor3hReR96yqt6PkTQfenllooQ447h6tOrnnuzwA8fMpq+jqg1oW8fTYYIncAYpVeTvkEFr/khQSbjoE8ykx9049OkE5MQEO9lC24tT7DwThQgf4Fhf8nGgAo3GYaON3crODpOr2pu5dBABz69t7F5yJBBo+r6QJdeLDWEoO7r1tceR3haA7gc7eZrCvpxSXXeKpo4P+hRixo9DeOFbqQVjKyWfBg9pnrEZKzK7R437YTTwhfoySG/YOCt3fs4aXlU3FjKortqQ6XyXaD0+Y/8VoqpyU9TRW45eN4oBxAH8Y/jLnNXfELJW+/p/MgO9Z+mBli2qqAP7dV/Arc2+YZRZwtBW8/p32y5ZsEuCS4O5AAgfR7Dde7zhiGfgvurQkfAXIrUG61rmxc2EZo18ph4vaWZI+QM0JdsbNlBJlPlwf9uguujQJy0j7TgTHdtRnjybTg55Hkk9S6l2rpYahumSewKHVosa1bh2Y6r9JGkdKvIDN/eeAwScrfjoLkCxWJuFZQ53FNP5w9XbQd1HhgHcVB/0fATG3sUUid1RTfc2+7pZVKldFSsaEK0v4k90tapQOk2HIbMhaJQtrUEL5+3sDanh8sOpbYRoQoqXWu6SQcUTQL9jzOrXNPWCJwXge4U7tlU1hkF012cAmvp8llQxf1IEMcw14pURxVOWATz4ITnYQjuF+vDXg5hgoiqXzO6mS91FQUBheURHIJxUeU1i3P0WOMpsm7vFYk0JJi/Ev+X3FwYD69cARPuP5GIc0PxoAFjcLRbNur0iMTrQmBBNYJ2ngU4x7SWfdTRl52Bqv7LmYW3C1CyTCPTHeWWIAM/Whm32COHsaj+2UQ739XB9t6NV0o9E9b7CW3XNiXzi9e0KiE+3rntukdIDBWrU2jsfQWuyFJRANxq8StHVv1JPy2C3Byco7qdNbASrnNXZ8G0L/Wp/pif4Ai9aEZ9Bb+TRx+REBdGlkF/s0dUdMSMr+6YCbuGxqPWdzcdqutvqkBzCksFcwAtjf55TeuH79M6AQa7r5PLeXxMFIlQKrXP9VJ275WGX+ptpf+tvTDBsecPnYQAlAWrVbRVJ7K2pRHwIjtSpbX96Y/lbKk6ZWXlBmh15r8yAWQsYxXgBOXYMAfHnUXF+rDqnB8bXDRtAn7bCziIqetSboK3NexMePvsCRLvmsoREA+kH8j4HWFpnNEaWgOmR7xyXHfTaz3slHc/YA6H6tl/L8d5tPcIwwD0tjvRaq3Y5BmYBSDClpv0VIX4s8D0XK3sPdpAb94HjPLkgboEz9EdZATW6ZdcmQvtKUwoWw+nAVKA7IcdY1UHnvNnIBplKci+knzewLz5/GGnzkGuuGky+0LTjtGBGR85EQICDqKChnm5pH3Z44nnWAk1YRdyu3g7QoFZ0h8jkr2ffjKmi+Qvsp+9GvNGZHmgW+YQAGUw7PPt8IPKbdy432vhKtRJjKWcSqq7helj81o3nfmaxVZ7Sqie8OOBk9WsyTD/ab7fQ5aWwQeJvnH6+ayo4IdIkOSBJjzXkgr+1TPhAx1AXDsxtCCj3TzQTLA1p782f7a8vdgPfwwrXmZxxbqo2h+6Zlo6mcMY4V7cFBOLm17VCvx9Qa2tAnkxEB+KYyQgbgAAnmNDOdOO6y2Cb+lke1MWQc9o+EMdQf7ubIG3Ek8GZ4k1PtGjbhwgOMPp5Em59JMVk/jU8/aF73Xcrd3UBNZyueQu0/xz2aGtZT8CRziOax2BWFXaeDzgZNV7oRtUzFoijoETf3xkAFFk3OMb7SgPh5wxU1+MygDIp9gZChH2qEcpgLh8pBIK90PXT1ZSU+ZExFK4Vm4GL/J7+K13lS5dQkW4HQwl6GX4yLqu8GhGWS2k75yel5IZIfFNdAL0NpKr2N5dQesBnxa42DLgJd6agS1jJsp1mO1dip7PU4P6diLLoTsZ4m3Q0QweiqeFfIGPLgF6v6mSVv6xe85VBD/1Mpe3AurRbcJ9SEo8NszNVy8rOCEexyIFcJRvYAlI/wk2I7r3p60FFLQXoH2q9xri/m41svRPbW0/EnPn2DWsmk0IiPpB60aa3+hiFfWuC8ZvWKEd9LxAk3HcOof6d77RewPaPsGw5lQAHcZN2vx1448u9pLfMLGQ3BSRRjBzRhKt7HcCw/7aqjtCDs5q76b4ZGphxN2th1WeXYlfnozX3ebKtX4Te11hf1tZP1diiGjIDAB1cR4Sb9rcFPC/nBARjlgDxd+tCBb1t91j71xJcgGjT3g/dUFnXXNiDrxkyoHANPk58ACPUa42hj8tgGrhiXOCmygxFZBiT2wyAJTDJ4wJEPmp6JIrDaSWYNqv4xH2wwdSTGYb3E0pXnS39nmLUsqoVZxzSoegqzd0o06wdbTXsaHGL+IF4JtIcXddTcD/dCd8hVf+fWPSV553kjMmMEULLS8HcgmptDO955dLGX78PjiDA6IsTHPm5IA6bc5ha0gaGkoEttXuxU11B2dOJ65/Q08tEF1+Y9cr2Nh/VECfQ33GyvR/gsdN1LuIeLpKMCAF2yRr769g9/4aJLZNRI71m2S91+Kp+Q0zubTcxoG2/6gm1Q79wkMj2XNO2ui7nWw8ULtu27CCvqTGX2PffD+xcwgh/TrOKvGZMM5jRFGDTn4NO/lwnDR/GY/waDZtkWDUPI0O8ztcFVqp6r2ZW+2bvkJ3raptYagFqu95VdIaml2CIp6CKets34x+fH2C+zH4cVFO7vj+6k2FU39PtRhWluYeZ3gDz1TLB9K2v7SD9gJU1qDxoRDrAWcrFGLyndhdtd0505+gEP79adK8fmFCWNYC+ahzVNcRH79E8dA1iqX/N0qq22xcOc20ALxLDspEj4QCFBQMgaIwoKbxr0Bd7Sbws6GiRK6tqoPfpiCle23axejRLyO1I+ahsEpWrzT5ZsCyS5RcY9jMfENFxSnhKsrfW8JHH6/rdQUMfmQPT3Uz9gY0C/pu1yuCnrPUvio0a1qMEosA/EwIzzid7cqsAAAAASUVORK5CYII=');
|
||||
|
||||
@mixin dithered-gradient($position, $start, $stop, $color) {
|
||||
background: radial-gradient(circle at $position, transparent $start, $color $stop);
|
||||
mask: $noise, radial-gradient(circle at $position, transparent $start, #000 ($stop + 10%));
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&::before {
|
||||
@include dithered-gradient(50%, 30%, 60%, #6cc8ff);
|
||||
}
|
||||
|
||||
&::after {
|
||||
mask-image:
|
||||
$noise, linear-gradient(45deg, #000 0%, transparent 25%, transparent 75%, #000 100%);
|
||||
background: linear-gradient(45deg, #6d6dff 10%, transparent 25%, transparent 75%, #6af789 90%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,27 +1,5 @@
|
||||
<template>
|
||||
<boxes />
|
||||
<div>
|
||||
here is the New New front
|
||||
</div>
|
||||
</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,14 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full flex flex-col items-center">
|
||||
<logo />
|
||||
<main>
|
||||
<newsletter />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.logo {
|
||||
filter: drop-shadow(2px 2px 0 rgb(0 0 0 / 0.8));
|
||||
}
|
||||
</style>
|
||||
@@ -1,221 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gradient-to-b from-slate-900 to-slate-800 p-8">
|
||||
<h1 class="text-3xl font-bold text-center text-white mb-8">Card 3D Playground</h1>
|
||||
|
||||
<div class="container mx-auto flex flex-col lg:flex-row gap-8">
|
||||
<!-- Controls Panel -->
|
||||
<div class="w-full lg:w-1/3 bg-slate-800 p-6 rounded-xl shadow-lg">
|
||||
<h2 class="text-xl font-semibold text-white mb-6">3D Controls</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Rotation -->
|
||||
<div>
|
||||
<h3 class="text-white font-medium mb-2">Rotation</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-300 mb-1">X-Axis (rotateX)</label>
|
||||
<input v-model="rotationX" type="range" min="-180" max="180" class="w-full" @input="updateTransform">
|
||||
<span class="text-xs text-gray-400">{{ rotationX }}°</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-300 mb-1">Y-Axis (rotateY)</label>
|
||||
<input v-model="rotationY" type="range" min="-180" max="180" class="w-full" @input="updateTransform">
|
||||
<span class="text-xs text-gray-400">{{ rotationY }}°</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-300 mb-1">Z-Axis (rotateZ)</label>
|
||||
<input v-model="rotationZ" type="range" min="-180" max="180" class="w-full" @input="updateTransform">
|
||||
<span class="text-xs text-gray-400">{{ rotationZ }}°</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Translation -->
|
||||
<div>
|
||||
<h3 class="text-white font-medium mb-2">Position (px)</h3>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-300 mb-1">X</label>
|
||||
<input v-model="translateX" type="range" min="-200" max="200" class="w-full" @input="updateTransform">
|
||||
<span class="text-xs text-gray-400">{{ translateX }}px</span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-300 mb-1">Y</label>
|
||||
<input v-model="translateY" type="range" min="-200" max="200" class="w-full" @input="updateTransform">
|
||||
<span class="text-xs text-gray-400">{{ translateY }}px</span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-300 mb-1">Z</label>
|
||||
<input v-model="translateZ" type="range" min="-500" max="500" class="w-full" @input="updateTransform">
|
||||
<span class="text-xs text-gray-400">{{ translateZ }}px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scale -->
|
||||
<div>
|
||||
<h3 class="text-white font-medium mb-2">Scale</h3>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-300 mb-1">X</label>
|
||||
<input v-model="scaleX" type="range" min="0.5" max="2" step="0.1" class="w-full"
|
||||
@input="updateTransform">
|
||||
<span class="text-xs text-gray-400">{{ scaleX }}x</span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-300 mb-1">Y</label>
|
||||
<input v-model="scaleY" type="range" min="0.5" max="2" step="0.1" class="w-full"
|
||||
@input="updateTransform">
|
||||
<span class="text-xs text-gray-400">{{ scaleY }}x</span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-300 mb-1">Z</label>
|
||||
<input v-model="scaleZ" type="range" min="0.5" max="2" step="0.1" class="w-full"
|
||||
@input="updateTransform">
|
||||
<span class="text-xs text-gray-400">{{ scaleZ }}x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset Button -->
|
||||
<button
|
||||
class="w-full mt-6 bg-esyellow hover:bg-yellow-500 text-black font-medium py-2 px-4 rounded-md transition-colors"
|
||||
@click="resetTransforms">
|
||||
Reset to Default
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Preview -->
|
||||
<div class="flex-1 flex items-center justify-center min-h-[60vh] lg:min-h-auto">
|
||||
<div class="relative w-64 h-96 transition-transform duration-300" :style="cardStyle">
|
||||
<div class="w-full h-full" :style="{
|
||||
transform: `
|
||||
perspective(1000px)
|
||||
rotateX(${rotationX}deg)
|
||||
rotateY(${rotationY}deg)
|
||||
rotateZ(${rotationZ}deg)
|
||||
translate3d(${translateX}px, ${translateY}px, ${translateZ}px)
|
||||
scale3d(${scaleX}, ${scaleY}, ${scaleZ})
|
||||
`,
|
||||
transformStyle: 'preserve-3d',
|
||||
transition: 'transform 0.3s ease-out',
|
||||
willChange: 'transform',
|
||||
backfaceVisibility: 'visible'
|
||||
}">
|
||||
<div
|
||||
class="w-full h-full bg-gradient-to-br from-cyan-500 to-blue-600 rounded-xl shadow-2xl flex items-center justify-center p-4">
|
||||
<div class="text-center text-white">
|
||||
<div class="text-6xl mb-2">🃏</div>
|
||||
<h3 class="text-xl font-bold">Card Playground</h3>
|
||||
<p class="text-sm opacity-80">Drag the sliders to transform me!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// State for 3D transformations
|
||||
import { useDataStore } from '~/store/data'
|
||||
|
||||
const rotationX = ref(0)
|
||||
const rotationY = ref(0)
|
||||
const rotationZ = ref(0)
|
||||
const translateX = ref(0)
|
||||
const translateY = ref(0)
|
||||
const translateZ = ref(0)
|
||||
const scaleX = ref(1)
|
||||
const scaleY = ref(1)
|
||||
const scaleZ = ref(1)
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// Computed property for the card's transform style
|
||||
const cardStyle = computed(() => {
|
||||
return {
|
||||
transform: `
|
||||
perspective(1000px)
|
||||
rotateX(${rotationX.value}deg)
|
||||
rotateY(${rotationY.value}deg)
|
||||
rotateZ(${rotationZ.value}deg)
|
||||
translate3d(${translateX.value}px, ${translateY.value}px, ${translateZ.value}px)
|
||||
scale3d(${scaleX.value}, ${scaleY.value}, ${scaleZ.value})
|
||||
`,
|
||||
transformStyle: 'preserve-3d',
|
||||
transition: 'transform 0.3s ease-out',
|
||||
willChange: 'transform',
|
||||
backfaceVisibility: 'visible'
|
||||
}
|
||||
})
|
||||
|
||||
// Function to update transform (for immediate feedback)
|
||||
const updateTransform = () => {
|
||||
// The computed property will handle the update
|
||||
}
|
||||
|
||||
// Function to reset all transforms
|
||||
const resetTransforms = () => {
|
||||
rotationX.value = 0
|
||||
rotationY.value = 0
|
||||
rotationZ.value = 0
|
||||
translateX.value = 0
|
||||
translateY.value = 0
|
||||
translateZ.value = 0
|
||||
scaleX.value = 1
|
||||
scaleY.value = 1
|
||||
scaleZ.value = 1
|
||||
}
|
||||
|
||||
dataStore.isLoading.value = false
|
||||
|
||||
// Set page metadata
|
||||
definePageMeta({
|
||||
layout: 'default'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add any additional styles here */
|
||||
.container {
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
/* Style range inputs */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: #475569;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb:hover {
|
||||
background: #d97706;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* Card styling */
|
||||
.card-3d {
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 0.3s ease-out;
|
||||
will-change: transform;
|
||||
backface-visibility: visible;
|
||||
}
|
||||
</style>
|
||||
@@ -1,78 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap justify-center items-center h-screen">
|
||||
<div class="bg-page-dark-bg text-white">
|
||||
<div class="flex flex-col-reverse bg-gradient-to-r from-primary to-primary-dark">
|
||||
<div class="mt-8 flex flex-wrap justify-center">
|
||||
<!-- <box :box="box" /> -->
|
||||
<div class="devtool absolute right-4 text-white bg-black rounded-2xl px-4 py-2">
|
||||
<!-- <button @click="currentPosition = boxPositions.side">side</button>
|
||||
<button @click="currentPosition = boxPositions.front">front</button>
|
||||
<button @click="currentPosition = boxPositions.back">back</button> -->
|
||||
<div class="w-full block">
|
||||
<input class="w-1/2" type="color" name="color1" id="color1" v-model="box.color1" />
|
||||
<input class="w-1/2" type="color" name="color1" id="color1" v-model="box.color2" />
|
||||
<div
|
||||
class="block w-full h-32"
|
||||
:style="{
|
||||
background: `linear-gradient(to top, ${box.color1}, ${box.color2})`
|
||||
}"
|
||||
></div>
|
||||
<!-- <label class="block">
|
||||
size: {{ size }}
|
||||
<input v-model.number="size" type="range" step="1" min="1" max="14">
|
||||
</label> -->
|
||||
</div>
|
||||
<!-- <div>
|
||||
<label class="block">
|
||||
X: {{ currentPosition.x }}
|
||||
<input v-model.number="currentPosition.x" type="range" step="1" min="-180" max="180">
|
||||
</label>
|
||||
<label class="block">
|
||||
Y: {{ currentPosition.y }}
|
||||
<input v-model.number="currentPosition.y" type="range" step="1" min="-180" max="180">
|
||||
</label>
|
||||
<label class="block">
|
||||
Z: {{ currentPosition.z }}
|
||||
<input v-model.number="currentPosition.z" type="range" step="1" min="-180" max="180">
|
||||
</label>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<card :track="track" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Box, Track } from '~~/types/types'
|
||||
|
||||
const box = ref<Box>({
|
||||
id: 'ES00A',
|
||||
name: 'zero',
|
||||
duration: 2794,
|
||||
description: 'Zero is for manifesto',
|
||||
color1: '#ffffff',
|
||||
color2: '#48959d',
|
||||
color3: '#00ff00',
|
||||
type: 'compilation'
|
||||
})
|
||||
|
||||
const track = ref<Track>({
|
||||
id: 1,
|
||||
boxId: 'ES00A',
|
||||
title: 'The grinding wheel',
|
||||
artist: {
|
||||
id: 0,
|
||||
name: "L'Efondras",
|
||||
url: '',
|
||||
coverId: '0024705317'
|
||||
},
|
||||
start: 0,
|
||||
url: 'https://arakirecords.bandcamp.com/track/the-grinding-wheel',
|
||||
coverId: 'a3236746052',
|
||||
type: 'compilation'
|
||||
})
|
||||
|
||||
//from-slate-800 to-zinc-900
|
||||
</script>
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineNuxtPlugin } from '#app'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
if (process.client) {
|
||||
import('atropos/element').then(({ default: AtroposComponent }) => {
|
||||
customElements.define('atropos-component', AtroposComponent)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,101 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
interface CardPosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type CardPositions = Record<string, Record<number, CardPosition>>
|
||||
|
||||
export const useCardStore = defineStore('card', {
|
||||
state: () => ({
|
||||
// Stocke les IDs des cartes déjà révélées
|
||||
revealedCards: new Set<number>(),
|
||||
// Stocke les positions personnalisées des cartes par box
|
||||
// Format: { [boxId]: { [trackId]: { x: number, y: number } } }
|
||||
cardPositions: {} as CardPositions
|
||||
}),
|
||||
|
||||
actions: {
|
||||
// Marquer une carte comme révélée
|
||||
revealCard(trackId: number) {
|
||||
this.revealedCards.add(trackId)
|
||||
this.saveToLocalStorage()
|
||||
},
|
||||
|
||||
// Vérifier si une carte est révélée
|
||||
isCardRevealed(trackId: number): boolean {
|
||||
return this.revealedCards.has(trackId)
|
||||
},
|
||||
|
||||
// Définir la position d'une carte dans une box
|
||||
setCardPosition(boxId: string, trackId: number, position: { x: number; y: number }) {
|
||||
if (!this.cardPositions[boxId]) {
|
||||
this.cardPositions[boxId] = {}
|
||||
}
|
||||
this.cardPositions[boxId][trackId] = position
|
||||
this.saveToLocalStorage()
|
||||
},
|
||||
|
||||
// Obtenir la position d'une carte dans une box
|
||||
getCardPosition(boxId: string, trackId: number): { x: number; y: number } | undefined {
|
||||
return this.cardPositions[boxId]?.[trackId]
|
||||
},
|
||||
|
||||
// Sauvegarder l'état dans le localStorage
|
||||
saveToLocalStorage() {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
'cardStore',
|
||||
JSON.stringify({
|
||||
revealedCards: Array.from(this.revealedCards),
|
||||
cardPositions: this.cardPositions
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('Failed to save card store to localStorage', e)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Charger l'état depuis le localStorage
|
||||
loadFromLocalStorage() {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const saved = localStorage.getItem('cardStore')
|
||||
if (saved) {
|
||||
const { revealedCards, cardPositions } = JSON.parse(saved)
|
||||
if (Array.isArray(revealedCards)) {
|
||||
this.revealedCards = new Set(
|
||||
revealedCards.filter((id): id is number => typeof id === 'number')
|
||||
)
|
||||
}
|
||||
if (cardPositions && typeof cardPositions === 'object') {
|
||||
this.cardPositions = cardPositions
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load card store from localStorage', e)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Initialiser le store
|
||||
initialize() {
|
||||
this.loadFromLocalStorage()
|
||||
}
|
||||
},
|
||||
|
||||
getters: {
|
||||
// Getter pour la réactivité dans les templates
|
||||
isRevealed: (state) => (trackId: number) => {
|
||||
return state.revealedCards.has(trackId)
|
||||
},
|
||||
|
||||
// Obtenir toutes les positions des cartes d'une box
|
||||
getBoxCardPositions: (state) => (boxId: string) => {
|
||||
return state.cardPositions[boxId] || {}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,316 +0,0 @@
|
||||
// ~/store/player.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import type { Track, Box } from '~/../types/types'
|
||||
import { useDataStore } from '~/store/data'
|
||||
import { useCardStore } from '~/store/card'
|
||||
|
||||
export const usePlayerStore = defineStore('player', {
|
||||
state: () => ({
|
||||
currentTrack: null as Track | null,
|
||||
position: 0,
|
||||
audio: null as HTMLAudioElement | null,
|
||||
progressionLast: 0,
|
||||
isPlaying: false,
|
||||
isLoading: false,
|
||||
history: [] as string[]
|
||||
}),
|
||||
|
||||
actions: {
|
||||
attachAudio(el: HTMLAudioElement) {
|
||||
this.audio = el
|
||||
// attach listeners if not already attached (idempotent enough for our use)
|
||||
this.audio.addEventListener('play', () => {
|
||||
this.isPlaying = true
|
||||
// Révéler la carte quand la lecture commence
|
||||
if (this.currentTrack) {
|
||||
const cardStore = useCardStore()
|
||||
if (!cardStore.isCardRevealed(this.currentTrack.id)) {
|
||||
requestAnimationFrame(() => {
|
||||
cardStore.revealCard(this.currentTrack!.id)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
this.audio.addEventListener('playing', () => {})
|
||||
this.audio.addEventListener('pause', () => {
|
||||
this.isPlaying = false
|
||||
})
|
||||
this.audio.addEventListener('ended', async () => {
|
||||
const track = this.currentTrack
|
||||
if (!track) return
|
||||
|
||||
const dataStore = useDataStore()
|
||||
// Comportement par défaut pour les playlists standards
|
||||
if (track.type === 'playlist') {
|
||||
const next = dataStore.getNextPlaylistTrack(track)
|
||||
if (next && next.boxId === track.boxId) {
|
||||
await this.playTrack(next)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Si on arrive ici, c'est qu'il n'y a pas de piste suivante
|
||||
this.currentTrack = null
|
||||
this.isPlaying = false
|
||||
})
|
||||
},
|
||||
|
||||
async playBox(box: Box) {
|
||||
// Si c'est la même box, on toggle simplement la lecture
|
||||
if (this.currentTrack?.boxId === box.id && this.currentTrack?.side === box.activeSide) {
|
||||
this.togglePlay()
|
||||
return
|
||||
}
|
||||
|
||||
// Sinon, on charge la première piste de la box
|
||||
try {
|
||||
const dataStore = useDataStore()
|
||||
const firstTrack = dataStore.getFirstTrackOfBox(box)
|
||||
if (firstTrack) {
|
||||
await this.playTrack(firstTrack)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error playing box:', error)
|
||||
}
|
||||
},
|
||||
|
||||
async playTrack(track: Track) {
|
||||
// Pour les autres types de pistes, on utilise la logique existante
|
||||
this.isCompilationTrack(track)
|
||||
? await this.playCompilationTrack(track)
|
||||
: await this.playPlaylistTrack(track)
|
||||
},
|
||||
|
||||
async playCompilationTrack(track: Track) {
|
||||
// Si c'est la même piste, on toggle simplement la lecture
|
||||
if (this.currentTrack?.id === track.id) {
|
||||
// Si la lecture est en cours, on met en pause
|
||||
if (this.isPlaying) {
|
||||
this.togglePlay()
|
||||
return
|
||||
}
|
||||
|
||||
// Si c'est une compilation, on vérifie si on est dans la plage de la piste
|
||||
if (track.type === 'compilation' && track.start !== undefined) {
|
||||
const dataStore = useDataStore()
|
||||
const nextTrack = dataStore.getNextTrack(track)
|
||||
|
||||
// Si on a une piste suivante et qu'on est dans la plage de la piste courante
|
||||
if (nextTrack?.start && this.position >= track.start && this.position < nextTrack.start) {
|
||||
this.togglePlay()
|
||||
return
|
||||
}
|
||||
// Si c'est la dernière piste de la compilation
|
||||
else if (!nextTrack && this.position >= track.start) {
|
||||
this.togglePlay()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sinon, on charge et on lit la piste
|
||||
this.currentTrack = track
|
||||
await this.loadAndPlayTrack(track)
|
||||
},
|
||||
|
||||
async playPlaylistTrack(track: Track) {
|
||||
// Toggle simple si c'est la même piste
|
||||
if (this.currentTrack?.id === track.id) {
|
||||
this.togglePlay()
|
||||
return
|
||||
}
|
||||
|
||||
// Sinon, on charge et on lit la piste
|
||||
this.currentTrack = track
|
||||
await this.loadAndPlayTrack(track)
|
||||
},
|
||||
|
||||
async loadTrack(track: Track) {
|
||||
if (!this.audio) return
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
this.currentTrack = track
|
||||
const audio = this.audio as HTMLAudioElement
|
||||
|
||||
// Si c'est la même source, on ne fait rien
|
||||
if (audio.src === track.url) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
// Nouvelle source
|
||||
audio.src = track.url
|
||||
|
||||
// Une fois que suffisamment de données sont chargées
|
||||
const onCanPlay = () => {
|
||||
audio.removeEventListener('canplay', onCanPlay)
|
||||
resolve()
|
||||
}
|
||||
audio.addEventListener('canplay', onCanPlay)
|
||||
|
||||
// Timeout de sécurité
|
||||
setTimeout(resolve, 1000)
|
||||
})
|
||||
},
|
||||
|
||||
async loadAndPlayTrack(track: Track) {
|
||||
if (!this.audio) {
|
||||
const newAudio = new Audio()
|
||||
this.attachAudio(newAudio)
|
||||
}
|
||||
|
||||
const audio = this.audio as HTMLAudioElement
|
||||
|
||||
try {
|
||||
this.isLoading = true
|
||||
// Mettre en pause
|
||||
audio.pause()
|
||||
|
||||
// Pour les compilations, on utilise l'URL de la piste avec le point de départ
|
||||
if (track.type === 'compilation' && track.start !== undefined) {
|
||||
audio.src = track.url
|
||||
audio.currentTime = track.start
|
||||
} else {
|
||||
// Pour les playlists, on charge la piste individuelle
|
||||
audio.currentTime = 0
|
||||
await this.loadTrack(track)
|
||||
}
|
||||
|
||||
// Attendre que les métadonnées soient chargées
|
||||
await new Promise<void>((resolve) => {
|
||||
const onCanPlay = () => {
|
||||
audio.removeEventListener('canplay', onCanPlay)
|
||||
resolve()
|
||||
}
|
||||
audio.addEventListener('canplay', onCanPlay)
|
||||
// Timeout de sécurité
|
||||
setTimeout(resolve, 1000)
|
||||
})
|
||||
|
||||
// Lancer la lecture
|
||||
await audio.play()
|
||||
this.history.push(this.currentTrack?.id)
|
||||
this.isLoading = false
|
||||
} catch (error) {
|
||||
console.error('Error loading/playing track:', error)
|
||||
this.isLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
async togglePlay() {
|
||||
if (!this.audio) return
|
||||
try {
|
||||
if (this.audio.paused) {
|
||||
await this.audio.play()
|
||||
} else {
|
||||
this.audio.pause()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling play state:', error)
|
||||
}
|
||||
},
|
||||
|
||||
updateTime() {
|
||||
const audio = this.audio
|
||||
if (!audio) return
|
||||
|
||||
// update current position
|
||||
this.position = audio.currentTime
|
||||
|
||||
// compute and cache a stable progression value
|
||||
const duration = audio.duration
|
||||
const progression = (this.position / duration) * 100
|
||||
if (!isNaN(progression)) {
|
||||
this.progressionLast = progression
|
||||
}
|
||||
// update current track when changing time in compilation
|
||||
const currentTrack = this.currentTrack
|
||||
if (currentTrack && currentTrack.type === 'compilation') {
|
||||
const dataStore = useDataStore()
|
||||
const tracks = dataStore
|
||||
.getTracksByboxId(currentTrack.boxId, currentTrack.side)
|
||||
.slice()
|
||||
.filter((t) => t.type === 'compilation')
|
||||
.sort((a, b) => (a.start ?? 0) - (b.start ?? 0))
|
||||
|
||||
if (tracks.length > 0) {
|
||||
const now = audio.currentTime
|
||||
// find the last track whose start <= now (fallback to first track)
|
||||
let nextTrack = tracks[0]
|
||||
for (const t of tracks) {
|
||||
const s = t.start ?? 0
|
||||
if (s <= now) {
|
||||
nextTrack = t
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (nextTrack && nextTrack.id !== currentTrack.id) {
|
||||
// only update metadata reference; do not reload audio
|
||||
this.currentTrack = nextTrack
|
||||
|
||||
// Révéler la carte avec une animation fluide
|
||||
const cardStore = useCardStore()
|
||||
if (nextTrack.id && !cardStore.isCardRevealed(nextTrack.id)) {
|
||||
// Utiliser requestAnimationFrame pour une meilleure synchronisation avec le rendu
|
||||
requestAnimationFrame(() => {
|
||||
cardStore.revealCard(nextTrack.id!)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getters: {
|
||||
isCurrentBox: (state) => {
|
||||
return (box: Box) => {
|
||||
if (box.type === 'compilation') {
|
||||
return box.id === state.currentTrack?.boxId && box.activeSide === state.currentTrack?.side
|
||||
} else {
|
||||
return box.id === state.currentTrack?.boxId
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
isCurrentSide: (state) => {
|
||||
return (side: string) => side === state.currentTrack?.side
|
||||
},
|
||||
|
||||
isPlaylistTrack: () => {
|
||||
return (track: Track) => {
|
||||
return track.type === 'playlist'
|
||||
}
|
||||
},
|
||||
|
||||
isCompilationTrack: () => {
|
||||
return (track: Track) => {
|
||||
return track.type === 'compilation'
|
||||
}
|
||||
},
|
||||
|
||||
isPaused: (state) => {
|
||||
return state.audio?.paused ?? true
|
||||
},
|
||||
|
||||
getCurrentTrack: (state) => state.currentTrack,
|
||||
|
||||
getCurrentBox: (state) => {
|
||||
return state.currentTrack ? state.currentTrack.url : null
|
||||
},
|
||||
|
||||
getCurrentProgression(state) {
|
||||
if (!state.audio) return 0
|
||||
const duration = state.audio.duration
|
||||
const progression = (state.position / duration) * 100
|
||||
return isNaN(progression) ? state.progressionLast : progression
|
||||
},
|
||||
|
||||
getCurrentCoverUrl(state) {
|
||||
const id = state.currentTrack?.coverId
|
||||
if (!id) return null
|
||||
return id.startsWith('http') ? id : `https://f4.bcbits.com/img/${id}_4.jpg`
|
||||
}
|
||||
}
|
||||
})
|
||||
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>
|
||||
@@ -5,10 +5,11 @@
|
||||
<img v-if="isCompilation" class="cover absolute" :src="`/${box.id}/${box.activeSide}/cover.jpg`" alt="" />
|
||||
<div v-else class="size-full flex flex-col justify-center items-center text-7xl text-black"
|
||||
v-html="box.description" />
|
||||
<CinemaScreen />
|
||||
</div>
|
||||
<div class="face back flex flex-row flex-wrap items-start p-4 overflow-hidden"
|
||||
: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">
|
||||
<span class="text-slate-700" v-if="isNotManifesto"> {{ track.order }}. </span>
|
||||
<p class="text-left text-slate-700">
|
||||
@@ -18,7 +19,7 @@
|
||||
<br />
|
||||
{{ track.artist.name }}
|
||||
</p>
|
||||
</li>
|
||||
</li> -->
|
||||
</div>
|
||||
<div class="face right" ref="rightFace" />
|
||||
<div class="face left" ref="leftFace" />
|
||||
@@ -42,7 +43,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
||||
import type { Box } from '~~/types/types'
|
||||
import type { Box } from '~~/types'
|
||||
import { useDataStore } from '~/store/data'
|
||||
|
||||
interface Props {
|
||||
@@ -142,13 +143,6 @@ function rotateBox() {
|
||||
applyTransform(0.8)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.box.activeSide,
|
||||
() => {
|
||||
rotateBox()
|
||||
}
|
||||
)
|
||||
|
||||
// --- Inertie ---
|
||||
function tickInertia() {
|
||||
if (!enableInertia) return
|
||||
@@ -246,6 +240,10 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
// --- Watchers ---
|
||||
watch(
|
||||
() => props.box.activeSide,
|
||||
() => rotateBox()
|
||||
)
|
||||
watch(
|
||||
() => props.box.state,
|
||||
() => applyBoxState()
|
||||
@@ -255,7 +253,10 @@ watch(
|
||||
() => applyColor(),
|
||||
{ deep: true }
|
||||
)
|
||||
watch(isDraggable, (enabled) => (enabled ? addListeners() : removeListeners()))
|
||||
watch(
|
||||
isDraggable,
|
||||
(enabled) => (enabled ? addListeners() : removeListeners())
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
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>
|
||||
@@ -2,10 +2,12 @@
|
||||
<div class="boxes" :class="{ 'box-selected': uiStore.isBoxSelected }">
|
||||
<box v-for="(box, i) in dataStore.boxes" :key="box.id" :tabindex="dataStore.boxes.length - i"
|
||||
:box="getBoxToDisplay(box)" @click="openBox(box)" class="text-center" :class="box.state" :id="box.id">
|
||||
<playButton @click.stop="playSelectedBox(box)" :objectToPlay="box" class="relative z-40 m-auto" />
|
||||
<template v-if="box.state === 'box-selected'">
|
||||
<deckCompilation :box="getBoxToDisplay(box)" class="box-page" v-if="box.type === 'compilation'"
|
||||
:key="`${box.id}-${box.activeSide}`" @click.stop />
|
||||
<template v-if="box.type === 'compilation'">
|
||||
<playButton @click.stop="playSelectedBox(box)" :objectToPlay="box" class="relative z-40 m-auto" />
|
||||
<deckCompilation :box="getBoxToDisplay(box)" class="box-page" :key="`${box.id}-${box.activeSide}`"
|
||||
@click.stop />
|
||||
</template>
|
||||
<deckPlaylist :box="box" class="box-page" v-if="box.type === 'playlist'" @click.stop />
|
||||
</template>
|
||||
</box>
|
||||
@@ -13,7 +15,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Box } from '~~/types/types'
|
||||
import type { Box } from '~~/types'
|
||||
import { useDataStore } from '~/store/data'
|
||||
import { usePlayerStore } from '~/store/player'
|
||||
import { useUiStore } from '~/store/ui'
|
||||
169
appOLDD/components/Bucket.vue
Normal file
169
appOLDD/components/Bucket.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="bucket" ref="bucket" :class="{ 'drag-over': isDragOver }" @dragenter.prevent="onDragEnter"
|
||||
@dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop.prevent="onDrop">
|
||||
<div v-if="tracks.length === 0" class="bucket-empty">
|
||||
Drop cards here
|
||||
</div>
|
||||
<draggable v-else v-model="tracks" item-key="id" class="bucket-cards" @start="handleDragStart" @end="handleDragEnd"
|
||||
:touch-start-threshold="50" :component-data="{
|
||||
tag: 'div',
|
||||
type: 'transition-group',
|
||||
name: 'list'
|
||||
}">
|
||||
<template #item="{ element: track }">
|
||||
<div class="bucket-card-wrapper">
|
||||
<card :track="track" tabindex="0" is-face-up class="bucket-card"
|
||||
@card-click="playerStore.playPlaylistTrack(track)" />
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, defineEmits, onMounted } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import { useCardStore } from '~/store/card'
|
||||
import { usePlayerStore } from '~/store/player'
|
||||
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'card-dropped', track: any): void
|
||||
(e: 'update:modelValue', value: any[]): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: any[]
|
||||
boxId?: string
|
||||
}>()
|
||||
|
||||
const cardStore = useCardStore()
|
||||
const playerStore = usePlayerStore()
|
||||
const isDragOver = ref(false)
|
||||
const drag = ref(false)
|
||||
const bucket = ref()
|
||||
|
||||
// Utilisation du bucket du store comme source de vérité
|
||||
const tracks = computed({
|
||||
get: () => cardStore.bucket,
|
||||
set: (value) => {
|
||||
// Update the store when the order changes
|
||||
cardStore.updateBucketOrder(value)
|
||||
}
|
||||
})
|
||||
|
||||
// Charger les données du localStorage au montage
|
||||
onMounted(() => {
|
||||
cardStore.loadBucketFromLocalStorage()
|
||||
})
|
||||
|
||||
// Gestion du drag and drop desktop
|
||||
const handleDragStart = (event: { item: HTMLElement }) => {
|
||||
drag.value = true
|
||||
// Émettre un événement personnalisé pour indiquer qu'un glisser a commencé depuis le bucket
|
||||
document.dispatchEvent(new CustomEvent('bucket-drag-start'))
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: { item: HTMLElement; newIndex: number; oldIndex: number }) => {
|
||||
drag.value = false
|
||||
isDragOver.value = false
|
||||
// Update the store with the new order if the position changed
|
||||
if (event.newIndex !== event.oldIndex) {
|
||||
// The store will handle the reordering automatically through the v-model binding
|
||||
}
|
||||
}
|
||||
|
||||
const onDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
const onDragLeave = () => {
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
isDragOver.value = false
|
||||
const cardData = e.dataTransfer?.getData('application/json')
|
||||
if (cardData) {
|
||||
try {
|
||||
const track = JSON.parse(cardData)
|
||||
cardStore.addToBucket(track)
|
||||
} catch (e) {
|
||||
console.error('Erreur lors du traitement de la carte déposée', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Écouter aussi les événements tactiles personnalisés
|
||||
bucket.value?.addEventListener('card-dropped-touch', (e: CustomEvent) => {
|
||||
emit('card-dropped', e.detail)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bucket {
|
||||
min-height: 200px;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.bucket.drag-over {
|
||||
border-color: #4CAF50;
|
||||
background-color: rgba(76, 175, 80, 0.1);
|
||||
}
|
||||
|
||||
.bucket-empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.bucket-cards {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bucket-card {
|
||||
transition: transform 0.2s;
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
/* Important pour le touch */
|
||||
}
|
||||
|
||||
.bucket-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.bucket-card:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.bucket-card-wrapper {
|
||||
width: 70px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.bucket:hover,
|
||||
.card-dragging {
|
||||
border: 2px dashed #ccc;
|
||||
background-color: rgba(255, 255, 255, 0.4);
|
||||
|
||||
.bucket-card-wrapper {
|
||||
width: 280px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
3
appOLDD/components/CinemaScreen.vue
Normal file
3
appOLDD/components/CinemaScreen.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<video class="h-full w-full object-cover" ref="video" muted autoplay src=""></video>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@
|
||||
<transition name="fade">
|
||||
<div v-if="data.isLoading" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-md" />
|
||||
<img src="/loader.svg" alt="Loading" class="border-esyellow/30 border-2 relative h-40 w-40 p-6 rounded-full">
|
||||
<img src="/loader.svg" alt="Loading" class="border-esyellow/30 border-4 relative h-40 w-40 p-6 rounded-full">
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
198
appOLDD/components/Platine.vue
Normal file
198
appOLDD/components/Platine.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div class="platine pointer-events-none" :class="{ 'loading': platineStore.isLoadingTrack, 'mounted': isMounted }"
|
||||
ref="platine">
|
||||
<img class="cover" :src="platineStore.currentTrack?.coverId" />
|
||||
<div class="disc pointer-events-auto fixed bg-transparent" ref="discRef" id="disc">
|
||||
<div class="bobine"
|
||||
:style="{ height: platineStore.progressPercentage + '%', width: platineStore.progressPercentage + '%' }"></div>
|
||||
|
||||
|
||||
<div class="disc-label rounded-full bg-cover bg-center">
|
||||
<img src="/favicon.svg" class="size-1/2 bg-black rounded-full p-5">
|
||||
<div v-if="platineStore.isLoadingTrack" class="loading-indicator">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!platineStore.isLoadingTrack" class="absolute top-1/2 right-8 size-1/12 rounded-full bg-esyellow">
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="w-full h-1/5 text-base">
|
||||
{{ platineStore.currentTrack?.title }}
|
||||
<br>
|
||||
{{ platineStore.currentTrack?.artist?.name }}
|
||||
</div> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { usePlatineStore } from '~/store/platine'
|
||||
import type { Track } from '~~/types'
|
||||
|
||||
const props = defineProps<{ track?: Track }>()
|
||||
const platineStore = usePlatineStore()
|
||||
const discRef = ref<HTMLElement>()
|
||||
const platine = ref<HTMLElement>()
|
||||
const isMounted = ref(false)
|
||||
|
||||
// Initialisation du lecteur
|
||||
onMounted(() => {
|
||||
isMounted.value = true
|
||||
if (discRef.value) {
|
||||
platineStore.initPlatine(discRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Nettoyage
|
||||
onUnmounted(() => {
|
||||
isMounted.value = false
|
||||
platineStore.cleanup()
|
||||
})
|
||||
|
||||
// Surveillance des changements de piste
|
||||
watch(() => props.track, (newTrack) => {
|
||||
if (newTrack) {
|
||||
platineStore.loadTrack(newTrack)
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.platine {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.card {
|
||||
position: absolute !important;
|
||||
top: -20%;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
transform: translate(-50%, 50%);
|
||||
}
|
||||
}
|
||||
|
||||
.disc {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.4);
|
||||
|
||||
.loading & {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.disc.is-scratching {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.disc-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
background-size: cover;
|
||||
width: 45%;
|
||||
aspect-ratio: 1/1;
|
||||
// background: no-repeat url(/favicon.svg) center center;
|
||||
background-size: 30%;
|
||||
border-radius: 50%;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.disc-middle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: rgb(26, 26, 26);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.button {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background: rgb(69, 69, 69);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.4rem;
|
||||
color: #fff;
|
||||
line-height: 1.3;
|
||||
cursor: pointer;
|
||||
will-change: box-shadow;
|
||||
transition:
|
||||
box-shadow 0.2s ease-out,
|
||||
transform 0.05s ease-in;
|
||||
}
|
||||
|
||||
.power.is-active {
|
||||
transform: translate(1px, 2px);
|
||||
color: red;
|
||||
}
|
||||
|
||||
.button[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.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 {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.bobine {
|
||||
@apply bg-slate-900 bg-opacity-50 backdrop-blur absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 rounded-full;
|
||||
}
|
||||
</style>
|
||||
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 { usePlayerStore } from '~/store/player'
|
||||
import { useUiStore } from '~/store/ui'
|
||||
import type { Box } from '~~/types/types'
|
||||
import type { Box } from '~~/types'
|
||||
|
||||
const uiStore = useUiStore()
|
||||
|
||||
177
appOLDD/components/deck/Playlist.vue
Normal file
177
appOLDD/components/deck/Playlist.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="flex flex-col fixed right-0 top-0 z-50" :class="props.class">
|
||||
<button @click="closeDatBox" v-if="uiStore.isBoxSelected"
|
||||
class="px-4 py-2 text-black hover:text-black bg-esyellow transition-colors z-50" aria-label="close the box">
|
||||
close
|
||||
</button>
|
||||
</div>
|
||||
<div class="controls flex justify-center z-50 relative" v-bind="attrs">
|
||||
<SearchInput @search="onSearch" />
|
||||
<SelectCardRank @change="onRankChange" />
|
||||
<SelectCardSuit @change="onSuitChange" />
|
||||
</div>
|
||||
<div ref="deck" class="deck flex flex-wrap justify-center gap-4" :class="{ 'pb-36': playerStore.currentTrack }"
|
||||
@dragover.prevent @drop.prevent="handleGlobalDrop">
|
||||
<card v-for="(track, i) in filteredTracks" :key="track.id" :track="track" :tabindex="i"
|
||||
@card-click="playerStore.playPlaylistTrack(track)" :is-face-up="isCardRevealed(track.id)"
|
||||
@click-card-symbol="openCardSharer()" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useDataStore } from '~/store/data'
|
||||
import { useCardStore } from '~/store/card'
|
||||
import { usePlayerStore } from '~/store/player'
|
||||
import { useUiStore } from '~/store/ui'
|
||||
import type { Box, Track } from '~~/types'
|
||||
import SelectCardSuit from '~/components/ui/SelectCardSuit.vue'
|
||||
import SelectCardRank from '~/components/ui/SelectCardRank.vue'
|
||||
import SearchInput from '~/components/ui/SearchInput.vue'
|
||||
|
||||
// Define the events this component emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', event: MouseEvent): void;
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
box: Box;
|
||||
class?: string;
|
||||
}>()
|
||||
|
||||
// Use useAttrs to get all other attributes
|
||||
const attrs = useAttrs()
|
||||
|
||||
const cardStore = useCardStore()
|
||||
const dataStore = useDataStore()
|
||||
const playerStore = usePlayerStore()
|
||||
const uiStore = useUiStore()
|
||||
|
||||
const deck = ref()
|
||||
const tracks = computed(() => dataStore.getTracksByboxId(props.box.id))
|
||||
|
||||
// Suivre si un glisser est en cours depuis le bucket
|
||||
const isDraggingFromBucket = ref(false)
|
||||
|
||||
// Gérer le dépôt d'une carte en dehors du bucket
|
||||
const handleGlobalDrop = (e: DragEvent) => {
|
||||
if (isDraggingFromBucket.value) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Récupérer les données de la carte glissée
|
||||
const cardData = e.dataTransfer?.getData('application/json')
|
||||
if (cardData) {
|
||||
try {
|
||||
const track = JSON.parse(cardData)
|
||||
// Retirer la carte du panier
|
||||
cardStore.removeFromBucket(track.id)
|
||||
// La carte réapparaîtra automatiquement dans la playlist
|
||||
// grâce à la computed property filteredTracks
|
||||
} catch (e) {
|
||||
console.error('Erreur lors du traitement de la carte glissée', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
isDraggingFromBucket.value = false
|
||||
}
|
||||
|
||||
// Gérer le début du glisser depuis le bucket
|
||||
const handleBucketDragStart = () => {
|
||||
isDraggingFromBucket.value = true
|
||||
}
|
||||
|
||||
// Configurer les écouteurs d'événements
|
||||
onMounted(() => {
|
||||
document.addEventListener('drop', handleGlobalDrop)
|
||||
document.addEventListener('dragover', (e) => e.preventDefault()) // Nécessaire pour permettre le drop
|
||||
document.addEventListener('bucket-drag-start', handleBucketDragStart)
|
||||
})
|
||||
|
||||
// Nettoyer les écouteurs d'événements
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('drop', handleGlobalDrop)
|
||||
document.removeEventListener('dragover', (e) => e.preventDefault())
|
||||
document.removeEventListener('bucket-drag-start', handleBucketDragStart)
|
||||
})
|
||||
|
||||
// Utiliser une computed property pour filteredTracks qui réagit aux changements
|
||||
const filteredTracks = computed(() => {
|
||||
let result = [...tracks.value]
|
||||
|
||||
// Exclure les pistes déjà dans le panier
|
||||
result = result.filter(track => !cardStore.isInBucket(track.id))
|
||||
|
||||
// Appliquer les autres filtres
|
||||
if (selectedSuit.value) {
|
||||
result = result.filter(track => track.card?.suit === selectedSuit.value)
|
||||
}
|
||||
|
||||
if (selectedRank.value) {
|
||||
result = result.filter(track => track.card?.rank === selectedRank.value)
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(track => {
|
||||
const artistName = typeof track.artist === 'object' ? track.artist?.name : String(track.artist || '')
|
||||
return (
|
||||
track.title?.toLowerCase().includes(query) ||
|
||||
artistName.toLowerCase().includes(query) ||
|
||||
String(track.year || '').includes(query)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// Variables réactives pour les filtres
|
||||
const selectedSuit = ref('')
|
||||
const selectedRank = ref('')
|
||||
const searchQuery = ref('')
|
||||
|
||||
const isCardRevealed = (trackId: number) => {
|
||||
// Si une recherche est en cours, révéler automatiquement les cartes correspondantes
|
||||
if (searchQuery.value || (selectedRank.value && selectedSuit.value)) return true
|
||||
return cardStore.isCardRevealed(trackId)
|
||||
}
|
||||
|
||||
const closeDatBox = (event: MouseEvent) => {
|
||||
uiStore.closeBox()
|
||||
emit('click', event)
|
||||
}
|
||||
|
||||
const openCardSharer = () => {
|
||||
uiStore.openCardSharer()
|
||||
}
|
||||
|
||||
const onSuitChange = (suit: string) => {
|
||||
selectedSuit.value = suit
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
const onRankChange = (rank: string) => {
|
||||
selectedRank.value = rank
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
const onSearch = (query: string) => {
|
||||
searchQuery.value = query
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
// Applique tous les filtres (couleur, rang et recherche)
|
||||
// La computed property filteredTracks se mettra automatiquement à jour
|
||||
// car elle dépend des mêmes réactifs que cette fonction
|
||||
const applyFilters = () => {
|
||||
// Cette fonction ne fait plus que déclencher la réévaluation des dépendances
|
||||
// La computed property filteredTracks fera le reste
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.deck {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
42
appOLDD/components/ui/Draggable.vue
Normal file
42
appOLDD/components/ui/Draggable.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div draggable="true" @dragstart="onDragStart" @dragend="onDragEnd"
|
||||
:class="['draggable', { 'is-dragging': isDragging }]">
|
||||
<slot :is-dragging="isDragging" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
data: any
|
||||
type?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['dragStart', 'dragEnd'])
|
||||
|
||||
const isDragging = ref(false)
|
||||
|
||||
const onDragStart = (e: DragEvent) => {
|
||||
isDragging.value = true
|
||||
e.dataTransfer?.setData('application/json', JSON.stringify(props.data))
|
||||
emit('dragStart', props.data)
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
isDragging.value = false
|
||||
emit('dragEnd')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.draggable {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.draggable.is-dragging {
|
||||
opacity: 0.5;
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
||||
53
appOLDD/components/ui/Droppable.vue
Normal file
53
appOLDD/components/ui/Droppable.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div @dragover.prevent="onDragOver" @dragenter.prevent="onDragEnter" @dragleave="onDragLeave" @drop.prevent="onDrop"
|
||||
:class="['droppable', { 'is-drag-over': isDragOver }]">
|
||||
<slot :is-dragging-over="isDragOver" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
accept?: string
|
||||
onDrop: (data: any) => void
|
||||
}>()
|
||||
|
||||
const isDragOver = ref(false)
|
||||
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!isDragOver.value) isDragOver.value = true
|
||||
}
|
||||
|
||||
const onDragEnter = (e: DragEvent) => {
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
const onDragLeave = () => {
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
isDragOver.value = false
|
||||
const data = e.dataTransfer?.getData('application/json')
|
||||
if (data) {
|
||||
try {
|
||||
props.onDrop(JSON.parse(data))
|
||||
} catch (e) {
|
||||
console.error('Erreur lors du drop', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.droppable {
|
||||
min-height: 100px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.droppable.is-drag-over {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
border: 2px dashed #4CAF50;
|
||||
}
|
||||
</style>
|
||||
127
appOLDD/components/ui/ModalSharer.vue
Normal file
127
appOLDD/components/ui/ModalSharer.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
||||
@click.self="close">
|
||||
<div class="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-900">Partager cette carte</h2>
|
||||
<button @click="close" class="text-gray-400 hover:text-gray-500">
|
||||
<span class="sr-only">Fermer</span>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="currentTrack" class="space-y-4">
|
||||
<div class="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg">
|
||||
<img :src="currentTrack.coverId || '/card-dock.svg'" :alt="currentTrack.title"
|
||||
class="w-12 h-12 rounded-md object-cover">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ currentTrack.title }}</p>
|
||||
<p class="text-sm text-gray-500 truncate">{{ typeof currentTrack.artist === 'object' ?
|
||||
currentTrack.artist?.name : currentTrack.artist || 'Artiste inconnu' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="share-link" class="block text-sm font-medium text-gray-700">Lien de partage</label>
|
||||
<div class="flex rounded-md shadow-sm">
|
||||
<input type="text" id="share-link" readonly :value="shareLink"
|
||||
class="flex-1 min-w-0 block w-full px-3 py-2 rounded-l-md border border-gray-300 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
@focus="selectText">
|
||||
<button @click="copyToClipboard"
|
||||
class="inline-flex items-center px-3 py-2 border border-l-0 border-gray-300 bg-gray-50 text-gray-700 text-sm font-medium rounded-r-md hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-2">
|
||||
<button @click="close"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useUiStore } from '~/store/ui'
|
||||
import type { Track } from '~~/types'
|
||||
|
||||
const uiStore = useUiStore()
|
||||
const currentTrack = ref<Track | null>(null)
|
||||
|
||||
// Utilisation d'une ref locale pour éviter les réactivités inutiles
|
||||
const isOpen = ref(false)
|
||||
|
||||
// Mise à jour de l'état uniquement quand nécessaire
|
||||
watch(() => uiStore.showCardSharer, (newVal) => {
|
||||
isOpen.value = newVal
|
||||
})
|
||||
|
||||
const shareLink = computed(() => {
|
||||
if (!currentTrack.value) return ''
|
||||
return `${window.location.origin}/track/${currentTrack.value.id}`
|
||||
})
|
||||
|
||||
const open = (track: Track) => {
|
||||
currentTrack.value = track
|
||||
isOpen.value = true
|
||||
uiStore.openCardSharer()
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false
|
||||
uiStore.showCardSharer = false
|
||||
// Nettoyage différé pour permettre l'animation
|
||||
setTimeout(() => {
|
||||
if (!isOpen.value) {
|
||||
currentTrack.value = null
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareLink.value)
|
||||
// Vous pourriez ajouter un toast ou une notification ici
|
||||
console.log('Lien copié dans le presse-papier')
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la copie :', err)
|
||||
}
|
||||
}
|
||||
|
||||
const selectText = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
input.select()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
25
appOLDD/components/ui/SearchInput.vue
Normal file
25
appOLDD/components/ui/SearchInput.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<input v-model="searchQuery" type="text" placeholder="Rechercher..."
|
||||
class="px-4 py-2 pl-10 w-48 m-4 h-12 font-bold text-black bg-esyellow border border-none rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-estyellow-dark focus:border-estyellow-dark"
|
||||
@input="handleSearch">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-6 pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['search'])
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
const handleSearch = () => {
|
||||
emit('search', searchQuery.value.trim().toLowerCase())
|
||||
}
|
||||
</script>
|
||||
37
appOLDD/components/ui/SelectCardRank.vue
Normal file
37
appOLDD/components/ui/SelectCardRank.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<select v-model="selectedRank" @change="handleChange"
|
||||
class="px-4 py-2 m-4 font-bold h-12 border-none text-center bg-esyellow transition-colors border border-esyellow-dark rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-estyellow-dark focus:border-estyellow-dark cursor-pointer appearance-none">
|
||||
<option value="">rank</option>
|
||||
<option v-for="rank in ranks" :key="rank.value" :value="rank.value">
|
||||
{{ rank.label }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const ranks = [
|
||||
{ value: 'A', label: 'Ace' },
|
||||
{ value: '2', label: '2' },
|
||||
{ value: '3', label: '3' },
|
||||
{ value: '4', label: '4' },
|
||||
{ value: '5', label: '5' },
|
||||
{ value: '6', label: '6' },
|
||||
{ value: '7', label: '7' },
|
||||
{ value: '8', label: '8' },
|
||||
{ value: '9', label: '9' },
|
||||
{ value: '10', label: '10' },
|
||||
{ value: 'J', label: 'Jack' },
|
||||
{ value: 'Q', label: 'Queen' },
|
||||
{ value: 'K', label: 'King' }
|
||||
]
|
||||
|
||||
const selectedRank = ref('')
|
||||
|
||||
const handleChange = () => {
|
||||
emit('change', selectedRank.value)
|
||||
}
|
||||
</script>
|
||||
28
appOLDD/components/ui/SelectCardSuit.vue
Normal file
28
appOLDD/components/ui/SelectCardSuit.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<select v-model="selectedSuit" @change="handleChange"
|
||||
class="px-4 py-2 m-4 text-black font-bold h-12 border-none text-center hover:text-black bg-esyellow transition-colors border border-esyellow-dark rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-esyellow-dark focus:border-esyellow-dark cursor-pointer appearance-none">
|
||||
<option value="">♠♣♦♥</option>
|
||||
<option v-for="suit in suits" :key="suit.value" :value="suit.value">
|
||||
{{ suit.label }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const suits = [
|
||||
{ value: '♥', label: '♥' },
|
||||
{ value: '♦', label: '♦' },
|
||||
{ value: '♣', label: '♣' },
|
||||
{ value: '♠', label: '♠' }
|
||||
]
|
||||
|
||||
const selectedSuit = ref('')
|
||||
|
||||
const handleChange = () => {
|
||||
emit('change', selectedSuit.value)
|
||||
}
|
||||
</script>
|
||||
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>
|
||||
202
appOLDD/pages/card/[id].vue
Normal file
202
appOLDD/pages/card/[id].vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="card-page">
|
||||
<div v-if="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<Transition name="card-fade" mode="out-in">
|
||||
<div v-if="!loading && track" class="card-container" @click="playTrack">
|
||||
<Card :track="track" :is-face-up="true" class="card-item" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { usePlayerStore } from '~/store/player'
|
||||
import { useCardStore } from '~/store/card'
|
||||
import { useDataStore } from '~/store/data'
|
||||
import type { Track } from '~~/types'
|
||||
|
||||
const route = useRoute()
|
||||
const playerStore = usePlayerStore()
|
||||
const cardStore = useCardStore()
|
||||
const dataStore = useDataStore()
|
||||
|
||||
const track = ref<Track | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const hasUserInteracted = ref(false)
|
||||
const audioElement = ref<HTMLAudioElement | null>(null)
|
||||
|
||||
// Récupérer les données de la piste
|
||||
const fetchTrack = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
// S'assurer que les données sont chargées
|
||||
if (!dataStore.isLoaded) {
|
||||
await dataStore.loadData()
|
||||
}
|
||||
|
||||
// Récupérer la piste par son ID
|
||||
const trackId = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id || ''
|
||||
const foundTrack = dataStore.getTrackById(trackId)
|
||||
|
||||
if (foundTrack) {
|
||||
track.value = foundTrack
|
||||
// Marquer la carte comme révélée
|
||||
if (foundTrack.id) {
|
||||
cardStore.revealCard(Number(foundTrack.id))
|
||||
}
|
||||
} else {
|
||||
error.value = 'Carte non trouvée'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur lors du chargement de la piste:', err)
|
||||
error.value = 'Une erreur est survenue lors du chargement de la piste'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Gérer la première interaction utilisateur
|
||||
const handleFirstInteraction = () => {
|
||||
if (!hasUserInteracted.value) {
|
||||
hasUserInteracted.value = true
|
||||
if (track.value) {
|
||||
playerStore.playTrack(track.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Configurer les écouteurs d'événements pour la première interaction
|
||||
const setupInteractionListeners = () => {
|
||||
const events: (keyof WindowEventMap)[] = ['click', 'touchstart', 'keydown']
|
||||
const handleInteraction = () => {
|
||||
handleFirstInteraction()
|
||||
events.forEach(event => {
|
||||
window.removeEventListener(event, handleInteraction as EventListener)
|
||||
})
|
||||
}
|
||||
|
||||
events.forEach(event => {
|
||||
window.addEventListener(event, handleInteraction as EventListener, { once: true } as AddEventListenerOptions)
|
||||
})
|
||||
|
||||
return () => {
|
||||
events.forEach(event => {
|
||||
window.removeEventListener(event, handleInteraction as EventListener)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Lire la piste
|
||||
const playTrack = () => {
|
||||
if (track.value) {
|
||||
playerStore.playTrack(track.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Charger les données au montage du composant
|
||||
onMounted(async () => {
|
||||
await fetchTrack()
|
||||
|
||||
// Configurer les écouteurs d'événements pour la première interaction
|
||||
const cleanup = setupInteractionListeners()
|
||||
|
||||
// Nettoyer les écouteurs lors du démontage du composant
|
||||
onBeforeUnmount(() => {
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
border-top-color: #4299e1;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.card-container {
|
||||
perspective: 1000px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.card-container:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.card-item {
|
||||
transform-style: preserve-3d;
|
||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
animation: cardAppear 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(20px) rotateY(10deg);
|
||||
}
|
||||
|
||||
@keyframes cardAppear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) rotateY(10deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) rotateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #feb2b2;
|
||||
background-color: #742a2a;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Animation de transition */
|
||||
.card-fade-enter-active,
|
||||
.card-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.card-fade-enter-from,
|
||||
.card-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
3
appOLDD/pages/story/holo.vue
Normal file
3
appOLDD/pages/story/holo.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
holo
|
||||
</template>
|
||||
46
appOLDD/pages/story/index.vue
Normal file
46
appOLDD/pages/story/index.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<h1>Liste des pages Story</h1>
|
||||
<ul>
|
||||
<li v-for="page in pages" :key="page.name">
|
||||
<NuxtLink :to="`/story/${page.name}`">
|
||||
{{ page.name }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const pages = [
|
||||
{ name: 'holo' },
|
||||
{ name: 'mix' },
|
||||
{ name: 'test' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
60
appOLDD/pages/story/mix.vue
Normal file
60
appOLDD/pages/story/mix.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="mix">
|
||||
<Platine :track="track1" />
|
||||
<Platine :track="track2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useDataStore } from '~/store/data'
|
||||
|
||||
const dataStore = useDataStore()
|
||||
const track1 = ref(null)
|
||||
const track2 = ref(null)
|
||||
|
||||
// Configuration du layout
|
||||
definePageMeta({
|
||||
layout: 'empty'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await dataStore.loadData()
|
||||
track1.value = dataStore.getRandomPlaylistTrack()
|
||||
track2.value = dataStore.getRandomPlaylistTrack()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.mix {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Écran portrait (plus haut que large) */
|
||||
@media (orientation: portrait) {
|
||||
.mix {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.platine {
|
||||
height: 50vh;
|
||||
|
||||
.disc {
|
||||
height: 100%;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Écran paysage (plus large que haut) */
|
||||
@media (orientation: landscape) {
|
||||
.mix {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.platine {
|
||||
width: 50vw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
4
appOLDD/pages/story/test.vue
Normal file
4
appOLDD/pages/story/test.vue
Normal file
@@ -0,0 +1,4 @@
|
||||
<template>
|
||||
<card />
|
||||
<card />
|
||||
</template>
|
||||
382
appOLDD/platine-tools/disc.ts
Normal file
382
appOLDD/platine-tools/disc.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
const TAU = Math.PI * 2
|
||||
|
||||
const targetFPS = 60
|
||||
const RPS = 0.75
|
||||
const RPM = RPS * 60
|
||||
|
||||
const RADIANS_PER_MINUTE = RPM * TAU
|
||||
const RADIANS_PER_SECOND = RADIANS_PER_MINUTE / 60
|
||||
const RADIANS_PER_MILLISECOND = RADIANS_PER_SECOND * 0.001
|
||||
|
||||
const ROTATION_SPEED = (TAU * RPS) / targetFPS
|
||||
|
||||
type Vector = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type NumberArray = Array<number>
|
||||
|
||||
const average = (arr: NumberArray) => arr.reduce((memo, val) => memo + val, 0) / arr.length
|
||||
|
||||
// Limit array size by cutting off from the start
|
||||
const limit = (arr: NumberArray, maxLength = 10) => {
|
||||
const deleteCount = Math.max(0, arr.length - maxLength)
|
||||
|
||||
return arr.slice(deleteCount)
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max))
|
||||
|
||||
const distanceBetween = (vec1: Vector, vec2: Vector) => Math.hypot(vec2.x - vec1.x, vec2.y - vec1.y)
|
||||
|
||||
const getElementCenter = (el: HTMLElement): Vector => {
|
||||
const { left, top, width, height } = el.getBoundingClientRect()
|
||||
|
||||
const x = left + width / 2
|
||||
const y = top + height / 2
|
||||
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
const angleBetween = (vec1: Vector, vec2: Vector) => Math.atan2(vec2.y - vec1.y, vec2.x - vec1.x)
|
||||
|
||||
const angleDifference = (x: number, y: number) => Math.atan2(Math.sin(x - y), Math.cos(x - y))
|
||||
|
||||
type DiscProgress = {
|
||||
playbackSpeed: number
|
||||
isReversed: boolean
|
||||
secondsPlayed: number
|
||||
progress: number
|
||||
}
|
||||
class Disc {
|
||||
public el: HTMLElement
|
||||
|
||||
private _playbackSpeed = 1
|
||||
private _duration = 0
|
||||
private _isDragging = false
|
||||
private _isPoweredOn = false
|
||||
|
||||
private _center: Vector
|
||||
|
||||
private _currentAngle = 0
|
||||
private _previousAngle = 0
|
||||
private _maxAngle = TAU
|
||||
|
||||
public rafId: number | null = null
|
||||
|
||||
public previousTimestamp: number
|
||||
|
||||
private _draggingSpeeds: Array<number> = []
|
||||
private _draggingFrom: Vector = { x: 0, y: 0 }
|
||||
|
||||
// Propriétés pour l'inertie
|
||||
private _inertiaVelocity: number = 0
|
||||
private _isInertiaActive: boolean = false
|
||||
private _basePlaybackSpeed: number = 1 // Vitesse de lecture normale
|
||||
private _inertiaFriction: number = 0.93 // Coefficient de frottement pour l'inertie (plus proche de 1 = plus long)
|
||||
private _lastDragVelocity: number = 0 // Dernière vitesse de drag
|
||||
private _lastDragTime: number = 0 // Dernier temps de drag
|
||||
private _inertiaAmplification: number = 25 // Facteur d'amplification de l'inertie
|
||||
|
||||
public isReversed: boolean = false
|
||||
|
||||
public callbacks = {
|
||||
onDragStart: (): void => {},
|
||||
onDragEnded: (secondsPlayed: number): void => {},
|
||||
onStop: (): void => {},
|
||||
onLoop: (params: DiscProgress): void => {}
|
||||
}
|
||||
|
||||
constructor(el: HTMLElement) {
|
||||
this.el = el
|
||||
|
||||
this._center = getElementCenter(this.el)
|
||||
|
||||
this.previousTimestamp = performance.now()
|
||||
|
||||
this.onDragStart = this.onDragStart.bind(this)
|
||||
this.onDragProgress = this.onDragProgress.bind(this)
|
||||
this.onDragEnd = this.onDragEnd.bind(this)
|
||||
this.loop = this.loop.bind(this)
|
||||
|
||||
this.init()
|
||||
}
|
||||
|
||||
init() {
|
||||
// Ajout du style pour désactiver le comportement tactile par défaut
|
||||
this.el.style.touchAction = 'none'
|
||||
|
||||
// Écouteurs pour la souris et le tactile
|
||||
this.el.addEventListener('pointerdown', this.onDragStart)
|
||||
this.el.addEventListener(
|
||||
'touchstart',
|
||||
(e) => {
|
||||
// Empêcher le défilement de la page
|
||||
e.preventDefault()
|
||||
this.onDragStart(e)
|
||||
},
|
||||
{ passive: false }
|
||||
)
|
||||
}
|
||||
|
||||
get playbackSpeed() {
|
||||
return this._playbackSpeed
|
||||
}
|
||||
|
||||
set playbackSpeed(s) {
|
||||
this._draggingSpeeds.push(s)
|
||||
this._draggingSpeeds = limit(this._draggingSpeeds, 10)
|
||||
|
||||
this._playbackSpeed = average(this._draggingSpeeds)
|
||||
this._playbackSpeed = clamp(this._playbackSpeed, -4, 4)
|
||||
}
|
||||
|
||||
get secondsPlayed() {
|
||||
return this._currentAngle / TAU / RPS
|
||||
}
|
||||
|
||||
set isDragging(d) {
|
||||
this._isDragging = d
|
||||
this.el.classList.toggle('is-scratching', d)
|
||||
}
|
||||
|
||||
get isDragging() {
|
||||
return this._isDragging
|
||||
}
|
||||
|
||||
powerOn() {
|
||||
if (!this.rafId) {
|
||||
this.start()
|
||||
}
|
||||
this._isPoweredOn = true
|
||||
this._basePlaybackSpeed = 1
|
||||
this._playbackSpeed = 1
|
||||
}
|
||||
|
||||
powerOff() {
|
||||
this._isPoweredOn = false
|
||||
this._basePlaybackSpeed = 0
|
||||
}
|
||||
|
||||
public setDuration(duration: number) {
|
||||
this._duration = duration
|
||||
this._maxAngle = duration * RPS * TAU
|
||||
}
|
||||
|
||||
onDragStart(e: PointerEvent | TouchEvent) {
|
||||
// Empêcher le comportement par défaut pour éviter le défilement
|
||||
e.preventDefault()
|
||||
|
||||
// Appeler le callback onDragStart
|
||||
this.callbacks.onDragStart()
|
||||
|
||||
// Obtenir les coordonnées du toucher ou de la souris
|
||||
const getCoords = (event: PointerEvent | TouchEvent): { x: number; y: number } => {
|
||||
// Gestion des événements tactiles
|
||||
const touchEvent = event as TouchEvent
|
||||
if (touchEvent.touches?.[0]) {
|
||||
return {
|
||||
x: touchEvent.touches[0].clientX,
|
||||
y: touchEvent.touches[0].clientY
|
||||
}
|
||||
}
|
||||
|
||||
// Gestion des événements de souris
|
||||
const mouseEvent = event as PointerEvent
|
||||
return {
|
||||
x: mouseEvent.clientX ?? this._center.x,
|
||||
y: mouseEvent.clientY ?? this._center.y
|
||||
}
|
||||
}
|
||||
|
||||
const startCoords = getCoords(e)
|
||||
|
||||
const onMove = (moveEvent: Event) => {
|
||||
if (!(moveEvent instanceof PointerEvent) && !(moveEvent instanceof TouchEvent)) return
|
||||
|
||||
const coords = getCoords(moveEvent)
|
||||
|
||||
this.onDragProgress({
|
||||
clientX: coords.x,
|
||||
clientY: coords.y,
|
||||
preventDefault: () => moveEvent.preventDefault(),
|
||||
stopPropagation: () => moveEvent.stopPropagation()
|
||||
} as MouseEvent)
|
||||
}
|
||||
|
||||
const onEnd = () => {
|
||||
document.removeEventListener('pointermove', onMove)
|
||||
document.removeEventListener('touchmove', onMove)
|
||||
document.removeEventListener('pointerup', onEnd)
|
||||
document.removeEventListener('touchend', onEnd)
|
||||
this.onDragEnd()
|
||||
}
|
||||
|
||||
document.addEventListener('pointermove', onMove)
|
||||
document.addEventListener('touchmove', onMove, { passive: false })
|
||||
document.addEventListener('pointerup', onEnd)
|
||||
document.addEventListener('touchend', onEnd)
|
||||
|
||||
this._center = getElementCenter(this.el)
|
||||
this._draggingFrom = startCoords
|
||||
this.isDragging = true
|
||||
}
|
||||
|
||||
onDragProgress(e: {
|
||||
clientX: number
|
||||
clientY: number
|
||||
preventDefault: () => void
|
||||
stopPropagation: () => void
|
||||
}) {
|
||||
const currentTime = performance.now()
|
||||
const deltaTime = currentTime - this._lastDragTime
|
||||
|
||||
const pointerPosition: Vector = {
|
||||
x: e.clientX,
|
||||
y: e.clientY
|
||||
}
|
||||
|
||||
const anglePointerToCenter = angleBetween(this._center, pointerPosition)
|
||||
const angle_DraggingFromToCenter = angleBetween(this._center, this._draggingFrom)
|
||||
const angleDragged = angleDifference(angle_DraggingFromToCenter, anglePointerToCenter)
|
||||
|
||||
// Calcul de la vitesse de déplacement angulaire (radians par milliseconde)
|
||||
// On inverse le signe pour que le sens de l'inertie soit naturel
|
||||
if (deltaTime > 0) {
|
||||
this._lastDragVelocity = -angleDragged / deltaTime
|
||||
}
|
||||
|
||||
this._lastDragTime = currentTime
|
||||
this.setAngle(this._currentAngle - angleDragged)
|
||||
this._draggingFrom = { ...pointerPosition }
|
||||
}
|
||||
|
||||
onDragEnd() {
|
||||
document.body.removeEventListener('pointermove', this.onDragProgress)
|
||||
document.body.removeEventListener('pointerup', this.onDragEnd)
|
||||
|
||||
// Activer l'inertie avec la vitesse de drag actuelle
|
||||
this._isInertiaActive = true
|
||||
// Augmenter la sensibilité du drag avec le facteur d'amplification
|
||||
this._inertiaVelocity = this._lastDragVelocity * this._inertiaAmplification
|
||||
this.isDragging = false
|
||||
|
||||
// Toujours conserver la vitesse de base actuelle (1 si allumé, 0 si éteint)
|
||||
this._basePlaybackSpeed = this._isPoweredOn ? 1 : 0
|
||||
|
||||
// Si le lecteur est éteint, s'assurer que la vitesse de base est bien à 0
|
||||
if (!this._isPoweredOn) {
|
||||
this._basePlaybackSpeed = 0
|
||||
}
|
||||
|
||||
this.callbacks.onDragEnded(this.secondsPlayed)
|
||||
}
|
||||
|
||||
autoRotate(currentTimestamp: number) {
|
||||
const timestampElapsed = currentTimestamp - this.previousTimestamp
|
||||
|
||||
if (this._isInertiaActive) {
|
||||
// Appliquer l'inertie
|
||||
const inertiaRotation = this._inertiaVelocity * timestampElapsed
|
||||
this.setAngle(this._currentAngle + inertiaRotation)
|
||||
|
||||
// Si le lecteur est allumé, faire une transition fluide vers la vitesse de lecture
|
||||
if (this._isPoweredOn) {
|
||||
// Si on est proche de la vitesse de lecture normale, on désactive l'inertie
|
||||
if (
|
||||
Math.abs(this._inertiaVelocity - RADIANS_PER_MILLISECOND * this._basePlaybackSpeed) <
|
||||
0.0001
|
||||
) {
|
||||
this._isInertiaActive = false
|
||||
this._inertiaVelocity = RADIANS_PER_MILLISECOND * this._basePlaybackSpeed
|
||||
} else {
|
||||
// Réduire progressivement la vitesse d'inertie vers la vitesse de lecture
|
||||
this._inertiaVelocity +=
|
||||
(RADIANS_PER_MILLISECOND * this._basePlaybackSpeed - this._inertiaVelocity) * 0.1
|
||||
}
|
||||
} else {
|
||||
// Si le lecteur est éteint, appliquer un frottement normal
|
||||
this._inertiaVelocity *= this._inertiaFriction
|
||||
|
||||
// Si la vitesse est très faible, on arrête l'inertie
|
||||
if (Math.abs(this._inertiaVelocity) < 0.0001) {
|
||||
this._isInertiaActive = false
|
||||
this._inertiaVelocity = 0
|
||||
this._playbackSpeed = 0 // Mettre à jour la vitesse de lecture à 0 uniquement à la fin
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Rotation normale à la vitesse de lecture de base
|
||||
const baseRotation = RADIANS_PER_MILLISECOND * this._basePlaybackSpeed * timestampElapsed
|
||||
this.setAngle(this._currentAngle + baseRotation)
|
||||
}
|
||||
}
|
||||
|
||||
setAngle(angle: number) {
|
||||
this._currentAngle = clamp(angle, 0, this._maxAngle)
|
||||
|
||||
return this._currentAngle
|
||||
}
|
||||
|
||||
start() {
|
||||
this.previousTimestamp = performance.now()
|
||||
|
||||
this.loop()
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId)
|
||||
this.rafId = null
|
||||
}
|
||||
this.callbacks.onStop()
|
||||
}
|
||||
|
||||
rewind() {
|
||||
this.setAngle(0)
|
||||
}
|
||||
|
||||
loop() {
|
||||
const currentTimestamp = performance.now()
|
||||
|
||||
if (!this.isDragging) {
|
||||
if (this._isPoweredOn) {
|
||||
this.autoRotate(currentTimestamp)
|
||||
} else {
|
||||
// Mettre à jour le timestamp même quand le lecteur est éteint
|
||||
// pour éviter un saut lors de la reprise
|
||||
this.previousTimestamp = currentTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
const timestampDifferenceMS = currentTimestamp - this.previousTimestamp
|
||||
|
||||
const rotated = this._currentAngle - this._previousAngle
|
||||
const rotationNormal = RADIANS_PER_MILLISECOND * timestampDifferenceMS
|
||||
|
||||
this.playbackSpeed = rotated / rotationNormal || 0
|
||||
this.isReversed = this._currentAngle < this._previousAngle
|
||||
|
||||
this._previousAngle = this._currentAngle
|
||||
this.previousTimestamp = performance.now()
|
||||
|
||||
this.el.style.transform = `rotate(${this._currentAngle}rad)`
|
||||
|
||||
const { playbackSpeed, isReversed, secondsPlayed, _duration } = this
|
||||
const progress = secondsPlayed / _duration
|
||||
|
||||
this.callbacks.onLoop({
|
||||
playbackSpeed,
|
||||
isReversed,
|
||||
secondsPlayed,
|
||||
progress
|
||||
})
|
||||
|
||||
this._previousAngle = this._currentAngle
|
||||
|
||||
this.rafId = requestAnimationFrame(this.loop)
|
||||
}
|
||||
}
|
||||
|
||||
export default Disc
|
||||
95
appOLDD/platine-tools/sampler.ts
Normal file
95
appOLDD/platine-tools/sampler.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
class Sampler {
|
||||
public audioContext: AudioContext = new AudioContext()
|
||||
public gainNode: GainNode = new GainNode(this.audioContext)
|
||||
|
||||
public audioBuffer: AudioBuffer | null = null
|
||||
public audioBufferReversed: AudioBuffer | null = null
|
||||
public audioSource: AudioBufferSourceNode | null = null
|
||||
|
||||
public duration: number = 0
|
||||
public isReversed: boolean = false
|
||||
|
||||
constructor() {
|
||||
this.gainNode.connect(this.audioContext.destination)
|
||||
}
|
||||
|
||||
async getAudioBuffer(audioUrl: string) {
|
||||
const response = await fetch(audioUrl)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
|
||||
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer)
|
||||
|
||||
return audioBuffer
|
||||
}
|
||||
|
||||
async loadTrack(audioUrl: string) {
|
||||
this.audioBuffer = await this.getAudioBuffer(audioUrl)
|
||||
this.audioBufferReversed = this.getReversedAudioBuffer(this.audioBuffer)
|
||||
|
||||
this.duration = this.audioBuffer.duration
|
||||
}
|
||||
|
||||
getReversedAudioBuffer(audioBuffer: AudioBuffer) {
|
||||
const bufferArray = audioBuffer.getChannelData(0).slice().reverse()
|
||||
|
||||
const audioBufferReversed = this.audioContext.createBuffer(
|
||||
1,
|
||||
audioBuffer.length,
|
||||
audioBuffer.sampleRate
|
||||
)
|
||||
|
||||
audioBufferReversed.getChannelData(0).set(bufferArray)
|
||||
|
||||
return audioBufferReversed
|
||||
}
|
||||
|
||||
changeDirection(isReversed: boolean, secondsPlayed: number) {
|
||||
this.isReversed = isReversed
|
||||
this.play(secondsPlayed)
|
||||
}
|
||||
|
||||
play(offset = 0) {
|
||||
this.pause()
|
||||
|
||||
const buffer = this.isReversed ? this.audioBufferReversed : this.audioBuffer
|
||||
|
||||
const cueTime = this.isReversed ? this.duration - offset : offset
|
||||
|
||||
this.audioSource = this.audioContext.createBufferSource()
|
||||
this.audioSource.buffer = buffer
|
||||
this.audioSource.loop = false
|
||||
|
||||
this.audioSource.connect(this.gainNode)
|
||||
|
||||
this.audioSource.start(0, cueTime)
|
||||
}
|
||||
|
||||
updateSpeed(speed: number, isReversed: boolean, secondsPlayed: number) {
|
||||
if (!this.audioSource) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isReversed !== this.isReversed) {
|
||||
this.changeDirection(isReversed, secondsPlayed)
|
||||
}
|
||||
|
||||
const { currentTime } = this.audioContext
|
||||
const speedAbsolute = Math.abs(speed)
|
||||
|
||||
this.audioSource.playbackRate.cancelScheduledValues(currentTime)
|
||||
this.audioSource.playbackRate.linearRampToValueAtTime(
|
||||
Math.max(0.001, speedAbsolute),
|
||||
currentTime
|
||||
)
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (!this.audioSource) {
|
||||
return
|
||||
}
|
||||
|
||||
this.audioSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
export default Sampler
|
||||
52
appOLDD/plugins/body-class.ts
Normal file
52
appOLDD/plugins/body-class.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { defineNuxtPlugin } from '#app'
|
||||
import { useHead } from '#imports'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
// Fonction pour ajouter une classe au body
|
||||
const addBodyClass = (className: string) => {
|
||||
if (process.client) {
|
||||
document.body.classList.add(className)
|
||||
} else {
|
||||
// Pour le SSR, on utilise useHead
|
||||
useHead({
|
||||
bodyAttrs: {
|
||||
class: className
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fonction pour supprimer une classe du body
|
||||
const removeBodyClass = (className: string) => {
|
||||
if (process.client) {
|
||||
document.body.classList.remove(className)
|
||||
}
|
||||
// Pas besoin de gérer la suppression côté SSR
|
||||
}
|
||||
|
||||
// Fonction pour vérifier si une classe est présente
|
||||
const hasBodyClass = (className: string) => {
|
||||
if (process.client) {
|
||||
return document.body.classList.contains(className)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Exposition des méthodes via le plugin
|
||||
return {
|
||||
provide: {
|
||||
bodyClass: {
|
||||
add: addBodyClass,
|
||||
remove: removeBodyClass,
|
||||
has: hasBodyClass,
|
||||
toggle: (className: string) => {
|
||||
if (hasBodyClass(className)) {
|
||||
removeBodyClass(className)
|
||||
} else {
|
||||
addBodyClass(className)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useUiStore } from '~/store/ui'
|
||||
import { usePlayerStore } from '~/store/player'
|
||||
import { useCardStore } from '~/store/card'
|
||||
import { useDataStore } from '~/store/data'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
// Ne s'exécuter que côté client
|
||||
@@ -7,6 +9,8 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
|
||||
const ui = useUiStore()
|
||||
const player = usePlayerStore()
|
||||
const cardStore = useCardStore()
|
||||
const dataStore = useDataStore()
|
||||
|
||||
function isInputElement(target: EventTarget | null): boolean {
|
||||
return (
|
||||
@@ -41,7 +45,25 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
|
||||
switch (e.code) {
|
||||
// Gestion de la barre d'espace pour play/pause
|
||||
case 'Space':
|
||||
case 'KeyR': // R pour révéler toutes les cartes
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!isInputElement(e.target as HTMLElement) && ui.getSelectedBox) {
|
||||
const tracks = dataStore.getTracksByboxId(ui.getSelectedBox.id)
|
||||
cardStore.revealAllCards(tracks)
|
||||
}
|
||||
break
|
||||
|
||||
case 'KeyH': // H pour cacher toutes les cartes
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!isInputElement(e.target as HTMLElement) && ui.getSelectedBox) {
|
||||
const tracks = dataStore.getTracksByboxId(ui.getSelectedBox.id)
|
||||
cardStore.hideAllCards(tracks)
|
||||
}
|
||||
break
|
||||
|
||||
case 'Space': // Espace pour play/pause
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -61,15 +83,6 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
ui.closeBox()
|
||||
break
|
||||
|
||||
// Gestion de la touche Entrée pour ouvrir une boîte
|
||||
case 'Enter':
|
||||
if (document.activeElement?.id) {
|
||||
e.preventDefault()
|
||||
ui.selectBox(document.activeElement.id)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
break
|
||||
|
||||
// Gestion des touches fléchées (à implémenter si nécessaire)
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
171
appOLDD/store/card.ts
Normal file
171
appOLDD/store/card.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { Track } from '~~/types'
|
||||
|
||||
export const useCardStore = defineStore('card', {
|
||||
state: () => ({
|
||||
// Stocke les IDs des cartes déjà révélées
|
||||
revealedCards: new Set<number>(),
|
||||
// Stocke les pistes dans le panier
|
||||
bucket: [] as Track[],
|
||||
// Stocke l'état d'ouverture du panier
|
||||
isBucketOpen: false
|
||||
}),
|
||||
|
||||
actions: {
|
||||
// Mettre à jour l'ordre des pistes dans le panier
|
||||
updateBucketOrder(newOrder: Track[]) {
|
||||
this.bucket = [...newOrder]
|
||||
this.saveBucketToLocalStorage()
|
||||
},
|
||||
|
||||
// Marquer une carte comme révélée
|
||||
revealCard(trackId: number) {
|
||||
this.revealedCards.add(trackId)
|
||||
this.saveToLocalStorage()
|
||||
},
|
||||
|
||||
hideCard(trackId: number) {
|
||||
this.revealedCards.delete(trackId)
|
||||
this.saveToLocalStorage()
|
||||
},
|
||||
|
||||
flipCard(track: any) {
|
||||
if (this.isRevealed(track.id)) {
|
||||
this.hideCard(track.id)
|
||||
} else {
|
||||
this.revealCard(track.id)
|
||||
}
|
||||
},
|
||||
|
||||
// Basculer l'état de révélation de toutes les cartes
|
||||
revealAllCards(tracks: Track[]) {
|
||||
tracks.forEach((track) => {
|
||||
this.revealCard(track.id)
|
||||
})
|
||||
this.saveToLocalStorage()
|
||||
},
|
||||
|
||||
hideAllCards(tracks: Track[]) {
|
||||
tracks.forEach((track) => {
|
||||
this.hideCard(track.id)
|
||||
})
|
||||
this.saveToLocalStorage()
|
||||
},
|
||||
|
||||
// Sauvegarder l'état dans le localStorage
|
||||
saveToLocalStorage() {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
'cardStore',
|
||||
JSON.stringify({
|
||||
revealedCards: Array.from(this.revealedCards)
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('Failed to save card store to localStorage', e)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Charger l'état depuis le localStorage
|
||||
loadFromLocalStorage() {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const saved = localStorage.getItem('cardStore')
|
||||
if (saved) {
|
||||
const { revealedCards } = JSON.parse(saved)
|
||||
if (Array.isArray(revealedCards)) {
|
||||
this.revealedCards = new Set(
|
||||
revealedCards.filter((id): id is number => typeof id === 'number')
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load card store from localStorage', e)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Initialiser le store
|
||||
initialize() {
|
||||
this.loadFromLocalStorage()
|
||||
},
|
||||
|
||||
// Gestion du panier
|
||||
addToBucket(track: Track) {
|
||||
// Vérifie si la piste n'est pas déjà dans le panier
|
||||
if (!this.bucket.some((item) => item.id === track.id)) {
|
||||
this.bucket.push(track)
|
||||
this.saveBucketToLocalStorage()
|
||||
}
|
||||
},
|
||||
|
||||
removeFromBucket(trackId: number) {
|
||||
const index = this.bucket.findIndex((item) => item.id === trackId)
|
||||
if (index !== -1) {
|
||||
this.bucket.splice(index, 1)
|
||||
this.saveBucketToLocalStorage()
|
||||
}
|
||||
},
|
||||
|
||||
clearBucket() {
|
||||
this.bucket = []
|
||||
this.saveBucketToLocalStorage()
|
||||
},
|
||||
|
||||
toggleBucket() {
|
||||
this.isBucketOpen = !this.isBucketOpen
|
||||
},
|
||||
|
||||
// Sauvegarder le panier dans le localStorage
|
||||
saveBucketToLocalStorage() {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem('cardStoreBucket', JSON.stringify(this.bucket))
|
||||
} catch (e) {
|
||||
console.error('Failed to save bucket to localStorage', e)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Charger le panier depuis le localStorage
|
||||
loadBucketFromLocalStorage() {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const saved = localStorage.getItem('cardStoreBucket')
|
||||
if (saved) {
|
||||
const bucket = JSON.parse(saved)
|
||||
if (Array.isArray(bucket)) {
|
||||
this.bucket = bucket
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load bucket from localStorage', e)
|
||||
}
|
||||
}
|
||||
},
|
||||
// Vérifier si une carte est révélée
|
||||
isCardRevealed(trackId: number): boolean {
|
||||
return this.revealedCards.has(trackId)
|
||||
}
|
||||
},
|
||||
|
||||
getters: {
|
||||
// Getter pour la réactivité dans les templates
|
||||
isRevealed: (state) => (trackId: number) => {
|
||||
return state.revealedCards.has(trackId)
|
||||
},
|
||||
|
||||
// Getters pour le panier
|
||||
bucketCount: (state) => state.bucket.length,
|
||||
|
||||
isInBucket: (state) => (trackId: number) => {
|
||||
return state.bucket.some((track) => track.id === trackId)
|
||||
},
|
||||
|
||||
bucketTotalDuration: (state) => {
|
||||
return state.bucket.reduce((total, track) => total + ((track as any).duration || 0), 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -22,7 +22,10 @@ export const useDataStore = defineStore('data', {
|
||||
|
||||
// Mapper les tracks pour remplacer l'artist avec un objet Artist cohérent
|
||||
const artistMap = new Map(this.artists.map((a) => [a.id, a]))
|
||||
const allTracks = [...(compilationTracks ?? []), ...(playlistTracks ?? [])]
|
||||
const allTracks = [
|
||||
...(Array.isArray(compilationTracks) ? compilationTracks : []),
|
||||
...(Array.isArray(playlistTracks) ? playlistTracks : [])
|
||||
]
|
||||
|
||||
this.tracks = allTracks.map((track) => {
|
||||
const a = track.artist as unknown
|
||||
@@ -54,6 +57,12 @@ export const useDataStore = defineStore('data', {
|
||||
if (box) {
|
||||
box.activeSide = side
|
||||
}
|
||||
},
|
||||
|
||||
getRandomPlaylistTrack() {
|
||||
if (this.tracks.length === 0) return null
|
||||
const randomIndex = Math.floor(Math.random() * this.tracks.length)
|
||||
return this.tracks[randomIndex]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -135,6 +144,45 @@ export const useDataStore = defineStore('data', {
|
||||
const index = tracksInBox.findIndex((t) => t.id === track.id)
|
||||
return index > 0 ? tracksInBox[index - 1] : null
|
||||
}
|
||||
},
|
||||
getYearColor: () => (year: number) => {
|
||||
// Palette élargie avec des différences plus marquées
|
||||
const colorMap: Record<number, string> = {
|
||||
// Années récentes - teintes froides et claires
|
||||
2025: '#3a4a6c', // bleu-gris clair
|
||||
2024: '#1e3a7a', // bleu vif
|
||||
2023: '#1a4d5c', // bleu-vert émeraude
|
||||
2022: '#3a4a6a', // bleu-gris moyen
|
||||
|
||||
// Années 2020-2021 - transition
|
||||
2021: '#3a2e6a', // bleu-violet
|
||||
2020: '#2a467a', // bleu-gris chaud
|
||||
|
||||
// Années 2010-2019 - teintes moyennes
|
||||
2019: '#2a2a7a', // bleu nuit profond
|
||||
2018: '#1e2a8a', // bleu roi
|
||||
2017: '#1a5a6a', // bleu canard vif
|
||||
2016: '#1a5a4a', // vert bleuté
|
||||
2015: '#1a3a7a', // bleu marine
|
||||
2014: '#4a1e7a', // violet profond
|
||||
2013: '#1a5a4a', // vert émeraude
|
||||
2012: '#1e3a9a', // bleu ciel profond
|
||||
|
||||
// Années 2000-2011 - teintes chaudes et foncées
|
||||
2011: '#1e293b', // slate-800 de base
|
||||
2010: '#2a467a', // bleu-gris chaud
|
||||
2009: '#3a4a6a', // bleu-gris moyen
|
||||
2008: '#1a3a8a', // bleu nuit clair
|
||||
2007: '#5a2a4a', // bordeaux
|
||||
2006: '#5a1e6a', // violet profond
|
||||
2005: '#3a1a7a', // bleu-violet foncé
|
||||
2004: '#2a1a5a', // bleu nuit profond
|
||||
2003: '#3a3a5a', // bleu-gris foncé
|
||||
2002: '#1a5a4a', // vert foncé
|
||||
2001: '#5a3a2a', // marron chaud
|
||||
2000: '#3a3a5a' // bleu-gris foncé
|
||||
}
|
||||
return colorMap[year] || '#1e293b' // slate-800 par défaut
|
||||
}
|
||||
}
|
||||
})
|
||||
169
appOLDD/store/platine.ts
Normal file
169
appOLDD/store/platine.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { Track } from '~~/types'
|
||||
import Disc from '~/platine-tools/disc'
|
||||
import Sampler from '~/platine-tools/sampler'
|
||||
import { useCardStore } from '~/store/card'
|
||||
|
||||
export const usePlatineStore = defineStore('platine', () => {
|
||||
// State
|
||||
const currentTrack = ref<Track | null>(null)
|
||||
const isPlaying = ref(false)
|
||||
const isLoadingTrack = ref(false)
|
||||
const isFirstDrag = ref(true)
|
||||
const progressPercentage = ref(0)
|
||||
const currentTurns = ref(0)
|
||||
const totalTurns = ref(0)
|
||||
const isMuted = ref(false)
|
||||
|
||||
// Refs pour les instances
|
||||
const disc = ref<Disc | null>(null)
|
||||
const sampler = ref<Sampler | null>(null)
|
||||
const discRef = ref<HTMLElement>()
|
||||
|
||||
// Actions
|
||||
const initPlatine = (element: HTMLElement) => {
|
||||
discRef.value = element
|
||||
disc.value = new Disc(element)
|
||||
sampler.value = new Sampler()
|
||||
|
||||
// Configurer les callbacks du disque
|
||||
if (disc.value) {
|
||||
disc.value.callbacks.onStop = () => {
|
||||
sampler.value?.pause()
|
||||
}
|
||||
|
||||
disc.value.callbacks.onDragStart = () => {
|
||||
if (isFirstDrag.value) {
|
||||
isFirstDrag.value = false
|
||||
togglePlay()
|
||||
if (sampler.value && disc.value) {
|
||||
sampler.value.play(disc.value.secondsPlayed)
|
||||
disc.value.powerOn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disc.value.callbacks.onDragEnded = () => {
|
||||
if (!isPlaying.value) return
|
||||
sampler.value?.play(disc.value?.secondsPlayed || 0)
|
||||
}
|
||||
|
||||
disc.value.callbacks.onLoop = ({ playbackSpeed, isReversed, secondsPlayed }) => {
|
||||
sampler.value?.updateSpeed(playbackSpeed, isReversed, secondsPlayed)
|
||||
updateTurns()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateTurns = () => {
|
||||
if (!disc.value) return
|
||||
|
||||
currentTurns.value = disc.value.secondsPlayed * 0.75
|
||||
totalTurns.value = (disc.value as any)._duration * 0.75
|
||||
progressPercentage.value = Math.min(
|
||||
100,
|
||||
(disc.value.secondsPlayed / (disc.value as any)._duration) * 100
|
||||
)
|
||||
}
|
||||
|
||||
const loadTrack = async (track: Track) => {
|
||||
const cardStore = useCardStore()
|
||||
if (!sampler.value || !track) return
|
||||
|
||||
currentTrack.value = track
|
||||
isLoadingTrack.value = true
|
||||
|
||||
try {
|
||||
await sampler.value.loadTrack(track.filePath)
|
||||
if (disc.value) {
|
||||
disc.value.setDuration(sampler.value.duration)
|
||||
updateTurns()
|
||||
play()
|
||||
}
|
||||
} finally {
|
||||
isLoadingTrack.value = false
|
||||
cardStore.revealCard(track.id)
|
||||
}
|
||||
}
|
||||
|
||||
const play = (position = 0) => {
|
||||
if (!disc.value || !sampler.value || !currentTrack.value) return
|
||||
|
||||
isPlaying.value = true
|
||||
sampler.value.play(position)
|
||||
disc.value.powerOn()
|
||||
}
|
||||
|
||||
const pause = () => {
|
||||
if (!disc.value || !sampler.value) return
|
||||
|
||||
isPlaying.value = false
|
||||
sampler.value.pause()
|
||||
disc.value.powerOff()
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
if (isPlaying.value) {
|
||||
pause()
|
||||
} else {
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
if (!sampler.value) return
|
||||
|
||||
isMuted.value = !isMuted.value
|
||||
if (isMuted.value) {
|
||||
sampler.value.mute()
|
||||
} else {
|
||||
sampler.value.unmute()
|
||||
}
|
||||
}
|
||||
|
||||
const seek = (position: number) => {
|
||||
if (!disc.value) return
|
||||
|
||||
disc.value.secondsPlayed = position
|
||||
if (sampler.value) {
|
||||
sampler.value.play(position)
|
||||
}
|
||||
}
|
||||
|
||||
// Nettoyage
|
||||
const cleanup = () => {
|
||||
if (disc.value) {
|
||||
disc.value.stop()
|
||||
disc.value.powerOff()
|
||||
}
|
||||
if (sampler.value) {
|
||||
sampler.value.pause()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
currentTrack,
|
||||
isPlaying,
|
||||
isLoadingTrack,
|
||||
progressPercentage,
|
||||
currentTurns,
|
||||
totalTurns,
|
||||
isMuted,
|
||||
|
||||
// Getters
|
||||
coverUrl: computed(() => currentTrack.value?.coverId || '/card-dock.svg'),
|
||||
|
||||
// Actions
|
||||
initPlatine,
|
||||
loadTrack,
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
toggleMute,
|
||||
seek,
|
||||
cleanup
|
||||
}
|
||||
})
|
||||
|
||||
export default usePlatineStore
|
||||
279
appOLDD/store/player.ts
Normal file
279
appOLDD/store/player.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
// ~/store/player.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import type { Track, Box } from '~/../types/types'
|
||||
import { useDataStore } from '~/store/data'
|
||||
import { useCardStore } from '~/store/card'
|
||||
import { usePlatineStore } from '~/store/platine'
|
||||
|
||||
export const usePlayerStore = defineStore('player', {
|
||||
state: () => ({
|
||||
currentTrack: null as Track | null,
|
||||
position: 0,
|
||||
progressionLast: 0,
|
||||
isLoading: false,
|
||||
history: [] as string[]
|
||||
}),
|
||||
|
||||
actions: {
|
||||
attachAudio() {
|
||||
const platineStore = usePlatineStore()
|
||||
|
||||
// Écouter les changements de piste dans le platineStore
|
||||
watch(
|
||||
() => platineStore.currentTrack,
|
||||
(newTrack) => {
|
||||
if (newTrack) {
|
||||
this.currentTrack = newTrack
|
||||
// Révéler la carte quand la lecture commence
|
||||
const cardStore = useCardStore()
|
||||
if (!cardStore.isCardRevealed(newTrack.id)) {
|
||||
requestAnimationFrame(() => {
|
||||
cardStore.revealCard(newTrack.id)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
this.currentTrack = null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Écouter les changements d'état de lecture
|
||||
watch(
|
||||
() => platineStore.isPlaying,
|
||||
(isPlaying) => {
|
||||
if (isPlaying) {
|
||||
// Gérer la logique de lecture suivante quand la lecture se termine
|
||||
if (platineStore.currentTrack?.type === 'playlist') {
|
||||
const dataStore = useDataStore()
|
||||
const nextTrack = dataStore.getNextPlaylistTrack(platineStore.currentTrack)
|
||||
if (nextTrack) {
|
||||
platineStore.loadTrack(nextTrack)
|
||||
platineStore.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
async playBox(box: Box) {
|
||||
const platineStore = usePlatineStore()
|
||||
|
||||
// Si c'est la même box, on toggle simplement la lecture
|
||||
if (this.currentTrack?.boxId === box.id && this.currentTrack?.side === box.activeSide) {
|
||||
platineStore.togglePlay()
|
||||
return
|
||||
}
|
||||
|
||||
// Sinon, on charge la première piste de la box
|
||||
try {
|
||||
const dataStore = useDataStore()
|
||||
const firstTrack = dataStore.getFirstTrackOfBox(box)
|
||||
if (firstTrack) {
|
||||
this.currentTrack = firstTrack
|
||||
await platineStore.loadTrack(firstTrack)
|
||||
await platineStore.play()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error playing box:', error)
|
||||
}
|
||||
},
|
||||
|
||||
async playTrack(track: Track) {
|
||||
const platineStore = usePlatineStore()
|
||||
// Si c'est la même piste, on toggle simplement la lecture
|
||||
if (this.currentTrack?.id === track.id) {
|
||||
platineStore.togglePlay()
|
||||
return
|
||||
}
|
||||
|
||||
// Sinon, on charge et on lit la piste
|
||||
this.currentTrack = track
|
||||
await platineStore.loadTrack(track)
|
||||
platineStore.play()
|
||||
},
|
||||
|
||||
async playCompilationTrack(track: Track) {
|
||||
const platineStore = usePlatineStore()
|
||||
|
||||
// Si c'est la même piste, on toggle simplement la lecture
|
||||
if (this.currentTrack?.id === track.id) {
|
||||
platineStore.togglePlay()
|
||||
return
|
||||
}
|
||||
|
||||
// Pour les compilations, on charge la piste avec le point de départ
|
||||
this.currentTrack = track
|
||||
await platineStore.loadTrack(track)
|
||||
|
||||
// Si c'est une compilation, on définit la position de départ
|
||||
if (track.type === 'compilation' && track.start !== undefined) {
|
||||
platineStore.seek(track.start)
|
||||
}
|
||||
|
||||
platineStore.play()
|
||||
},
|
||||
|
||||
async playPlaylistTrack(track: Track) {
|
||||
const platineStore = usePlatineStore()
|
||||
|
||||
// Toggle simple si c'est la même piste
|
||||
if (this.currentTrack?.id === track.id) {
|
||||
platineStore.togglePlay()
|
||||
return
|
||||
}
|
||||
|
||||
// Sinon, on charge et on lit la piste
|
||||
this.currentTrack = track
|
||||
await platineStore.loadTrack(track)
|
||||
platineStore.play()
|
||||
},
|
||||
|
||||
async loadTrack(track: Track) {
|
||||
const platineStore = usePlatineStore()
|
||||
await platineStore.loadTrack(track)
|
||||
},
|
||||
|
||||
async loadAndPlayTrack(track: Track) {
|
||||
const platineStore = usePlatineStore()
|
||||
|
||||
try {
|
||||
this.isLoading = true
|
||||
|
||||
// Charger la piste
|
||||
await platineStore.loadTrack(track)
|
||||
|
||||
// Pour les compilations, on définit la position de départ
|
||||
if (track.type === 'compilation' && track.start !== undefined) {
|
||||
platineStore.seek(track.start)
|
||||
}
|
||||
|
||||
// Lancer la lecture
|
||||
await platineStore.play()
|
||||
this.history.push(track.id.toString()) // S'assurer que l'ID est une chaîne
|
||||
this.isLoading = false
|
||||
} catch (error) {
|
||||
console.error('Error loading/playing track:', error)
|
||||
this.isLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
togglePlay() {
|
||||
const platineStore = usePlatineStore()
|
||||
platineStore.togglePlay()
|
||||
},
|
||||
|
||||
updateTime() {
|
||||
const platineStore = usePlatineStore()
|
||||
|
||||
// Mettre à jour la position actuelle
|
||||
if (platineStore.currentTrack) {
|
||||
this.position = platineStore.currentTurns / 0.75 // Convertir les tours en secondes
|
||||
|
||||
// Calculer et mettre en cache la progression
|
||||
const duration = platineStore.totalTurns / 0.75 // Durée totale en secondes
|
||||
const progression = (this.position / duration) * 100
|
||||
if (!isNaN(progression) && isFinite(progression)) {
|
||||
this.progressionLast = progression
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// update current track when changing time in compilation
|
||||
async updateCurrentTrack() {
|
||||
const platineStore = usePlatineStore()
|
||||
const currentTrack = this.currentTrack
|
||||
if (currentTrack && currentTrack.type === 'compilation') {
|
||||
const dataStore = useDataStore()
|
||||
const tracks = dataStore
|
||||
.getTracksByboxId(currentTrack.boxId, currentTrack.side)
|
||||
.slice()
|
||||
.filter((t) => t.type === 'compilation')
|
||||
.sort((a, b) => (a.start ?? 0) - (b.start ?? 0))
|
||||
|
||||
if (tracks.length > 0) {
|
||||
const now = platineStore.currentTurns / 0.75
|
||||
// find the last track whose start <= now (fallback to first track)
|
||||
let nextTrack = tracks[0]
|
||||
for (const t of tracks) {
|
||||
const s = t.start ?? 0
|
||||
if (s <= now) {
|
||||
nextTrack = t
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (nextTrack && nextTrack.id !== currentTrack.id) {
|
||||
// only update metadata reference; do not reload audio
|
||||
this.currentTrack = nextTrack
|
||||
|
||||
// Révéler la carte avec une animation fluide
|
||||
const cardStore = useCardStore()
|
||||
if (nextTrack.id && !cardStore.isCardRevealed(nextTrack.id)) {
|
||||
// Utiliser requestAnimationFrame pour une meilleure synchronisation avec le rendu
|
||||
requestAnimationFrame(() => {
|
||||
cardStore.revealCard(nextTrack.id)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getters: {
|
||||
isCurrentBox: (state) => {
|
||||
return (box: Box) => {
|
||||
if (box.type === 'compilation') {
|
||||
return box.id === state.currentTrack?.boxId && box.activeSide === state.currentTrack?.side
|
||||
} else {
|
||||
return box.id === state.currentTrack?.boxId
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
isCurrentSide: (state) => {
|
||||
return (side: string) => side === state.currentTrack?.side
|
||||
},
|
||||
|
||||
isPlaylistTrack: () => {
|
||||
return (track: Track) => {
|
||||
return track.type === 'playlist'
|
||||
}
|
||||
},
|
||||
|
||||
isCompilationTrack: () => {
|
||||
return (track: Track) => {
|
||||
return track.type === 'compilation'
|
||||
}
|
||||
},
|
||||
|
||||
isPaused() {
|
||||
const platineStore = usePlatineStore()
|
||||
return !platineStore.isPlaying
|
||||
},
|
||||
|
||||
getCurrentTrack: (state) => state.currentTrack,
|
||||
|
||||
getCurrentBox: (state) => {
|
||||
return state.currentTrack ? state.currentTrack.boxId : null
|
||||
},
|
||||
|
||||
getCurrentProgression() {
|
||||
const platineStore = usePlatineStore()
|
||||
if (!platineStore.currentTrack) return 0
|
||||
|
||||
// Calculer la progression en fonction des tours actuels et totaux
|
||||
if (platineStore.totalTurns > 0) {
|
||||
return (platineStore.currentTurns / platineStore.totalTurns) * 100
|
||||
}
|
||||
return 0
|
||||
},
|
||||
|
||||
getCurrentCoverUrl(state) {
|
||||
const id = state.currentTrack?.coverId
|
||||
if (!id) return null
|
||||
return id.startsWith('http') ? id : `https://f4.bcbits.com/img/${id}_4.jpg`
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -6,7 +6,8 @@ export const useUiStore = defineStore('ui', {
|
||||
state: () => ({
|
||||
// UI-only state can live here later
|
||||
showSearch: false,
|
||||
searchQuery: ''
|
||||
searchQuery: '',
|
||||
showCardSharer: false
|
||||
}),
|
||||
|
||||
actions: {
|
||||
@@ -46,8 +47,10 @@ export const useUiStore = defineStore('ui', {
|
||||
dataStore.boxes.forEach((box) => {
|
||||
box.state = 'box-list'
|
||||
})
|
||||
// Scroll back to the unselected box in the list
|
||||
if (selectedBox) this.scrollToBox(selectedBox)
|
||||
},
|
||||
|
||||
openCardSharer() {
|
||||
this.showCardSharer = true
|
||||
},
|
||||
|
||||
scrollToBox(box: Box) {
|
||||
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
|
||||
working_dir: /app
|
||||
ports:
|
||||
- "${PORT}:${PORT_EXPOSED}"
|
||||
- '${PORT}:${PORT_EXPOSED}'
|
||||
volumes:
|
||||
- $MEDIA_DIR:/app/mnt/media
|
||||
- evilspins:/app/data
|
||||
environment:
|
||||
VIRTUAL_HOST: "${DOMAIN}"
|
||||
LETSENCRYPT_HOST: "${DOMAIN}"
|
||||
PUID: "${PUID}"
|
||||
PGID: "${PGID}"
|
||||
VIRTUAL_HOST: '${DOMAIN}'
|
||||
LETSENCRYPT_HOST: '${DOMAIN}'
|
||||
PUID: '${PUID}'
|
||||
PGID: '${PGID}'
|
||||
|
||||
volumes:
|
||||
evilspins:
|
||||
|
||||
networks:
|
||||
default:
|
||||
|
||||
11
drizzle.config.ts
Normal file
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'
|
||||
|
||||
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',
|
||||
devtools: { enabled: true },
|
||||
modules: ['@nuxt/eslint', '@nuxtjs/tailwindcss', '@pinia/nuxt'],
|
||||
typescript: {
|
||||
tsConfig: {
|
||||
include: ['types/**/*.ts']
|
||||
}
|
||||
},
|
||||
vite: {
|
||||
plugins: [tsconfigPaths()]
|
||||
},
|
||||
@@ -19,6 +36,7 @@ export default defineNuxtConfig({
|
||||
{ rel: 'apple-touch-icon', sizes: '180x180', href: '/favicon/apple-touch-icon.png' },
|
||||
{ rel: 'manifest', href: '/favicon/site.webmanifest' }
|
||||
],
|
||||
viewport: 'width=device-width, initial-scale=1.0, maximum-scale=1.0',
|
||||
script: isProd
|
||||
? [
|
||||
{
|
||||
|
||||
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",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
@@ -11,14 +12,19 @@
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"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": {
|
||||
"@libsql/client": "^0.17.0",
|
||||
"@nuxt/eslint": "1.9.0",
|
||||
"@nuxtjs/tailwindcss": "6.14.0",
|
||||
"@pinia/nuxt": "0.11.2",
|
||||
"atropos": "^2.0.2",
|
||||
"nuxt": "^4.2.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"nuxt": "^4.3.0",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.5.1",
|
||||
@@ -27,7 +33,7 @@
|
||||
"engines": {
|
||||
"pnpm": ">=10 <11"
|
||||
},
|
||||
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748",
|
||||
"packageManager": "pnpm@10.28.0",
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.1",
|
||||
"@eslint/js": "^9.39.1",
|
||||
@@ -35,7 +41,9 @@
|
||||
"@nuxtjs/eslint-config-typescript": "^12.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^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-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-vue": "9.3.0",
|
||||
@@ -43,6 +51,8 @@
|
||||
"globals": "^16.5.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"sass-embedded": "^1.93.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.7.3",
|
||||
"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/card-dock.svg
Normal file
1
public/card-dock.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="224" xmlns="http://www.w3.org/2000/svg" height="320" id="screenshot-0fc9bf1a-468d-8062-8007-12c4a2492f68" viewBox="0 0 224 320" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-0fc9bf1a-468d-8062-8007-12c4a2492f68"><defs><clipPath id="frame-clip-0fc9bf1a-468d-8062-8007-12c4a2492f68-render-1" class="frame-clip frame-clip-def"><rect rx="0" ry="0" x="0" y="0" width="224" height="320" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"/></clipPath></defs><g class="frame-container-wrapper"><g class="frame-container-blur"><g class="frame-container-shadows"><g clip-path="url(#frame-clip-0fc9bf1a-468d-8062-8007-12c4a2492f68-render-1)" fill="none"><g class="fills" id="fills-0fc9bf1a-468d-8062-8007-12c4a2492f68"><rect width="224" height="320" class="frame-background" x="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(255, 255, 255); fill-opacity: 1;" ry="0" rx="0" y="0"/></g><g class="frame-children"><g id="shape-0fc9bf1a-468d-8062-8007-12c9d568a6f9"><g class="fills" id="fills-0fc9bf1a-468d-8062-8007-12c9d568a6f9"><rect rx="20" ry="20" x="0" y="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="224" height="320" style="fill: rgb(0, 0, 0); fill-opacity: 0.15;"/></g></g><g id="shape-0fc9bf1a-468d-8062-8007-12c8693c73a0"><g class="fills" id="fills-0fc9bf1a-468d-8062-8007-12c8693c73a0"><ellipse cx="112" cy="160.00000000000003" rx="144" ry="144" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(177, 178, 181); fill-opacity: 0.2;"/></g></g><g id="shape-0fc9bf1a-468d-8062-8007-12c85a924e31"><g class="fills" id="fills-0fc9bf1a-468d-8062-8007-12c85a924e31"><ellipse cx="112" cy="160.00000000000003" rx="112.5" ry="112.50000000000004" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(177, 178, 181); fill-opacity: 0.2;"/></g></g><g id="shape-0fc9bf1a-468d-8062-8007-12c7cdce5e0a"><g class="fills" id="fills-0fc9bf1a-468d-8062-8007-12c7cdce5e0a"><ellipse cx="112" cy="160" rx="82" ry="82.00000000000003" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(177, 178, 181); fill-opacity: 0.2;"/></g></g><g id="shape-0fc9bf1a-468d-8062-8007-12c820876e0e"><g class="fills" id="fills-0fc9bf1a-468d-8062-8007-12c820876e0e"><ellipse cx="112" cy="160" rx="51" ry="51" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" style="fill: rgb(177, 178, 181); fill-opacity: 0.2;"/></g></g></g></g></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
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,71 +0,0 @@
|
||||
import { eventHandler } from 'h3'
|
||||
import type { Box } from '~~/types/types'
|
||||
|
||||
const boxes: Box[] = [
|
||||
{
|
||||
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'
|
||||
}
|
||||
]
|
||||
|
||||
export default eventHandler(() => {
|
||||
return boxes.map((box) => ({
|
||||
...box,
|
||||
state: 'box-hidden' as const
|
||||
}))
|
||||
})
|
||||
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,75 +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 basePath = path.join(process.cwd(), '/mnt/media/files/music')
|
||||
|
||||
try {
|
||||
let allTracks: any[] = []
|
||||
|
||||
const dirPath = basePath
|
||||
const urlPrefix = `https://files.erudi.fr/music`
|
||||
|
||||
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}/${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'
|
||||
|
||||
export default eventHandler(() => {
|
||||
@@ -448,8 +482,105 @@ export default eventHandler(() => {
|
||||
return tracks.map((track, index) => ({
|
||||
id: index + 1,
|
||||
...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`,
|
||||
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)
|
||||
}
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user