Compare commits

..

110 Commits

Author SHA1 Message Date
9705257178 Update app/components/Platine.vue
All checks were successful
Deploy App / build (push) Successful in 35s
Deploy App / deploy (push) Successful in 28s
2026-01-26 16:20:43 +00:00
2f78442deb Update server/api/tracks/playlist.ts
All checks were successful
Deploy App / build (push) Successful in 3m45s
Deploy App / deploy (push) Successful in 20s
2026-01-26 16:11:11 +00:00
valere
c586cc3932 PLATINE blur bobine
All checks were successful
Deploy App / build (push) Successful in 1m53s
Deploy App / deploy (push) Successful in 15s
2026-01-04 23:01:26 +01:00
valere
11694d36dd CI copy server files
All checks were successful
Deploy App / build (push) Successful in 51s
Deploy App / deploy (push) Successful in 14s
2026-01-04 10:51:27 +01:00
valere
3b05938162 CI install sqlite3
All checks were successful
Deploy App / build (push) Successful in 2m36s
Deploy App / deploy (push) Successful in 17s
2026-01-04 10:34:10 +01:00
valere
f75a1481bd platine mobile size
All checks were successful
Deploy App / build (push) Successful in 20s
Deploy App / deploy (push) Successful in 14s
2026-01-02 22:34:11 +01:00
valere
bb791e35d1 platine transition
All checks were successful
Deploy App / build (push) Successful in 2m4s
Deploy App / deploy (push) Successful in 16s
2026-01-01 20:50:37 +01:00
valere
a5fe876e3f bucket cards management
All checks were successful
Deploy App / build (push) Successful in 3m57s
Deploy App / deploy (push) Successful in 17s
2025-12-31 17:23:11 +01:00
valere
9001025837 SQLITE 3 2025-12-31 16:31:53 +01:00
valere
afb20fe75f bucket + card sharer
All checks were successful
Deploy App / build (push) Successful in 1m57s
Deploy App / deploy (push) Successful in 16s
2025-12-26 19:27:33 +01:00
valere
d8fe645e5c unified gost cards (mouse + touch)
All checks were successful
Deploy App / build (push) Successful in 31s
Deploy App / deploy (push) Successful in 15s
2025-12-24 12:25:58 +01:00
valere
ad938abf79 draggable / touchable card v0.1
All checks were successful
Deploy App / build (push) Successful in 1m53s
Deploy App / deploy (push) Successful in 18s
2025-12-24 06:00:15 +01:00
valere
1f4f7868ca drop old code
All checks were successful
Deploy App / build (push) Successful in 1m0s
Deploy App / deploy (push) Successful in 14s
2025-12-23 12:56:42 +01:00
valere
2c826e29ea compose elt v0.1
Some checks failed
Deploy App / build (push) Has been cancelled
Deploy App / deploy (push) Has been cancelled
2025-12-23 12:55:37 +01:00
valere
8efafc4642 Bucket v0.1
All checks were successful
Deploy App / build (push) Successful in 2m1s
Deploy App / deploy (push) Successful in 16s
2025-12-22 09:56:27 +01:00
valere
ecc1c22475 raccourciclavier [R]eveal [H]ide 2025-12-22 09:52:16 +01:00
valere
5948b4efbd clean type 2025-12-22 09:46:38 +01:00
valere
55cae0b9c6 toogle cards 2025-12-21 20:20:20 +01:00
valere
c0d79591c3 PLATINE drag to play & random tracks
All checks were successful
Deploy App / build (push) Successful in 1m59s
Deploy App / deploy (push) Successful in 17s
2025-12-19 11:41:47 +01:00
valere
1c4cbfe21c platine as component
All checks were successful
Deploy App / build (push) Successful in 53s
Deploy App / deploy (push) Successful in 15s
2025-12-18 19:59:23 +01:00
valere
2be5724c9f platine / bobine progression
All checks were successful
Deploy App / build (push) Successful in 51s
Deploy App / deploy (push) Successful in 14s
2025-12-17 22:15:40 +01:00
valere
dc2cba500c set route /mix & /draggable
All checks were successful
Deploy App / build (push) Successful in 3m13s
Deploy App / deploy (push) Successful in 18s
2025-12-17 19:34:25 +01:00
valere
65aaa71a3d FEAT: playlist filters
All checks were successful
Deploy App / build (push) Successful in 3m34s
Deploy App / deploy (push) Successful in 19s
2025-12-08 23:48:21 +01:00
valere
6176995032 Platine etape 1 2025-12-07 19:44:21 +01:00
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
valere
8ebda83a22 clean player off/on style
All checks were successful
Deploy App / build (push) Successful in 1m8s
Deploy App / deploy (push) Successful in 14s
2025-10-21 23:38:48 +02:00
valere
82de231548 clean style
All checks were successful
Deploy App / build (push) Successful in 1m11s
Deploy App / deploy (push) Successful in 14s
2025-10-21 22:50:03 +02:00
valere
f59c496c5d route v1
All checks were successful
Deploy App / build (push) Successful in 2m25s
Deploy App / deploy (push) Successful in 15s
2025-10-21 00:09:26 +02:00
valere
61b0b6395f favorite: v0.1
All checks were successful
Deploy App / build (push) Successful in 1m9s
Deploy App / deploy (push) Successful in 15s
2025-10-16 02:04:12 +02:00
valere
0aa1a57b78 search v1
All checks were successful
Deploy App / build (push) Successful in 1m5s
Deploy App / deploy (push) Successful in 14s
2025-10-16 01:45:28 +02:00
valere
3ad8cb8795 eS v1
All checks were successful
Deploy App / build (push) Successful in 2m14s
Deploy App / deploy (push) Successful in 14s
2025-10-16 00:42:38 +02:00
valere
ce73155cfa wait to navigate
All checks were successful
Deploy App / build (push) Successful in 55s
Deploy App / deploy (push) Successful in 25s
2025-10-12 03:57:42 +02:00
valere
8e4f34dd21 play/pause works
All checks were successful
Deploy App / build (push) Successful in 1m17s
Deploy App / deploy (push) Successful in 15s
2025-10-12 03:49:17 +02:00
valere
d21e731bbe fix mobile touch
All checks were successful
Deploy App / build (push) Successful in 1m11s
Deploy App / deploy (push) Successful in 15s
2025-10-11 22:20:51 +02:00
valere
9618f76a6c fix card rank ♣ is for automne
All checks were successful
Deploy App / build (push) Successful in 1m19s
Deploy App / deploy (push) Successful in 15s
2025-10-11 11:15:59 +02:00
valere
4d424eee54 ♠♣♦♥
All checks were successful
Deploy App / build (push) Successful in 2m29s
Deploy App / deploy (push) Successful in 15s
2025-10-11 09:56:14 +02:00
valere
2135b0fec6 add coverid
All checks were successful
Deploy App / build (push) Successful in 1m53s
Deploy App / deploy (push) Successful in 19s
2025-10-10 01:41:22 +02:00
valere
e2c5693948 playlists support v1
All checks were successful
Deploy App / build (push) Successful in 4m19s
Deploy App / deploy (push) Successful in 16s
2025-10-09 22:47:30 +02:00
valere
c86e345117 add folder for playlists api 3
All checks were successful
Deploy App / build (push) Successful in 1m7s
Deploy App / deploy (push) Successful in 16s
2025-10-04 01:17:24 +02:00
valere
83459227aa add folder for playlists api 2
All checks were successful
Deploy App / build (push) Successful in 1m9s
Deploy App / deploy (push) Successful in 16s
2025-10-04 01:13:38 +02:00
valere
22358b3ebb add folder for playlists api
All checks were successful
Deploy App / build (push) Successful in 1m8s
Deploy App / deploy (push) Successful in 16s
2025-10-04 01:08:14 +02:00
valere
96ffb4b10a add working player
All checks were successful
Deploy App / build (push) Successful in 1m20s
Deploy App / deploy (push) Successful in 16s
2025-10-04 00:49:12 +02:00
valere
fef1a8c234 flippable cards 2025-10-03 09:44:30 +02:00
valere
43b1a11027 add cards & tracks
All checks were successful
Deploy App / build (push) Successful in 1m13s
Deploy App / deploy (push) Successful in 15s
2025-10-02 00:38:54 +02:00
valere
8c1290beae CI drop debug
All checks were successful
Deploy App / build (push) Successful in 19s
Deploy App / deploy (push) Successful in 14s
2025-10-01 09:39:12 +02:00
valere
aba081cf05 CI debug
All checks were successful
Deploy App / build (push) Successful in 18s
Deploy App / deploy (push) Successful in 15s
2025-10-01 09:09:21 +02:00
valere
ae0cfc69c8 drop threejs
Some checks failed
Deploy App / build (push) Failing after 16s
Deploy App / deploy (push) Has been skipped
2025-10-01 09:00:49 +02:00
valere
377fb5935b CI drop ls debug
Some checks failed
Deploy App / build (push) Failing after 1m0s
Deploy App / deploy (push) Has been skipped
2025-10-01 08:55:37 +02:00
valere
9acc43a384 drop .env from git ignore
Some checks failed
Deploy App / build (push) Failing after 1m8s
Deploy App / deploy (push) Has been skipped
2025-10-01 08:54:45 +02:00
valere
ab3057ac7f ls DEBUG
Some checks failed
Deploy App / build (push) Failing after 12s
Deploy App / deploy (push) Has been skipped
2025-10-01 08:52:26 +02:00
valere
deedc333e3 add .env to cp
Some checks failed
Deploy App / build (push) Failing after 12s
Deploy App / deploy (push) Has been skipped
2025-10-01 08:48:35 +02:00
valere
454700c428 config.sh -> .env
Some checks failed
Deploy App / build (push) Failing after 1m28s
Deploy App / deploy (push) Has been skipped
2025-10-01 08:29:25 +02:00
valere
bd5ed09d5e add card verso
Some checks failed
Deploy App / build (push) Failing after 1m18s
Deploy App / deploy (push) Has been skipped
2025-09-30 12:37:24 +02:00
valere
c028fda489 drop position in studio 2025-09-30 12:35:59 +02:00
valere
5c7c8e7a0d CI create build step
Some checks failed
Deploy App / build (push) Failing after 35s
Deploy App / deploy (push) Has been skipped
2025-09-30 12:17:02 +02:00
valere
9438394db8 animations + cards
All checks were successful
Deploy App / deploy (push) Successful in 30s
2025-09-30 01:10:12 +02:00
valere
631bc65c70 studio v1.1
Some checks failed
Deploy App / deploy (push) Failing after 13s
2025-09-20 21:39:56 +02:00
valere
9e697822e4 studio v1
All checks were successful
Deploy App / deploy (push) Successful in 1m49s
2025-09-20 17:18:29 +02:00
valere
5912b97349 re init CI with docker rmi
Some checks failed
Deploy App / deploy (push) Has been cancelled
2025-09-18 15:01:41 +02:00
valere
403afdfbc3 drop build stage to make server change works on prod
All checks were successful
Deploy App / build (push) Successful in 48s
Deploy App / test (push) Successful in 49s
Deploy App / deploy (push) Successful in 16s
2025-09-18 14:45:54 +02:00
valere
0707ad4de2 fix eventHandler undefined
All checks were successful
Deploy App / build (push) Successful in 52s
Deploy App / test (push) Successful in 50s
Deploy App / deploy (push) Successful in 13s
2025-09-18 14:05:13 +02:00
valere
16813bf1de server fix: import eventHandler
All checks were successful
Deploy App / build (push) Successful in 49s
Deploy App / test (push) Successful in 50s
Deploy App / deploy (push) Successful in 1m33s
2025-09-18 12:58:10 +02:00
valere
c92f16c73c CI test with pnpm generate
Some checks failed
Deploy App / build (push) Successful in 48s
Deploy App / test (push) Failing after 46s
Deploy App / deploy (push) Has been skipped
2025-09-18 10:55:40 +02:00
valere
9c9688a4ea gitea action test
Some checks failed
Deploy App / build (push) Successful in 48s
Deploy App / test (push) Failing after 1m32s
Deploy App / deploy (push) Has been skipped
2025-09-18 10:32:30 +02:00
valere
e76b009629 gitea action test
Some checks failed
Deploy App / build (push) Successful in 47s
Deploy App / test (push) Failing after 54s
Deploy App / deploy (push) Has been skipped
2025-09-18 10:27:03 +02:00
valere
655574171f gitea action ...
Some checks failed
Deploy App / build (push) Successful in 52s
Deploy App / deploy (push) Has been cancelled
Deploy App / test (push) Has been cancelled
2025-09-18 10:17:57 +02:00
valere
6d7eb3ab7d gitea action, try without artifacts
Some checks failed
Deploy App / build (push) Failing after 14s
Deploy App / test (push) Has been skipped
Deploy App / deploy (push) Has been skipped
2025-09-18 10:10:58 +02:00
valere
4268132d4c gitea action version for CI
Some checks failed
Deploy App / build (push) Has been cancelled
Deploy App / test (push) Has been cancelled
Deploy App / deploy (push) Has been cancelled
2025-09-18 10:07:02 +02:00
valere
27c5910e88 try pnpm auto version for CI
Some checks failed
Deploy App / build (push) Failing after 51s
Deploy App / test (push) Has been skipped
Deploy App / deploy (push) Has been skipped
2025-09-18 10:00:25 +02:00
valere
019a20875a try pnpm 10 for CI
Some checks failed
Deploy App / build (push) Failing after 13s
Deploy App / test (push) Has been skipped
Deploy App / deploy (push) Has been skipped
2025-09-18 09:56:39 +02:00
valere
fa470ad162 replace npm by pnpm for CI
Some checks failed
Deploy App / build (push) Failing after 15s
Deploy App / test (push) Has been skipped
Deploy App / deploy (push) Has been skipped
2025-09-18 09:50:18 +02:00
valere
88da1369bf add test to CI
Some checks failed
Deploy App / build (push) Failing after 1m11s
Deploy App / test (push) Has been skipped
Deploy App / deploy (push) Has been skipped
2025-09-18 09:42:30 +02:00
valere
0c1cf30996 first 3D model
All checks were successful
Deploy App / deploy (push) Successful in 1m18s
2025-09-17 23:39:43 +02:00
valere
116d15d1ce add visuals & design page
All checks were successful
Deploy App / deploy (push) Successful in 1m27s
2025-09-11 19:10:54 +02:00
valere
250ed0c84a fix 404 & logo favicon
All checks were successful
Deploy App / deploy (push) Successful in 4m59s
2025-09-01 10:42:53 +02:00
valere
79b1dfd16c add error page
All checks were successful
Deploy App / deploy (push) Successful in 2m12s
2025-08-31 12:11:15 +02:00
valere
13bce90000 favicon svg lint clean homewait page
All checks were successful
Deploy App / deploy (push) Successful in 1m40s
2025-08-30 18:06:32 +02:00
valere
9224f5b393 hurry set landing page
All checks were successful
Deploy App / deploy (push) Successful in 1m52s
2025-08-30 16:30:08 +02:00
valere
70d4410dbd atropos works on remote ?
All checks were successful
Deploy App / deploy (push) Successful in 1m37s
2025-08-30 12:52:22 +02:00
valere
f7e7ea7583 fix url evilspins.com
All checks were successful
Deploy App / deploy (push) Successful in 1m1s
2025-08-30 10:35:36 +02:00
valere
7a1cdf2178 install atropos (demo on /wait)
All checks were successful
Deploy App / deploy (push) Successful in 1m49s
2025-08-30 10:26:32 +02:00
valere
d013e62fcf button play + black index 2025-03-22 19:14:15 +01:00
valere
9c1204b46b change bkg (need update home !) 2025-03-16 09:07:59 +01:00
valere
8541050011 style: playlsits 2024-12-30 12:30:39 +01:00
valere
480e8f008c TEST-CI: wording test 2024-11-15 11:04:29 +01:00
valere
7f8eb7bbb9 FIX: umami script 2024-11-09 12:50:46 +01:00
valere
7e9c0d3caf FEAT: add umami tracking script 2024-11-06 12:52:26 +01:00
valere
f972137389 FIX: scroll only screen 2024-11-03 17:27:54 +01:00
valere
7f8ed0e8a0 FEAT: playlists player v1 2024-11-01 13:15:23 +01:00
valere
0ca4cc3bfe FEAT: api for playlists v1 2024-10-28 13:59:38 +01:00
141 changed files with 19383 additions and 9547 deletions

View File

@@ -1,16 +0,0 @@
kind: pipeline
type: docker
name: default
steps:
- name: deploy
image: docker:dind
commands:
- apk add --upgrade npm bash findutils rsync sed
- WORKDIR="/var/docker-web/apps/$DRONE_REPO_NAME"
- rm -rf $WORKDIR
- mkdir $WORKDIR
- rsync -av --exclude ./node_modules /drone/src/ $WORKDIR
- cd $WORKDIR
- npm ci
- bash /var/docker-web/src/cli.sh up $DRONE_REPO_NAME

4
.env
View File

@@ -1,3 +1,3 @@
DOMAIN=evilspins.com
PORT=7783
DASHBOARD_HIDDEN=false
PORT=7901
PORT_EXPOSED=3000

35
.github/workflows/deploy.yml vendored Normal file
View File

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

1
.gitignore vendored
View File

@@ -17,3 +17,4 @@ logs
.DS_Store
.fleet
.idea
mnt

2
.nvmrc
View File

@@ -1 +1 @@
18.20.2
20

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"printWidth": 100
}

9
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"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,19 +1,20 @@
# INSTALL
FROM node:18-alpine as builder
WORKDIR /app
COPY . .
RUN npm ci && npm cache clean --force
ADD . .
# Builder
FROM node:20-alpine AS builder
# BUILD
WORKDIR /app
COPY package*.json ./
RUN npm install --legacy-peer-deps
COPY . .
RUN npm run build
# PROD
FROM node:18-alpine
# Runtime
FROM node:20-alpine AS runner
RUN apk add --no-cache python3 make g++ sqlite
WORKDIR /app
COPY --from=builder /app/.output /app/.output
COPY --from=builder /app/.nuxt /app/.nuxt
COPY --from=builder /app/.env /app/.env
ENV HOST 0.0.0.0
COPY --from=builder /app/.output ./.output
COPY package*.json ./
COPY ./server/database ./server/database
EXPOSE 3000
CMD source .env && node .output/server/index.mjs
CMD ["node", ".output/server/index.mjs"]

View File

@@ -1 +1,75 @@
# evilSpins
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

11
app.vue
View File

@@ -1,11 +0,0 @@
<template>
<NuxtPage />
</template>
<script setup>
// @todo : laod datas as plugin/middleware (cant load pinia in plugin/middleware) ?
onMounted(async ()=>{
const dataStore = await useDataStore()
await dataStore.loadData()
})
</script>

82
app/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>

399
app/components/Box.vue Normal file
View File

@@ -0,0 +1,399 @@
<template>
<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="isCompilation" class="cover absolute" :src="`/${box.id}/${box.activeSide}/cover.jpg`" alt="" />
<div v-else class="size-full flex flex-col justify-center items-center text-7xl text-black"
v-html="box.description" />
<CinemaScreen />
</div>
<div class="face back flex flex-row flex-wrap items-start p-4 overflow-hidden"
:class="{ 'overflow-y-scroll': !isCompilation }" ref="backFace">
<!-- <li class="list-none text-xxs w-1/2 flex flex-row"
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 }}
</p>
</li> -->
</div>
<div class="face right" ref="rightFace" />
<div class="face left" ref="leftFace" />
<div class="face top" ref="topFace">
<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-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>
</div>
<div class="face bottom" ref="bottomFace" />
</div>
<slot></slot>
</article>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
import type { Box } from '~~/types/types'
import { useDataStore } from '~/store/data'
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>()
const domBox = ref<HTMLElement>()
const frontFace = ref<HTMLElement>()
const backFace = ref<HTMLElement>()
const rightFace = ref<HTMLElement>()
const leftFace = ref<HTMLElement>()
const topFace = ref<HTMLElement>()
const bottomFace = ref<HTMLElement>()
// --- Angles ---
const rotateX = ref(0)
const rotateY = ref(0)
const rotateZ = ref(0)
// --- Drag + inertie ---
let dragging = false
let lastPointer = { x: 0, y: 0, time: 0 }
let velocity = { x: 0, y: 0 }
let raf: number | null = null
const sensitivity = $isMobile ? 0.5 : 0.15
const friction = 0.95
const minVelocity = 0.02
const enableInertia = true
// --- Transformations ---
function applyTransform(duration = 0.5) {
if (!domBox.value) return
rotateX.value = Math.round(rotateX.value)
rotateY.value = Math.round(rotateY.value)
rotateZ.value = Math.round(rotateZ.value)
domBox.value.style.transition = `transform ${duration}s ease`
domBox.value.style.transform = `rotateX(${rotateX.value}deg) rotateY(${rotateY.value}deg) rotateZ(${rotateZ.value}deg)`
}
// --- Gestion BoxState ---
function applyBoxState() {
switch (props.box.state) {
case 'box-list':
rotateX.value = 76
rotateY.value = 0
rotateZ.value = 150
break
case 'box-selected':
rotateX.value = -20
rotateY.value = 20
rotateZ.value = 0
break
case 'box-hidden':
rotateX.value = 76
rotateY.value = 0
rotateZ.value = 150
break
}
applyTransform(0.8) // transition fluide
}
// --- Couleurs ---
function applyColor() {
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})`
leftFace.value.style.background = `linear-gradient(to top, ${props.box.color1}, ${props.box.color2})`
rightFace.value.style.background = `linear-gradient(to top, ${props.box.color1}, ${props.box.color2})`
topFace.value.style.background = `linear-gradient(to top, ${props.box.color2}, ${props.box.color2})`
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
velocity.x *= friction
velocity.y *= friction
rotateX.value += velocity.y
rotateY.value += velocity.x
rotateX.value = Math.max(-80, Math.min(80, rotateX.value))
applyTransform(0.05) // court duration pour inertie fluide
if (Math.abs(velocity.x) > minVelocity || Math.abs(velocity.y) > minVelocity) {
raf = requestAnimationFrame(tickInertia)
} else {
raf = null
}
}
// --- Pointer events ---
let listenersAttached = false
const down = (ev: PointerEvent) => {
ev.preventDefault()
dragging = true
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
}
}
const move = (ev: PointerEvent) => {
if (!dragging) return
ev.preventDefault()
const now = performance.now()
const dx = ev.clientX - lastPointer.x
const dy = ev.clientY - lastPointer.y
const dt = Math.max(1, now - lastPointer.time)
rotateY.value += dx * sensitivity
rotateX.value -= dy * sensitivity
rotateX.value = Math.max(-80, Math.min(80, rotateX.value))
velocity.x = (dx / dt) * 16 * sensitivity
velocity.y = (-dy / dt) * 16 * sensitivity
lastPointer = { x: ev.clientX, y: ev.clientY, time: now }
applyTransform(0) // immédiat pendant drag
}
const end = (ev: PointerEvent) => {
if (!dragging) return
dragging = false
try {
domBox.value?.releasePointerCapture(ev.pointerId)
} catch { }
if (enableInertia && (Math.abs(velocity.x) > minVelocity || Math.abs(velocity.y) > minVelocity)) {
if (!raf) raf = requestAnimationFrame(tickInertia)
}
}
function addListeners() {
if (!domBox.value || listenersAttached) return
domBox.value.addEventListener('pointerdown', down)
domBox.value.addEventListener('pointermove', move)
domBox.value.addEventListener('pointerup', end)
domBox.value.addEventListener('pointercancel', end)
domBox.value.addEventListener('pointerleave', end)
listenersAttached = true
}
function removeListeners() {
if (!domBox.value || !listenersAttached) return
domBox.value.removeEventListener('pointerdown', down)
domBox.value.removeEventListener('pointermove', move)
domBox.value.removeEventListener('pointerup', end)
domBox.value.removeEventListener('pointercancel', end)
domBox.value.removeEventListener('pointerleave', end)
listenersAttached = false
}
onMounted(() => {
applyColor()
applyBoxState()
if (isDraggable.value) addListeners()
})
onBeforeUnmount(() => {
cancelAnimationFrame(raf!)
removeListeners()
})
// --- Watchers ---
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: 7px;
--height: calc(var(--size) * (100 / 3));
--width: calc(var(--size) * 50);
--depth: calc(var(--size) * 10);
transition:
height 0.5s ease,
opacity 0.5s ease;
&.box-list {
height: calc(var(--size) * 20);
@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 {
perspective: 2000px;
}
&.box-hidden {
height: 0;
opacity: 0;
z-index: 0;
}
&-object {
width: var(--width);
height: var(--height);
position: relative;
transform-style: preserve-3d;
margin: auto;
user-select: none;
.box-list & {
cursor: pointer;
}
.box-selected & {
cursor: grab;
}
&:active {
cursor: grabbing;
}
}
.face {
position: absolute;
color: white;
font-weight: 600;
backface-visibility: hidden;
box-sizing: border-box;
// border: 1px solid black;
}
.front,
.back {
width: 100%;
height: 100%;
filter: brightness(1.1);
}
.face.top,
.face.bottom {
width: var(--width);
height: var(--depth);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.face.left,
.face.right {
width: var(--depth);
height: var(--height);
filter: brightness(0.8);
}
.face.front {
transform: translateX(0) translateY(0) translateZ(var(--depth));
}
.face.back {
transform: rotateY(180deg) translateX(0) translateY(0) translateZ(0);
}
.face.right {
transform: rotateY(90deg) translateX(calc(var(--depth) * -1)) translateY(0px) translateZ(var(--width));
transform-origin: top left;
}
.face.left {
transform: rotateY(-90deg) translateX(calc(var(--depth) / 2)) translateY(0) translateZ(calc(var(--depth) / 2));
}
.face.top {
transform: rotateX(90deg) translateX(0px) translateY(calc(var(--depth) / 2)) translateZ(calc(var(--depth) / 2));
}
.face.top>* {
@apply rotate-180;
}
.face.bottom {
transform: rotateX(-90deg) translateX(0px) translateY(calc(var(--depth) * -0.5)) translateZ(calc(var(--height) - var(--depth) / 2));
}
.cover {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Deck fade in/out purely in CSS */
.box-page {
opacity: 0;
transition: opacity 0.25s ease;
pointer-events: none;
}
&.box-selected .box-page {
opacity: 1;
pointer-events: auto;
}
:deep(.indice) {
@apply text-xl p-2 relative bg-black/50 rounded-full backdrop-blur-md;
}
}
</style>

91
app/components/Boxes.vue Normal file
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/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>

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

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

519
app/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/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>

25
app/components/Loader.vue Normal file
View File

@@ -0,0 +1,25 @@
<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" />
<img src="/loader.svg" alt="Loading" class="border-esyellow/30 border-4 relative h-40 w-40 p-6 rounded-full">
</div>
</transition>
</template>
<script setup lang="ts">
import { useDataStore } from '~/store/data'
const data = useDataStore()
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

6
app/components/Logo.vue Normal file
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>

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

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

View File

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

40
app/components/Player.vue Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,221 @@
<template>
<transition name="fade">
<div v-if="ui.showSearch" class="fixed inset-0 z-50 flex items-center justify-center transition-all">
<div class="absolute inset-0 bg-black/60 backdrop-blur-md" @click="close"></div>
<div
class="relative w-full max-w-2xl rounded-xl bg-white shadow-xl ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-slate-700"
role="dialog" aria-modal="true" @keydown.esc.prevent.stop="close">
<div class="flex items-center gap-2 dark:border-slate-700">
<svg class="ml-4 h-7 w-7 text-slate-500" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input ref="inputRef" v-model="ui.searchQuery" type="text" placeholder="Rechercher boxes, artistes, tracks..."
class="flex-1 bg-transparent px-2 py-2 text-slate-900 text-3xl placeholder-slate-400 outline-none dark:text-slate-100"
@keydown.down.prevent="move(1)" @keydown.up.prevent="move(-1)" @keydown.enter.prevent="confirm" />
</div>
<div class="max-h-72 overflow-auto results-scroll">
<template v-if="results.length">
<ul class="divide-y divide-slate-100 dark:divide-slate-800">
<li v-for="(resultItem, idx) in results" :key="resultItem.key" :class="[
'flex cursor-pointer items-center justify-between gap-3 px-2 py-3 hover:bg-slate-50 dark:hover:bg-slate-800',
idx === activeIndex ? 'bg-slate-100 dark:bg-slate-800' : ''
]" @mouseenter="activeIndex = idx" @click="selectResult(resultItem)">
<div class="flex items-center gap-3">
<img v-if="coverUrlFor(resultItem)" :src="coverUrlFor(resultItem)" alt="" loading="lazy"
class="h-10 w-10 rounded object-cover ring-1 ring-slate-200 dark:ring-slate-700" />
<span
class="inline-flex min-w-[68px] items-center justify-center rounded-md border px-2 py-0.5 text-xs font-semibold uppercase text-slate-600 dark:text-slate-300 dark:border-slate-600">{{
resultItem.type }}</span>
<span class="text-slate-900 dark:text-slate-100">{{ resultItem.label }}</span>
</div>
<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>
</div>
</li>
</ul>
</template>
<div v-else-if="ui.searchQuery" class="px-2 py-6 text-center text-slate-500 dark:text-slate-400">
Aucun résultat
</div>
</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useUiStore } from '~/store/ui'
import { useDataStore } from '~/store/data'
import { usePlayerStore } from '~/store/player'
const ui = useUiStore()
const data = useDataStore()
const player = usePlayerStore()
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()
type ResultItem = {
key: string
type: 'BOX' | 'ARTIST' | 'TRACK'
label: string
sublabel?: string
payload: any
}
const results = computed<ResultItem[]>(() => {
const q = normalized(ui.searchQuery || '')
if (!q) return []
const out: ResultItem[] = []
for (const b of data.boxes) {
const label = `${b.id}`
if (normalized(label).includes(q)) {
out.push({ key: `box:${b.id}`, type: 'BOX', label, payload: b })
}
}
for (const a of data.artists) {
if (normalized(a.name).includes(q)) {
out.push({
key: `artist:${a.id}`,
type: 'ARTIST',
label: a.name,
payload: a
})
}
}
for (const track of data.tracks) {
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)) {
out.push({ key: `track:${track.id}`, type: 'TRACK', label, sublabel: sub, payload: track })
}
}
return out.slice(0, 100)
})
const move = (delta: number) => {
if (!results.value.length) return
activeIndex.value = (activeIndex.value + delta + results.value.length) % results.value.length
}
const coverUrlFor = (ResultItem: ResultItem): string | undefined => {
if (ResultItem.type === 'BOX') {
return `/${ResultItem.payload.id}/cover.jpg`
}
if (ResultItem.type === 'TRACK') {
const track = ResultItem.payload
if (track && track.type === 'playlist' && track.coverId) return track.coverId as string
if (track && track.coverId) return track.coverId as string
return `/${track.boxId}/cover.jpg`
}
if (ResultItem.type === 'ARTIST') {
const tracks = data.getTracksByArtistId(ResultItem.payload.id)
if (tracks && tracks.length) {
const track = tracks[0]!
if (track.type === 'playlist' && track.coverId) return track.coverId as string
if (track.coverId) return track.coverId as string
return `/${track.boxId}/cover.jpg`
}
}
return undefined
}
const selectResult = (ResultItem: ResultItem) => {
if (ResultItem.type === 'BOX') {
ui.selectBox(ResultItem.payload.id)
nextTick(() => ui.scrollToBox(ResultItem.payload))
} else if (ResultItem.type === 'ARTIST') {
const tracks = data.getTracksByArtistId(ResultItem.payload.id)
if (tracks && tracks.length) {
const track = tracks[0]!
const box = data.getBoxById(track.boxId)
if (box) {
ui.selectBox(box.id)
}
}
} else if (ResultItem.type === 'TRACK') {
const track = ResultItem.payload
const box = data.getBoxById(track.boxId)
if (box) {
ui.selectBox(box.id)
player.playTrack(track)
}
}
close()
}
const confirm = () => {
const ResultItem = results.value[activeIndex.value]
if (ResultItem) selectResult(ResultItem)
}
watch(
() => ui.showSearch,
async (open) => {
if (open) {
activeIndex.value = 0
await nextTick()
inputRef.value?.focus()
}
}
)
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.results-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(100, 116, 139, 0.6) transparent;
}
.results-scroll::-webkit-scrollbar {
width: 8px;
}
.results-scroll::-webkit-scrollbar-track {
background: transparent;
}
.results-scroll::-webkit-scrollbar-thumb {
background-color: rgba(100, 116, 139, 0.6);
border-radius: 9999px;
border: 2px solid transparent;
background-clip: content-box;
}
.dark .results-scroll {
scrollbar-color: rgba(148, 163, 184, 0.5) transparent;
}
.dark .results-scroll::-webkit-scrollbar-thumb {
background-color: rgba(148, 163, 184, 0.5);
}
</style>

View File

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

View File

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

View File

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

View File

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

18
app/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>

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

@@ -0,0 +1,55 @@
<template>
<slot />
<Bucket />
<Platine v-if="playerStore.currentTrack" />
</template>
<script setup lang="ts">
import type { Track } from '~~/types/types'
import { usePlayerStore } from '~/store/player'
const playerStore = usePlayerStore()
const onCardDropped = (card: Track) => {
console.log('Carte déposée dans le bucket:', card)
}
</script>
<style scoped>
.bucket,
.platine {
position: fixed;
bottom: -100%;
right: 0;
height: auto;
}
.bucket {
z-index: 70;
bottom: -260px;
width: 100%;
overflow-x: scroll;
transition: bottom .3s ease;
&:hover,
.card-dragging & {
bottom: 0;
}
.bucket-card-wrapper {
width: 100%;
}
}
.platine {
bottom: -100%;
transition: bottom 2s ease;
&.mounted {
z-index: 80;
bottom: 0;
width: 100%;
max-width: 450px;
}
}
</style>

26
app/pages/box/[id].vue Normal file
View File

@@ -0,0 +1,26 @@
<template>
<boxes />
</template>
<script setup lang="ts">
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()
onMounted(async () => {
await dataStore.loadData()
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
if (typeof idParam === 'string' && idParam.length > 0) {
uiStore.selectBox(idParam)
}
})
</script>

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

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

27
app/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>

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

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

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

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

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

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

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

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

34
app/pages/track/[id].vue Normal file
View File

@@ -0,0 +1,34 @@
<template>
<boxes />
</template>
<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()
const route = useRoute()
onMounted(async () => {
await dataStore.loadData()
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
const id = Number(idParam)
if (!Number.isNaN(id)) {
const track = dataStore.tracks.find((t) => t.id === id)
if (track) {
// Open the box containing this track without changing global UI flow/animations
uiStore.selectBox(track.boxId)
playerStore.loadTrack(track)
}
}
})
</script>

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

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

View File

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

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

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

14
app/plugins/card.store.ts Normal file
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,6 @@
export default defineNuxtPlugin((nuxtApp) => {
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
)
nuxtApp.provide('isMobile', isMobile)
})

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 })
})

16
app/router.options.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { RouterScrollBehavior } from 'vue-router'
const scrollBehavior: RouterScrollBehavior = (to, from, savedPosition) => {
if (savedPosition) {
return savedPosition
}
if (to.hash) {
return { el: to.hash }
}
// Preserve current scroll position on navigation (no scroll-to-top)
return false
}
export default {
scrollBehavior
}

171
app/store/card.ts Normal file
View File

@@ -0,0 +1,171 @@
import { defineStore } from 'pinia'
import type { Track } from '~~/types/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)
}
}
})

188
app/store/data.ts Normal file
View File

@@ -0,0 +1,188 @@
import type { Box, Artist, Track } from '~/../types/types'
import { defineStore } from 'pinia'
export const useDataStore = defineStore('data', {
state: () => ({
boxes: [] as Box[], // Store your box data here
artists: [] as Artist[], // Store artist data here
tracks: [] as Track[], // Store track data here
isLoaded: false, // Remember if data is already loaded
isLoading: true
}),
actions: {
async loadData() {
if (this.isLoaded) return
this.isLoading = true
try {
this.boxes = await $fetch<Box[]>('/api/boxes')
this.artists = await $fetch<Artist[]>('/api/artists')
const compilationTracks = await $fetch<Track[]>('/api/tracks/compilation')
const playlistTracks = await $fetch<Track[]>('/api/tracks/playlist')
// 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 = [
...(Array.isArray(compilationTracks) ? compilationTracks : []),
...(Array.isArray(playlistTracks) ? playlistTracks : [])
]
this.tracks = allTracks.map((track) => {
const a = track.artist as unknown
let artistObj: Artist
if (typeof a === 'number') {
artistObj = artistMap.get(a) ?? { id: a, name: String(a), url: '', coverId: '' }
} else if (typeof a === 'string') {
artistObj = { id: 0, name: a, url: '', coverId: '' }
} else if (a && typeof a === 'object' && 'id' in (a as any)) {
const idVal = (a as any).id as number | undefined
artistObj = idVal != null ? artistMap.get(idVal) ?? (a as Artist) : (a as Artist)
} else {
artistObj = { id: 0, name: '', url: '', coverId: '' }
}
return {
...track,
artist: artistObj
}
})
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]
}
},
getters: {
// Obtenir toutes les boxes
getBoxById: (state) => {
return (id: string) => {
return state.boxes.find((box) => box.id === id)
}
},
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),
// Obtenir toutes les pistes d'un artiste donné
getTracksByArtistId: (state) => (artistId: number) => {
return state.tracks.filter(
(track) =>
typeof track.artist === 'object' &&
!!track.artist &&
'id' in track.artist &&
(track.artist as Artist).id === artistId
)
},
getFirstTrackOfBox() {
return (box: Box) => {
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
}
},
getNextPlaylistTrack: (state) => {
return (track: Track) => {
const tracksInPlaylist = state.tracks
.filter((t) => t.boxId === track.boxId)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
const index = tracksInPlaylist.findIndex((t) => t.id === track.id)
return index >= 0 && index < tracksInPlaylist.length - 1
? tracksInPlaylist[index + 1]
: null
}
},
getNextTrack: (state) => {
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 && t.side === track.side)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
// Trouver lindex de la track courante
const index = tracksInBox.findIndex((t) => t.id === track.id)
// Retourner la track suivante ou null si cest la dernière
return index >= 0 && index < tracksInBox.length - 1 ? tracksInBox[index + 1] : null
}
},
getPrevTrack: (state) => {
return (track: Track) => {
const tracksInBox = state.tracks
.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
app/store/platine.ts Normal file
View File

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

279
app/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`
}
}
})

86
app/store/ui.ts Normal file
View File

@@ -0,0 +1,86 @@
import { defineStore } from 'pinia'
import { useDataStore } from '~/store/data'
import type { Box } from '~/../types/types'
export const useUiStore = defineStore('ui', {
state: () => ({
// UI-only state can live here later
showSearch: false,
searchQuery: '',
showCardSharer: false
}),
actions: {
openSearch() {
this.showSearch = true
// reset query on open to avoid stale state
this.searchQuery = ''
},
closeSearch() {
this.showSearch = false
this.searchQuery = ''
},
setSearchQuery(q: string) {
this.searchQuery = q
},
listBoxes() {
const dataStore = useDataStore()
dataStore.boxes.forEach((box) => {
box.state = 'box-list'
})
},
selectBox(id: string) {
const dataStore = useDataStore()
dataStore.boxes.forEach((box) => {
id = id.replace(/[AB]$/, '')
box.state = box.id === id ? 'box-selected' : 'box-hidden'
})
},
closeBox() {
const selectedBox = this.getSelectedBox
const dataStore = useDataStore()
dataStore.boxes.forEach((box) => {
box.state = 'box-list'
})
},
openCardSharer() {
this.showCardSharer = true
},
scrollToBox(box: Box) {
if (box) {
const boxElement = document.getElementById(box.id)
if (boxElement) {
setTimeout(() => {
// 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)
}
}
}
},
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
}
}
})

11
app/store/user.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { Compilation, Artist, Track } from '~/../types/types'
// stores/user.ts
import { defineStore } from 'pinia'
export const useDataStore = defineStore('data', {
state: () => ({
badge: [] // un badge par user achievement pour enrichir le déchifrage de l'app (afichage des nom des titres/artiste, collection de carte déjà joué (et du coups possibilité de les rejouer dans son deck))
// evilSpins est un jeux mais pas vraiment pokemon (un morceau = un pokemon) mais aussi un jeux d'aventure / exploration ou plus on progresse plus on peu voir de chose
})
})

View File

@@ -1,45 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-black;
}
.button {
text-decoration: none;
box-shadow: 0 8px 0 0 black;
transition: all .3s;
border: 8px black solid;
line-height: 100%;
border-width: 2px;
border-radius: 100px;
cursor: pointer;
color: black;
font-size: 26px;
background-color: #ffffff59;
height: 40px;
width: 40px;
@media (min-width: 780px) {
height: 70px;
width: 70px;
}
}
.button:hover {
background-color: #fdec50ff;
}
.button:active {
box-shadow: 0 0 0 0 black;
}
.button--screened {
top: 74px;
}
.compilation {
cursor: pointer;
max-width: 420px;
}

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

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

View File

@@ -1,22 +0,0 @@
<template>
<NuxtLink :to="'/compilations/' + props.data.id" class="compilation mx-auto p-4 inline-flex">
<atropos-component class="my-atropos" active-offset="80" shadow-scale="1.05">
<img :src="props.data.id + '/bkg.jpg'" data-atropos-offset="-8" />
<img :src="props.data.id + '/object.png'" data-atropos-offset="-3" class="absolute inset-0 object-cover" />
<img :src="props.data.id + '/name.png'" data-atropos-offset="0" class="absolute inset-0 object-cover" />
<img src="/logo.svg" data-atropos-offset="0" width="70%" class="logo absolute inset-0" />
</atropos-component>
</NuxtLink>
</template>
<script setup>
const props = defineProps(['data', 'template'])
</script>
<style scoped>
.logo {
filter: drop-shadow(4px 4px 0 rgb(0 0 0 / 0.5));
left: 14%;
top: 10%;
}
</style>

View File

@@ -1,11 +0,0 @@
<template>
<section>
<div v-for="compilation in store.getAllCompilations" class="text-white">
<compilationObject :data="compilation" template="full" />
</div>
</section>
</template>
<script setup lang="ts">
const store = useDataStore()
</script>

View File

@@ -1,31 +0,0 @@
<script setup lang="ts">
const isLoaded = ref(false)
const isPlaying = ref(false)
const video = ref()
async function play() {
await video.value.player.playVideo()
}
function stateChange(event) {
isPlaying.value = event.data === 1
}
</script>
<template>
<div>
<div class="flex items-center justify-center p-5">
<ScriptYouTubePlayer ref="video" video-id="iyPiiZly864" class="group" @ready="isLoaded = true" @state-change="stateChange">
<template #awaitingLoad>
<div class="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 h-[48px] w-[68px]">
<svg height="100%" version="1.1" viewBox="0 0 68 48" width="100%"><path d="M66.52,7.74c-0.78-2.93-2.49-5.41-5.42-6.19C55.79,.13,34,0,34,0S12.21,.13,6.9,1.55 C3.97,2.33,2.27,4.81,1.48,7.74C0.06,13.05,0,24,0,24s0.06,10.95,1.48,16.26c0.78,2.93,2.49,5.41,5.42,6.19 C12.21,47.87,34,48,34,48s21.79-0.13,27.1-1.55c2.93-0.78,4.64-3.26,5.42-6.19C67.94,34.95,68,24,68,24S67.94,13.05,66.52,7.74z" fill="#f00" /><path d="M 45,24 27,14 27,34" fill="#fff" /></svg>
</div>
</template>
</ScriptYouTubePlayer>
</div>
<div class="text-center">
<div v-if="!isLoaded" class="mb-5" size="sm" color="blue" variant="soft" title="Click to load" description="Clicking the video will load the Vimeo iframe and start the video." />
<button v-if="isLoaded && !isPlaying" @click="play">
Play Video
</button>
</div>
</div>
</template>

View File

@@ -1,28 +0,0 @@
<template>
<div class="compilation mx-auto p-4 inline-flex">
<atropos-component ref="atropos" class="my-atropos" active-offset="80" shadow-scale="1.05">
<img src="/zero/sky-b.jpg" data-atropos-offset="-8" />
<img src="/zero/propeller-b.png" data-atropos-offset="-3" class="absolute inset-0 object-cover" />
<img src="/zero/zero-b.png" data-atropos-offset="0" class="absolute inset-0 object-cover" />
<img src="/logo.svg" data-atropos-offset="0" width="70%" class="logo absolute inset-0" />
</atropos-component>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
const atropos = ref(null)
</script>
<style scoped>
/* .my-atropos {
width: 320px;
height: 160px;
} */
.logo {
filter: drop-shadow(4px 4px 0 rgb(0 0 0 / 0.5));
left: 14%;
top: 10%;
}
</style>

View File

@@ -1,18 +1,21 @@
services:
evilspins:
build: .
image: local/evilspins
build:
context: .
dockerfile: Dockerfile
container_name: evilspins
restart: unless-stopped
working_dir: /app
ports:
- $PORT:3000
- "${PORT}:${PORT_EXPOSED}"
volumes:
- $MEDIA_DIR:/app/mnt/media
environment:
VIRTUAL_HOST: "${DOMAIN}"
LETSENCRYPT_HOST: "${DOMAIN}"
PUID: "${PUID}"
PGID: "${PGID}"
volumes:
- "${MEDIA_DIR}:/app/media"
networks:
default:

20
eslint.config.mjs Normal file
View File

@@ -0,0 +1,20 @@
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt({
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'
}
})

122
logo.svg

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

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,24 +1,35 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
import tsconfigPaths from 'vite-tsconfig-paths'
const isProd = process.env.NODE_ENV === 'production'
export default defineNuxtConfig({
ssr: true,
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
css: ['~/assets/css/main.css'],
postcss: {
plugins: {
tailwindcss: {},
autoprefixer: {},
modules: ['@nuxt/eslint', '@nuxtjs/tailwindcss', '@pinia/nuxt'],
vite: {
plugins: [tsconfigPaths()]
},
},
app: {
head: {
charset: 'utf-8',
viewport: 'width=device-width, initial-scale=1',
link: [
{ rel: 'icon', type: 'image/png', href: '/favicon/favicon-96x96.png', sizes: '96x96' },
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon/favicon.svg' },
{ rel: 'shortcut icon', href: '/favicon/favicon.ico' },
{ 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
? [
{
src: 'https://umami.erudi.fr/script.js',
defer: true,
'data-website-id': '615690ea-0306-48cc-8feb-e9093fe6a1b7'
}
]
: [],
meta: [{ name: 'apple-mobile-web-app-title', content: 'evilSpins' }]
}
}
},
compatibilityDate: '2024-07-10',
modules: ['@pinia/nuxt']
})

8186
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,55 @@
{
"name": "nuxt-app",
"private": true,
"type": "module",
"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"
"postinstall": "nuxt prepare",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"migrate": "tsx server/database/migrate.ts",
"db:reset": "rm -f server/database/evilspins.db && npm run migrate"
},
"dependencies": {
"@pinia/nuxt": "^0.5.5",
"@nuxt/eslint": "1.9.0",
"@nuxtjs/tailwindcss": "6.14.0",
"@pinia/nuxt": "0.11.2",
"@types/chokidar": "^2.1.7",
"atropos": "^2.0.2",
"nuxt": "^3.12.3",
"pinia": "^2.2.4",
"unhead": "^1.9.15",
"vue": "^3.4.31",
"vue-router": "^4.4.0"
"better-sqlite3": "^12.5.0",
"chokidar": "^5.0.0",
"nuxt": "^4.2.0",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-router": "^4.5.1",
"vuedraggable": "^4.1.0"
},
"engines": {
"pnpm": ">=10 <11"
},
"packageManager": "pnpm@10.27.0",
"devDependencies": {
"autoprefixer": "^10.4.19",
"postcss": "^8.4.39",
"sass": "^1.77.6",
"tailwindcss": "^3.4.4"
"@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.1",
"@nuxt/eslint-config": "^1.10.0",
"@nuxtjs/eslint-config-typescript": "^12.1.0",
"@types/better-sqlite3": "^7.6.13",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"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",
"vite-tsconfig-paths": "^5.1.4"
}
}

View File

@@ -1,137 +0,0 @@
<template>
<div class="text-white p-12 text-lg flex flex-col items-center">
<a href="/" class="mb-12">
<img class="logo" src="/logo.svg">
</a>
<div class="max-w-4xl">
<h1 class="text-esyellow text-6xl mb-8">About evilSpins ...</h1>
<pre>
Rather than explaining the "artistic" approach of evilspins in too formal a manner, I prefer to freely share the notes and sketches as a testament to the project's long genesis (2019 -> 2024) :
</pre>
<pre>
"The idea of the name comes from Anton Newcombe, who defines music as something that must remain independent,
hence his quote "keep music evil", from which 'evil' and 'spins' refer to music as an object that turns (cassette,
vinyl, CD...), music is a living object in perpetual motion and it's 'evil' / independent / unmanageable because
it makes you dance, it takes the listener without even realizing it.
</pre>
<img src="https://files.erudi.fr/evilspins/ABOUT-1.jpg">
<pre>
Each compilation is the original soundtrack of a non-existent film. It must leave room for imagination and
reflection.
A compilation tries to produce the effect of a film, to provoke imagination.
It's an opportunity to decontextualize forgotten, outdated, or unconsciously caricatured musical genres
(bluegrass,
punk, folk, hip-hop...) or simply to discover new genres.
Approach music without thinking about the stylistic references of the piece, like in a film.
</pre>
<img src="https://files.erudi.fr/evilspins/ABOUT-2.jpg">
<pre>
A compilation can therefore offer several readings,
- a panel of characters,
- several versions (short, long like a director's cut, side A/B (side B could be a parallel version with the same
artists in the same order but with different titles very close))
- several possible reading modes (mode 1: only the mixed sound and artworks (only play/pause is available),
mode 2: portraits and names of the protagonists, anecdotes, quotes, and web links (possibility to go to the
next/previous track and to 'seek'))
- an artwork (square format (vinyl) or 16/9 + black band (cinema)) - vocal samples to make characters speak?
(samples of artists in interviews, for example)
- a mix must follow this order:
List of quintessence sound :
1. accessible but instrumental
2. more researched and instrumental
3. more rhythmic instrumental
4->7. rhythmic sung
8. rhythmic instrumental
9. Instrumental
10. bluegrass final Evils spins from the idea that music needs the imagination of its listener.
11. hidden track
</pre>
<img src="https://files.erudi.fr/evilspins/ABOUT-3.jpg">
<pre>
Design: timeline of the player like the itinerary of a journey on a map OR like a display board at a train
station/airport "A problem created cannot be solved by thinking in the same way it was created." "God only depends
on the imagination of men" Drawing situations where you meet someone you think you know well but who doesn't have
the same vision of things as you. Example: discussion about music and completely divergent points of view, (Nick
Waterhouse = completely inaccessible jazz) Going to see local labels inventing a religion and therefore a god, is
only a way to personify one's conscience. The projects converge on the concept of a "cyber residence of artists"
drawing a face to one's morality. Dreams are nightmares, nightmares are dreams. Starting from reflections on the
use
of new technologies; questioning one's use of the internet on one's computer, phone, etc., allows one to wonder
what? when? why do I need the internet, to watch a movie, to listen to music... In my case, the answers to these
questions propose a use that mixes tools and methods from different eras. TOOLS & METHODS FROM DIFFERENT
ERAS/CONTEXTS Just like in the film 'La fille du 14 juillet', it can be very interesting to play on the contrasts
between different eras by mixing them in the same visual and sound realization. Idea of realization graphic novel
or
comic book: Observation: with the revolution of the internet and new technologies, cinema has become a major art
that everyone masters more or less. The public's gaze is sharpened and everyone knows how to apprehend
cinematographic realization. The plans, camera movements, sound effects are a language that everyone knows how to
read (cinema is carried by all through downloading & streaming) ... and even write (I can make a film with my
phone,
reflex cameras have become affordable). How to draw cinema? Try to take key scenes from cinema, draw/paint them
and
animate them. fixed plan? traveling? zoom? panoramic? plunge? counter-plunge? wide-angle? long focal? work on the
Wikipedia of 'film noir' re-watch film noir link with the polar noir? film noir has a pretentious approach at
first
glance. so we need to find a technique (visual? sound?) to disarm this bad image. to do this we need to start by
isolating and understanding the possible pretentious aspect of film noir.
</pre>
<img src="https://files.erudi.fr/evilspins/ABOUT-4.jpg">
<pre>
The solitude of the character? The
voice-overs that narrate the thoughts? The appearance of the protagonist? The cliché (visual & script) of the
polar
noir too worn out? (the detective alone in his thoughts, in his apartment smoking a cigarette on the balcony) the
cigarette can still be a more powerful visual element than a cliché transition with animation of the cigarette?
cigarette = huge filmographic symbol == we find it in most films especially in the era of film noir == commercial
influence tool == influence of American culture (cowboy), == symbol of the ephemeral, like life, like the
characters
of a film, like a film. the 2 mega clichés: Nicholson/Polansky in Chinatown & Ridley Scott in Blade Runner How to
integrate the cigarette? = 3D animation? (often ugly, technically very difficult, aesthetically difficult) =
hand-drawn animation? (long... very long to do but good pretext to train) = vector-drawn animation? (technically
moderately difficult but aesthetically difficult) = film excerpts? (technically easy & aesthetically very easy BUT
not really creation (montage), and illegal... to check) = animation of the spin (film zoom in Cassette, CD, Vinyl,
the plates of a hard drive (joke -> why not make a montage effect), find an equivalent for digital (server bays,
server fan? (joke))) why not call the box to meet at Slift to turn it properly and start a collaboration?
otherwise,
a lot of material to Bellecours photo + ... Fnac? no, much more interesting to go through a collaboration. Take
out
the rushes of films.
</pre>
<img src="https://files.erudi.fr/evilspins/ABOUT-5.jpg">
<pre>
The thinking of Jack Nicholson in Chinatown No visual drawing during the reading of the piece,
only why not the log / cover / artwork of the album the reading should not look like a PowerPoint, the
counter-example is everywhere on YouTube. On the other hand, a drawn and animated artwork in parallax why not. See
3D js cover -> at the mouse movement: preview with animation -> At the click fade of the box zoom on the cover the
animation is no longer manual but automatic (linear movement? random?) after the cigarette... the smoke... too
cliché? possible but the smoke can make a transition between 2 listens / animations. the listening ends the smoke
arrives brings us to the cigarette which brings us to a protagonist who brings us to a film excerpt. why
evilspins?
The spin obviously refers to the movement of music both by its format (cassette, vinyl, CD) and by its effect
(membrane, air movement, dance) possible sound effect: end of the piece Purple Mercy - Purple -> the end of the
piece ends with an abrupt snare with a subtle reverb -> effect felt: conclusion on an impression of grand space ->
effect analyzed / symbolic: grandeur of the proposal but above all empty room -> no public -> therefore call to
the
public -> idea expressible: maybe too pretentious again. film noir has a pretentious image at first glance. we
need
to find a technique (visual? sound?) to disarm this bad image. to do this we need to start by isolating and
understanding the possible pretentious aspect of film noir. The solitude of the character? The voice-overs that
narrate the thoughts? The appearance of the protagonist? The cliché (visual & script) of the polar noir too worn
out? (the detective alone in his thoughts, in his apartment smoking a cigarette on the balcony)"
</pre>
<img src="https://files.erudi.fr/evilspins/ABOUT-6.jpg">
</div>
</div>
</template>
<style scoped>
img {
margin: 32px 0;
}
pre {
white-space: pre-line;
font-family: sans-serif;
}
</style>

View File

@@ -1,154 +0,0 @@
<template @keydown.esc="closePlayer()">
<div class="text-white w-full flex items-center flex-col">
<button class="text-sm md:text-5xl leading-none button button--close m-3 flex justify-center items-center z-50"
@click="closePlayer()">
</button>
<video class="mixPlayer w-full" controls ref="mixPlayer">
<source :src="videoSD" type="video/mp4">
</video>
<nav class="text-esyellow w-full flex" v-if="currentTrack">
<button v-for="(track, index) in tracks" @click="listenTo(track.start)" :index="track.id"
class="border-l-wihte-400 border-l-2 p-2 flex-grow hover:bg-esyellow hover:text-black"
:class="{ 'border-l-0': index === 0, 'bg-esyellow text-black': track.id === currentTrack.id }">
<span class="block">
{{ index + 1 }}
</span>
<span class="hidden 2xl:block">
{{ track.title }}
</span>
<span class="hidden lg:block">
{{ getArtistName(track.artist) }}
</span>
</button>
</nav>
<article class="text-white p-8 max-w-5xl" v-if="currentTrack">
<div class="flex flex-col sm:flex-row items-center ">
<a :href="currentTrack.url" target="_blank" class="mr-4">
<atropos-component>
<img class="flex-grow-0" :src="'https://f4.bcbits.com/img/' + currentTrack.cover + '_8.jpg'" />
</atropos-component>
</a>
<div>
<a :href="currentTrack.url" target="_blank" rel="noopener noreferrer">
<h3 class="text-5xl">
{{ currentTrack.title }}
</h3>
</a>
<a v-if="currentArtist" :href="currentArtist.url" target="_blank" rel="noopener noreferrer">
<h2 class="font-bold text-6xl text-esyellow">
{{ currentArtist.name }}
</h2>
</a>
<h4 class="text-xl text-slate-200">
{{ compilation.name }}
</h4>
</div>
</div>
<p class="block mt-10">
see artist page:<br>
<a v-if="currentArtist" target="_blank" class="underline text-orange-500 hover:text-orange-400"
:href="currentArtist.url">
{{ currentArtist.name }}
</a><br>
purchase the track:<br>
<a target="_blank" class="underline text-orange-500 hover:text-orange-400" :href="currentTrack.url">
{{ currentTrack.title }}
</a><br>
<br>
</p>
</article>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const store = useDataStore()
const compilation = ref()
const tracks = ref()
const mixPlayer = ref()
const videoSD = ref()
const currentTrack = ref()
const { isLoaded } = storeToRefs(store)
const currentArtist = computed(() => {
return store.getArtistById(currentTrack.value.artist)
})
const getArtistName = (id: number) => {
return store.getArtistById(id)?.name
}
// LOAD DATAs
onMounted(() => {
loadCompilation() // if user arrive directly on compilation page
})
watch(isLoaded, () => {
loadCompilation() // if the user came from another page
})
const watchPlayingTrack = () => {
setInterval(() => {
if (mixPlayer.value && compilation.value.id) {
currentTrack.value = tracks.value.find((track: { start: number; }, index: number) => {
const nextTrackStart = tracks.value[index + 1]?.start ?? Infinity
return track.start <= mixPlayer.value.currentTime && mixPlayer.value.currentTime < nextTrackStart
})
}
}, 1000)
}
const loadCompilation = () => {
if (isLoaded.value) {
compilation.value = store.getCompilationById(route.params.id as string)
tracks.value = store.getTracksByCompilationId(route.params.id as string)
videoSD.value = 'https://files.erudi.fr/evilspins/' + compilation.value.id + '-SD.mp4'
mixPlayer.value.load()
mixPlayer.value.play()
mixPlayer.value.focus()
watchPlayingTrack()
}
}
const listenTo = (start: number) => {
mixPlayer.value.currentTime = start
mixPlayer.value.play()
}
const closePlayer = async () => {
await navigateTo('/')
}
</script>
<style lang="scss" scoped>
body {
margin: 0;
}
.logo {
filter: drop-shadow(8px 8px 0 rgb(0 0 0 / 0.8));
}
a:hover {
text-decoration: underline;
}
.mixPlayer {
background: black;
max-height: 70vh;
}
nav>button:first-child {
border-left: none;
}
.button--close {
position: fixed;
right: 2vw;
&:after {
content: "\00d7";
}
}
</style>

View File

@@ -1,127 +0,0 @@
<template>
<div>
<div class="w-full flex justify-center">
<nav class="[&>*]:p-2 text-white bottom-0 right-0 fixed flex justify-center z-50">
<a href="https://www.youtube.com/channel/UCATtFHnOLDCv8qroi2KW3ZA" target="about:blank" class="mt-1">
<img src="/youtube.svg" alt="youtube channel" />
</a>
<a href="mailto:contact@evilspins.com">📬</a>
<a href="/about"></a>
</nav>
</div>
<section class="splash-screen flex items-center flex-col">
<figure class="ui">
<img class="logo" src="/logo.svg">
<h1 class="text-white pt-6 text-sm md:text-md lg:text-lg text-center font-bold tracking-widest">Compilations
Indépendantes
</h1>
<button class="button button--screened relative top-16 flex justify-center items-center" @click="scrollDown()">
</button>
</figure>
<div class="shadow screen" />
<video class="animation screen" loop autoplay muted ref="animation">
<source src="https://files.erudi.fr/evilspins/sloughi-run-loop-big.webm" type="video/webm">
<source src="https://files.erudi.fr/evilspins/sloughi-run-loop-small.webm" type="video/webm"
media="all and (max-width: 640px)">
</video>
</section>
<section class="flex justify-center">
<div class="flex max-w-2xl">
<compilationsList />
</div>
</section>
</div>
</template>
<script setup lang="ts">
// SEO
useSeoMeta({
title: 'evilSpins - compilations indépendantes',
ogTitle: 'evilSpins - compilations indépendantes',
description: 'evilSpins - compilations indépendantes, la bande originale d\'un film qui n\'existe pas',
ogDescription: 'evilSpins - compilations indépendantes, la bande originale d\'un film qui n\'existe pas',
ogImage: 'https://evilspins.com/logo.svg'
})
const dataStore = useDataStore()
const scrollDown = function () {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' })
}
</script>
<style lang="scss" scoped>
body {
margin: 0;
overflow-x: hidden;
}
.logo,
.button,
.shadow,
.animation,
.mix {
transition: .7s opacity;
}
.screen {
position: absolute;
height: 100vh;
min-width: 100%;
max-width: 100%;
}
.splash-screen {
position: relative;
height: 100vh;
background-color: black;
}
.animation {
z-index: 1;
object-fit: cover;
opacity: .8;
/* opacity: 0; */
}
.mix {
z-index: 4;
position: fixed;
}
.shadow {
z-index: 3;
box-shadow: rgb(0, 0, 0) 0px 0px 170px 70px inset;
opacity: .9;
}
.ui {
z-index: 4;
position: absolute;
top: 50%;
left: 50%;
max-width: 80%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
}
.logo {
filter: drop-shadow(8px 8px 0 rgb(0 0 0 / 0.8));
}
.mixPlayer {
background: black;
max-height: 70vh;
}
.hide {
opacity: 0;
z-index: 0;
}
.show {
opacity: 1;
}
</style>

View File

@@ -1,5 +0,0 @@
<template>
<div class="h-screen w-full flex justify-center p-16">
<img src="/logo.svg">
</div>
</template>

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)
})
}
})

12046
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
public/ES00/A/cover.jpg Normal file

Binary file not shown.

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

BIN
public/ES00/B/cover.jpg Normal file

Binary file not shown.

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

BIN
public/ES01/A/bkg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

BIN
public/ES01/A/cover.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
public/ES01/A/name.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
public/ES01/A/object.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

51
public/ES01/A/title.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
public/ES01/B/B.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

BIN
public/ES01/B/bkg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

BIN
public/ES01/B/cover.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
public/ES01/B/name.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

22
public/ES01/B/name.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.1 KiB

BIN
public/ES01/B/object.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

51
public/ES01/B/title.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
public/ESPLAYLISTS/bkg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
public/ESPLAYLISTS/name.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

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

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

After

Width:  |  Height:  |  Size: 2.6 KiB

1
public/face-down.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

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