Compare commits

68 Commits

Author SHA1 Message Date
valere
b8cc3d277d clean CI
All checks were successful
Deploy App / build (push) Successful in 1m57s
Deploy App / deploy (push) Successful in 31s
2026-02-03 15:08:43 +01:00
valere
19d19edb1c deploy 1
All checks were successful
Deploy App / build (push) Successful in 2m24s
Deploy App / deploy (push) Successful in 24s
2026-02-02 21:34:07 +01:00
valere
98e6213fa1 sub domain deploy for branches 22
All checks were successful
Deploy App / build (push) Successful in 18s
Deploy App / deploy (push) Successful in 19s
2026-02-02 21:21:00 +01:00
valere
8e78511738 sub domain deploy for branches 21
All checks were successful
Deploy App / build (push) Successful in 21s
Deploy App / deploy (push) Successful in 28s
2026-02-02 21:14:24 +01:00
valere
dbd992854b sub domain deploy for branches 20
All checks were successful
Deploy App / build (push) Successful in 11s
Deploy App / deploy (push) Successful in 12s
2026-02-02 21:13:21 +01:00
valere
b9a3d0184f yeah
All checks were successful
Deploy App / build (push) Successful in 10s
Deploy App / deploy (push) Successful in 11s
2026-02-02 21:00:28 +01:00
valere
e257e076c4 sub domain deploy for branches 19
All checks were successful
Deploy App / build (push) Successful in 10s
Deploy App / deploy (push) Successful in 10s
2026-02-02 20:58:48 +01:00
valere
57df3dbd5b sub domain deploy for branches 18
All checks were successful
Deploy App / build (push) Successful in 10s
Deploy App / deploy (push) Successful in 10s
2026-02-02 20:57:48 +01:00
valere
4ff3da8380 sub domain deploy for branches 17 2026-02-02 20:57:28 +01:00
valere
c5e5f1abf5 sub domain deploy for branches 16
All checks were successful
Deploy App / build (push) Successful in 1m37s
Deploy App / deploy (push) Successful in 23s
2026-02-02 20:53:25 +01:00
valere
a0fce1a8d3 sub domain deploy for branches 1(
Some checks failed
Deploy App / build (push) Failing after 10s
Deploy App / deploy (push) Has been skipped
2026-02-02 20:50:38 +01:00
valere
0cbdfdeff1 sub domain deploy for branches 14
Some checks failed
Deploy App / build (push) Failing after 26s
Deploy App / deploy (push) Has been skipped
2026-02-02 20:42:49 +01:00
valere
a48cd4049d sub domain deploy for branches 13
Some checks failed
Deploy App / build (push) Waiting to run
Deploy App / deploy (push) Has been cancelled
2026-02-02 20:39:09 +01:00
valere
0b709ff0dc sub domain deploy for branches 12
All checks were successful
Deploy App / build (push) Successful in 11s
Deploy App / deploy (push) Successful in 10s
2026-02-02 20:37:23 +01:00
valere
75507452ac sub domain deploy for branches 11
All checks were successful
Deploy App / build (push) Successful in 11s
Deploy App / deploy (push) Successful in 10s
2026-02-02 20:31:49 +01:00
valere
8337eb9e4c sub domain deploy for branches 10
Some checks failed
Deploy App / build (push) Failing after 3m14s
Deploy App / deploy (push) Has been skipped
2026-02-02 20:25:18 +01:00
valere
1310210ac9 sub domain deploy for branches 9
Some checks failed
Deploy App / build (push) Failing after 1m8s
Deploy App / deploy (push) Has been skipped
2026-02-02 20:03:11 +01:00
valere
deeff36440 sub domain deploy for branches 8
Some checks failed
Deploy App / build (push) Failing after 53s
Deploy App / deploy (push) Has been skipped
2026-02-02 20:01:26 +01:00
valere
f896a8a828 sub domain deploy for branches 7
All checks were successful
Deploy App / build (push) Successful in 16s
Deploy App / deploy (push) Successful in 13s
2026-02-02 19:58:24 +01:00
valere
b769eac9cc sub domain deploy for branches 6
All checks were successful
Deploy App / build (push) Successful in 29s
Deploy App / deploy (push) Successful in 14s
2026-02-02 19:53:39 +01:00
valere
64eb4d09b9 sub domain deploy for branches 5
Some checks failed
Deploy App / build (push) Waiting to run
Deploy App / deploy (push) Has been cancelled
2026-02-02 19:38:31 +01:00
valere
0a587b5e69 sub domain deploy for branches 4
Some checks failed
Deploy App / build (push) Failing after 13s
Deploy App / deploy (push) Has been skipped
2026-02-02 19:37:36 +01:00
valere
7a9f4d369c sub domain deploy for branches 3
All checks were successful
Deploy App / build (push) Successful in 34s
Deploy App / deploy (push) Successful in 27s
2026-02-02 15:34:59 +01:00
valere
d40ca3b1d1 sub domain deploy for branches 2
All checks were successful
Deploy App / build (push) Successful in 14s
Deploy App / deploy (push) Successful in 11s
2026-02-02 12:00:57 +01:00
valere
8573165e4b sub domain deploy for branches
All checks were successful
Deploy App / build (push) Successful in 1m46s
Deploy App / deploy (push) Successful in 35s
2026-02-02 11:41:26 +01:00
valere
c586cc3932 PLATINE blur bobine
All checks were successful
Deploy App / build (push) Successful in 1m53s
Deploy App / deploy (push) Successful in 15s
2026-01-04 23:01:26 +01:00
valere
11694d36dd CI copy server files
All checks were successful
Deploy App / build (push) Successful in 51s
Deploy App / deploy (push) Successful in 14s
2026-01-04 10:51:27 +01:00
valere
3b05938162 CI install sqlite3
All checks were successful
Deploy App / build (push) Successful in 2m36s
Deploy App / deploy (push) Successful in 17s
2026-01-04 10:34:10 +01:00
valere
f75a1481bd platine mobile size
All checks were successful
Deploy App / build (push) Successful in 20s
Deploy App / deploy (push) Successful in 14s
2026-01-02 22:34:11 +01:00
valere
bb791e35d1 platine transition
All checks were successful
Deploy App / build (push) Successful in 2m4s
Deploy App / deploy (push) Successful in 16s
2026-01-01 20:50:37 +01:00
valere
a5fe876e3f bucket cards management
All checks were successful
Deploy App / build (push) Successful in 3m57s
Deploy App / deploy (push) Successful in 17s
2025-12-31 17:23:11 +01:00
valere
9001025837 SQLITE 3 2025-12-31 16:31:53 +01:00
valere
afb20fe75f bucket + card sharer
All checks were successful
Deploy App / build (push) Successful in 1m57s
Deploy App / deploy (push) Successful in 16s
2025-12-26 19:27:33 +01:00
valere
d8fe645e5c unified gost cards (mouse + touch)
All checks were successful
Deploy App / build (push) Successful in 31s
Deploy App / deploy (push) Successful in 15s
2025-12-24 12:25:58 +01:00
valere
ad938abf79 draggable / touchable card v0.1
All checks were successful
Deploy App / build (push) Successful in 1m53s
Deploy App / deploy (push) Successful in 18s
2025-12-24 06:00:15 +01:00
valere
1f4f7868ca drop old code
All checks were successful
Deploy App / build (push) Successful in 1m0s
Deploy App / deploy (push) Successful in 14s
2025-12-23 12:56:42 +01:00
valere
2c826e29ea compose elt v0.1
Some checks failed
Deploy App / build (push) Has been cancelled
Deploy App / deploy (push) Has been cancelled
2025-12-23 12:55:37 +01:00
valere
8efafc4642 Bucket v0.1
All checks were successful
Deploy App / build (push) Successful in 2m1s
Deploy App / deploy (push) Successful in 16s
2025-12-22 09:56:27 +01:00
valere
ecc1c22475 raccourciclavier [R]eveal [H]ide 2025-12-22 09:52:16 +01:00
valere
5948b4efbd clean type 2025-12-22 09:46:38 +01:00
valere
55cae0b9c6 toogle cards 2025-12-21 20:20:20 +01:00
valere
c0d79591c3 PLATINE drag to play & random tracks
All checks were successful
Deploy App / build (push) Successful in 1m59s
Deploy App / deploy (push) Successful in 17s
2025-12-19 11:41:47 +01:00
valere
1c4cbfe21c platine as component
All checks were successful
Deploy App / build (push) Successful in 53s
Deploy App / deploy (push) Successful in 15s
2025-12-18 19:59:23 +01:00
valere
2be5724c9f platine / bobine progression
All checks were successful
Deploy App / build (push) Successful in 51s
Deploy App / deploy (push) Successful in 14s
2025-12-17 22:15:40 +01:00
valere
dc2cba500c set route /mix & /draggable
All checks were successful
Deploy App / build (push) Successful in 3m13s
Deploy App / deploy (push) Successful in 18s
2025-12-17 19:34:25 +01:00
valere
65aaa71a3d FEAT: playlist filters
All checks were successful
Deploy App / build (push) Successful in 3m34s
Deploy App / deploy (push) Successful in 19s
2025-12-08 23:48:21 +01:00
valere
6176995032 Platine etape 1 2025-12-07 19:44:21 +01:00
valere
9f70419ea5 DEBUG: title/artist/id selectable
All checks were successful
Deploy App / build (push) Successful in 47s
Deploy App / deploy (push) Successful in 15s
2025-11-27 09:13:44 +01:00
valere
a79f044096 add debug mode
All checks were successful
Deploy App / build (push) Successful in 1m4s
Deploy App / deploy (push) Successful in 19s
2025-11-27 08:52:17 +01:00
valere
27697ca797 update cover card url & artist/title fonts
All checks were successful
Deploy App / build (push) Successful in 2m10s
Deploy App / deploy (push) Successful in 15s
2025-11-26 20:21:00 +01:00
valere
ba34ecece0 playlist is a yellow box 2025-11-26 16:19:21 +01:00
valere
b2b3b69561 update CI for docker-web 25.11 & hide player history
All checks were successful
Deploy App / build (push) Successful in 3m9s
Deploy App / deploy (push) Successful in 19s
2025-11-23 20:55:06 +01:00
valere
90cbc0be18 imporve cards animations
Some checks failed
Deploy App / build (push) Failing after 25s
Deploy App / deploy (push) Has been skipped
2025-11-23 20:42:49 +01:00
valere
1b8b998622 FEAT: side A/B
All checks were successful
Deploy App / build (push) Successful in 14s
Deploy App / deploy (push) Successful in 9s
2025-11-15 21:56:37 +01:00
valere
3424d2d6fc update ci
All checks were successful
Deploy App / build (push) Successful in 22s
Deploy App / deploy (push) Successful in 15s
2025-11-12 20:54:44 +01:00
valere
f9aeb03f82 WIP: add TODO and small fixies
All checks were successful
Deploy App / build (push) Successful in 1m31s
Deploy App / deploy (push) Successful in 15s
2025-11-11 19:23:33 +01:00
valere
10311256ea PNPM fix for pnpm dev
All checks were successful
Deploy App / build (push) Successful in 1m39s
Deploy App / deploy (push) Successful in 15s
2025-11-09 12:16:08 +01:00
valere
e577c3b116 again
All checks were successful
Deploy App / build (push) Successful in 1m12s
Deploy App / deploy (push) Successful in 17s
2025-11-07 22:08:41 +01:00
valere
2174f7794a rm eslint for ci
All checks were successful
Deploy App / build (push) Successful in 47s
Deploy App / deploy (push) Successful in 22s
2025-11-07 22:06:14 +01:00
valere
6bc047fa5e Generate package-lock.json for Docker build
All checks were successful
Deploy App / build (push) Successful in 26s
Deploy App / deploy (push) Successful in 22s
2025-11-07 21:10:05 +01:00
valere
282e892d1c try fix C1
All checks were successful
Deploy App / build (push) Successful in 22s
Deploy App / deploy (push) Successful in 21s
2025-11-07 20:46:34 +01:00
valere
f187390038 CI pnpm -> npm 2
All checks were successful
Deploy App / build (push) Successful in 21s
Deploy App / deploy (push) Successful in 22s
2025-11-07 20:12:13 +01:00
valere
8bfa7f856f CI pnpm -> npm
All checks were successful
Deploy App / build (push) Successful in 23s
Deploy App / deploy (push) Successful in 21s
2025-11-07 20:10:09 +01:00
valere
4014335150 WINDSURF rules
All checks were successful
Deploy App / build (push) Successful in 1m7s
Deploy App / deploy (push) Successful in 45s
2025-11-07 11:42:21 +01:00
valere
34d22b3b17 evilSpins v1
All checks were successful
Deploy App / build (push) Successful in 43s
Deploy App / deploy (push) Successful in 41s
2025-11-04 22:41:41 +01:00
valere
deb15b3ea1 update docker-web apps path in deploy.yml
All checks were successful
Deploy App / build (push) Successful in 18s
Deploy App / deploy (push) Successful in 15s
2025-10-30 15:51:46 +01:00
valere
9771c799f2 add default layout
All checks were successful
Deploy App / build (push) Successful in 2m20s
Deploy App / deploy (push) Successful in 14s
2025-10-29 19:37:37 +01:00
valere
25d56ec4ef refacto card / box / deck ajout du template default 2025-10-28 10:44:10 +01:00
142 changed files with 11836 additions and 5146 deletions

13
.env
View File

@@ -1,3 +1,10 @@
DOMAIN="evilspins.com"
PORT="7901"
PORT_EXPOSED="3000"
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

View File

@@ -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
View 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
View 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
View 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
View File

@@ -5,6 +5,7 @@
.nitro
.cache
dist
drizzle
# Node dependencies
node_modules

View File

@@ -3,5 +3,7 @@
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
"eslint.useFlatConfig": true,
"svg.preview.background": "editor"
}

49
.windsurf/rules/global.md Normal file
View File

@@ -0,0 +1,49 @@
---
trigger: always_on
---
You have extensive expertise in Vue 3, Nuxt 4, TypeScript, Node.js, Vite, Vue Router, Pinia, VueUse, Nuxt UI, and Tailwind CSS. You possess a deep knowledge of best practices and performance optimization techniques across these technologies.
Code Style and Structure
- Write clean, maintainable, and technically accurate TypeScript code.
- Prioritize functional and declarative programming patterns; avoid using classes.
- Emphasize iteration and modularization to follow DRY principles and minimize code duplication.
- Prefer Composition API <script setup> style.
- Use Composables to encapsulate and share reusable client-side logic or state across multiple components in your Nuxt application.
Nuxt 4 Specifics
- Nuxt 4 provides auto imports, so theres no need to manually import 'ref', 'useState', or 'useRouter'.
- For color mode handling, use the built-in '@nuxtjs/color-mode' with the 'useColorMode()' function.
- Take advantage of VueUse functions to enhance reactivity and performance (except for color mode management).
- Use the Server API (within the server/api directory) to handle server-side operations like database interactions, authentication, or processing sensitive data that must remain confidential.
- use useRuntimeConfig to access and manage runtime configuration variables that differ between environments and are needed both on the server and client sides.
- For SEO use useHead and useSeoMeta.
- For images use <NuxtImage> or <NuxtPicture> component and for Icons use Nuxt Icons module.
- use app.config.ts for app theme configuration.
Fetching Data
1. Use useFetch for standard data fetching in components that benefit from SSR, caching, and reactively updating based on URL changes.
2. Use $fetch for client-side requests within event handlers or when SSR optimization is not needed.
3. Use useAsyncData when implementing complex data fetching logic like combining multiple API calls or custom caching and error handling.
4. Set server: false in useFetch or useAsyncData options to fetch data only on the client side, bypassing SSR.
5. Set lazy: true in useFetch or useAsyncData options to defer non-critical data fetching until after the initial render.
Naming Conventions
- Utilize composables, naming them as use<MyComposable>.
- Use **PascalCase** for component file names (e.g., components/MyComponent.vue).
- Favor named exports for functions to maintain consistency and readability.
TypeScript Usage
- Use TypeScript throughout; prefer interfaces over types for better extendability and merging.
- Avoid enums, opting for maps for improved type safety and flexibility.
- Use functional components with TypeScript interfaces.
UI and Styling
- Use Nuxt UI and Tailwind CSS for components and styling.
- Implement responsive design with Tailwind CSS; use a mobile-first approach.

View File

@@ -1,22 +1,20 @@
# Stage de build
FROM node:20-alpine AS build
# Builder
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install --legacy-peer-deps
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
RUN npm run build
# Stage production
FROM node:20-alpine
# Runtime
FROM node:20 AS runner
RUN apt-get update && apt-get install -y sqlite3 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app/.output .output
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=builder /app/.output ./.output
COPY package*.json ./
COPY ./data ./data
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

View File

@@ -1,84 +1,8 @@
<template>
<div>
<div class="min-h-screen bg-gray-100">
<NuxtRouteAnnouncer />
<NuxtLayout>
<NuxtPage />
<SearchModal />
<Loader />
<Player />
</NuxtLayout>
</div>
</template>
<script setup>
import SearchModal from '~/components/SearchModal.vue'
import Player from '~/components/player.vue'
import Loader from '~/components/Loader.vue'
import { useUiStore } from '~/store/ui'
import { usePlayerStore } from '~/store/player'
import { watch, computed } from 'vue'
const ui = useUiStore()
const player = usePlayerStore()
const { $isMobile } = useNuxtApp()
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 (process.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
}
button {
@apply bg-esyellow text-slate-700;
}
input[type="email"] {
@apply bg-slate-900 text-esyellow;
}
</style>

201
app/components/Card.vue Normal file
View File

@@ -0,0 +1,201 @@
<template>
<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">
<div class="suit text-7xl absolute"
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.card?.suit]">
<img :src="`/${props.card?.suit}.svg`" />
</div>
<div class="rank text-white font-bold absolute -mt-1">
{{ props.card?.rank }}
</div>
</div>
<!-- Cover -->
<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="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="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 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>
</div>
</article>
</template>
<script setup lang="ts">
import type { Card } from '~~/types/types'
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">
.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;
.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-card {
@apply z-50 scale-110;
outline: none;
.face-up {
@apply shadow-2xl;
transition:
box-shadow 0.6s,
transform 0.6s;
}
}
&:focus,
&.current-card {
.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-28 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;
}
}
}
</style>

View File

@@ -0,0 +1,29 @@
<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">
const props = withDefaults(defineProps<{
isLoading?: boolean;
isPlaying?: boolean;
}>(), {
isLoading: false,
isPlaying: false
})
</script>
<style>
.loading,
.play-button-changed {
opacity: 1 !important;
}
</style>

View File

@@ -1,71 +0,0 @@
<template>
<div class="flex flex-col-reverse mt-16" :class="!!playerStore.currentTrack ? 'mb-36' : 'mb-16'">
<box v-for="(box, i) in dataStore.boxes.slice()" :key="box.id" :tabindex="dataStore.boxes.length - i" :box="box"
@click="onBoxClick(box)" class="text-center" :class="box.state" :id="box.id">
<button @click.stop="playSelectedBox(box)" v-if="box.state === 'box-selected'"
class="relative z-40 rounded-full size-24 bottom-1/2 text-4xl tex-bold text-esyellow backdrop-blur-sm bg-black/25">
{{ !playerStore.isPaused && playerStore.currentTrack?.boxId === box.id ? 'I I' : '▶' }}
</button>
<deck :box="box" class="box-page" v-if="box.state === 'box-selected'" @click.stop />
</box>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import type { Box } from '~~/types/types'
import { useDataStore } from '~/store/data'
import { usePlayerStore } from '~/store/player'
import { useUiStore } from '~/store/ui'
const dataStore = useDataStore()
const playerStore = usePlayerStore()
const uiStore = useUiStore()
function openBox(id: string) {
uiStore.selectBox(id)
// Scroll to the top smoothly
window.scrollTo({
top: 0,
behavior: 'smooth'
});
}
function onBoxClick(b: Box) {
if (b.state !== 'box-selected') {
openBox(b.id)
}
}
function playSelectedBox(b: Box) {
playerStore.playBox(b)
}
function KeyboardAction(e: KeyboardEvent) {
switch (e.key) {
case 'Escape':
uiStore.closeBox()
break;
case 'ArrowUp':
break;
case 'Enter':
if (document.activeElement?.id) {
openBox(document.activeElement.id)
}
break;
case 'ArrowDown':
break;
case 'ArrowLeft':
break;
case 'ArrowRight':
break;
default:
break;
}
}
onMounted(async () => {
window.addEventListener('keydown', KeyboardAction)
})
</script>

View File

@@ -1,121 +0,0 @@
<template>
<article @click="() => playerStore.playTrack(props.track).catch(err => console.error(err))"
class="card flip-card isplaying w-56 h-80" :class="isFaceUp ? 'face-up' : 'face-down'">
<div class="flip-inner">
<!-- Face-Up -->
<main
class="flip-front backdrop-blur-sm border-1 -mt-12 z-10 card w-56 h-80 p-3 bg-opacity-40 hover:bg-opacity-80 hover:shadow-xl transition-all bg-white 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="suit text-7xl absolute"
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
<img :src="`/${props.track.card?.suit}.svg`" />
</div>
<div class="rank text-white font-bold absolute -mt-1">
{{ props.track.card?.rank }}
</div>
</div>
<!-- Cover -->
<figure class="flex-1 overflow-hidden rounded-t-xl cursor-pointer">
<img :src="coverUrl" alt="Pochette de l'album" 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 }}
</h2>
<p class="text-sm text-neutral-500 truncate">
<template v-if="isPlaylistTrack">
{{ props.track.artist.name }}
</template>
</p>
</div>
</main>
<!-- Face-Down -->
<footer
class="flip-back backdrop-blur-sm -mt-12 z-10 card w-56 h-80 p-3 bg-opacity-10 bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden">
<div class="h-full flex p-16 text-center bg-slate-800 rounded-xl">
<img src="/favicon.svg" />
<div class="label label--id" v-if="isOrder">
{{ props.track.order }}
</div>
</div>
</footer>
</div>
</article>
</template>
<script setup lang="ts">
import type { Track } from '~~/types/types'
import { usePlayerStore } from '~/store/player'
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 = props.track.coverId.startsWith('http')
? props.track.coverId
: `https://f4.bcbits.com/img/${props.track.coverId}_4.jpg`;
</script>
<style lang="scss">
.label {
@apply rounded-full size-7 p-2 bg-esyellow leading-3 -mt-6;
font-weight: bold;
text-align: center;
}
/* Flip effect */
.flip-card {
perspective: 1000px;
.flip-inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.6s;
transform-style: preserve-3d;
.face-down & {
transform: rotateY(180deg);
}
.face-up & {
transform: rotateY(0deg);
}
}
.flip-front,
.flip-back {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
will-change: transform;
}
.flip-front {
transform: rotateY(0deg);
}
.flip-back {
transform: rotateY(180deg);
}
.,
.,
.,
. {
@apply text-5xl size-14;
}
}
</style>

View File

@@ -1,52 +0,0 @@
<template>
<div>
<div class="z-50 tools fixed top-0 -left-0 hidden">
<button @click="setDisplay('pile')">pile</button>
<button @click="setDisplay('plateau')">plateau</button>
<button @click="setDisplay('holdem')">holdem</button>
</div>
<div ref="deck" class="deck flex flex-wrap justify-center gap-4">
<card v-for="(track, i) in tracks" :key="track.id" :track="track" tabindex="i" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useDataStore } from '~/store/data'
import type { Box } from '~~/types/types'
const props = defineProps<{
box: Box
}>()
const dataStore = useDataStore()
const deck = ref()
const tracks = computed(() => dataStore.getTracksByboxId(props.box.id))
function setDisplay(displayMode) {
deck.value.classList.remove('pile', 'plateau', 'holdem')
deck.value.classList.add(displayMode)
}
</script>
<style lang="scss">
.deck {
@apply transition-all;
&.pile {
@apply relative;
.card {
@apply absolute top-0;
}
}
&.plateau {
@apply mt-8 p-8 w-full flex flex-wrap justify-around;
}
&.holdem {
/* style holdem */
}
}
</style>

View File

@@ -1,8 +0,0 @@
<template>
<header class="py-4">
<img class="logo w-80" src="/logo.svg" alt="">
<h1 class="text-center">
mix-tapes
</h1>
</header>
</template>

View File

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

View File

@@ -1,16 +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>

3
app/layouts/default.vue Normal file
View File

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

43
app/pages/card/[slug].vue Normal file
View 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>

View File

@@ -1,28 +1,5 @@
<template>
<div class="w-full flex flex-col items-center">
<div @click="uiStore.closeBox()" class="cursor-pointer">
<logo />
</div>
<main>
<boxes />
</main>
<div>
here is the New New front
</div>
</template>
<script setup>
import { useUiStore } from '~/store/ui'
import { useDataStore } from '~/store/data'
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>

View File

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

View File

@@ -1,76 +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>

View File

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

View File

@@ -1,6 +0,0 @@
import { useFavoritesStore } from '~/store/favorites'
export default defineNuxtPlugin(() => {
const fav = useFavoritesStore()
fav.load()
})

View File

@@ -1,17 +0,0 @@
import { useUiStore } from '~/store/ui'
export default defineNuxtPlugin((nuxtApp) => {
const ui = useUiStore()
const isMobile = nuxtApp.$isMobile as boolean | undefined
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && (e.key === 'f' || e.key === 'F')) {
if (isMobile) return
e.preventDefault()
if (!ui.showSearch) ui.openSearch()
}
}
if (process.client) {
window.addEventListener('keydown', onKeyDown)
}
})

View File

@@ -1,52 +0,0 @@
import { defineStore } from 'pinia'
import type { Track } from '~/../types/types'
export const FAVORITES_BOX_ID = 'FAV'
const STORAGE_KEY = 'evilspins:favorites:v1'
export const useFavoritesStore = defineStore('favorites', {
state: () => ({
trackIds: [] as number[]
}),
actions: {
load() {
if (!process.client) return
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const arr = JSON.parse(raw)
if (Array.isArray(arr)) this.trackIds = arr.filter((x) => typeof x === 'number')
}
} catch {}
},
save() {
if (!process.client) return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.trackIds))
} catch {}
},
toggle(track: Track) {
const id = track.id
const idx = this.trackIds.indexOf(id)
if (idx >= 0) this.trackIds.splice(idx, 1)
else this.trackIds.unshift(id)
this.save()
},
add(track: Track) {
if (!this.trackIds.includes(track.id)) {
this.trackIds.unshift(track.id)
this.save()
}
},
remove(trackId: number) {
const idx = this.trackIds.indexOf(trackId)
if (idx >= 0) {
this.trackIds.splice(idx, 1)
this.save()
}
},
isFavorite(trackId: number) {
return this.trackIds.includes(trackId)
}
}
})

View File

@@ -1,200 +0,0 @@
// ~/store/player.ts
import { defineStore } from 'pinia'
import type { Track, Box } from '~/../types/types'
import { useDataStore } from '~/store/data'
export const usePlayerStore = defineStore('player', {
state: () => ({
currentTrack: null as Track | null,
position: 0,
audio: null as HTMLAudioElement | null,
isPaused: true,
progressionLast: 0
}),
actions: {
attachAudio(el: HTMLAudioElement) {
this.audio = el
// attach listeners if not already attached (idempotent enough for our use)
this.audio.addEventListener('play', () => {
this.isPaused = false
})
this.audio.addEventListener('playing', () => {
this.isPaused = false
})
this.audio.addEventListener('pause', () => {
this.isPaused = true
})
this.audio.addEventListener('ended', () => {
this.isPaused = true
const track = this.currentTrack
if (!track) return
const dataStore = useDataStore()
if (track.type === 'playlist') {
const next = dataStore.getNextPlaylistTrack(track)
if (next && next.boxId === track.boxId) {
this.playTrack(next)
}
} else {
console.log('ended')
this.currentTrack = null
}
})
},
async playBox(box: Box) {
if (this.currentTrack?.boxId === box.id) {
this.togglePlay()
} else {
const dataStore = useDataStore()
const first = dataStore.getFirstTrackOfBox(box)
if (first) {
await this.playTrack(first)
}
}
},
async playTrack(track: Track) {
// mettre à jour la piste courante uniquement après avoir géré le toggle
if (this.currentTrack && this.currentTrack?.id === track.id) {
this.togglePlay()
} else {
this.currentTrack = track
if (!this.audio) {
// fallback: create an audio element and attach listeners
this.attachAudio(new Audio())
}
// Interrompre toute lecture en cours avant de charger une nouvelle source
const audio = this.audio as HTMLAudioElement
try {
audio.pause()
} catch (_) {}
// on entre en phase de chargement
this.isPaused = true
audio.src = track.url
audio.load()
// lancer la lecture (seek si nécessaire une fois les metadata chargées)
try {
const wantedStart = track.start ?? 0
// Attendre que les metadata soient prêtes pour pouvoir positionner currentTime
await new Promise<void>((resolve) => {
if (audio.readyState >= 1) return resolve()
const onLoaded = () => resolve()
audio.addEventListener('loadedmetadata', onLoaded, { once: true })
})
// Appliquer le temps de départ
audio.currentTime = wantedStart > 0 ? wantedStart : 0
await new Promise<void>((resolve) => {
const onCanPlay = () => {
if (wantedStart > 0 && audio.currentTime < wantedStart - 0.05) {
audio.currentTime = wantedStart
}
resolve()
}
if (audio.readyState >= 3) return resolve()
audio.addEventListener('canplay', onCanPlay, { once: true })
})
this.isPaused = false
await audio.play()
} catch (err: any) {
// Ignorer les AbortError (arrivent lorsqu'une nouvelle source est chargée rapidement)
if (err && err.name === 'AbortError') return
this.isPaused = true
console.error('Impossible de lire la piste :', err)
}
}
},
togglePlay() {
if (!this.audio) return
if (this.audio.paused) {
this.isPaused = false
this.audio
.play()
.then(() => {
this.isPaused = false
})
.catch((err) => console.error(err))
} else {
this.audio.pause()
this.isPaused = true
}
},
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 cur = this.currentTrack
if (cur && cur.type === 'compilation') {
const dataStore = useDataStore()
const tracks = dataStore
.getTracksByboxId(cur.boxId)
.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 found = tracks[0]
for (const t of tracks) {
const s = t.start ?? 0
if (s <= now) {
found = t
} else {
break
}
}
if (found && found.id !== cur.id) {
// only update metadata reference; do not reload audio
this.currentTrack = found
}
}
}
}
},
getters: {
isCurrentBox: (state) => {
return (boxId: string) => boxId === state.currentTrack?.boxId
},
isPlaylistTrack: () => {
return (track: Track) => {
return track.type === 'playlist'
}
},
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
View 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
View 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>

View File

@@ -2,35 +2,35 @@
<article class="box box-scene z-10" ref="scene">
<div ref="domBox" class="box-object" :class="{ 'is-draggable': isDraggable }">
<div class="face front relative" ref="frontFace">
<img v-if="box.duration" class="cover absolute" :src="`/${box.id}/cover.jpg`" alt="">
<div class="size-full flex flex-col justify-center items-center text-7xl" v-html="box.description" v-else />
<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-col flex-wrap items-start p-4 overflow-hidden" ref="backFace">
<li class="list-none text-xxs w-1/2 flex flex-row"
v-for="track in dataStore.getTracksByboxId(box.id).slice(0, -1)" :key="track.id" :track="track">
<span class="" v-if="isNotManifesto">
{{ track.order }}.
</span>
<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"
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">
<i class="text-slate-950">
{{ track.title }}
</i> <br /> {{ track.artist.name }}
</i>
<br />
{{ track.artist.name }}
</p>
</li>
</li> -->
</div>
<div class="face right" ref="rightFace" />
<div class="face left" ref="leftFace" />
<div class="face top" ref="topFace">
<template v-if="box.duration !== 0">
<img class="logo h-full p-1" src="/logo.svg" alt="">
<img class="absolute block h-1/2" style="left:5%;" :src="`/${box.id}/title.svg`" alt="">
<template v-if="isCompilation">
<img class="logo h-full p-3" src="/logo.svg" alt="" />
<img class="absolute block h-10" style="left: 5%" :src="`/${box.id}/${box.activeSide}/title.svg`" alt="" />
</template>
<template v-else>
<span class="absolute block h-1/2 right-6">
playlist
</span>
<img class="logo h-full p-1" src="/favicon.svg" alt="">
<span class="absolute block h-1/2" style="left:5%;">
<template v-if="box.type === 'playlist'">
<span class="absolute block h-1/2 right-6 text-black"> </span>
<span class="absolute block h-1/2 text-black" style="left: 5%">
{{ box.name }}
</span>
</template>
@@ -43,18 +43,21 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
import type { Box, BoxState } from '~~/types/types'
import type { Box } from '~~/types'
import { useDataStore } from '~/store/data'
const props = defineProps<{
interface Props {
box: Box
}>();
}
const props = withDefaults(defineProps<Props>(), {})
const { $isMobile } = useNuxtApp()
const dataStore = useDataStore()
const isDraggable = computed(() => !['box-list', 'box-hidden'].includes(props.box.state))
const isNotManifesto = computed(() => !props.box.id.startsWith('ES00'))
const isCompilation = computed(() => props.box.type === 'compilation')
// --- Réfs ---
const scene = ref<HTMLElement>()
@@ -117,7 +120,8 @@ function applyBoxState() {
// --- Couleurs ---
function applyColor() {
if (!frontFace.value || !backFace.value || !leftFace.value || !topFace.value || !bottomFace.value) return
if (!frontFace.value || !backFace.value || !leftFace.value || !topFace.value || !bottomFace.value)
return
frontFace.value.style.background = props.box.color2
backFace.value.style.background = `linear-gradient(to top, ${props.box.color1}, ${props.box.color2})`
@@ -127,6 +131,18 @@ function applyColor() {
bottomFace.value.style.background = props.box.color1
}
// --- Rotation complète ---
function rotateBox() {
if (!domBox.value) return
rotateX.value = -20
rotateY.value = rotateY.value === 20 ? 380 : 20
applyTransform(0.8)
}
// --- Inertie ---
function tickInertia() {
if (!enableInertia) return
@@ -156,7 +172,10 @@ const down = (ev: PointerEvent) => {
domBox.value?.setPointerCapture(ev.pointerId)
lastPointer = { x: ev.clientX, y: ev.clientY, time: performance.now() }
velocity = { x: 0, y: 0 }
if (raf) { cancelAnimationFrame(raf); raf = null }
if (raf) {
cancelAnimationFrame(raf)
raf = null
}
}
const move = (ev: PointerEvent) => {
@@ -181,7 +200,9 @@ const move = (ev: PointerEvent) => {
const end = (ev: PointerEvent) => {
if (!dragging) return
dragging = false
try { domBox.value?.releasePointerCapture(ev.pointerId) } catch { }
try {
domBox.value?.releasePointerCapture(ev.pointerId)
} catch { }
if (enableInertia && (Math.abs(velocity.x) > minVelocity || Math.abs(velocity.y) > minVelocity)) {
if (!raf) raf = requestAnimationFrame(tickInertia)
}
@@ -219,29 +240,45 @@ onBeforeUnmount(() => {
})
// --- Watchers ---
watch(() => props.box.state, () => applyBoxState())
watch(() => props.box, () => applyColor(), { deep: true })
watch(isDraggable, (enabled) => (enabled ? addListeners() : removeListeners()))
watch(
() => props.box.activeSide,
() => rotateBox()
)
watch(
() => props.box.state,
() => applyBoxState()
)
watch(
() => props.box,
() => applyColor(),
{ deep: true }
)
watch(
isDraggable,
(enabled) => (enabled ? addListeners() : removeListeners())
)
</script>
<style lang="scss" scoped>
.box {
--size: 6px;
--size: 7px;
--height: calc(var(--size) * (100 / 3));
--width: calc(var(--size) * 50);
--depth: calc(var(--size) * 10);
transition: height .5s ease, opacity .5s ease;
transition:
height 0.5s ease,
opacity 0.5s ease;
&.box-list {
height: calc(var(--size) * 20);
@apply hover:scale-105;
transition: all .5s ease;
@apply hover:scale-105 hover:z-20 focus-visible:scale-105 focus-visible:z-20 focus-visible:outline-none;
transition: all 0.5s ease;
will-change: transform;
}
&.box-selected {
height: calc(var(--size) * 34);
padding-top: 80px;
}
&-scene {
@@ -270,7 +307,6 @@ watch(isDraggable, (enabled) => (enabled ? addListeners() : removeListeners()))
cursor: grab;
}
&:active {
cursor: grabbing;
}
@@ -282,13 +318,14 @@ watch(isDraggable, (enabled) => (enabled ? addListeners() : removeListeners()))
font-weight: 600;
backface-visibility: hidden;
box-sizing: border-box;
border: 1px solid black;
// border: 1px solid black;
}
.front,
.back {
width: 100%;
height: 100%;
filter: brightness(1.1);
}
.face.top,
@@ -305,6 +342,7 @@ watch(isDraggable, (enabled) => (enabled ? addListeners() : removeListeners()))
.face.right {
width: var(--depth);
height: var(--height);
filter: brightness(0.8);
}
.face.front {
@@ -345,7 +383,7 @@ watch(isDraggable, (enabled) => (enabled ? addListeners() : removeListeners()))
/* Deck fade in/out purely in CSS */
.box-page {
opacity: 0;
transition: opacity .25s ease;
transition: opacity 0.25s ease;
pointer-events: none;
}
@@ -354,16 +392,6 @@ watch(isDraggable, (enabled) => (enabled ? addListeners() : removeListeners()))
pointer-events: auto;
}
/* for tabindex */
&:focus-visible {
outline: 0;
@apply scale-105 outline-none;
.face {
// border: 4px solid rgba(0, 0, 0, 0.5);
}
}
:deep(.indice) {
@apply text-xl p-2 relative bg-black/50 rounded-full backdrop-blur-md;
}

View File

@@ -0,0 +1,9 @@
<template>
<div>
wait for it
</div>
</template>
<script>
console.log('wait for it')
</script>

View File

@@ -0,0 +1,91 @@
<template>
<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">
<template v-if="box.state === 'box-selected'">
<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>
</div>
</template>
<script lang="ts" setup>
import type { Box } from '~~/types'
import { useDataStore } from '~/store/data'
import { usePlayerStore } from '~/store/player'
import { useUiStore } from '~/store/ui'
const dataStore = useDataStore()
const playerStore = usePlayerStore()
const uiStore = useUiStore()
// Retourne la box avec les propriétés du côté sélectionné si c'est une compilation
function getBoxToDisplay(box: Box) {
if (box.type !== 'compilation' || !('sides' in box)) return box
const side = box.sides?.[box.activeSide]
if (!side) return box
return {
...box,
...side
}
}
function openBox(box: Box) {
if (box.state !== 'box-selected') {
uiStore.selectBox(box.id)
// Scroll to the top smoothly
window.scrollTo({
top: 0,
behavior: 'smooth'
})
}
}
function playSelectedBox(box: Box) {
playerStore.playBox(box)
}
</script>
<style lang="scss" scoped>
.boxes {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
width: 100%;
transition: margin-top 0.5s ease;
min-height: 100vh;
&.box-selected {
justify-content: flex-start;
.box {
width: 100%;
}
}
.box {
.play-button {
position: relative;
z-index: 40;
bottom: -50%;
opacity: 0;
}
&.box-selected .play-button {
opacity: 1;
z-index: 20;
bottom: 20%;
transition: bottom 0.7s ease, opacity 0.7s ease;
}
}
}
</style>

View File

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

519
appOLDD/components/Card.vue Normal file
View 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>

View File

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

View File

@@ -1,8 +1,8 @@
<template>
<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"></div>
<img src="/loader.svg" alt="Loading" class="relative h-40 w-40" />
<div class="absolute inset-0 bg-black/60 backdrop-blur-md" />
<img src="/loader.svg" alt="Loading" class="border-esyellow/30 border-4 relative h-40 w-40 p-6 rounded-full">
</div>
</transition>
</template>

View File

@@ -0,0 +1,6 @@
<template>
<header class="py-4">
<img class="logo w-80" src="/logo.svg" alt="" >
<h1 class="text-center">mix-tapes</h1>
</header>
</template>

View File

@@ -0,0 +1,198 @@
<template>
<div class="platine pointer-events-none" :class="{ 'loading': platineStore.isLoadingTrack, 'mounted': isMounted }"
ref="platine">
<img class="cover" :src="platineStore.currentTrack?.coverId" />
<div class="disc pointer-events-auto fixed bg-transparent" ref="discRef" id="disc">
<div class="bobine"
:style="{ height: platineStore.progressPercentage + '%', width: platineStore.progressPercentage + '%' }"></div>
<div class="disc-label rounded-full bg-cover bg-center">
<img src="/favicon.svg" class="size-1/2 bg-black rounded-full p-5">
<div v-if="platineStore.isLoadingTrack" class="loading-indicator">
<div class="spinner"></div>
</div>
</div>
<div v-if="!platineStore.isLoadingTrack" class="absolute top-1/2 right-8 size-1/12 rounded-full bg-esyellow">
</div>
</div>
<!-- <div class="w-full h-1/5 text-base">
{{ platineStore.currentTrack?.title }}
<br>
{{ platineStore.currentTrack?.artist?.name }}
</div> -->
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { usePlatineStore } from '~/store/platine'
import type { Track } from '~~/types'
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>

View 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>

View File

@@ -1,9 +1,11 @@
<template>
<div class="player-container fixed left-0 z-50 w-full h-20 bg-white"
<div class="player fixed left-0 z-50 w-full h-20"
:class="playerStore.currentTrack ? '-bottom-0 opacity-100' : '-bottom-32 opacity-0'">
<div class="flex items-center gap-3 p-2">
<NuxtLink v-if="playerStore.currentTrack" :to="`/track/${playerStore.currentTrack.id}`">
<img v-if="playerStore.getCurrentCoverUrl" :src="playerStore.getCurrentCoverUrl as string" alt="Current cover"
class="size-16 object-cover object-center rounded" />
class="size-16 object-cover object-center rounded">
</NuxtLink>
<audio ref="audioRef" class="flex-1" controls />
</div>
</div>
@@ -19,19 +21,20 @@ const audioRef = ref<HTMLAudioElement | null>(null)
onMounted(() => {
if (audioRef.value) {
playerStore.attachAudio(audioRef.value)
audioRef.value.addEventListener("timeupdate", playerStore.updateTime)
audioRef.value.addEventListener('timeupdate', playerStore.updateTime)
}
})
onUnmounted(() => {
if (audioRef.value) {
audioRef.value.removeEventListener("timeupdate", playerStore.updateTime)
audioRef.value.removeEventListener('timeupdate', playerStore.updateTime)
}
})
</script>
<style>
.player-container {
.player {
transition: all 1s ease-in-out;
background-color: rgba(255, 255, 255, 0.5);
}
</style>

View File

@@ -0,0 +1,192 @@
<template>
<div>
<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>
<div ref="deck" class="deck flex flex-wrap justify-center gap-4" :class="{ 'pb-36': playerStore.currentTrack }">
<card v-for="(track, i) in tracks" :key="track.id" :track="track" tabindex="i"
:is-face-up="isCardRevealed(track.id)" />
</div>
<ul>
<li>
<button @click="backToBox">backToBox</button>
<button @click="toggleCards">toggleCards</button>
<button @click="switchSide">Face {{ box.activeSide }}</button>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
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'
const uiStore = useUiStore()
const props = defineProps<{
box: Box
}>()
const cardStore = useCardStore()
const dataStore = useDataStore()
const playerStore = usePlayerStore()
const deck = ref()
const tracks = computed(() =>
dataStore.getTracksByboxId(props.box.id, props.box.activeSide).sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
)
const isCardRevealed = (trackId: number) => cardStore.isCardRevealed(trackId)
const distribute = () => {
deck.value.querySelectorAll('.card').forEach((card: HTMLElement, index: number) => {
setTimeout(() => {
card.classList.remove('half-outside')
card.classList.add('outside')
}, index * 12)
})
}
const halfOutside = () => {
deck.value.querySelectorAll('.card').forEach((card: HTMLElement) => {
card.classList.remove('outside')
card.classList.add('half-outside')
})
}
const backToBox = () => {
deck.value.querySelectorAll('.card').forEach((card: HTMLElement) => {
card.classList.remove('half-outside', 'outside')
})
}
const toggleCards = () => {
if (document.querySelector('.card.outside')) {
halfOutside()
} else {
distribute()
}
}
const initDeck = () => {
setTimeout(() => {
if (!playerStore.isCurrentBox(props.box)) {
halfOutside()
}
}, 800)
if (playerStore.isCurrentBox(props.box)) {
distribute()
}
}
// Fonction pour sélectionner un côté (A ou B)
const switchSide = () => {
dataStore.setActiveSideByBoxId(props.box.id, props.box.activeSide === 'A' ? 'B' : 'A')
initDeck()
}
const closeDatBox = () => {
backToBox()
setTimeout(() => {
uiStore.closeBox()
}, 300)
}
onMounted(() => {
// if is a track change do not init
initDeck()
})
</script>
<style lang="scss" scoped>
.deck {
@apply h-screen w-screen fixed top-0 left-0 -z-10 overflow-hidden;
.card {
position: absolute;
top: 0;
right: calc(50% - 120px);
z-index: 1;
transition: all 0.5s ease;
will-change: transform;
display: block;
z-index: 2;
opacity: 0;
translate: 0 0;
transform: rotateX(-20deg) rotateY(20deg) rotateZ(90deg) translate3d(40px, 0, 0);
// half outside the box
&.half-outside {
opacity: 1;
top: 0;
&:nth-child(1) {
translate: 120px 20px;
transform: rotateX(-20deg) rotateY(20deg) rotateZ(90deg) translate3d(0, -100px, 0);
}
&:nth-child(2) {
translate: 150px 20px;
transform: rotateX(-20deg) rotateY(20deg) rotateZ(90deg) translate3d(0, -40px, 0);
}
&:nth-child(3) {
translate: 190px 20px;
transform: rotateX(-20deg) rotateY(20deg) rotateZ(90deg) translate3d(0, 30px, 0);
}
&:nth-child(4) {
translate: 240px 20px;
transform: rotateX(-20deg) rotateY(20deg) rotateZ(90deg) translate3d(0, 120px, 0);
}
&:nth-child(5) {
translate: 280px 20px;
transform: rotateX(-20deg) rotateY(20deg) rotateZ(90deg) translate3d(0, 200px, 0);
}
&:nth-child(6),
&:nth-child(7),
&:nth-child(8),
&:nth-child(9),
&:nth-child(10),
&:nth-child(11) {
opacity: 0;
}
&.current-track {
@apply shadow-none
}
}
// outside the box
&.outside {
opacity: 1;
transform: rotateX(0deg) rotateY(0deg) rotateZ(0deg) translate3d(0, 0, 0);
top: 50%;
right: calc(50% + 320px);
@apply translate-y-40;
&:hover {
@apply z-40 translate-y-32;
}
&.current-track {
@apply z-30 translate-y-28;
}
@for $i from 0 through 10 {
&:nth-child(#{$i + 1}) {
translate: calc(#{$i + 1} * 33%);
}
}
}
}
}
</style>

View File

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

View File

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

View File

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

View File

@@ -34,20 +34,6 @@
<div class="flex items-center gap-2">
<span v-if="resultItem.sublabel" class="text-sm text-slate-500 dark:text-slate-400">{{
resultItem.sublabel }}</span>
<button v-if="resultItem.type === 'TRACK'"
class="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800" aria-label="Toggle favorite"
@click.stop="fav.toggle(resultItem.payload)">
<svg v-if="fav.isFavorite(resultItem.payload.id)" class="h-5 w-5 text-rose-500" viewBox="0 0 24 24"
fill="currentColor">
<path
d="M12 21s-6.716-4.35-9.428-7.062C.86 12.226.5 10.64.5 9.5.5 6.462 2.962 4 6 4c1.657 0 3.157.806 4 2.09C10.843 4.806 12.343 4 14 4c3.038 0 5.5 2.462 5.5 5.5 0 1.14-.36 2.726-2.072 4.438C18.716 16.65 12 21 12 21z" />
</svg>
<svg v-else class="h-5 w-5 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path
d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8z" />
</svg>
</button>
</div>
</li>
</ul>
@@ -66,18 +52,20 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useUiStore } from '~/store/ui'
import { useDataStore } from '~/store/data'
import { usePlayerStore } from '~/store/player'
import { useFavoritesStore } from '~/store/favorites'
const ui = useUiStore()
const data = useDataStore()
const player = usePlayerStore()
const fav = useFavoritesStore()
const inputRef = ref<HTMLInputElement | null>(null)
const activeIndex = ref(0)
const close = () => ui.closeSearch()
const normalized = (s: string) => s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase()
const normalized = (s: string) =>
s
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase()
type ResultItem = {
key: string
@@ -110,7 +98,10 @@ const results = computed<ResultItem[]>(() => {
}
}
for (const track of data.tracks) {
const artistName = typeof track.artist === 'object' && track.artist ? (track.artist as any).name ?? '' : String(track.artist)
const artistName =
typeof track.artist === 'object' && track.artist
? ((track.artist as any).name ?? '')
: String(track.artist)
const label = track.title
const sub = artistName
if (normalized(label).includes(q) || normalized(sub).includes(q)) {
@@ -163,17 +154,12 @@ const selectResult = (ResultItem: ResultItem) => {
}
} else if (ResultItem.type === 'TRACK') {
const track = ResultItem.payload
// If the selected track is a favorite, just play it without navigating/selecting its box
if (fav.isFavorite(track.id)) {
player.playTrack(track)
} else {
const box = data.getBoxById(track.boxId)
if (box) {
ui.selectBox(box.id)
player.playTrack(track)
}
}
}
close()
}

View File

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

View File

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

View File

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

View File

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

18
appOLDD/error.vue Normal file
View 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>

View 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>

View File

@@ -1,20 +1,17 @@
<template>
<div class="w-full flex flex-col items-center">
<div @click="uiStore.closeBox()" class="cursor-pointer">
<logo />
</div>
<main>
<boxes />
</main>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useUiStore } from '~/store/ui'
import { useDataStore } from '~/store/data'
// Configuration du layout
definePageMeta({
layout: 'default'
})
const uiStore = useUiStore()
const dataStore = useDataStore()
const route = useRoute()

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

@@ -0,0 +1,202 @@
<template>
<div class="card-page">
<div v-if="loading" class="loading">
<div class="spinner"></div>
</div>
<Transition name="card-fade" mode="out-in">
<div v-if="!loading && track" class="card-container" @click="playTrack">
<Card :track="track" :is-face-up="true" class="card-item" />
</div>
<div v-else-if="error" class="error-message">
{{ error }}
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { usePlayerStore } from '~/store/player'
import { useCardStore } from '~/store/card'
import { useDataStore } from '~/store/data'
import type { Track } from '~~/types'
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
View 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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,18 @@
<template>
<div class="w-full flex flex-col items-center">
<div @click="uiStore.closeBox()" class="cursor-pointer">
<logo />
</div>
<main>
<boxes />
</main>
</div>
</template>
<script setup>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useUiStore } from '~/store/ui'
import { useDataStore } from '~/store/data'
import { usePlayerStore } from '~/store/player'
// Configuration du layout
definePageMeta({
layout: 'default'
})
const uiStore = useUiStore()
const dataStore = useDataStore()
const playerStore = usePlayerStore()
@@ -28,7 +27,7 @@ onMounted(async () => {
if (track) {
// Open the box containing this track without changing global UI flow/animations
uiStore.selectBox(track.boxId)
playerStore.playTrack(track)
playerStore.loadTrack(track)
}
}
})

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
import { defineNuxtPlugin } from '#app'
import { useCardStore } from '~/store/card'
export default defineNuxtPlugin(() => {
// Le code s'exécute uniquement côté client
const cardStore = useCardStore()
cardStore.initialize()
return {
provide: {
cardStore
}
}
})

View File

@@ -0,0 +1,98 @@
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
if (process.server) return
const ui = useUiStore()
const player = usePlayerStore()
const cardStore = useCardStore()
const dataStore = useDataStore()
function isInputElement(target: EventTarget | null): boolean {
return (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable)
)
}
function handleKeyDown(e: KeyboardEvent) {
// console.log('Key pressed:', e.code, 'Key:', e.key, 'Target:', e.target)
// Ne pas interférer avec les champs de formulaire
if (isInputElement(e.target as HTMLElement)) {
return
}
// Gestion du raccourci de recherche (Ctrl+F / Cmd+F)
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') {
e.preventDefault()
if (!ui.showSearch) {
ui.openSearch()
}
return
}
// Gestion des autres touches uniquement si pas de touche de contrôle enfoncée
if (e.ctrlKey || e.altKey || e.metaKey) {
return
}
switch (e.code) {
// Gestion de la barre d'espace pour play/pause
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()
const selectedBox = ui.getSelectedBox
// Si une box est sélectionnée et qu'aucune piste n'est en cours de lecture
if (selectedBox && !player.currentTrack) {
player.playBox(selectedBox)
} else if (player.currentTrack) {
// Comportement normal si une piste est déjà chargée
player.togglePlay()
}
return false
// Gestion de la touche Échap pour fermer la boîte
case 'Escape':
e.preventDefault()
ui.closeBox()
break
// Gestion des touches fléchées (à implémenter si nécessaire)
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowLeft':
case 'ArrowRight':
// Implémentation future de la navigation au clavier
break
}
}
// Ajout de l'écouteur d'événements avec capture pour intercepter l'événement plus tôt
window.addEventListener('keydown', handleKeyDown, { capture: true, passive: false })
})

171
appOLDD/store/card.ts Normal file
View 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)
}
}
})

View File

@@ -1,9 +1,5 @@
import type { Box, Artist, Track } from '~/../types/types'
import { FAVORITES_BOX_ID, useFavoritesStore } from '~/store/favorites'
// stores/data.ts
import { defineStore } from 'pinia'
import { useUiStore } from '~/store/ui'
export const useDataStore = defineStore('data', {
state: () => ({
@@ -26,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
@@ -47,25 +46,23 @@ export const useDataStore = defineStore('data', {
artist: artistObj
}
})
const favBox: Box = {
id: FAVORITES_BOX_ID,
type: 'playlist',
name: 'Favoris',
duration: 0,
tracks: [],
description: '',
color1: '#0f172a',
color2: '#1e293b',
color3: '#334155',
state: 'box-list'
}
if (!this.boxes.find((b) => b.id === FAVORITES_BOX_ID)) {
this.boxes = [favBox, ...this.boxes]
}
this.isLoaded = true
} finally {
this.isLoading = false
}
},
setActiveSideByBoxId(boxId: string, side: 'A' | 'B') {
const box = this.boxes.find((box) => box.id === boxId.replace(/[AB]$/, ''))
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]
}
},
@@ -76,15 +73,21 @@ export const useDataStore = defineStore('data', {
return state.boxes.find((box) => box.id === id)
}
},
// Obtenir toutes les pistes d'une box donnée
getTracksByboxId: (state) => (id: string) => {
if (id === FAVORITES_BOX_ID) {
const fav = useFavoritesStore()
return fav.trackIds
.map((tid) => state.tracks.find((t) => t.id === tid))
.filter((t): t is Track => !!t)
getTrackById: (state) => {
return (id: string) => {
return state.tracks.find((track) => track.id === id)
}
},
getTracksByboxId: (state) => (id: string, side?: 'A' | 'B') => {
const box = state.boxes.find((box) => box.id === id)
if (box?.type !== 'compilation' || !side) {
return state.tracks.filter((track) => track.boxId === id)
}
return state.tracks.filter((track) => track.boxId === id && track.side === side)
},
getActiveSideByBoxId: (state) => (id: string) => {
const box = state.boxes.find((box) => box.id === id)
return box?.activeSide
},
// Filtrer les artistes selon certains critères
getArtistById: (state) => (id: number) => state.artists.find((artist) => artist.id === id),
@@ -101,7 +104,7 @@ export const useDataStore = defineStore('data', {
},
getFirstTrackOfBox() {
return (box: Box) => {
const tracks = this.getTracksByboxId(box.id)
const tracks = this.getTracksByboxId(box.id, box.activeSide)
.slice()
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
return tracks.length > 0 ? tracks[0] : null
@@ -123,7 +126,7 @@ export const useDataStore = defineStore('data', {
return (track: Track) => {
// Récupérer toutes les tracks de la même box et les trier par ordre
const tracksInBox = state.tracks
.filter((t) => t.boxId === track.boxId)
.filter((t) => t.boxId === track.boxId && t.side === track.side)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
// Trouver lindex de la track courante
@@ -135,12 +138,51 @@ export const useDataStore = defineStore('data', {
getPrevTrack: (state) => {
return (track: Track) => {
const tracksInBox = state.tracks
.filter((t) => t.boxId === track.boxId)
.filter((t) => t.boxId === track.boxId && t.side === track.side)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
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
View 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
View 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`
}
}
})

View File

@@ -6,7 +6,8 @@ export const useUiStore = defineStore('ui', {
state: () => ({
// UI-only state can live here later
showSearch: false,
searchQuery: ''
searchQuery: '',
showCardSharer: false
}),
actions: {
@@ -35,6 +36,7 @@ export const useUiStore = defineStore('ui', {
selectBox(id: string) {
const dataStore = useDataStore()
dataStore.boxes.forEach((box) => {
id = id.replace(/[AB]$/, '')
box.state = box.id === id ? 'box-selected' : 'box-hidden'
})
},
@@ -45,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) {
@@ -54,7 +58,15 @@ export const useUiStore = defineStore('ui', {
const boxElement = document.getElementById(box.id)
if (boxElement) {
setTimeout(() => {
boxElement.scrollIntoView({ behavior: 'smooth' })
// Récupérer la position de l'élément
const elementRect = boxElement.getBoundingClientRect()
// Calculer la position de défilement (une boîte plus haut)
const offsetPosition = elementRect.top + window.pageYOffset - elementRect.height * 1.5
// Faire défiler à la nouvelle position
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
})
}, 333)
}
}
@@ -62,6 +74,10 @@ export const useUiStore = defineStore('ui', {
},
getters: {
isBoxSelected: () => {
const dataStore = useDataStore()
return dataStore.boxes.some((box) => box.state === 'box-selected')
},
getSelectedBox: () => {
const dataStore = useDataStore()
return (dataStore.boxes as Box[]).find((box) => box.state === 'box-selected') || null

3
assets/scss/z-index.scss Normal file
View File

@@ -0,0 +1,3 @@
body {
background-color: red !important;
}

146
data/Artists.json Normal file
View 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
View 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
View 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

Binary file not shown.

View File

@@ -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
View 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!
}
})

View File

@@ -1,23 +1,20 @@
// @ts-check
import withNuxt from "./.nuxt/eslint.config.mjs";
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt([eslintPluginPrettierRecommended])
export default withNuxt({
rules: {
// Garde l'ordre correct : class avant @click
"vue/attributes-order": "error",
// Contrôle du nombre d'attributs par ligne
"vue/max-attributes-per-line": [
"error",
{
singleline: 3, // autorise jusquà 3 attributs sur une ligne
multiline: {
max: 1, // si retour à la ligne, 1 attr par ligne
allowFirstLine: false,
},
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:vue/vue3-recommended'
],
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 'latest',
sourceType: 'module'
},
});
rules: {
'@typescript-eslint/ban-types': 'off',
'vue/multi-word-component-names': 'off',
'vue/script-setup-uses-vars': 'error'
}
})

30
notes.md Normal file
View File

@@ -0,0 +1,30 @@
first card :
translate: 310px 20px;
transform: rotateX(-20deg) rotateY(20deg) rotateZ(90deg) translateY(260px);
last card :
translate: 120px 20px;
transform: rotateX(-20deg) rotateY(20deg) rotateZ(90deg) translateY(-100px);
0. [ ] make half outside works
1. [ ] ne plus afficher les playlists sur la home page
2. [ ] ajouter un bouton "créer une compilation" sur la page de box
3. [ ] la page createCompilation liste toutes les tracks classées par dates
avec un deck vide en bas de page
4. [ ] les cards sont draggable vers le deck
5. [ ] un bouton avec un oeil permet d'afficher toutes les cartes temporairement
6. [ ] un bouton joker/dés selectionne une carte au hasard,
scroll vers la carte et la lit\*
7. [ ] ajouter un lien vers la track dans le lecteur (revoir les watch dans app.vue)
8. [ ] réunir les playlists dans une seule box
9. [ ] cette box aura un deck qui classera par date / couleur / suite etc ... c'est la pioche = LE Deck
10. [ ] et la compilation n'aura pas de deck MAIS un JukeBox !
11. [ ] il faudra ausse debug sur firefox

View File

@@ -1,10 +1,32 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
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()]
},
app: {
head: {
link: [
@@ -14,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
? [
{
@@ -23,9 +46,7 @@ export default defineNuxtConfig({
}
]
: [],
meta: [
{ name: 'apple-mobile-web-app-title', content: 'evilSpins' }
]
meta: [{ name: 'apple-mobile-web-app-title', content: 'evilSpins' }]
}
}
})

View File

@@ -1,34 +1,58 @@
{
"name": "nuxt-app",
"name": "evilspins",
"type": "module",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev --host",
"dev": "nuxt dev --host 0.0.0.0",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"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",
"eslint": "^9.33.0",
"nuxt": "^4.0.3",
"drizzle-orm": "^0.45.1",
"nuxt": "^4.3.0",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
"vue-router": "^4.5.1",
"vuedraggable": "^4.1.0"
},
"engines": {
"pnpm": ">=10 <11"
},
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748",
"packageManager": "pnpm@10.28.0",
"devDependencies": {
"sass-embedded": "^1.93.2"
"@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.1",
"@nuxt/eslint-config": "^1.10.0",
"@nuxtjs/eslint-config-typescript": "^12.1.0",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"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",
"espree": "^10.4.0",
"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"
}
}

9122
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

7
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,7 @@
ignoredBuiltDependencies:
- esbuild
onlyBuiltDependencies:
- '@parcel/watcher'
- better-sqlite3
- unrs-resolver

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

48
public/ES00/A/title.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 177 KiB

View File

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

48
public/ES00/B/title.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.8 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 149 KiB

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Some files were not shown because too many files have changed in this diff Show More