Compare commits
	
		
			30 Commits
		
	
	
		
			bd5ed09d5e
			...
			master
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | deb15b3ea1 | ||
|  | 9771c799f2 | ||
|  | 25d56ec4ef | ||
|  | 8ebda83a22 | ||
|  | 82de231548 | ||
|  | f59c496c5d | ||
|  | 61b0b6395f | ||
|  | 0aa1a57b78 | ||
|  | 3ad8cb8795 | ||
|  | ce73155cfa | ||
|  | 8e4f34dd21 | ||
|  | d21e731bbe | ||
|  | 9618f76a6c | ||
|  | 4d424eee54 | ||
|  | 2135b0fec6 | ||
|  | e2c5693948 | ||
|  | c86e345117 | ||
|  | 83459227aa | ||
|  | 22358b3ebb | ||
|  | 96ffb4b10a | ||
|  | fef1a8c234 | ||
|  | 43b1a11027 | ||
|  | 8c1290beae | ||
|  | aba081cf05 | ||
|  | ae0cfc69c8 | ||
|  | 377fb5935b | ||
|  | 9acc43a384 | ||
|  | ab3057ac7f | ||
|  | deedc333e3 | ||
|  | 454700c428 | 
							
								
								
									
										3
									
								
								.env
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | |||||||
|  | DOMAIN="evilspins.com" | ||||||
|  | PORT="7901" | ||||||
|  | PORT_EXPOSED="3000" | ||||||
							
								
								
									
										2
									
								
								.github/workflows/deploy.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -12,7 +12,7 @@ jobs: | |||||||
|       - name: Prepare and build app |       - name: Prepare and build app | ||||||
|         run: | |         run: | | ||||||
|           REPO_NAME="${GITHUB_REPOSITORY##*/}" |           REPO_NAME="${GITHUB_REPOSITORY##*/}" | ||||||
|           APP_DIR="/var/docker-web/apps/${REPO_NAME}" |           APP_DIR="/var/docker-web/store/apps/${REPO_NAME}" | ||||||
|           bash /var/docker-web/src/cli.sh down "${REPO_NAME}" |           bash /var/docker-web/src/cli.sh down "${REPO_NAME}" | ||||||
|           rm -rf "$APP_DIR" |           rm -rf "$APP_DIR" | ||||||
|           mkdir "$APP_DIR" |           mkdir "$APP_DIR" | ||||||
|   | |||||||
							
								
								
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -17,8 +17,4 @@ logs | |||||||
| .DS_Store | .DS_Store | ||||||
| .fleet | .fleet | ||||||
| .idea | .idea | ||||||
|  | mnt | ||||||
| # Local env files |  | ||||||
| .env |  | ||||||
| .env.* |  | ||||||
| !.env.example |  | ||||||
							
								
								
									
										8
									
								
								.prettierrc
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,8 @@ | |||||||
|  | { | ||||||
|  |   "semi": false, | ||||||
|  |   "singleQuote": true, | ||||||
|  |   "tabWidth": 2, | ||||||
|  |   "trailingComma": "none", | ||||||
|  |   "printWidth": 100 | ||||||
|  | } | ||||||
|  |  | ||||||
							
								
								
									
										2
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,4 +1,6 @@ | |||||||
| { | { | ||||||
|  |   "editor.defaultFormatter": "esbenp.prettier-vscode", | ||||||
|  |   "editor.formatOnSave": true, | ||||||
|   "editor.codeActionsOnSave": { |   "editor.codeActionsOnSave": { | ||||||
|     "source.fixAll.eslint": "explicit" |     "source.fixAll.eslint": "explicit" | ||||||
|   } |   } | ||||||
|   | |||||||
							
								
								
									
										66
									
								
								app/app.vue
									
									
									
									
									
								
							
							
						
						| @@ -1,20 +1,80 @@ | |||||||
| <template> | <template> | ||||||
|   <div> |   <div> | ||||||
|     <NuxtRouteAnnouncer /> |     <NuxtRouteAnnouncer /> | ||||||
|     <NuxtPage /> |     <NuxtLayout> | ||||||
|  |       <NuxtPage /> | ||||||
|  |     </NuxtLayout> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup> | <script setup> | ||||||
|  | import { useUiStore } from '~/store/ui' | ||||||
|  | import { usePlayerStore } from '~/store/player' | ||||||
|  | import { watch, computed } from 'vue' | ||||||
|  |  | ||||||
|  | const ui = useUiStore() | ||||||
|  | const player = usePlayerStore() | ||||||
|  | const { $isMobile } = useNuxtApp() | ||||||
| useHead({ | useHead({ | ||||||
|   bodyAttrs: { |   bodyAttrs: { | ||||||
|     class: 'bg-slate-100 dark:bg-slate-900' |     class: 'bg-slate-100' | ||||||
|   } |   } | ||||||
| }) | }) | ||||||
|  |  | ||||||
|  | const router = useRouter() | ||||||
|  | const route = useRoute() | ||||||
|  |  | ||||||
|  | watch( | ||||||
|  |   () => player.currentTrack?.id, | ||||||
|  |   (id) => { | ||||||
|  |     if (!id) { | ||||||
|  |       if (route.name === 'track-id') router.replace({ path: '/' }) | ||||||
|  |       return | ||||||
|  |     } | ||||||
|  |     const currentParam = Number( | ||||||
|  |       Array.isArray(route.params.id) ? route.params.id[0] : route.params.id | ||||||
|  |     ) | ||||||
|  |     if (route.name === 'track-id' && currentParam === id) return | ||||||
|  |     router.replace({ name: 'track-id', params: { id } }) | ||||||
|  |   }, | ||||||
|  |   { flush: 'post' } | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // Keep URL in sync with selected box: /box/:id when a box is selected, back to / when none | ||||||
|  | const selectedBoxId = computed(() => ui.getSelectedBox?.id) | ||||||
|  | watch( | ||||||
|  |   () => selectedBoxId.value, | ||||||
|  |   (id) => { | ||||||
|  |     if (process.client) { | ||||||
|  |       if (!id) { | ||||||
|  |         // Back to root path without navigation to preserve UI state/animations | ||||||
|  |         if (location.pathname.startsWith('/box/')) { | ||||||
|  |           history.replaceState(null, '', '/') | ||||||
|  |         } | ||||||
|  |         return | ||||||
|  |       } | ||||||
|  |       const currentId = location.pathname.startsWith('/box/') ? location.pathname.split('/').pop() : null | ||||||
|  |       if (currentId === id) return | ||||||
|  |       requestAnimationFrame(() => { | ||||||
|  |         history.replaceState(null, '', `/box/${id}`) | ||||||
|  |       }) | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   { flush: 'post' } | ||||||
|  | ) | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <style> | <style> | ||||||
|  | button, | ||||||
|  | input { | ||||||
|  |   @apply px-4 py-2 m-4 rounded-md text-center font-bold | ||||||
|  | } | ||||||
|  |  | ||||||
| button { | button { | ||||||
|   @apply px-4 py-2 m-4 bg-esyellow text-slate-700 rounded-md; |   @apply bg-esyellow text-slate-700; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | input[type="email"] { | ||||||
|  |   @apply bg-slate-900 text-esyellow; | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
							
								
								
									
										25
									
								
								app/components/Loader.vue
									
									
									
									
									
										Normal 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"></div> | ||||||
|  |       <img src="/loader.svg" alt="Loading" class="relative h-40 w-40" /> | ||||||
|  |     </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> | ||||||
							
								
								
									
										235
									
								
								app/components/SearchModal.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,235 @@ | |||||||
|  | <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> | ||||||
|  |                   <button v-if="resultItem.type === 'TRACK'" | ||||||
|  |                     class="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800" aria-label="Toggle favorite" | ||||||
|  |                     @click.stop="fav.toggle(resultItem.payload)"> | ||||||
|  |                     <svg v-if="fav.isFavorite(resultItem.payload.id)" class="h-5 w-5 text-rose-500" viewBox="0 0 24 24" | ||||||
|  |                       fill="currentColor"> | ||||||
|  |                       <path | ||||||
|  |                         d="M12 21s-6.716-4.35-9.428-7.062C.86 12.226.5 10.64.5 9.5.5 6.462 2.962 4 6 4c1.657 0 3.157.806 4 2.09C10.843 4.806 12.343 4 14 4c3.038 0 5.5 2.462 5.5 5.5 0 1.14-.36 2.726-2.072 4.438C18.716 16.65 12 21 12 21z" /> | ||||||
|  |                     </svg> | ||||||
|  |                     <svg v-else class="h-5 w-5 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" | ||||||
|  |                       stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||||||
|  |                       <path | ||||||
|  |                         d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8z" /> | ||||||
|  |                     </svg> | ||||||
|  |                   </button> | ||||||
|  |                 </div> | ||||||
|  |               </li> | ||||||
|  |             </ul> | ||||||
|  |           </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' | ||||||
|  | import { useFavoritesStore } from '~/store/favorites' | ||||||
|  |  | ||||||
|  | const ui = useUiStore() | ||||||
|  | const data = useDataStore() | ||||||
|  | const player = usePlayerStore() | ||||||
|  | const fav = useFavoritesStore() | ||||||
|  | const inputRef = ref<HTMLInputElement | null>(null) | ||||||
|  | const activeIndex = ref(0) | ||||||
|  |  | ||||||
|  | const close = () => ui.closeSearch() | ||||||
|  |  | ||||||
|  | const normalized = (s: string) => s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase() | ||||||
|  |  | ||||||
|  | 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 | ||||||
|  |     // If the selected track is a favorite, just play it without navigating/selecting its box | ||||||
|  |     if (fav.isFavorite(track.id)) { | ||||||
|  |       player.playTrack(track) | ||||||
|  |     } else { | ||||||
|  |       const box = data.getBoxById(track.boxId) | ||||||
|  |       if (box) { | ||||||
|  |         ui.selectBox(box.id) | ||||||
|  |         player.playTrack(track) | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   close() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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> | ||||||
							
								
								
									
										371
									
								
								app/components/box.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,371 @@ | |||||||
|  | <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="box.duration" class="cover absolute" :src="`/${box.id}/cover.jpg`" alt=""> | ||||||
|  |         <div class="size-full flex flex-col justify-center items-center text-7xl" v-html="box.description" v-else /> | ||||||
|  |       </div> | ||||||
|  |       <div class="face back flex flex-col flex-wrap items-start p-4 overflow-hidden" ref="backFace"> | ||||||
|  |         <li class="list-none text-xxs w-1/2 flex flex-row" | ||||||
|  |           v-for="track in dataStore.getTracksByboxId(box.id).slice(0, -1)" :key="track.id" :track="track"> | ||||||
|  |           <span class="" v-if="isNotManifesto"> | ||||||
|  |             {{ track.order }}. | ||||||
|  |           </span> | ||||||
|  |           <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="box.duration !== 0"> | ||||||
|  |           <img class="logo h-full p-1" src="/logo.svg" alt=""> | ||||||
|  |           <img class="absolute block h-1/2" style="left:5%;" :src="`/${box.id}/title.svg`" alt=""> | ||||||
|  |         </template> | ||||||
|  |         <template v-else> | ||||||
|  |           <span class="absolute block h-1/2 right-6"> | ||||||
|  |             playlist | ||||||
|  |           </span> | ||||||
|  |           <img class="logo h-full p-1" src="/favicon.svg" alt=""> | ||||||
|  |           <span class="absolute block h-1/2" style="left:5%;"> | ||||||
|  |             {{ 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, BoxState } from '~~/types/types' | ||||||
|  | import { useDataStore } from '~/store/data' | ||||||
|  |  | ||||||
|  | const props = defineProps<{ | ||||||
|  |   box: Box | ||||||
|  | }>(); | ||||||
|  |  | ||||||
|  | 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')) | ||||||
|  |  | ||||||
|  | // --- 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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // --- 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.state, () => applyBoxState()) | ||||||
|  | watch(() => props.box, () => applyColor(), { deep: true }) | ||||||
|  | watch(isDraggable, (enabled) => (enabled ? addListeners() : removeListeners())) | ||||||
|  |  | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style lang="scss" scoped> | ||||||
|  | .box { | ||||||
|  |   --size: 6px; | ||||||
|  |   --height: calc(var(--size) * (100 / 3)); | ||||||
|  |   --width: calc(var(--size) * 50); | ||||||
|  |   --depth: calc(var(--size) * 10); | ||||||
|  |   transition: height .5s ease, opacity .5s ease; | ||||||
|  |  | ||||||
|  |   &.box-list { | ||||||
|  |     height: calc(var(--size) * 20); | ||||||
|  |     @apply hover:scale-105; | ||||||
|  |     transition: all .5s ease; | ||||||
|  |     will-change: transform; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   &.box-selected { | ||||||
|  |     height: calc(var(--size) * 34); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   &-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%; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .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); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .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 .25s ease; | ||||||
|  |     pointer-events: none; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   &.box-selected .box-page { | ||||||
|  |     opacity: 1; | ||||||
|  |     pointer-events: auto; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /* for tabindex */ | ||||||
|  |   &:focus-visible { | ||||||
|  |     outline: 0; | ||||||
|  |     @apply scale-105 outline-none; | ||||||
|  |  | ||||||
|  |     .face { | ||||||
|  |       // border: 4px solid rgba(0, 0, 0, 0.5); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   :deep(.indice) { | ||||||
|  |     @apply text-xl p-2 relative bg-black/50 rounded-full backdrop-blur-md; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </style> | ||||||
							
								
								
									
										48
									
								
								app/components/boxes.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,48 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="boxes"> | ||||||
|  |     <box v-for="(box, i) in dataStore.boxes.slice()" :key="box.id" :tabindex="dataStore.boxes.length - i" :box="box" | ||||||
|  |       @click="onBoxClick(box)" class="text-center" :class="box.state" :id="box.id"> | ||||||
|  |       <button @click.stop="playSelectedBox(box)" v-if="box.state === 'box-selected'" | ||||||
|  |         class="relative z-40 rounded-full size-24 bottom-1/2 text-4xl tex-bold text-esyellow backdrop-blur-sm bg-black/25"> | ||||||
|  |         {{ !playerStore.isPaused && playerStore.currentTrack?.boxId === box.id ? 'I I' : '▶' }} | ||||||
|  |       </button> | ||||||
|  |       <deck :box="box" class="box-page" v-if="box.state === 'box-selected'" @click.stop /> | ||||||
|  |     </box> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup lang="ts"> | ||||||
|  | import type { Box } from '~~/types/types' | ||||||
|  | import { useDataStore } from '~/store/data' | ||||||
|  | import { usePlayerStore } from '~/store/player' | ||||||
|  | import { useUiStore } from '~/store/ui' | ||||||
|  |  | ||||||
|  | const dataStore = useDataStore() | ||||||
|  | const playerStore = usePlayerStore() | ||||||
|  | const uiStore = useUiStore() | ||||||
|  |  | ||||||
|  | function openBox(id: string) { | ||||||
|  |   uiStore.selectBox(id) | ||||||
|  |   // Scroll to the top smoothly | ||||||
|  |   window.scrollTo({ | ||||||
|  |     top: 0, | ||||||
|  |     behavior: 'smooth' | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function onBoxClick(b: Box) { | ||||||
|  |   if (b.state !== 'box-selected') { | ||||||
|  |     openBox(b.id) | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function playSelectedBox(b: Box) { | ||||||
|  |   playerStore.playBox(b) | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style lang="scss"> | ||||||
|  | .boxes { | ||||||
|  |   @apply flex flex-col-reverse; | ||||||
|  | } | ||||||
|  | </style> | ||||||
							
								
								
									
										118
									
								
								app/components/card.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,118 @@ | |||||||
|  | <template> | ||||||
|  |   <article class="card isplaying w-56 h-80" :class="isFaceUp ? 'face-up' : 'face-down'"> | ||||||
|  |     <div class="flip-inner"> | ||||||
|  |       <!-- Face-Up --> | ||||||
|  |       <main | ||||||
|  |         class="face-up backdrop-blur-sm border-1 z-10 card w-56 h-80 p-3 bg-opacity-40 hover:bg-opacity-80 hover:shadow-xl transition-all bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden"> | ||||||
|  |         <div class="flex items-center justify-center size-7 absolute top-7 right-7" v-if="isPlaylistTrack"> | ||||||
|  |           <div class="suit text-7xl absolute" | ||||||
|  |             :class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]"> | ||||||
|  |             <img :src="`/${props.track.card?.suit}.svg`" /> | ||||||
|  |           </div> | ||||||
|  |           <div class="rank text-white font-bold absolute -mt-1"> | ||||||
|  |             {{ props.track.card?.rank }} | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |         <!-- Cover --> | ||||||
|  |         <figure class="flex-1 overflow-hidden rounded-t-xl cursor-pointer"> | ||||||
|  |           <img :src="coverUrl" alt="Pochette de l'album" class="w-full h-full object-cover object-center" /> | ||||||
|  |         </figure> | ||||||
|  |  | ||||||
|  |         <!-- Body --> | ||||||
|  |         <div class="p-3 text-center bg-white rounded-b-xl"> | ||||||
|  |           <div class="label" v-if="isOrder"> | ||||||
|  |             {{ props.track.order }} | ||||||
|  |           </div> | ||||||
|  |           <h2 class="text-base text-neutral-800 font-bold truncate"> | ||||||
|  |             {{ props.track.title }} | ||||||
|  |           </h2> | ||||||
|  |           <p class="text-sm text-neutral-500 truncate"> | ||||||
|  |             <template v-if="isPlaylistTrack"> | ||||||
|  |               {{ props.track.artist.name }} | ||||||
|  |             </template> | ||||||
|  |           </p> | ||||||
|  |         </div> | ||||||
|  |       </main> | ||||||
|  |  | ||||||
|  |       <!-- Face-Down --> | ||||||
|  |       <footer | ||||||
|  |         class="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"> | ||||||
|  |         <div class="h-full flex p-16 text-center bg-slate-800 rounded-xl"> | ||||||
|  |           <img src="/favicon.svg" /> | ||||||
|  |           <div class="label label--id" v-if="isOrder"> | ||||||
|  |             {{ props.track.order }} | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </footer> | ||||||
|  |     </div> | ||||||
|  |   </article> | ||||||
|  |  | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup lang="ts"> | ||||||
|  | import type { Track } from '~~/types/types' | ||||||
|  |  | ||||||
|  | const props = withDefaults(defineProps<{ track: Track; isFaceUp?: boolean }>(), { | ||||||
|  |   isFaceUp: false | ||||||
|  | }) | ||||||
|  | const isManifesto = computed(() => props.track.boxId.startsWith('ES00')) | ||||||
|  | const isOrder = computed(() => props.track.order && !isManifesto) | ||||||
|  | const isPlaylistTrack = computed(() => props.track.type === 'playlist') | ||||||
|  | const isRedCard = computed(() => props.track.card?.suit === '♥' || props.track.card?.suit === '♦') | ||||||
|  | const coverUrl = props.track.coverId.startsWith('http') | ||||||
|  |   ? props.track.coverId | ||||||
|  |   : `https://f4.bcbits.com/img/${props.track.coverId}_4.jpg`; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style lang="scss"> | ||||||
|  | .label { | ||||||
|  |   @apply rounded-full size-7 p-2 bg-esyellow leading-3 -mt-6; | ||||||
|  |   font-weight: bold; | ||||||
|  |   text-align: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Flip effect */ | ||||||
|  | .card { | ||||||
|  |   perspective: 1000px; | ||||||
|  |  | ||||||
|  |   .flip-inner { | ||||||
|  |     position: relative; | ||||||
|  |     width: 100%; | ||||||
|  |     height: 100%; | ||||||
|  |     transition: transform 0.6s; | ||||||
|  |     transform-style: preserve-3d; | ||||||
|  |  | ||||||
|  |     .face-down & { | ||||||
|  |       transform: rotateY(180deg); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .face-up & { | ||||||
|  |       transform: rotateY(0deg); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .face-down, | ||||||
|  |   .face-up { | ||||||
|  |     position: absolute; | ||||||
|  |     width: 100%; | ||||||
|  |     height: 100%; | ||||||
|  |     backface-visibility: hidden; | ||||||
|  |     will-change: transform; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .face-up { | ||||||
|  |     transform: rotateY(0deg); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .face-down { | ||||||
|  |     transform: rotateY(180deg); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .♠, | ||||||
|  |   .♣, | ||||||
|  |   .♦, | ||||||
|  |   .♥ { | ||||||
|  |     @apply text-5xl size-14; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </style> | ||||||
							
								
								
									
										55
									
								
								app/components/deck.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,55 @@ | |||||||
|  | <template> | ||||||
|  |   <div> | ||||||
|  |     <div class="deck-order"> | ||||||
|  |       <button @click="orderDeck('pile')">pile</button> | ||||||
|  |       <button @click="orderDeck('plateau')">plateau</button> | ||||||
|  |       <button @click="orderDeck('holdem')">holdem</button> | ||||||
|  |     </div> | ||||||
|  |     <div ref="deck" class="deck flex flex-wrap justify-center gap-4"> | ||||||
|  |       <card v-for="(track, i) in tracks" :key="track.id" :track="track" tabindex="i" | ||||||
|  |         @click="() => playerStore.playTrack(track).catch(err => console.error(err))" /> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup lang="ts"> | ||||||
|  | import { computed, ref } from 'vue' | ||||||
|  | import { useDataStore } from '~/store/data' | ||||||
|  | import type { Box } from '~~/types/types' | ||||||
|  | import { usePlayerStore } from '~/store/player' | ||||||
|  |  | ||||||
|  | const props = defineProps<{ | ||||||
|  |   box: Box | ||||||
|  | }>() | ||||||
|  | const dataStore = useDataStore() | ||||||
|  | const deck = ref() | ||||||
|  | const tracks = computed(() => dataStore.getTracksByboxId(props.box.id)) | ||||||
|  | const playerStore = usePlayerStore() | ||||||
|  |  | ||||||
|  | function orderDeck(order: string) { | ||||||
|  |   deck.value.classList.remove('pile', 'plateau', 'holdem') | ||||||
|  |   deck.value.classList.add(order) | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style lang="scss"> | ||||||
|  | .deck { | ||||||
|  |   @apply transition-all; | ||||||
|  |  | ||||||
|  |   &.pile { | ||||||
|  |     @apply relative; | ||||||
|  |  | ||||||
|  |     .card { | ||||||
|  |       @apply absolute top-0; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   &.plateau { | ||||||
|  |     @apply mt-8 p-8 w-full flex flex-wrap justify-around; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   &.holdem { | ||||||
|  |     /* style holdem */ | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </style> | ||||||
							
								
								
									
										8
									
								
								app/components/logo.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,8 @@ | |||||||
|  | <template> | ||||||
|  |   <header class="py-4"> | ||||||
|  |     <img class="logo w-80" src="/logo.svg" alt=""> | ||||||
|  |     <h1 class="text-center"> | ||||||
|  |       mix-tapes | ||||||
|  |     </h1> | ||||||
|  |   </header> | ||||||
|  | </template> | ||||||
| @@ -1,126 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <div class="w-96 m-6"> |  | ||||||
|     <atropos-component class="atropos game-box atropos-rotate-touch-scroll-y" rotate-touch="scroll-y" rotate-x-max="24" |  | ||||||
|       rotate-y-max="24"> |  | ||||||
|  |  | ||||||
|       <div class="atropos-inner relative"> |  | ||||||
|         <div class="game-box-bg bg-gradient-to-t from-slate-800 to-zinc-900 h-96 relative" data-atropos-offset="-8" /> |  | ||||||
|         <img :src="id + '/object.png'" data-atropos-offset="-3" class="absolute bottom-0 inset-0 h-96 object-cover"> |  | ||||||
|         <img :src="id + '/name.png'" data-atropos-offset="0" class="absolute inset-0 self-end justify-self-end p-4"> |  | ||||||
|         <img src="/logo.svg" data-atropos-offset="0" width="70%" |  | ||||||
|           class="logo absolute inset-0 self-center justify-self-center"> |  | ||||||
|         <!-- <img src="/play.svg" width="20%" class="absolute play"> --> |  | ||||||
|       </div> |  | ||||||
|       <span class="game-box-t" /> |  | ||||||
|       <span class="game-box-r" /> |  | ||||||
|       <span class="game-box-b" /> |  | ||||||
|       <span class="game-box-l" /> |  | ||||||
|     </atropos-component> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <script setup> |  | ||||||
| const id = 'ES01A' |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <style> |  | ||||||
| .game-box .logo { |  | ||||||
|   filter: drop-shadow(4px 4px 0 rgb(0 0 0 / 0.8)); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .game-box { |  | ||||||
|   --side-color: #004297; |  | ||||||
|   --side-size: 32px; |  | ||||||
|   aspect-ratio: 526 / 656; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .atropos-rotate { |  | ||||||
|   position: relative; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .game-box .atropos-rotate:before { |  | ||||||
|   content: ""; |  | ||||||
|   position: absolute; |  | ||||||
|   left: 0; |  | ||||||
|   width: calc(100% + 8px); |  | ||||||
|   height: calc(100% + 16px); |  | ||||||
|   top: -8px; |  | ||||||
|   background: #086ef4; |  | ||||||
|   z-index: 1; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .atropos-inner { |  | ||||||
|   width: 100%; |  | ||||||
|   height: 100%; |  | ||||||
|   overflow: hidden; |  | ||||||
|   transform-style: preserve-3d; |  | ||||||
|   transform: translateZ(0); |  | ||||||
|   display: block; |  | ||||||
|   z-index: 1; |  | ||||||
|   position: relative; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .game-box-t, |  | ||||||
| .game-box-r, |  | ||||||
| .game-box-b, |  | ||||||
| .game-box-l { |  | ||||||
|   transform-style: preserve-3d; |  | ||||||
|   backface-visibility: hidden; |  | ||||||
|   position: absolute; |  | ||||||
|   /* display: none; */ |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .game-box-t { |  | ||||||
|   width: calc(100% + 8px); |  | ||||||
|   height: var(--side-size); |  | ||||||
|   background: var(--side-color); |  | ||||||
|   left: 0; |  | ||||||
|   top: -8px; |  | ||||||
|   transform: translate3d(0, 0, -32px) rotateX(90deg); |  | ||||||
|   transform-origin: center top; |  | ||||||
|   top: -8px; |  | ||||||
|   transform: translateZ(-32px) rotateX(90deg); |  | ||||||
|   transform-origin: center top; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .game-box-b { |  | ||||||
|   width: calc(100% + 8px); |  | ||||||
|   height: var(--side-size); |  | ||||||
|   background: var(--side-color); |  | ||||||
|   left: 0; |  | ||||||
|   bottom: -8px; |  | ||||||
|   transform: translate3d(0, 0, -32px) rotateX(-90deg); |  | ||||||
|   transform-origin: center bottom; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .game-box-r { |  | ||||||
|   width: var(--side-size); |  | ||||||
|   height: calc(100% + 16px); |  | ||||||
|   background: var(--side-color); |  | ||||||
|   right: -8px; |  | ||||||
|   top: -8px; |  | ||||||
|   transform: translate3d(0, 0, -32px) rotateY(90deg); |  | ||||||
|   transform-origin: right center; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .game-box-l { |  | ||||||
|   width: var(--side-size); |  | ||||||
|   height: calc(100% + 16px); |  | ||||||
|   background: var(--side-color); |  | ||||||
|   left: 0px; |  | ||||||
|   top: -8px; |  | ||||||
|   transform: translate3d(0, 0, -32px) rotateY(-90deg); |  | ||||||
|   transform-origin: left center; |  | ||||||
|   overflow: hidden; |  | ||||||
|  |  | ||||||
|   &::before { |  | ||||||
|     content: ''; |  | ||||||
|     position: absolute; |  | ||||||
|     width: 100%; |  | ||||||
|     height: 10.4%; |  | ||||||
|     background: #a5a5a5; |  | ||||||
|     left: 0; |  | ||||||
|     top: 9px; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -1,296 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <article class="box box-scene z-10" ref="scene"> |  | ||||||
|     <div class="box-object" ref="box"> |  | ||||||
|       <div class="face front relative" ref="frontFace"> |  | ||||||
|         <img class="cover absolute" :src="`/${compilation.id}/cover.jpg`" alt=""> |  | ||||||
|       </div> |  | ||||||
|       <div class="face back" ref="backFace"> |  | ||||||
|         {{ compilation.description }} |  | ||||||
|       </div> |  | ||||||
|       <div class="face right" ref="rightFace" /> |  | ||||||
|       <div class="face left" ref="leftFace" /> |  | ||||||
|       <div class="face top" ref="topFace"> |  | ||||||
|         <img class="logo h-full p-1" src="/logo.svg" alt=""> |  | ||||||
|         <img class="absolute block h-1/2" style="left:5%;" :src="`/${compilation.id}/title.svg`" alt=""> |  | ||||||
|       </div> |  | ||||||
|       <div class="face bottom" ref="bottomFace" /> |  | ||||||
|     </div> |  | ||||||
|     <OrganismCompilationPage :compilation="compilation" class="box-page" v-if="props.BoxState === 'selected'" /> |  | ||||||
|   </article> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <script setup lang="ts"> |  | ||||||
| import { ref, onMounted, onBeforeUnmount, watch } from 'vue' |  | ||||||
| import type { Compilation, BoxState } from '~~/types/types' |  | ||||||
|  |  | ||||||
| const props = withDefaults( |  | ||||||
|   defineProps<{ |  | ||||||
|     compilation: Compilation |  | ||||||
|     BoxState?: BoxState |  | ||||||
|   }>(), |  | ||||||
|   { BoxState: 'list' } |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| // --- Réfs --- |  | ||||||
| const scene = ref<HTMLElement>() |  | ||||||
| const box = 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 = 0.3 |  | ||||||
| const friction = 0.95 |  | ||||||
| const minVelocity = 0.02 |  | ||||||
| const enableInertia = true |  | ||||||
|  |  | ||||||
| // --- Transformations --- |  | ||||||
| function applyTransform(duration = 0.5) { |  | ||||||
|   if (!box.value) return |  | ||||||
|   rotateX.value = Math.round(rotateX.value) |  | ||||||
|   rotateY.value = Math.round(rotateY.value) |  | ||||||
|   rotateZ.value = Math.round(rotateZ.value) |  | ||||||
|  |  | ||||||
|   box.value.style.transition = `transform ${duration}s ease` |  | ||||||
|   box.value.style.transform = `rotateX(${rotateX.value}deg) rotateY(${rotateY.value}deg) rotateZ(${rotateZ.value}deg)` |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // --- Gestion BoxState --- |  | ||||||
| function applyBoxState() { |  | ||||||
|   switch (props.BoxState) { |  | ||||||
|     case 'list': |  | ||||||
|       rotateX.value = 76 |  | ||||||
|       rotateY.value = 0 |  | ||||||
|       rotateZ.value = 150 |  | ||||||
|       break |  | ||||||
|     case 'selected': |  | ||||||
|       rotateX.value = -20 |  | ||||||
|       rotateY.value = 20 |  | ||||||
|       rotateZ.value = 0 |  | ||||||
|       break |  | ||||||
|     case 'hide': |  | ||||||
|       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.compilation.color2 |  | ||||||
|   backFace.value.style.background = `linear-gradient(to top, ${props.compilation.color1}, ${props.compilation.color2})` |  | ||||||
|   leftFace.value.style.background = `linear-gradient(to top, ${props.compilation.color1}, ${props.compilation.color2})` |  | ||||||
|   rightFace.value.style.background = `linear-gradient(to top, ${props.compilation.color1}, ${props.compilation.color2})` |  | ||||||
|   topFace.value.style.background = `linear-gradient(to top, ${props.compilation.color2}, ${props.compilation.color2})` |  | ||||||
|   bottomFace.value.style.background = props.compilation.color1 |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // --- 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 --- |  | ||||||
| onMounted(() => { |  | ||||||
|   applyColor() |  | ||||||
|   applyBoxState() |  | ||||||
|  |  | ||||||
|   const down = (ev: PointerEvent) => { |  | ||||||
|     ev.preventDefault() |  | ||||||
|     dragging = true |  | ||||||
|     scene.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 { scene.value?.releasePointerCapture(ev.pointerId) } catch { } |  | ||||||
|     if (enableInertia && (Math.abs(velocity.x) > minVelocity || Math.abs(velocity.y) > minVelocity)) { |  | ||||||
|       if (!raf) raf = requestAnimationFrame(tickInertia) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   scene.value?.addEventListener('pointerdown', down) |  | ||||||
|   scene.value?.addEventListener('pointermove', move) |  | ||||||
|   scene.value?.addEventListener('pointerup', end) |  | ||||||
|   scene.value?.addEventListener('pointercancel', end) |  | ||||||
|   scene.value?.addEventListener('pointerleave', end) |  | ||||||
|  |  | ||||||
|   onBeforeUnmount(() => { |  | ||||||
|     cancelAnimationFrame(raf!) |  | ||||||
|   }) |  | ||||||
| }) |  | ||||||
|  |  | ||||||
| // --- Watchers --- |  | ||||||
| watch(() => props.BoxState, () => applyBoxState()) |  | ||||||
| watch(() => props.compilation, () => applyColor(), { deep: true }) |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <style lang="scss" scoped> |  | ||||||
| .box { |  | ||||||
|   --size: 6px; |  | ||||||
|   --height: calc(var(--size) * (100 / 3)); |  | ||||||
|   --width: calc(var(--size) * 50); |  | ||||||
|   --depth: calc(var(--size) * 10); |  | ||||||
|   transition: all .5s; |  | ||||||
|  |  | ||||||
|   &.hide { |  | ||||||
|     height: 0; |  | ||||||
|     opacity: 0; |  | ||||||
|     z-index: 0; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   &.list { |  | ||||||
|     @apply hover:scale-105; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   &-scene { |  | ||||||
|     height: calc(var(--size) * 20); |  | ||||||
|     width: var(--width); |  | ||||||
|     perspective: 1000px; |  | ||||||
|     touch-action: none; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   &-object { |  | ||||||
|     width: var(--width); |  | ||||||
|     height: var(--height); |  | ||||||
|     position: relative; |  | ||||||
|     transform-style: preserve-3d; |  | ||||||
|     margin: auto; |  | ||||||
|     user-select: none; |  | ||||||
|  |  | ||||||
|     .list & { |  | ||||||
|       cursor: pointer; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     .selected & { |  | ||||||
|       cursor: grab; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     &:active { |  | ||||||
|       cursor: grabbing; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .face { |  | ||||||
|     position: absolute; |  | ||||||
|     display: flex; |  | ||||||
|     align-items: center; |  | ||||||
|     justify-content: center; |  | ||||||
|     font-size: 20px; |  | ||||||
|     color: white; |  | ||||||
|     font-weight: 600; |  | ||||||
|     backface-visibility: hidden; |  | ||||||
|     box-sizing: border-box; |  | ||||||
|     border: 1px solid black; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .front, |  | ||||||
|   .back { |  | ||||||
|     width: 100%; |  | ||||||
|     height: 100%; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .face.top, |  | ||||||
|   .face.bottom { |  | ||||||
|     width: var(--width); |  | ||||||
|     height: var(--depth); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .face.left, |  | ||||||
|   .face.right { |  | ||||||
|     width: var(--depth); |  | ||||||
|     height: var(--height); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .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; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -1,35 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <article class="relative"> |  | ||||||
|     <main |  | ||||||
|       class="absolute top-0 backdrop-blur-sm z-40 -mt-12 z-10 card w-56 h-80 p-3 bg-opacity-10 bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden"> |  | ||||||
|       <!-- Cover --> |  | ||||||
|       <figure class="flex-1 overflow-hidden rounded-t-xl"> |  | ||||||
|         <img :src="coverUrl" alt="Pochette de l'album" class="w-full h-full object-cover object-center" /> |  | ||||||
|       </figure> |  | ||||||
|  |  | ||||||
|       <!-- Body --> |  | ||||||
|       <div class="p-3 text-center bg-white rounded-b-xl"> |  | ||||||
|         <h2 class="text-base text-neutral-800 font-bold truncate">{{ props.track.title }}</h2> |  | ||||||
|         <p class="text-sm text-neutral-500 truncate"> |  | ||||||
|           {{ props.track.artist.name }} |  | ||||||
|         </p> |  | ||||||
|       </div> |  | ||||||
|     </main> |  | ||||||
|  |  | ||||||
|     <footer |  | ||||||
|       class="absolute top-0 ml-32 backdrop-blur-sm -mt-12 z-10 card w-56 h-80 p-3 bg-opacity-10 bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden"> |  | ||||||
|       <!-- Back --> |  | ||||||
|       <div class="h-full flex p-16 text-center bg-slate-800 rounded-xl"> |  | ||||||
|         <img src="/favicon.svg" /> |  | ||||||
|       </div> |  | ||||||
|     </footer> |  | ||||||
|   </article> |  | ||||||
|  |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <script setup lang="ts"> |  | ||||||
| import type { Track } from '~~/types/types' |  | ||||||
|  |  | ||||||
| const props = defineProps<{ track: Track }>() |  | ||||||
| const coverUrl = `https://f4.bcbits.com/img/${props.track.artist.coverId}_4.jpg` |  | ||||||
| </script> |  | ||||||
| @@ -1,103 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <h1 class="flex items-center justify-center min-h-screen"> |  | ||||||
|     <atropos-component class="my-atropos"> |  | ||||||
|       <span class="game-box-t" /> |  | ||||||
|       <span class="game-box-r" /> |  | ||||||
|       <span class="game-box-b" /> |  | ||||||
|       <span class="game-box-l" /> |  | ||||||
|       <div class="game-box-bg bg-gradient-to-t from-slate-800 to-zinc-900 h-60" data-atropos-offset="-8" /> |  | ||||||
|       <img :src="id + '/object.png'" data-atropos-offset="-3" class="absolute inset-0 object-cover"> |  | ||||||
|       <img :src="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"> |  | ||||||
|       <img src="/play.svg" width="20%" class="absolute play"> |  | ||||||
|     </atropos-component> |  | ||||||
|   </h1> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <script setup> |  | ||||||
| const id = 'ES01A' |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <style> |  | ||||||
| /* .atropos-inner, |  | ||||||
| .game-box-bg { |  | ||||||
|   width: 300px; |  | ||||||
|   height: 300px; |  | ||||||
| } */ |  | ||||||
|  |  | ||||||
| .game-box-t, |  | ||||||
| .game-box-r, |  | ||||||
| .game-box-b, |  | ||||||
| .game-box-l { |  | ||||||
|   transform-style: preserve-3d; |  | ||||||
|   backface-visibility: hidden; |  | ||||||
|   position: absolute; |  | ||||||
|   /* display: none; */ |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .game-box-t { |  | ||||||
|   width: calc(100% + 8px); |  | ||||||
|   height: var(--side-size); |  | ||||||
|   background: var(--side-color); |  | ||||||
|   left: 0; |  | ||||||
|   top: -8px; |  | ||||||
|   transform: translate3d(0, 0, -32px) rotateX(90deg); |  | ||||||
|   transform-origin: center top; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .game-box-b { |  | ||||||
|   width: calc(100% + 8px); |  | ||||||
|   height: var(--side-size); |  | ||||||
|   background: var(--side-color); |  | ||||||
|   left: 0; |  | ||||||
|   bottom: -8px; |  | ||||||
|   transform: translate3d(0, 0, -32px) rotateX(-90deg); |  | ||||||
|   transform-origin: center bottom; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .game-box-r { |  | ||||||
|   width: var(--side-size); |  | ||||||
|   height: calc(100% + 16px); |  | ||||||
|   background: var(--side-color); |  | ||||||
|   right: -8px; |  | ||||||
|   top: -8px; |  | ||||||
|   transform: translate3d(0, 0, -32px) rotateY(90deg); |  | ||||||
|   transform-origin: right center; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .game-box-l { |  | ||||||
|   width: var(--side-size); |  | ||||||
|   height: calc(100% + 16px); |  | ||||||
|   background: var(--side-color); |  | ||||||
|   left: 0px; |  | ||||||
|   top: -8px; |  | ||||||
|   transform: translate3d(0, 0, -32px) rotateY(-90deg); |  | ||||||
|   transform-origin: left center; |  | ||||||
|   overflow: hidden; |  | ||||||
|  |  | ||||||
|   &::before { |  | ||||||
|     content: ''; |  | ||||||
|     position: absolute; |  | ||||||
|     width: 100%; |  | ||||||
|     height: 10.4%; |  | ||||||
|     background: #a5a5a5; |  | ||||||
|     left: 0; |  | ||||||
|     top: 9px; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .atropos-rotate { |  | ||||||
|   position: relative; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .atropos-rotate:before { |  | ||||||
|   content: ""; |  | ||||||
|   position: absolute; |  | ||||||
|   left: 0; |  | ||||||
|   width: calc(100% + 8px); |  | ||||||
|   height: calc(100% + 16px); |  | ||||||
|   top: -8px; |  | ||||||
|   background: #086ef4; |  | ||||||
|   z-index: 1; |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
							
								
								
									
										11
									
								
								app/components/newsletter.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,11 @@ | |||||||
|  | <template> | ||||||
|  |   <form class="h-screen flex justify-center items-center flex-col absolute top-0 left-1/2 -translate-x-1/2"> | ||||||
|  |     <label for="email" class="block text-xl"> | ||||||
|  |       be notify when's evilSpins open : | ||||||
|  |     </label> | ||||||
|  |     <div> | ||||||
|  |       <input type="email" name="" id="email" placeholder="email"> | ||||||
|  |       <button>ok</button> | ||||||
|  |     </div> | ||||||
|  |   </form> | ||||||
|  | </template> | ||||||
| @@ -1,53 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <div class="flex flex-col-reverse mt-16"> |  | ||||||
|     <molecule-box v-for="compilation in dataStore.getAllCompilations.slice().reverse()" :key="compilation.id" |  | ||||||
|       :compilation="compilation" :BoxState="boxStates[compilation.id]" @click="() => openCompilation(compilation.id)" |  | ||||||
|       :class="boxStates[compilation.id]" /> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <script setup lang="ts"> |  | ||||||
| import { useDataStore } from '~/store/data' |  | ||||||
| import type { BoxState } from '~~/types/types' |  | ||||||
| import { useRouter } from 'vue-router' |  | ||||||
|  |  | ||||||
| const dataStore = useDataStore() |  | ||||||
| const router = useRouter() |  | ||||||
| const boxStates = ref<Record<string, BoxState>>({}) |  | ||||||
|  |  | ||||||
| function openCompilation(id: string) { |  | ||||||
|   if (boxStates.value[id] === 'list') { |  | ||||||
|     for (const key in boxStates.value) { |  | ||||||
|       boxStates.value[key] = (key === id) ? 'selected' : 'hide' |  | ||||||
|     } |  | ||||||
|     window.history.pushState({}, '', '/compilation/' + id) |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function closeCompilation(e: KeyboardEvent) { |  | ||||||
|   if (e.key === 'Escape') { |  | ||||||
|     for (const key in boxStates.value) { |  | ||||||
|       boxStates.value[key] = 'list' |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|   window.history.pushState({}, '', '/') |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| onMounted(async () => { |  | ||||||
|   const dataStore = await useDataStore() |  | ||||||
|   await dataStore.loadData() |  | ||||||
|  |  | ||||||
|   dataStore.getAllCompilations.forEach(c => { |  | ||||||
|     if (!(c.id in boxStates.value)) boxStates.value[c.id] = 'hide' |  | ||||||
|   }) |  | ||||||
|  |  | ||||||
|   window.addEventListener('keydown', closeCompilation) |  | ||||||
|  |  | ||||||
|   setTimeout(() => { |  | ||||||
|     dataStore.getAllCompilations.forEach(c => { |  | ||||||
|       boxStates.value[c.id] = 'list' |  | ||||||
|     }) |  | ||||||
|   }, 333) |  | ||||||
| }) |  | ||||||
| </script> |  | ||||||
| @@ -1,17 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <div class="mt-8 p-8 w-96"> |  | ||||||
|     <MoleculeCard v-for="track in dataStore.getTracksByCompilationId(compilation.id)" :key="track.id" :track="track" /> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <script setup lang="ts"> |  | ||||||
| import { useDataStore } from '~/store/data' |  | ||||||
| import type { Compilation } from '~~/types/types' |  | ||||||
|  |  | ||||||
| const props = defineProps<{ |  | ||||||
|   compilation: Compilation |  | ||||||
| }>() |  | ||||||
|  |  | ||||||
| const dataStore = useDataStore() |  | ||||||
|  |  | ||||||
| </script> |  | ||||||
							
								
								
									
										37
									
								
								app/components/player.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,37 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="player-container fixed left-0 z-50 w-full h-20 bg-white" | ||||||
|  |     :class="playerStore.currentTrack ? '-bottom-0 opacity-100' : '-bottom-32 opacity-0'"> | ||||||
|  |     <div class="flex items-center gap-3 p-2"> | ||||||
|  |       <img v-if="playerStore.getCurrentCoverUrl" :src="playerStore.getCurrentCoverUrl as string" alt="Current cover" | ||||||
|  |         class="size-16 object-cover object-center rounded" /> | ||||||
|  |       <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-container { | ||||||
|  |   transition: all 1s ease-in-out; | ||||||
|  | } | ||||||
|  | </style> | ||||||
							
								
								
									
										32
									
								
								app/layouts/default.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,32 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="w-full min-h-screen flex flex-col items-center bg-gray-50"> | ||||||
|  |     <!-- Header avec logo --> | ||||||
|  |     <header class="w-full py-4 px-6 bg-white shadow-sm"> | ||||||
|  |       <div class="max-w-7xl mx-auto w-full flex justify-center"> | ||||||
|  |         <div @click="navigateToHome" class="cursor-pointer inline-block"> | ||||||
|  |           <logo /> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </header> | ||||||
|  |  | ||||||
|  |     <!-- Contenu principal --> | ||||||
|  |     <main class="w-full max-w-7xl flex-1 p-6"> | ||||||
|  |       <slot /> | ||||||
|  |     </main> | ||||||
|  |  | ||||||
|  |     <!-- Player de musique fixe en bas --> | ||||||
|  |     <SearchModal /> | ||||||
|  |     <Loader /> | ||||||
|  |     <Player class="w-full border-t border-gray-200" /> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup> | ||||||
|  | import { useRouter } from 'vue-router' | ||||||
|  |  | ||||||
|  | const router = useRouter() | ||||||
|  |  | ||||||
|  | const navigateToHome = () => { | ||||||
|  |   router.push('/') | ||||||
|  | } | ||||||
|  | </script> | ||||||
							
								
								
									
										35
									
								
								app/pages/box/[id].vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,35 @@ | |||||||
|  | <template> | ||||||
|  |   <boxes /> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup lang="ts"> | ||||||
|  | import { onMounted } from 'vue' | ||||||
|  | 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 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) | ||||||
|  |  | ||||||
|  |     // Lire automatiquement la box si on est sur la page d'une box | ||||||
|  |     const box = dataStore.boxes.find(b => b.id === idParam) | ||||||
|  |     if (box) { | ||||||
|  |       const player = usePlayerStore() | ||||||
|  |       player.playBox(box).catch(console.error) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | }) | ||||||
|  | </script> | ||||||
| @@ -1,20 +1,27 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="w-full flex flex-col items-center"> |   <boxes /> | ||||||
|     <header class="py-4"> |  | ||||||
|       <img class="logo p-1 w-80" src="/logo.svg" alt=""> |  | ||||||
|       <h1 class="dark:text-white text-center"> |  | ||||||
|         compilations |  | ||||||
|         indépendantes |  | ||||||
|       </h1> |  | ||||||
|     </header> |  | ||||||
|     <main> |  | ||||||
|       <OrganismCompilationList /> |  | ||||||
|     </main> |  | ||||||
|   </div> |  | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
|  | <script setup> | ||||||
|  | import { useUiStore } from '~/store/ui' | ||||||
|  | import { useDataStore } from '~/store/data' | ||||||
|  |  | ||||||
|  | // Configuration du layout | ||||||
|  | definePageMeta({ | ||||||
|  |   layout: 'default' | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | const uiStore = useUiStore() | ||||||
|  |  | ||||||
|  | onMounted(async () => { | ||||||
|  |   const dataStore = useDataStore() | ||||||
|  |   await dataStore.loadData() | ||||||
|  |   uiStore.listBoxes() | ||||||
|  | }) | ||||||
|  | </script> | ||||||
|  |  | ||||||
| <style> | <style> | ||||||
| .logo { | .logo { | ||||||
|   filter: drop-shadow(2px 2px 0 rgb(0 0 0 / 0.8)); |   filter: drop-shadow(3px 3px 0 rgb(0 0 0 / 0.7)); | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
							
								
								
									
										14
									
								
								app/pages/newsletter.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,14 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="w-full flex flex-col items-center"> | ||||||
|  |     <logo /> | ||||||
|  |     <main> | ||||||
|  |       <newsletter /> | ||||||
|  |     </main> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <style> | ||||||
|  | .logo { | ||||||
|  |   filter: drop-shadow(2px 2px 0 rgb(0 0 0 / 0.8)); | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -1,24 +1,24 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="flex flex-wrap justify-center"> |   <div class="flex flex-wrap justify-center items-center h-screen"> | ||||||
|     <div class="bg-page-dark-bg text-white"> |     <div class="bg-page-dark-bg text-white"> | ||||||
|       <div class="flex flex-col-reverse bg-gradient-to-r from-primary to-primary-dark"> |       <div class="flex flex-col-reverse bg-gradient-to-r from-primary to-primary-dark"> | ||||||
|         <div class="mt-8 flex flex-wrap justify-center"> |         <div class="mt-8 flex flex-wrap justify-center"> | ||||||
|           <molecule-box :compilation="compilation" /> |           <!-- <box :box="box" /> --> | ||||||
|           <div class="devtool  absolute right-4 text-white bg-black rounded-2xl px-4 py-2"> |           <div class="devtool  absolute right-4 text-white bg-black rounded-2xl px-4 py-2"> | ||||||
|             <!-- <button @click="currentPosition = boxPositions.side">side</button> |             <!-- <button @click="currentPosition = boxPositions.side">side</button> | ||||||
|             <button @click="currentPosition = boxPositions.front">front</button> |             <button @click="currentPosition = boxPositions.front">front</button> | ||||||
|             <button @click="currentPosition = boxPositions.back">back</button> --> |             <button @click="currentPosition = boxPositions.back">back</button> --> | ||||||
|             <div class="w-full block"> |             <div class="w-full block"> | ||||||
|               <input class="w-1/2" type="color" name="color1" id="color1" v-model="compilation.color1"> |               <input class="w-1/2" type="color" name="color1" id="color1" v-model="box.color1"> | ||||||
|               <input class="w-1/2" type="color" name="color1" id="color1" v-model="compilation.color2"> |               <input class="w-1/2" type="color" name="color1" id="color1" v-model="box.color2"> | ||||||
|               <div class="block w-full h-32" :style="{ |               <div class="block w-full h-32" :style="{ | ||||||
|                 background: `linear-gradient(to top, ${compilation.color1}, ${compilation.color2})` |                 background: `linear-gradient(to top, ${box.color1}, ${box.color2})` | ||||||
|               }"> |               }"> | ||||||
|               </div> |               </div> | ||||||
|               <label class="block"> |               <!-- <label class="block"> | ||||||
|                 size: {{ size }} |                 size: {{ size }} | ||||||
|                 <input v-model.number="size" type="range" step="1" min="1" max="14"> |                 <input v-model.number="size" type="range" step="1" min="1" max="14"> | ||||||
|               </label> |               </label> --> | ||||||
|             </div> |             </div> | ||||||
|             <!-- <div> |             <!-- <div> | ||||||
|               <label class="block"> |               <label class="block"> | ||||||
| @@ -37,15 +37,15 @@ | |||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|       <molecule-card :track="track" /> |       <card :track="track" /> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import type { BoxPosition, Compilation, Track } from '~~/types/types' | import type { Box, Track } from '~~/types/types' | ||||||
|  |  | ||||||
| const compilation = ref<Compilation>({ | const box = ref<Box>({ | ||||||
|   id: 'ES00A', |   id: 'ES00A', | ||||||
|   name: 'zero', |   name: 'zero', | ||||||
|   duration: 2794, |   duration: 2794, | ||||||
| @@ -53,11 +53,12 @@ const compilation = ref<Compilation>({ | |||||||
|   color1: '#ffffff', |   color1: '#ffffff', | ||||||
|   color2: '#48959d', |   color2: '#48959d', | ||||||
|   color3: '#00ff00', |   color3: '#00ff00', | ||||||
|  |   type: 'compilation' | ||||||
| }) | }) | ||||||
|  |  | ||||||
| const track = ref<Track>({ | const track = ref<Track>({ | ||||||
|   id: 1, |   id: 1, | ||||||
|   compilationId: 'ES00A', |   boxId: 'ES00A', | ||||||
|   title: 'The grinding wheel', |   title: 'The grinding wheel', | ||||||
|   artist: { |   artist: { | ||||||
|     id: 0, |     id: 0, | ||||||
| @@ -68,6 +69,7 @@ const track = ref<Track>({ | |||||||
|   start: 0, |   start: 0, | ||||||
|   url: 'https://arakirecords.bandcamp.com/track/the-grinding-wheel', |   url: 'https://arakirecords.bandcamp.com/track/the-grinding-wheel', | ||||||
|   coverId: 'a3236746052', |   coverId: 'a3236746052', | ||||||
|  |   type: 'compilation', | ||||||
| }) | }) | ||||||
|  |  | ||||||
| //from-slate-800 to-zinc-900  | //from-slate-800 to-zinc-900  | ||||||
|   | |||||||
							
								
								
									
										33
									
								
								app/pages/track/[id].vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,33 @@ | |||||||
|  | <template> | ||||||
|  |   <boxes /> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup> | ||||||
|  | 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.playTrack(track) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | }) | ||||||
|  | </script> | ||||||
							
								
								
									
										6
									
								
								app/plugins/device.client.ts
									
									
									
									
									
										Normal 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) | ||||||
|  | }) | ||||||
							
								
								
									
										6
									
								
								app/plugins/favorites.client.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,6 @@ | |||||||
|  | import { useFavoritesStore } from '~/store/favorites' | ||||||
|  |  | ||||||
|  | export default defineNuxtPlugin(() => { | ||||||
|  |   const fav = useFavoritesStore() | ||||||
|  |   fav.load() | ||||||
|  | }) | ||||||
							
								
								
									
										106
									
								
								app/plugins/shortcut.client.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,106 @@ | |||||||
|  | import { useUiStore } from '~/store/ui' | ||||||
|  | import { usePlayerStore } from '~/store/player' | ||||||
|  | import { onBeforeUnmount, onUnmounted, onMounted } from 'vue' | ||||||
|  | import { useRoute } from 'vue-router' | ||||||
|  | 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 route = useRoute() | ||||||
|  |   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)) { | ||||||
|  |       console.log('Input element, ignoring key') | ||||||
|  |       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 'Space': | ||||||
|  |         console.log('Space pressed, toggling play/pause') | ||||||
|  |         e.preventDefault() | ||||||
|  |         e.stopPropagation() | ||||||
|  |         if (player.currentTrack) { | ||||||
|  |           console.log('Toggling play state') | ||||||
|  |           player.togglePlay() | ||||||
|  |         } else { | ||||||
|  |           console.log('No current track to play/pause') | ||||||
|  |         } | ||||||
|  |         return false | ||||||
|  |  | ||||||
|  |       // Gestion de la touche Échap pour fermer la boîte | ||||||
|  |       case 'Escape': | ||||||
|  |         e.preventDefault() | ||||||
|  |         ui.closeBox() | ||||||
|  |         break | ||||||
|  |  | ||||||
|  |       // Gestion de la touche Entrée pour ouvrir une boîte | ||||||
|  |       case 'Enter': | ||||||
|  |         if (document.activeElement?.id) { | ||||||
|  |           e.preventDefault() | ||||||
|  |           ui.selectBox(document.activeElement.id) | ||||||
|  |           window.scrollTo({ top: 0, behavior: 'smooth' }) | ||||||
|  |         } | ||||||
|  |         break | ||||||
|  |  | ||||||
|  |       // Gestion des touches fléchées (à implémenter si nécessaire) | ||||||
|  |       case 'ArrowUp': | ||||||
|  |       case 'ArrowDown': | ||||||
|  |       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 }) | ||||||
|  |   console.log('Keyboard event listener added') | ||||||
|  |  | ||||||
|  |   // Nettoyage lors de la destruction | ||||||
|  |   const stop = () => { | ||||||
|  |     window.removeEventListener('keydown', handleKeyDown) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Nettoyage quand le composant est démonté | ||||||
|  |   onUnmounted(stop) | ||||||
|  |  | ||||||
|  |   // Nettoyage quand la page est déchargée | ||||||
|  |   if (process.client) { | ||||||
|  |     window.addEventListener('unload', stop) | ||||||
|  |     onBeforeUnmount(() => { | ||||||
|  |       window.removeEventListener('unload', stop) | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  | }) | ||||||
							
								
								
									
										16
									
								
								app/router.options.ts
									
									
									
									
									
										Normal 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 | ||||||
|  | } | ||||||
| @@ -1,58 +1,146 @@ | |||||||
| import type { Compilation, Artist, Track } from '~/../types/types' | import type { Box, Artist, Track } from '~/../types/types' | ||||||
|  | import { FAVORITES_BOX_ID, useFavoritesStore } from '~/store/favorites' | ||||||
|  |  | ||||||
| // stores/data.ts | // stores/data.ts | ||||||
| import { defineStore } from 'pinia' | import { defineStore } from 'pinia' | ||||||
|  | import { useUiStore } from '~/store/ui' | ||||||
|  |  | ||||||
| export const useDataStore = defineStore('data', { | export const useDataStore = defineStore('data', { | ||||||
|   state: () => ({ |   state: () => ({ | ||||||
|     compilations: [] as Compilation[],  // Store your compilation data here |     boxes: [] as Box[], // Store your box data here | ||||||
|     artists: [] as Artist[],       // Store artist data here |     artists: [] as Artist[], // Store artist data here | ||||||
|     tracks: [] as Track[],        // Store track data here |     tracks: [] as Track[], // Store track data here | ||||||
|     isLoaded: false,                   // Remember if data is already loaded |     isLoaded: false, // Remember if data is already loaded | ||||||
|  |     isLoading: true | ||||||
|   }), |   }), | ||||||
|  |  | ||||||
|   actions: { |   actions: { | ||||||
|     async loadData() { |     async loadData() { | ||||||
|       if (this.isLoaded) return |       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') | ||||||
|  |  | ||||||
|       const { data: compilations } = await useFetch<Compilation[]>('/api/compilations') |         // Mapper les tracks pour remplacer l'artist avec un objet Artist cohérent | ||||||
|       const { data: artists } = await useFetch<Artist[]>('/api/artists') |         const artistMap = new Map(this.artists.map((a) => [a.id, a])) | ||||||
|       const { data: rawTracks } = await useFetch<{ id: number, compilationId: string, title: string, artist: number, start: number, url: string, coverId: string }[]>('/api/tracks') |         const allTracks = [...(compilationTracks ?? []), ...(playlistTracks ?? [])] | ||||||
|  |  | ||||||
|       // Stocker les données de base |         this.tracks = allTracks.map((track) => { | ||||||
|       this.compilations = compilations.value ?? [] |           const a = track.artist as unknown | ||||||
|       this.artists = artists.value ?? [] |           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: '' } | ||||||
|  |           } | ||||||
|  |  | ||||||
|       // Mapper les tracks pour remplacer l'artistId par l'objet Artist |           return { | ||||||
|       const artistMap = new Map(this.artists.map(a => [a.id, a])) |             ...track, | ||||||
|  |             artist: artistObj | ||||||
|       this.tracks = (rawTracks.value ?? []).map(track => ({ |           } | ||||||
|         ...track, |         }) | ||||||
|         artist: artistMap.get(track.artist) ?? { id: track.artist, name: 'Unknown', url: '', coverId: '' } |         const favBox: Box = { | ||||||
|       })) |           id: FAVORITES_BOX_ID, | ||||||
|  |           type: 'playlist', | ||||||
|       this.isLoaded = true |           name: 'Favoris', | ||||||
|  |           duration: 0, | ||||||
|  |           tracks: [], | ||||||
|  |           description: '', | ||||||
|  |           color1: '#0f172a', | ||||||
|  |           color2: '#1e293b', | ||||||
|  |           color3: '#334155', | ||||||
|  |           state: 'box-list' | ||||||
|  |         } | ||||||
|  |         if (!this.boxes.find((b) => b.id === FAVORITES_BOX_ID)) { | ||||||
|  |           this.boxes = [favBox, ...this.boxes] | ||||||
|  |         } | ||||||
|  |         this.isLoaded = true | ||||||
|  |       } finally { | ||||||
|  |         this.isLoading = false | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|  |  | ||||||
|   getters: { |   getters: { | ||||||
|     // Obtenir tous les compilations |     // Obtenir toutes les boxes | ||||||
|     getAllCompilations: (state) => state.compilations, |     getBoxById: (state) => { | ||||||
|     getCompilationById: (state) => { |  | ||||||
|       return (id: string) => { |       return (id: string) => { | ||||||
|         return state.compilations.find(compilation => compilation.id === id) |         return state.boxes.find((box) => box.id === id) | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     // Obtenir toutes les pistes d'une compilation donnée |     // Obtenir toutes les pistes d'une box donnée | ||||||
|     getTracksByCompilationId: (state) => (compilationId: string) => { |     getTracksByboxId: (state) => (id: string) => { | ||||||
|       return state.tracks.filter(track => track.compilationId === compilationId) |       if (id === FAVORITES_BOX_ID) { | ||||||
|  |         const fav = useFavoritesStore() | ||||||
|  |         return fav.trackIds | ||||||
|  |           .map((tid) => state.tracks.find((t) => t.id === tid)) | ||||||
|  |           .filter((t): t is Track => !!t) | ||||||
|  |       } | ||||||
|  |       return state.tracks.filter((track) => track.boxId === id) | ||||||
|     }, |     }, | ||||||
|     // Filtrer les artistes selon certains critères |     // Filtrer les artistes selon certains critères | ||||||
|     getArtistById: (state) => (id: number) => state.artists.find(artist => artist.id === id), |     getArtistById: (state) => (id: number) => state.artists.find((artist) => artist.id === id), | ||||||
|  |  | ||||||
|     // Obtenir toutes les pistes d'un artiste donné |     // Obtenir toutes les pistes d'un artiste donné | ||||||
|     getTracksByArtistId: (state) => (artistId: number) => { |     getTracksByArtistId: (state) => (artistId: number) => { | ||||||
|       return state.tracks.filter(track => track.artist.id === artistId) |       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) | ||||||
|  |           .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) | ||||||
|  |           .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) | ||||||
|  |  | ||||||
|  |         // Trouver l’index de la track courante | ||||||
|  |         const index = tracksInBox.findIndex((t) => t.id === track.id) | ||||||
|  |         // Retourner la track suivante ou null si c’est 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) | ||||||
|  |           .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 | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
| }) | }) | ||||||
|   | |||||||
							
								
								
									
										52
									
								
								app/store/favorites.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,52 @@ | |||||||
|  | import { defineStore } from 'pinia' | ||||||
|  | import type { Track } from '~/../types/types' | ||||||
|  |  | ||||||
|  | export const FAVORITES_BOX_ID = 'FAV' | ||||||
|  | const STORAGE_KEY = 'evilspins:favorites:v1' | ||||||
|  |  | ||||||
|  | export const useFavoritesStore = defineStore('favorites', { | ||||||
|  |   state: () => ({ | ||||||
|  |     trackIds: [] as number[] | ||||||
|  |   }), | ||||||
|  |   actions: { | ||||||
|  |     load() { | ||||||
|  |       if (!process.client) return | ||||||
|  |       try { | ||||||
|  |         const raw = localStorage.getItem(STORAGE_KEY) | ||||||
|  |         if (raw) { | ||||||
|  |           const arr = JSON.parse(raw) | ||||||
|  |           if (Array.isArray(arr)) this.trackIds = arr.filter((x) => typeof x === 'number') | ||||||
|  |         } | ||||||
|  |       } catch {} | ||||||
|  |     }, | ||||||
|  |     save() { | ||||||
|  |       if (!process.client) return | ||||||
|  |       try { | ||||||
|  |         localStorage.setItem(STORAGE_KEY, JSON.stringify(this.trackIds)) | ||||||
|  |       } catch {} | ||||||
|  |     }, | ||||||
|  |     toggle(track: Track) { | ||||||
|  |       const id = track.id | ||||||
|  |       const idx = this.trackIds.indexOf(id) | ||||||
|  |       if (idx >= 0) this.trackIds.splice(idx, 1) | ||||||
|  |       else this.trackIds.unshift(id) | ||||||
|  |       this.save() | ||||||
|  |     }, | ||||||
|  |     add(track: Track) { | ||||||
|  |       if (!this.trackIds.includes(track.id)) { | ||||||
|  |         this.trackIds.unshift(track.id) | ||||||
|  |         this.save() | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     remove(trackId: number) { | ||||||
|  |       const idx = this.trackIds.indexOf(trackId) | ||||||
|  |       if (idx >= 0) { | ||||||
|  |         this.trackIds.splice(idx, 1) | ||||||
|  |         this.save() | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     isFavorite(trackId: number) { | ||||||
|  |       return this.trackIds.includes(trackId) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | }) | ||||||
							
								
								
									
										200
									
								
								app/store/player.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,200 @@ | |||||||
|  | // ~/store/player.ts | ||||||
|  | import { defineStore } from 'pinia' | ||||||
|  | import type { Track, Box } from '~/../types/types' | ||||||
|  | import { useDataStore } from '~/store/data' | ||||||
|  |  | ||||||
|  | export const usePlayerStore = defineStore('player', { | ||||||
|  |   state: () => ({ | ||||||
|  |     currentTrack: null as Track | null, | ||||||
|  |     position: 0, | ||||||
|  |     audio: null as HTMLAudioElement | null, | ||||||
|  |     isPaused: true, | ||||||
|  |     progressionLast: 0 | ||||||
|  |   }), | ||||||
|  |  | ||||||
|  |   actions: { | ||||||
|  |     attachAudio(el: HTMLAudioElement) { | ||||||
|  |       this.audio = el | ||||||
|  |       // attach listeners if not already attached (idempotent enough for our use) | ||||||
|  |       this.audio.addEventListener('play', () => { | ||||||
|  |         this.isPaused = false | ||||||
|  |       }) | ||||||
|  |       this.audio.addEventListener('playing', () => { | ||||||
|  |         this.isPaused = false | ||||||
|  |       }) | ||||||
|  |       this.audio.addEventListener('pause', () => { | ||||||
|  |         this.isPaused = true | ||||||
|  |       }) | ||||||
|  |       this.audio.addEventListener('ended', () => { | ||||||
|  |         this.isPaused = true | ||||||
|  |         const track = this.currentTrack | ||||||
|  |         if (!track) return | ||||||
|  |         const dataStore = useDataStore() | ||||||
|  |         if (track.type === 'playlist') { | ||||||
|  |           const next = dataStore.getNextPlaylistTrack(track) | ||||||
|  |           if (next && next.boxId === track.boxId) { | ||||||
|  |             this.playTrack(next) | ||||||
|  |           } | ||||||
|  |         } else { | ||||||
|  |           console.log('ended') | ||||||
|  |           this.currentTrack = null | ||||||
|  |         } | ||||||
|  |       }) | ||||||
|  |     }, | ||||||
|  |     async playBox(box: Box) { | ||||||
|  |       if (this.currentTrack?.boxId === box.id) { | ||||||
|  |         this.togglePlay() | ||||||
|  |       } else { | ||||||
|  |         const dataStore = useDataStore() | ||||||
|  |         const first = dataStore.getFirstTrackOfBox(box) | ||||||
|  |         if (first) { | ||||||
|  |           await this.playTrack(first) | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     async playTrack(track: Track) { | ||||||
|  |       // mettre à jour la piste courante uniquement après avoir géré le toggle | ||||||
|  |       if (this.currentTrack && this.currentTrack?.id === track.id) { | ||||||
|  |         this.togglePlay() | ||||||
|  |       } else { | ||||||
|  |         this.currentTrack = track | ||||||
|  |         if (!this.audio) { | ||||||
|  |           // fallback: create an audio element and attach listeners | ||||||
|  |           this.attachAudio(new Audio()) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Interrompre toute lecture en cours avant de charger une nouvelle source | ||||||
|  |         const audio = this.audio as HTMLAudioElement | ||||||
|  |         try { | ||||||
|  |           audio.pause() | ||||||
|  |         } catch (_) {} | ||||||
|  |  | ||||||
|  |         // on entre en phase de chargement | ||||||
|  |         this.isPaused = true | ||||||
|  |         audio.src = track.url | ||||||
|  |         audio.load() | ||||||
|  |  | ||||||
|  |         // lancer la lecture (seek si nécessaire une fois les metadata chargées) | ||||||
|  |         try { | ||||||
|  |           const wantedStart = track.start ?? 0 | ||||||
|  |  | ||||||
|  |           // Attendre que les metadata soient prêtes pour pouvoir positionner currentTime | ||||||
|  |           await new Promise<void>((resolve) => { | ||||||
|  |             if (audio.readyState >= 1) return resolve() | ||||||
|  |             const onLoaded = () => resolve() | ||||||
|  |             audio.addEventListener('loadedmetadata', onLoaded, { once: true }) | ||||||
|  |           }) | ||||||
|  |           // Appliquer le temps de départ | ||||||
|  |           audio.currentTime = wantedStart > 0 ? wantedStart : 0 | ||||||
|  |           await new Promise<void>((resolve) => { | ||||||
|  |             const onCanPlay = () => { | ||||||
|  |               if (wantedStart > 0 && audio.currentTime < wantedStart - 0.05) { | ||||||
|  |                 audio.currentTime = wantedStart | ||||||
|  |               } | ||||||
|  |               resolve() | ||||||
|  |             } | ||||||
|  |             if (audio.readyState >= 3) return resolve() | ||||||
|  |             audio.addEventListener('canplay', onCanPlay, { once: true }) | ||||||
|  |           }) | ||||||
|  |           this.isPaused = false | ||||||
|  |           await audio.play() | ||||||
|  |         } catch (err: any) { | ||||||
|  |           // Ignorer les AbortError (arrivent lorsqu'une nouvelle source est chargée rapidement) | ||||||
|  |           if (err && err.name === 'AbortError') return | ||||||
|  |           this.isPaused = true | ||||||
|  |           console.error('Impossible de lire la piste :', err) | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     togglePlay() { | ||||||
|  |       if (!this.audio) return | ||||||
|  |       if (this.audio.paused) { | ||||||
|  |         this.isPaused = false | ||||||
|  |         this.audio | ||||||
|  |           .play() | ||||||
|  |           .then(() => { | ||||||
|  |             this.isPaused = false | ||||||
|  |           }) | ||||||
|  |           .catch((err) => console.error(err)) | ||||||
|  |       } else { | ||||||
|  |         this.audio.pause() | ||||||
|  |         this.isPaused = true | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     updateTime() { | ||||||
|  |       const audio = this.audio | ||||||
|  |       if (!audio) return | ||||||
|  |  | ||||||
|  |       // update current position | ||||||
|  |       this.position = audio.currentTime | ||||||
|  |  | ||||||
|  |       // compute and cache a stable progression value | ||||||
|  |       const duration = audio.duration | ||||||
|  |       const progression = (this.position / duration) * 100 | ||||||
|  |       if (!isNaN(progression)) { | ||||||
|  |         this.progressionLast = progression | ||||||
|  |       } | ||||||
|  |       // update current track when changing time in compilation | ||||||
|  |       const cur = this.currentTrack | ||||||
|  |       if (cur && cur.type === 'compilation') { | ||||||
|  |         const dataStore = useDataStore() | ||||||
|  |         const tracks = dataStore | ||||||
|  |           .getTracksByboxId(cur.boxId) | ||||||
|  |           .slice() | ||||||
|  |           .filter((t) => t.type === 'compilation') | ||||||
|  |           .sort((a, b) => (a.start ?? 0) - (b.start ?? 0)) | ||||||
|  |  | ||||||
|  |         if (tracks.length > 0) { | ||||||
|  |           const now = audio.currentTime | ||||||
|  |           // find the last track whose start <= now (fallback to first track) | ||||||
|  |           let found = tracks[0] | ||||||
|  |           for (const t of tracks) { | ||||||
|  |             const s = t.start ?? 0 | ||||||
|  |             if (s <= now) { | ||||||
|  |               found = t | ||||||
|  |             } else { | ||||||
|  |               break | ||||||
|  |             } | ||||||
|  |           } | ||||||
|  |           if (found && found.id !== cur.id) { | ||||||
|  |             // only update metadata reference; do not reload audio | ||||||
|  |             this.currentTrack = found | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   getters: { | ||||||
|  |     isCurrentBox: (state) => { | ||||||
|  |       return (boxId: string) => boxId === state.currentTrack?.boxId | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     isPlaylistTrack: () => { | ||||||
|  |       return (track: Track) => { | ||||||
|  |         return track.type === 'playlist' | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     getCurrentTrack: (state) => state.currentTrack, | ||||||
|  |  | ||||||
|  |     getCurrentBox: (state) => { | ||||||
|  |       return state.currentTrack ? state.currentTrack.url : null | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     getCurrentProgression(state) { | ||||||
|  |       if (!state.audio) return 0 | ||||||
|  |       const duration = state.audio.duration | ||||||
|  |       const progression = (state.position / duration) * 100 | ||||||
|  |       return isNaN(progression) ? state.progressionLast : progression | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     getCurrentCoverUrl(state) { | ||||||
|  |       const id = state.currentTrack?.coverId | ||||||
|  |       if (!id) return null | ||||||
|  |       return id.startsWith('http') ? id : `https://f4.bcbits.com/img/${id}_4.jpg` | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | }) | ||||||
							
								
								
									
										70
									
								
								app/store/ui.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,70 @@ | |||||||
|  | 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: '' | ||||||
|  |   }), | ||||||
|  |  | ||||||
|  |   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) => { | ||||||
|  |         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' | ||||||
|  |       }) | ||||||
|  |       // Scroll back to the unselected box in the list | ||||||
|  |       if (selectedBox) this.scrollToBox(selectedBox) | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     scrollToBox(box: Box) { | ||||||
|  |       if (box) { | ||||||
|  |         const boxElement = document.getElementById(box.id) | ||||||
|  |         if (boxElement) { | ||||||
|  |           setTimeout(() => { | ||||||
|  |             boxElement.scrollIntoView({ behavior: 'smooth' }) | ||||||
|  |           }, 333) | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   getters: { | ||||||
|  |     getSelectedBox: () => { | ||||||
|  |       const dataStore = useDataStore() | ||||||
|  |       return (dataStore.boxes as Box[]).find((box) => box.state === 'box-selected') || null | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | }) | ||||||
							
								
								
									
										11
									
								
								app/store/user.ts
									
									
									
									
									
										Normal 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 | ||||||
|  |   }) | ||||||
|  | }) | ||||||
| @@ -1,5 +0,0 @@ | |||||||
| export REPO_NAME="evilspins" |  | ||||||
| export DOMAIN="evilspins.com" |  | ||||||
| export PORT="7901" |  | ||||||
| export PORT_EXPOSED="3000" |  | ||||||
| export REDIRECTIONS="" # example.$MAIN_DOMAIN->/route $MAIN_DOMAIN->url /route->/another-route /route->url |  | ||||||
| @@ -10,7 +10,7 @@ services: | |||||||
|     ports: |     ports: | ||||||
|       - "${PORT}:${PORT_EXPOSED}" |       - "${PORT}:${PORT_EXPOSED}" | ||||||
|     volumes: |     volumes: | ||||||
|       - "${MEDIA_DIR}:/mnt/media" |       - $MEDIA_DIR:/app/mnt/media | ||||||
|     environment: |     environment: | ||||||
|       VIRTUAL_HOST: "${DOMAIN}" |       VIRTUAL_HOST: "${DOMAIN}" | ||||||
|       LETSENCRYPT_HOST: "${DOMAIN}" |       LETSENCRYPT_HOST: "${DOMAIN}" | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
| // @ts-check | // @ts-check | ||||||
| import withNuxt from "./.nuxt/eslint.config.mjs"; | import withNuxt from "./.nuxt/eslint.config.mjs"; | ||||||
|  | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended' | ||||||
|  |  | ||||||
|  | export default withNuxt([eslintPluginPrettierRecommended]) | ||||||
| export default withNuxt({ | export default withNuxt({ | ||||||
|   rules: { |   rules: { | ||||||
|     // Garde l'ordre correct : class avant @click |     // Garde l'ordre correct : class avant @click | ||||||
|   | |||||||
| @@ -1,4 +1,6 @@ | |||||||
| // https://nuxt.com/docs/api/configuration/nuxt-config | // https://nuxt.com/docs/api/configuration/nuxt-config | ||||||
|  | const isProd = process.env.NODE_ENV === 'production' | ||||||
|  |  | ||||||
| export default defineNuxtConfig({ | export default defineNuxtConfig({ | ||||||
|   compatibilityDate: '2025-07-15', |   compatibilityDate: '2025-07-15', | ||||||
|   devtools: { enabled: true }, |   devtools: { enabled: true }, | ||||||
| @@ -12,13 +14,15 @@ export default defineNuxtConfig({ | |||||||
|         { rel: 'apple-touch-icon', sizes: '180x180', href: '/favicon/apple-touch-icon.png' }, |         { rel: 'apple-touch-icon', sizes: '180x180', href: '/favicon/apple-touch-icon.png' }, | ||||||
|         { rel: 'manifest', href: '/favicon/site.webmanifest' } |         { rel: 'manifest', href: '/favicon/site.webmanifest' } | ||||||
|       ], |       ], | ||||||
|       script: [ |       script: isProd | ||||||
|         { |         ? [ | ||||||
|           src: 'https://umami.erudi.fr/script.js', |             { | ||||||
|           defer: true, |               src: 'https://umami.erudi.fr/script.js', | ||||||
|           'data-website-id': '615690ea-0306-48cc-8feb-e9093fe6a1b7' |               defer: true, | ||||||
|         } |               'data-website-id': '615690ea-0306-48cc-8feb-e9093fe6a1b7' | ||||||
|       ], |             } | ||||||
|  |           ] | ||||||
|  |         : [], | ||||||
|       meta: [ |       meta: [ | ||||||
|         { name: 'apple-mobile-web-app-title', content: 'evilSpins' } |         { name: 'apple-mobile-web-app-title', content: 'evilSpins' } | ||||||
|       ] |       ] | ||||||
|   | |||||||
| @@ -7,7 +7,11 @@ | |||||||
|     "dev": "nuxt dev --host", |     "dev": "nuxt dev --host", | ||||||
|     "generate": "nuxt generate", |     "generate": "nuxt generate", | ||||||
|     "preview": "nuxt preview", |     "preview": "nuxt preview", | ||||||
|     "postinstall": "nuxt prepare" |     "postinstall": "nuxt prepare", | ||||||
|  |     "lint": "eslint .", | ||||||
|  |     "lint:fix": "eslint . --fix", | ||||||
|  |     "format": "prettier --check .", | ||||||
|  |     "format:fix": "prettier --write ." | ||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@nuxt/eslint": "1.9.0", |     "@nuxt/eslint": "1.9.0", | ||||||
|   | |||||||
| Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 20 KiB | 
| Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 20 KiB | 
							
								
								
									
										53
									
								
								public/loader.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,53 @@ | |||||||
|  | <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||||
|  | <svg | ||||||
|  |    viewBox="-20 -10 45.311569 71.596806" | ||||||
|  |    version="1.1" | ||||||
|  |    id="svg1" | ||||||
|  |    sodipodi:docname="favicon.svg" | ||||||
|  |    width="25.311569" | ||||||
|  |    height="51.596806" | ||||||
|  |    inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)" | ||||||
|  |    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||||
|  |    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||||
|  |    xmlns="http://www.w3.org/2000/svg" | ||||||
|  |    xmlns:svg="http://www.w3.org/2000/svg"> | ||||||
|  |   <defs | ||||||
|  |      id="defs1"> | ||||||
|  |     <filter id="blur" x="-20%" y="-20%" width="140%" height="140%"> | ||||||
|  |       <feGaussianBlur stdDeviation="1 5" /> | ||||||
|  |     </filter> | ||||||
|  |   </defs> | ||||||
|  |   <sodipodi:namedview | ||||||
|  |      id="namedview1" | ||||||
|  |      pagecolor="#505050" | ||||||
|  |      bordercolor="#eeeeee" | ||||||
|  |      borderopacity="1" | ||||||
|  |      inkscape:showpageshadow="0" | ||||||
|  |      inkscape:pageopacity="0" | ||||||
|  |      inkscape:pagecheckerboard="0" | ||||||
|  |      inkscape:deskcolor="#505050" | ||||||
|  |      inkscape:zoom="12.1" | ||||||
|  |      inkscape:cx="61.77686" | ||||||
|  |      inkscape:cy="29.958678" | ||||||
|  |      inkscape:window-width="1920" | ||||||
|  |      inkscape:window-height="1132" | ||||||
|  |      inkscape:window-x="0" | ||||||
|  |      inkscape:window-y="0" | ||||||
|  |      inkscape:window-maximized="1" | ||||||
|  |      inkscape:current-layer="svg1" /> | ||||||
|  |   <path | ||||||
|  |      d="m 13.81157,0.8 -3.3,1.7 -0.5999997,0.3 v 0.3 h -0.4 v 0.3 l -2.2,1.4 -0.6,0.5 h -0.4 v 0.3 h -0.3 l -0.8,1 -1.6,1 c -0.3,0.3 -1,1.4 -1.4,1.7 l -0.3,-0.2 v 0.4 l -1.19999992,1 c -0.4,0.4 -1.80000008,2 -2.10000008,2.7 l -0.1,0.3 -0.7,0.6 -0.4,1.6 -0.9,2.2 c 0,0.333333 -0.1,0.666667 -0.3,1 l -0.2,1 0.2,0.4 -0.2,0.5 0.6,0.7 0.2,0.5 c 1.103691,0.814425 2.36312908,1.393085 3.70000008,1.7 l 0.89999992,-0.2 0.8,0.2 h 1.4 l 1.8,1.9 1.1,0.8 c 1,0 0.7,1.1 1.1,1.7 0.3,0.2 0.5,-0.2 0.6,0.4 l -0.2,0.3 c 0,0.6 0.1,1.2 0.3,1.8 l 0.2,0.5 v 0.3 l -1,3 -1.6,2 -0.2,0.7 -0.2,0.4 -0.2,-0.1 -0.1,0.3 -1.3,1.6 -0.7,0.7 -0.2,0.3 -0.2,-0.2 -0.1,0.3 -1.5,0.7 c -3.6,2.8 0.3,0.2 -1.79999992,1.9 l -1.40000008,0.8 -1.6,1.1 -1,0.5 -0.1,0.3 h -0.4 c -0.2,0.06667 -0.4,0.233333 -0.6,0.5 l -2,1.4 -1.2,0.9 -0.5,0.2 v 0.2 h -0.3 V 49 h -0.2 c -0.3000003,0.1 0.1,0.2 0.1,0.2 v 0.3 c 0.06667,0.466667 0.366667,0.8 0.9,1 l 0.1,0.6 0.6,0.2 c 2,0.6 2.7,0.2 4.7,-0.4 l 1.4,-0.3 h 0.4 l 0.90000008,-0.4 c 5.49999992,-2.7 -0.2,0.2 4.69999992,-2.6 l 1.5,-0.8 v -0.3 h 0.4 v -0.2 c 3.5,-2.5 1,0 4.9999997,-3.7 l 0.8,-1 0.5,-0.3 V 41 l 0.5,-0.1 0.5,-1.5 1,-1.4 0.6,-2 v -2.8 l -0.2,-1.9 0.2,-0.2 c 0,-0.7 -0.5,-1.2 -0.6,-1.9 V 28 l -0.3,-0.5 0.1,-0.3 -0.2,-0.2 -0.6,-0.9 c -1.7,-2.7 0,0 -1.4,-1.8 l -0.3,-0.5 h -0.4 l 0.1,-0.4 -0.3,-0.1 -0.5,-0.7 -0.9999997,-0.7 -0.7,-0.6 -0.2,-0.5 -0.6,-0.2 c -1,-0.5 -1.4,-1.3 -2.2,-1.9 l -0.4,-0.2 v -1 l -0.5,-0.9 0.4,-1 0.8,-2.6 0.9,-1.8 0.3,-0.6 0.4,-0.2 0.9,-1 1.9999997,-2.7 0.8,-0.9 0.5,-0.3 0.2,-0.6 1.6,-1.6 0.7,-1 0.2,-0.3 h 0.3 l 0.6,-2" | ||||||
|  |      fill="#fdec50" | ||||||
|  |      id="path1" | ||||||
|  |      filter="url(#blur)" | ||||||
|  |      sodipodi:nodetypes="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"> | ||||||
|  |          <animateTransform  | ||||||
|  |       attributeName="transform" | ||||||
|  |       attributeType="XML" | ||||||
|  |       type="rotate" | ||||||
|  |        from="0 2.6557845 25.798403" | ||||||
|  |        to="-360 2.6557845 25.798403" | ||||||
|  |       dur="0.4s" | ||||||
|  |       repeatCount="indefinite" /> | ||||||
|  |       </path> | ||||||
|  | </svg> | ||||||
| After Width: | Height: | Size: 3.1 KiB | 
							
								
								
									
										59
									
								
								public/♠.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,59 @@ | |||||||
|  | <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||||
|  | <!-- Created with Inkscape (http://www.inkscape.org/) --> | ||||||
|  |  | ||||||
|  | <svg | ||||||
|  |    width="180mm" | ||||||
|  |    height="180mm" | ||||||
|  |    viewBox="0 0 180 180" | ||||||
|  |    version="1.1" | ||||||
|  |    id="svg1" | ||||||
|  |    inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)" | ||||||
|  |    sodipodi:docname="spades.svg" | ||||||
|  |    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||||
|  |    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||||
|  |    xmlns="http://www.w3.org/2000/svg" | ||||||
|  |    xmlns:svg="http://www.w3.org/2000/svg"> | ||||||
|  |   <sodipodi:namedview | ||||||
|  |      id="namedview1" | ||||||
|  |      pagecolor="#505050" | ||||||
|  |      bordercolor="#eeeeee" | ||||||
|  |      borderopacity="1" | ||||||
|  |      inkscape:showpageshadow="0" | ||||||
|  |      inkscape:pageopacity="0" | ||||||
|  |      inkscape:pagecheckerboard="0" | ||||||
|  |      inkscape:deskcolor="#505050" | ||||||
|  |      inkscape:document-units="mm" | ||||||
|  |      inkscape:zoom="0.1984375" | ||||||
|  |      inkscape:cx="2980.7874" | ||||||
|  |      inkscape:cy="798.74016" | ||||||
|  |      inkscape:window-width="1920" | ||||||
|  |      inkscape:window-height="1132" | ||||||
|  |      inkscape:window-x="0" | ||||||
|  |      inkscape:window-y="0" | ||||||
|  |      inkscape:window-maximized="1" | ||||||
|  |      inkscape:current-layer="layer1"> | ||||||
|  |     <inkscape:page | ||||||
|  |        x="0" | ||||||
|  |        y="0" | ||||||
|  |        width="180" | ||||||
|  |        height="180" | ||||||
|  |        id="page1" | ||||||
|  |        margin="0 0.625 0 0" | ||||||
|  |        bleed="0" /> | ||||||
|  |   </sodipodi:namedview> | ||||||
|  |   <defs | ||||||
|  |      id="defs1" /> | ||||||
|  |   <g | ||||||
|  |      inkscape:label="Layer 1" | ||||||
|  |      inkscape:groupmode="layer" | ||||||
|  |      id="layer1"> | ||||||
|  |     <path | ||||||
|  |        fill="#000000" | ||||||
|  |        d="m 83.298209,123.60739 a 0.11377083,0.11377083 0 0 0 -0.17463,-0.12171 c -4.318,3.08239 -8.95085,5.57477 -14.05202,7.07495 -2.81252,0.82815 -6.18596,0.79111 -9.03817,0.69057 -14.65791,-0.52388 -27.23356,-10.09121 -30.90598,-24.43427 -4.73075,-18.473218 3.94494,-34.184176 16.9889,-46.704259 7.05644,-6.773334 14.67644,-12.961938 22.20383,-19.198167 5.41073,-4.484688 10.49338,-9.339794 17.15559,-16.123714 0.87048,-0.88635 2.00289,-1.86796 3.27818,-2.12725 q 3.07711,-0.62971 5.503343,1.79123 c 4.92654,4.91067 9.165158,9.24983 15.062728,14.353651 9.05404,7.836958 16.97302,13.95677 25.05604,21.666729 q 6.31825,6.027208 11.00137,13.350875 c 5.88698,9.207499 8.01688,19.698228 6.13834,30.487945 -2.36273,13.55989 -12.94871,23.97919 -26.33927,26.3816 q -4.953,0.88636 -10.35315,0.48684 c -6.477,-0.4789 -12.38779,-4.10369 -17.692688,-7.54857 q -0.13494,-0.0873 -0.20373,-0.1217 a 0.1031875,0.1031875 0 0 0 -0.14287,0.1217 c 3.780888,12.18142 7.791978,22.91821 14.030848,33.53594 q 0.15346,0.26194 -0.15081,0.26987 -0.56885,0.0132 -1.28323,-0.0873 c -4.2836,-0.61383 -8.636,-1.11389 -12.964578,-1.27529 q -8.702153,-0.32543 -17.399003,0.2249 -1.79917,0.11377 -8.79475,1.15094 -0.27781,0.0397 -0.84667,-0.037 -0.14552,-0.0185 -0.0926,-0.15611 0.254,-0.67469 0.61648,-1.27 3.5216,-5.78908 6.19918,-12.01473 4.29155,-9.98273 7.19932,-20.36762 z" | ||||||
|  |        id="path1" | ||||||
|  |        style="stroke-width:0.264583" /> | ||||||
|  |     <g | ||||||
|  |        id="g5" | ||||||
|  |        transform="matrix(0.26458333,0,0,0.26458333,527.61409,-106.44214)" /> | ||||||
|  |   </g> | ||||||
|  | </svg> | ||||||
| After Width: | Height: | Size: 2.9 KiB | 
							
								
								
									
										58
									
								
								public/♣.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,58 @@ | |||||||
|  | <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||||
|  | <!-- Created with Inkscape (http://www.inkscape.org/) --> | ||||||
|  |  | ||||||
|  | <svg | ||||||
|  |    width="180mm" | ||||||
|  |    height="180mm" | ||||||
|  |    viewBox="0 0 180 180" | ||||||
|  |    version="1.1" | ||||||
|  |    id="svg1" | ||||||
|  |    inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)" | ||||||
|  |    sodipodi:docname="clubs.svg" | ||||||
|  |    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||||
|  |    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||||
|  |    xmlns="http://www.w3.org/2000/svg" | ||||||
|  |    xmlns:svg="http://www.w3.org/2000/svg"> | ||||||
|  |   <sodipodi:namedview | ||||||
|  |      id="namedview1" | ||||||
|  |      pagecolor="#505050" | ||||||
|  |      bordercolor="#eeeeee" | ||||||
|  |      borderopacity="1" | ||||||
|  |      inkscape:showpageshadow="0" | ||||||
|  |      inkscape:pageopacity="0" | ||||||
|  |      inkscape:pagecheckerboard="0" | ||||||
|  |      inkscape:deskcolor="#505050" | ||||||
|  |      inkscape:document-units="mm" | ||||||
|  |      inkscape:zoom="0.1984375" | ||||||
|  |      inkscape:cx="2980.7874" | ||||||
|  |      inkscape:cy="798.74016" | ||||||
|  |      inkscape:window-width="1920" | ||||||
|  |      inkscape:window-height="1132" | ||||||
|  |      inkscape:window-x="0" | ||||||
|  |      inkscape:window-y="0" | ||||||
|  |      inkscape:window-maximized="1" | ||||||
|  |      inkscape:current-layer="layer1"> | ||||||
|  |     <inkscape:page | ||||||
|  |        x="0" | ||||||
|  |        y="0" | ||||||
|  |        width="180" | ||||||
|  |        height="180" | ||||||
|  |        id="page1" | ||||||
|  |        margin="0 0.625 0 0" | ||||||
|  |        bleed="0" /> | ||||||
|  |   </sodipodi:namedview> | ||||||
|  |   <defs | ||||||
|  |      id="defs1" /> | ||||||
|  |   <g | ||||||
|  |      inkscape:label="Layer 1" | ||||||
|  |      inkscape:groupmode="layer" | ||||||
|  |      id="layer1"> | ||||||
|  |     <path | ||||||
|  |        id="path3" | ||||||
|  |        style="fill:#000000;stroke-width:0.264583" | ||||||
|  |        d="m 90.000022,16.86819 a 32.819258,32.819258 0 0 0 -32.819176,32.819182 32.819258,32.819258 0 0 0 12.24214,25.567427 c 0.45986,0.67849 0.93396,1.34539 1.41956,1.99937 a 32.819258,32.819258 0 0 0 -16.14785,-4.24729 32.819258,32.819258 0 0 0 -32.81919,32.819191 32.819258,32.819258 0 0 0 32.81919,32.81919 32.819258,32.819258 0 0 0 31.463186,-23.48229 c 0.13863,-0.0901 0.27759,-0.17961 0.41703,-0.26871 a 0.06614583,0.06614583 0 0 1 0.10025,0.0713 c -1.82915,8.07332 -4.03647,16.0141 -6.62233,23.82283 -2.8284,8.54604 -6.180456,16.69282 -10.834476,24.02178 a 0.11641667,0.11641667 0 0 0 0.0713,0.17468 c 0.61736,0.14288 1.10161,0.18153 1.45262,0.11626 4.75192,-0.86077 9.865556,-1.33004 15.340666,-1.40766 8.12006,-0.11377 15.345448,0.0267 23.145368,1.4025 0.34043,0.06 0.79935,0.0246 1.37614,-0.10594 0.0952,-0.0212 0.11812,-0.074 0.0687,-0.15864 -4.21217,-7.23019 -7.6376,-14.81736 -10.27638,-22.76192 -2.416528,-7.27075 -4.641798,-15.08437 -6.675568,-23.43991 -0.11994,-0.49389 -0.17573,-0.9406 -0.16691,-1.341 a 0.12435417,0.12170833 16 0 1 0.1881,-0.10336 c 0.0396,0.0238 0.0789,0.0479 0.11834,0.0718 4.05187,13.52893 16.596578,23.38824 31.444088,23.38824 18.12557,0 32.81967,-14.69362 32.81967,-32.81919 0,-18.125581 -14.6941,-32.819191 -32.81967,-32.819191 -5.84735,0 -11.33713,1.52926 -16.09205,4.20956 0.51661,-0.6831 1.01243,-1.36973 1.48622,-2.06034 A 32.819258,32.819258 0 0 0 122.81917,49.687332 32.819258,32.819258 0 0 0 89.999982,16.86815 Z" /> | ||||||
|  |     <g | ||||||
|  |        id="g5" | ||||||
|  |        transform="matrix(0.26458333,0,0,0.26458333,527.61409,-106.44214)" /> | ||||||
|  |   </g> | ||||||
|  | </svg> | ||||||
| After Width: | Height: | Size: 3.0 KiB | 
							
								
								
									
										59
									
								
								public/♥.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,59 @@ | |||||||
|  | <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||||
|  | <!-- Created with Inkscape (http://www.inkscape.org/) --> | ||||||
|  |  | ||||||
|  | <svg | ||||||
|  |    width="180mm" | ||||||
|  |    height="180mm" | ||||||
|  |    viewBox="0 0 180 180" | ||||||
|  |    version="1.1" | ||||||
|  |    id="svg1" | ||||||
|  |    inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)" | ||||||
|  |    sodipodi:docname="hearts.svg" | ||||||
|  |    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||||
|  |    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||||
|  |    xmlns="http://www.w3.org/2000/svg" | ||||||
|  |    xmlns:svg="http://www.w3.org/2000/svg"> | ||||||
|  |   <sodipodi:namedview | ||||||
|  |      id="namedview1" | ||||||
|  |      pagecolor="#505050" | ||||||
|  |      bordercolor="#eeeeee" | ||||||
|  |      borderopacity="1" | ||||||
|  |      inkscape:showpageshadow="0" | ||||||
|  |      inkscape:pageopacity="0" | ||||||
|  |      inkscape:pagecheckerboard="0" | ||||||
|  |      inkscape:deskcolor="#505050" | ||||||
|  |      inkscape:document-units="mm" | ||||||
|  |      inkscape:zoom="0.1984375" | ||||||
|  |      inkscape:cx="2980.7874" | ||||||
|  |      inkscape:cy="798.74016" | ||||||
|  |      inkscape:window-width="1920" | ||||||
|  |      inkscape:window-height="1132" | ||||||
|  |      inkscape:window-x="0" | ||||||
|  |      inkscape:window-y="0" | ||||||
|  |      inkscape:window-maximized="1" | ||||||
|  |      inkscape:current-layer="layer1"> | ||||||
|  |     <inkscape:page | ||||||
|  |        x="0" | ||||||
|  |        y="0" | ||||||
|  |        width="180" | ||||||
|  |        height="180" | ||||||
|  |        id="page1" | ||||||
|  |        margin="0 0.625 0 0" | ||||||
|  |        bleed="0" /> | ||||||
|  |   </sodipodi:namedview> | ||||||
|  |   <defs | ||||||
|  |      id="defs1" /> | ||||||
|  |   <g | ||||||
|  |      inkscape:label="Layer 1" | ||||||
|  |      inkscape:groupmode="layer" | ||||||
|  |      id="layer1"> | ||||||
|  |     <path | ||||||
|  |        fill="#ff0000" | ||||||
|  |        d="m 90.078668,46.355621 q 1.66952,-1.568979 3.34698,-3.127375 7.781392,-7.228416 17.774712,-10.871729 c 1.62983,-0.595311 3.87879,-0.997478 5.77849,-1.113894 q 5.4954,-0.336021 10.70504,0.502708 c 13.57313,2.185457 24.52159,12.00679 28.21781,25.225373 4.10634,14.681729 -0.11377,29.458708 -9.41916,41.301458 q -4.50586,5.733518 -8.43756,9.477368 c -13.77686,13.10746 -29.337,24.08501 -42.410062,37.95446 -1.97909,2.09814 -4.07988,3.78619 -7.10936,2.96598 -1.91293,-0.51859 -3.4872,-2.17488 -4.89479,-3.67771 -8.852957,-9.45091 -19.282827,-17.72707 -29.262907,-26.16462 -12.95136,-10.94846 -25.90536,-22.314958 -30.46148,-39.441436 -2.35744,-8.863542 -1.70656,-19.187583 2.05581,-27.585458 4.45558,-9.956271 13.47258,-17.118541 24.05856,-19.645312 2.14577,-0.513291 4.91596,-0.928686 7.34748,-1.00277 4.51379,-0.132291 8.46138,0.06085 12.63121,1.664228 q 9.239247,3.550708 16.494117,10.371667 1.68275,1.582208 3.36815,3.167062 0.10848,0.100542 0.21696,0 z" | ||||||
|  |        id="path2" | ||||||
|  |        style="stroke-width:0.264583" /> | ||||||
|  |     <g | ||||||
|  |        id="g5" | ||||||
|  |        transform="matrix(0.26458333,0,0,0.26458333,527.61409,-106.44214)" /> | ||||||
|  |   </g> | ||||||
|  | </svg> | ||||||
| After Width: | Height: | Size: 2.5 KiB | 
							
								
								
									
										59
									
								
								public/♦.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,59 @@ | |||||||
|  | <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||||
|  | <!-- Created with Inkscape (http://www.inkscape.org/) --> | ||||||
|  |  | ||||||
|  | <svg | ||||||
|  |    width="180mm" | ||||||
|  |    height="180mm" | ||||||
|  |    viewBox="0 0 180 180" | ||||||
|  |    version="1.1" | ||||||
|  |    id="svg1" | ||||||
|  |    inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)" | ||||||
|  |    sodipodi:docname="diamonds.svg" | ||||||
|  |    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||||
|  |    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||||
|  |    xmlns="http://www.w3.org/2000/svg" | ||||||
|  |    xmlns:svg="http://www.w3.org/2000/svg"> | ||||||
|  |   <sodipodi:namedview | ||||||
|  |      id="namedview1" | ||||||
|  |      pagecolor="#505050" | ||||||
|  |      bordercolor="#eeeeee" | ||||||
|  |      borderopacity="1" | ||||||
|  |      inkscape:showpageshadow="0" | ||||||
|  |      inkscape:pageopacity="0" | ||||||
|  |      inkscape:pagecheckerboard="0" | ||||||
|  |      inkscape:deskcolor="#505050" | ||||||
|  |      inkscape:document-units="mm" | ||||||
|  |      inkscape:zoom="0.1984375" | ||||||
|  |      inkscape:cx="2980.7874" | ||||||
|  |      inkscape:cy="798.74016" | ||||||
|  |      inkscape:window-width="1920" | ||||||
|  |      inkscape:window-height="1132" | ||||||
|  |      inkscape:window-x="0" | ||||||
|  |      inkscape:window-y="0" | ||||||
|  |      inkscape:window-maximized="1" | ||||||
|  |      inkscape:current-layer="layer1"> | ||||||
|  |     <inkscape:page | ||||||
|  |        x="0" | ||||||
|  |        y="0" | ||||||
|  |        width="180" | ||||||
|  |        height="180" | ||||||
|  |        id="page1" | ||||||
|  |        margin="0 0.625 0 0" | ||||||
|  |        bleed="0" /> | ||||||
|  |   </sodipodi:namedview> | ||||||
|  |   <defs | ||||||
|  |      id="defs1" /> | ||||||
|  |   <g | ||||||
|  |      inkscape:label="Layer 1" | ||||||
|  |      inkscape:groupmode="layer" | ||||||
|  |      id="layer1"> | ||||||
|  |     <path | ||||||
|  |        fill="#0000ff" | ||||||
|  |        d="m 92.443692,23.012778 c 0.48419,0.17727 0.91546,0.37836 1.29646,0.762 q 1.95527,1.97909 2.92364,3.15648 22.412858,27.278537 46.466128,57.380187 c 1.05304,1.31762 2.37066,2.667 2.75166,4.18835 0.76465,3.03477 -0.59795,4.47411 -2.65641,7.05115 q -20.58988,25.791585 -41.48402,51.339755 -3.653898,4.46881 -7.482418,8.7921 c -2.02406,2.28336 -6.21242,2.42359 -8.28146,0.20902 q -2.45004,-2.61937 -3.86821,-4.34445 -23.88393,-29.09094 -47.3366,-58.531135 -0.11906,-0.15346 -0.27252,-0.43656 c -1.10596,-2.05581 -0.57944,-4.68048 0.7964,-6.45583 q 1.57162,-2.02936 3.17764,-4.01903 c 15.55221,-19.26165 31.14146,-38.502157 46.92385,-57.575967 1.7489,-2.11138 4.52703,-2.44475 7.04586,-1.51607 z" | ||||||
|  |        id="path4" | ||||||
|  |        style="fill:#ff0000;stroke-width:0.264583" /> | ||||||
|  |     <g | ||||||
|  |        id="g5" | ||||||
|  |        transform="matrix(0.26458333,0,0,0.26458333,527.61409,-106.44214)" /> | ||||||
|  |   </g> | ||||||
|  | </svg> | ||||||
| After Width: | Height: | Size: 2.2 KiB | 
| @@ -36,7 +36,7 @@ export default eventHandler(() => { | |||||||
|       id: 5, |       id: 5, | ||||||
|       name: "New candys", |       name: "New candys", | ||||||
|       url: "https://newcandys.bandcamp.com", |       url: "https://newcandys.bandcamp.com", | ||||||
|       coverId: "0033518637", |       coverId: "0039963261", | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       id: 6, |       id: 6, | ||||||
| @@ -67,6 +67,84 @@ export default eventHandler(() => { | |||||||
|       name: "I love UFO", |       name: "I love UFO", | ||||||
|       url: "https://bruitblanc.bandcamp.com", |       url: "https://bruitblanc.bandcamp.com", | ||||||
|       coverId: "a2203158939", |       coverId: "a2203158939", | ||||||
|     } |     }, | ||||||
|  |     { | ||||||
|  |       id: 11, | ||||||
|  |       name: "Kid Congo & The Pink Monkey Birds", | ||||||
|  |       url: "https://kidcongothepinkmonkeybirds.bandcamp.com/", | ||||||
|  |       coverId: "0017196290", | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 12, | ||||||
|  |       name: "Firefriend", | ||||||
|  |       url: "https://firefriend.bandcamp.com/", | ||||||
|  |       coverId: "0031072203", | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 13, | ||||||
|  |       name: "Squid", | ||||||
|  |       url: "https://squiduk.bandcamp.com/", | ||||||
|  |       coverId: "0037649385", | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 14, | ||||||
|  |       name: "Lysistrata", | ||||||
|  |       url: "https://lysistrata.bandcamp.com/", | ||||||
|  |       coverId: "0033900158", | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 15, | ||||||
|  |       name: "Pablo X Broadcasting Services", | ||||||
|  |       url: "https://pabloxbroadcastingservices.bandcamp.com/", | ||||||
|  |       coverId: "0036956486", | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 16, | ||||||
|  |       name: "Night Beats", | ||||||
|  |       url: "https://nightbeats.bandcamp.com/", | ||||||
|  |       coverId: "0036987720", | ||||||
|  |     }, | ||||||
|  |         { | ||||||
|  |       id: 17, | ||||||
|  |       name: "Deltron 3030", | ||||||
|  |       url: "https://delthefunkyhomosapien.bandcamp.com/", | ||||||
|  |       coverId: "0005254781", | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 18, | ||||||
|  |       name: "The Amorphous Androgynous", | ||||||
|  |       url: "https://theaa.bandcamp.com/", | ||||||
|  |       coverId: "0022226700", | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 19, | ||||||
|  |       name: "Wooden Shjips", | ||||||
|  |       url: "https://woodenshjips.bandcamp.com/", | ||||||
|  |       coverId: "0012406678", | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 20, | ||||||
|  |       name: "Silas J. Dirge", | ||||||
|  |       url: "https://silasjdirge.bandcamp.com/", | ||||||
|  |       coverId: "0035751570", | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 21, | ||||||
|  |       name: "Secret Colours", | ||||||
|  |       url: "https://secretcolours.bandcamp.com/", | ||||||
|  |       coverId: "0010661379", | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 22, | ||||||
|  |       name: "Larry McNeil And The Blue Knights", | ||||||
|  |       url: "https://www.discogs.com/artist/6528940-Larry-McNeil-And-The-Blue-Knights", | ||||||
|  |       coverId: "https://i.discogs.com/Yr05_neEXwzPwKlDeV7dimmTG34atkAMgpxbMBhHBkI/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTEyMTEw/ODE1LTE1Mjg1NjU1/NzQtMjcyOC5qcGVn.jpeg", | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 23, | ||||||
|  |       name: "Hugo Blanco", | ||||||
|  |       url: "https://elpalmasmusic.bandcamp.com/album/color-de-tr-pico-compiled-by-el-dr-gon-criollo-el-palmas", | ||||||
|  |       coverId: "0016886708", | ||||||
|  |     }, | ||||||
|   ] |   ] | ||||||
| }) | }) | ||||||
|   | |||||||
							
								
								
									
										177
									
								
								server/api/boxes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,177 @@ | |||||||
|  | import { eventHandler } from 'h3' | ||||||
|  |  | ||||||
|  | export default eventHandler(() => { | ||||||
|  |   const boxes = [ | ||||||
|  |     { | ||||||
|  |       id: 'ES2012', | ||||||
|  |       type: 'playlist', | ||||||
|  |       name: '2012', | ||||||
|  |       duration: 0, | ||||||
|  |       description: '🐉<i class="indice">💧</i>', | ||||||
|  |       color1: '#ffffff', | ||||||
|  |       color2: '#32021F', | ||||||
|  |       color3: '#00ff00' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 'ES2013', | ||||||
|  |       type: 'playlist', | ||||||
|  |       name: '2013', | ||||||
|  |       duration: 0, | ||||||
|  |       description: '🐍<i class="indice">💧</i>', | ||||||
|  |       color1: '#ffffff', | ||||||
|  |       color2: '#32021F', | ||||||
|  |       color3: '#00ff00' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 'ES2015', | ||||||
|  |       type: 'playlist', | ||||||
|  |       name: '2015', | ||||||
|  |       duration: 0, | ||||||
|  |       description: '🐐<i class="indice">🌳</i>', | ||||||
|  |       color1: '#ffffff', | ||||||
|  |       color2: '#32021F', | ||||||
|  |       color3: '#00ff00' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 'ES2016', | ||||||
|  |       type: 'playlist', | ||||||
|  |       name: '2016', | ||||||
|  |       duration: 0, | ||||||
|  |       description: '🐒<i class="indice">🔥</i>', | ||||||
|  |       color1: '#ffffff', | ||||||
|  |       color2: '#32021F', | ||||||
|  |       color3: '#00ff00' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 'ES2017', | ||||||
|  |       type: 'playlist', | ||||||
|  |       name: '2017', | ||||||
|  |       duration: 0, | ||||||
|  |       description: '🐓<i class="indice">🔥</i>', | ||||||
|  |       color1: '#ffffff', | ||||||
|  |       color2: '#32021F', | ||||||
|  |       color3: '#00ff00' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 'ES2018', | ||||||
|  |       type: 'playlist', | ||||||
|  |       name: '2018', | ||||||
|  |       duration: 0, | ||||||
|  |       description: '🐕<i class="indice">🌱</i>', | ||||||
|  |       color1: '#ffffff', | ||||||
|  |       color2: '#32021F', | ||||||
|  |       color3: '#00ff00' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 'ES2019', | ||||||
|  |       type: 'playlist', | ||||||
|  |       name: '2019', | ||||||
|  |       duration: 0, | ||||||
|  |       description: '🐖<i class="indice">🌱</i>', | ||||||
|  |       color1: '#ffffff', | ||||||
|  |       color2: '#32021F', | ||||||
|  |       color3: '#00ff00' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 'ES2020', | ||||||
|  |       type: 'playlist', | ||||||
|  |       name: '2020', | ||||||
|  |       duration: 0, | ||||||
|  |       description: '🐀<i class="indice">🪙</i>', | ||||||
|  |       color1: '#ffffff', | ||||||
|  |       color2: '#32021F', | ||||||
|  |       color3: '#00ff00' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 'ES2021', | ||||||
|  |       type: 'playlist', | ||||||
|  |       name: '2021', | ||||||
|  |       duration: 0, | ||||||
|  |       description: '🐃<i class="indice">🪙</i>', | ||||||
|  |       color1: '#ffffff', | ||||||
|  |       color2: '#32021F', | ||||||
|  |       color3: '#00ff00' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 'ES2022', | ||||||
|  |       type: 'playlist', | ||||||
|  |       name: '2022', | ||||||
|  |       duration: 0, | ||||||
|  |       description: '🐅<i class="indice">💧</i>', | ||||||
|  |       color1: '#ffffff', | ||||||
|  |       color2: '#32021F', | ||||||
|  |       color3: '#00ff00' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 'ES2023', | ||||||
|  |       type: 'playlist', | ||||||
|  |       name: '2023', | ||||||
|  |       duration: 0, | ||||||
|  |       description: '🐇<i class="indice">💧</i>', | ||||||
|  |       color1: '#ffffff', | ||||||
|  |       color2: '#32021F', | ||||||
|  |       color3: '#00ff00' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 'ES2024', | ||||||
|  |       type: 'playlist', | ||||||
|  |       name: '2024', | ||||||
|  |       duration: 0, | ||||||
|  |       description: '🐉<i class="indice">🌳</i>', | ||||||
|  |       color1: '#ffffff', | ||||||
|  |       color2: '#32021F', | ||||||
|  |       color3: '#00ff00' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 'ES2025', | ||||||
|  |       type: 'playlist', | ||||||
|  |       name: '2025', | ||||||
|  |       duration: 0, | ||||||
|  |       description: '🐍<i class="indice">🌳</i>', | ||||||
|  |       color1: '#ffffff', | ||||||
|  |       color2: '#32021F', | ||||||
|  |       color3: '#00ff00' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 'ES00A', | ||||||
|  |       type: 'compilation', | ||||||
|  |       name: 'manifeste', | ||||||
|  |       duration: 2794, | ||||||
|  |       description: 'Zero is for manifesto', | ||||||
|  |       color1: '#ffffff', | ||||||
|  |       color2: '#48959d', | ||||||
|  |       color3: '#00ff00' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 'ES00B', | ||||||
|  |       type: 'compilation', | ||||||
|  |       name: 'manifeste B', | ||||||
|  |       duration: 2470, | ||||||
|  |       description: 'Even Zero has a b-side', | ||||||
|  |       color1: '#0d01b9', | ||||||
|  |       color2: '#3b7589', | ||||||
|  |       color3: '#00ff00' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 'ES01A', | ||||||
|  |       type: 'compilation', | ||||||
|  |       name: '...', | ||||||
|  |       duration: 3487, | ||||||
|  |       description: '...', | ||||||
|  |       color1: '#c7b3aa', | ||||||
|  |       color2: '#000100', | ||||||
|  |       color3: '#00ff00' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 'ES01B', | ||||||
|  |       type: 'compilation', | ||||||
|  |       name: '... B', | ||||||
|  |       duration: 3773, | ||||||
|  |       description: '...', | ||||||
|  |       color1: '#f7dd01', | ||||||
|  |       color2: '#010103', | ||||||
|  |       color3: '#00ff00' | ||||||
|  |     } | ||||||
|  |   ] | ||||||
|  |   return boxes.map((b) => ({ ...b, state: 'box-hidden' })) // boxes are first hidden to allow the animation to work (hidden -> list -> selected) | ||||||
|  | }) | ||||||
| @@ -1,42 +0,0 @@ | |||||||
| import { eventHandler } from 'h3' |  | ||||||
|  |  | ||||||
| export default eventHandler(() => { |  | ||||||
|   return [ |  | ||||||
|     { |  | ||||||
|       id: 'ES00A', |  | ||||||
|       name: 'zero', |  | ||||||
|       duration: 2794, |  | ||||||
|       description: 'Zero is for manifesto', |  | ||||||
|       color1: '#ffffff', |  | ||||||
|       color2: '#48959d', |  | ||||||
|       color3: '#00ff00', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 'ES00B', |  | ||||||
|       name: 'zero b-side', |  | ||||||
|       duration: 2470, |  | ||||||
|       description: 'Even Zero has a b-side', |  | ||||||
|       color1: '#0d01b9', |  | ||||||
|       color2: '#3b7589', |  | ||||||
|       color3: '#00ff00', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 'ES01A', |  | ||||||
|       name: 'first', |  | ||||||
|       duration: 3487, |  | ||||||
|       description: '...', |  | ||||||
|       color1: '#c7b3aa', |  | ||||||
|       color2: '#000100', |  | ||||||
|       color3: '#00ff00', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 'ES01B', |  | ||||||
|       name: 'first b-side', |  | ||||||
|       duration: 3773, |  | ||||||
|       description: '...', |  | ||||||
|       color1: '#f7dd01', |  | ||||||
|       color2: '#010103', |  | ||||||
|       color3: '#00ff00', |  | ||||||
|     } |  | ||||||
|   ] |  | ||||||
| }) |  | ||||||
| @@ -1,24 +0,0 @@ | |||||||
| import fs from 'fs' |  | ||||||
| import path from 'path' |  | ||||||
| import { eventHandler } from 'h3' |  | ||||||
|  |  | ||||||
| export default eventHandler(async (event) => { |  | ||||||
|   const id = event.context.params?.id || '' |  | ||||||
|  |  | ||||||
|   const directoryPath = path.join(process.cwd(), 'media/files/music/' + id) // replace 'your-folder' with the folder you want to list |  | ||||||
|  |  | ||||||
|   try { |  | ||||||
|     // Read the directory contents |  | ||||||
|     const files = await fs.promises.readdir(directoryPath) |  | ||||||
|  |  | ||||||
|     return { |  | ||||||
|       success: true, |  | ||||||
|       files: files.filter(file => !file.startsWith('.')) // optional: exclude unwanted files |  | ||||||
|     } |  | ||||||
|   } catch (error) { |  | ||||||
|     return { |  | ||||||
|       success: false, |  | ||||||
|       error: error.message |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| }) |  | ||||||
| @@ -1,22 +0,0 @@ | |||||||
| import fs from 'fs' |  | ||||||
| import path from 'path' |  | ||||||
| import { eventHandler } from 'h3' |  | ||||||
|  |  | ||||||
| export default eventHandler(async (event) => { |  | ||||||
|   const directoryPath = path.join(process.cwd(), 'media/files/music') |  | ||||||
|  |  | ||||||
|   try { |  | ||||||
|     // Read the directory contents |  | ||||||
|     const files = await fs.promises.readdir(directoryPath) |  | ||||||
|  |  | ||||||
|     return { |  | ||||||
|       success: true, |  | ||||||
|       files: files.filter(file => !file.startsWith('.')).reverse() // exclude unwanted files |  | ||||||
|     } |  | ||||||
|   } catch (error) { |  | ||||||
|     return { |  | ||||||
|       success: false, |  | ||||||
|       error: error.message |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| }) |  | ||||||
| @@ -1,204 +0,0 @@ | |||||||
| import { eventHandler } from 'h3' |  | ||||||
|  |  | ||||||
| export default eventHandler(() => { |  | ||||||
|   return [ |  | ||||||
|     { |  | ||||||
|       id: 1, |  | ||||||
|       compilationId: 'ES00A', |  | ||||||
|       title: 'The grinding wheel', |  | ||||||
|       artist: 0, |  | ||||||
|       start: 0, |  | ||||||
|       url: 'https://arakirecords.bandcamp.com/track/the-grinding-wheel', |  | ||||||
|       coverId: 'a3236746052', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 2, |  | ||||||
|       compilationId: 'ES00A', |  | ||||||
|       title: 'Bleach', |  | ||||||
|       artist: 1, |  | ||||||
|       start: 393, |  | ||||||
|       url: 'https://the-kundalini-genie.bandcamp.com/track/bleach-2', |  | ||||||
|       coverId: 'a1714786533', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 3, |  | ||||||
|       compilationId: 'ES00A', |  | ||||||
|       title: 'Televised mind', |  | ||||||
|       artist: 2, |  | ||||||
|       start: 892, |  | ||||||
|       url: 'https://fontainesdc.bandcamp.com/track/televised-mind', |  | ||||||
|       coverId: 'a3772806156' |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 4, |  | ||||||
|       compilationId: 'ES00A', |  | ||||||
|       title: 'In it', |  | ||||||
|       artist: 3, |  | ||||||
|       start: 1138, |  | ||||||
|       url: 'https://howlinbananarecords.bandcamp.com/track/in-it', |  | ||||||
|       coverId: 'a1720372066', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 5, |  | ||||||
|       compilationId: 'ES00A', |  | ||||||
|       title: 'Bad michel', |  | ||||||
|       artist: 4, |  | ||||||
|       start: 1245, |  | ||||||
|       url: 'https://johnnymafia.bandcamp.com/track/bad-michel-3', |  | ||||||
|       coverId: 'a0984622869', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 6, |  | ||||||
|       compilationId: 'ES00A', |  | ||||||
|       title: 'Overall', |  | ||||||
|       artist: 5, |  | ||||||
|       start: 1394, |  | ||||||
|       url: 'https://newcandys.bandcamp.com/track/overall', |  | ||||||
|       coverId: 'a0559661270', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 7, |  | ||||||
|       compilationId: 'ES00A', |  | ||||||
|       title: 'Blowup', |  | ||||||
|       artist: 6, |  | ||||||
|       start: 1674, |  | ||||||
|       url: 'https://magicshoppe.bandcamp.com/track/blowup', |  | ||||||
|       coverId: 'a1444895293', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 8, |  | ||||||
|       compilationId: 'ES00A', |  | ||||||
|       title: 'Guitar jet', |  | ||||||
|       artist: 7, |  | ||||||
|       start: 1880, |  | ||||||
|       url: 'https://radiomartiko.bandcamp.com/track/guitare-jet', |  | ||||||
|       coverId: 'a1494681687', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 9, |  | ||||||
|       compilationId: 'ES00A', |  | ||||||
|       title: 'Intercontinental radio waves', |  | ||||||
|       artist: 8, |  | ||||||
|       start: 2024, |  | ||||||
|       url: 'https://traams.bandcamp.com/track/intercontinental-radio-waves', |  | ||||||
|       coverId: 'a0046738552', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 10, |  | ||||||
|       compilationId: 'ES00A', |  | ||||||
|       title: 'Here comes the sun', |  | ||||||
|       artist: 9, |  | ||||||
|       start: 2211, |  | ||||||
|       url: 'https://blue-orchid.bandcamp.com/track/here-come-the-sun', |  | ||||||
|       coverId: 'a4102567047', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 11, |  | ||||||
|       compilationId: 'ES00A', |  | ||||||
|       title: 'Like in the movies', |  | ||||||
|       artist: 10, |  | ||||||
|       start: 2559, |  | ||||||
|       url: 'https://bruitblanc.bandcamp.com/track/like-in-the-movies-2', |  | ||||||
|       coverId: 'a2203158939', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 21, |  | ||||||
|       compilationId: 'ES00B', |  | ||||||
|       title: 'Ce que révèle l\'éclipse', |  | ||||||
|       artist: 0, |  | ||||||
|       start: 0, |  | ||||||
|       url: 'https://arakirecords.bandcamp.com/track/ce-que-r-v-le-l-clipse', |  | ||||||
|       coverId: 'a3236746052', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 22, |  | ||||||
|       compilationId: 'ES00B', |  | ||||||
|       title: 'Bleedin\' Gums Mushrool', |  | ||||||
|       artist: 1, |  | ||||||
|       start: 263, |  | ||||||
|       url: 'https://the-kundalini-genie.bandcamp.com/track/bleedin-gums-mushroom', |  | ||||||
|       coverId: 'a1714786533', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 23, |  | ||||||
|       compilationId: 'ES00B', |  | ||||||
|       title: 'A lucid dream', |  | ||||||
|       artist: 2, |  | ||||||
|       start: 554, |  | ||||||
|       url: 'https://fontainesdc.bandcamp.com/track/a-lucid-dream', |  | ||||||
|       coverId: 'a3772806156', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 24, |  | ||||||
|       compilationId: 'ES00B', |  | ||||||
|       title: 'Lights off', |  | ||||||
|       artist: 3, |  | ||||||
|       start: 781, |  | ||||||
|       url: 'https://howlinbananarecords.bandcamp.com/track/lights-off', |  | ||||||
|       coverId: 'a1720372066', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 25, |  | ||||||
|       compilationId: 'ES00B', |  | ||||||
|       title: 'I\'m sentimental', |  | ||||||
|       artist: 4, |  | ||||||
|       start: 969, |  | ||||||
|       url: 'https://johnnymafia.bandcamp.com/track/im-sentimental-2', |  | ||||||
|       coverId: 'a2333676849', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 26, |  | ||||||
|       compilationId: 'ES00B', |  | ||||||
|       title: 'Thrill or trip', |  | ||||||
|       artist: 5, |  | ||||||
|       start: 1128, |  | ||||||
|       url: 'https://newcandys.bandcamp.com/track/thrill-or-trip', |  | ||||||
|       coverId: 'a0559661270', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 27, |  | ||||||
|       compilationId: 'ES00B', |  | ||||||
|       title: 'Redhead', |  | ||||||
|       artist: 6, |  | ||||||
|       start: 1303, |  | ||||||
|       url: 'https://magicshoppe.bandcamp.com/track/redhead', |  | ||||||
|       coverId: 'a0594426943', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 28, |  | ||||||
|       compilationId: 'ES00B', |  | ||||||
|       title: 'Supersonic twist', |  | ||||||
|       artist: 7, |  | ||||||
|       start: 1584, |  | ||||||
|       url: 'https://open.spotify.com/track/66voQIZAJ3zD3Eju2qtNjF', |  | ||||||
|       coverId: 'a1494681687', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 29, |  | ||||||
|       compilationId: 'ES00B', |  | ||||||
|       title: 'Flowers', |  | ||||||
|       artist: 8, |  | ||||||
|       start: 1749, |  | ||||||
|       url: 'https://traams.bandcamp.com/track/flowers', |  | ||||||
|       coverId: 'a3644668199', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 30, |  | ||||||
|       compilationId: 'ES00B', |  | ||||||
|       title: 'The shade', |  | ||||||
|       artist: 9, |  | ||||||
|       start: 1924, |  | ||||||
|       url: 'https://blue-orchid.bandcamp.com/track/the-shade', |  | ||||||
|       coverId: 'a0804204790', |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: 31, |  | ||||||
|       compilationId: 'ES00B', |  | ||||||
|       title: 'Like in the movies', |  | ||||||
|       artist: 10, |  | ||||||
|       start: 2185, |  | ||||||
|       url: 'https://bruitblanc.bandcamp.com/track/like-in-the-movies', |  | ||||||
|       coverId: 'a3647322740', |  | ||||||
|     }, |  | ||||||
|   ] |  | ||||||
| }) |  | ||||||
							
								
								
									
										411
									
								
								server/api/tracks/compilation.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,411 @@ | |||||||
|  | import { eventHandler } from 'h3' | ||||||
|  |  | ||||||
|  | export default eventHandler(() => { | ||||||
|  |   const tracks = [ | ||||||
|  |     { | ||||||
|  |       order: 1, | ||||||
|  |       boxId: 'ES00A', | ||||||
|  |       title: 'The grinding wheel', | ||||||
|  |       artist: 0, | ||||||
|  |       start: 0, | ||||||
|  |       link: 'https://arakirecords.bandcamp.com/track/the-grinding-wheel', | ||||||
|  |       coverId: 'a3236746052' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 2, | ||||||
|  |       boxId: 'ES00A', | ||||||
|  |       title: 'Bleach', | ||||||
|  |       artist: 1, | ||||||
|  |       start: 392, | ||||||
|  |       link: 'https://the-kundalini-genie.bandcamp.com/track/bleach-2', | ||||||
|  |       coverId: 'a1714786533' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 3, | ||||||
|  |       boxId: 'ES00A', | ||||||
|  |       title: 'Televised mind', | ||||||
|  |       artist: 2, | ||||||
|  |       start: 896, | ||||||
|  |       link: 'https://fontainesdc.bandcamp.com/track/televised-mind', | ||||||
|  |       coverId: 'a3772806156' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 4, | ||||||
|  |       boxId: 'ES00A', | ||||||
|  |       title: 'In it', | ||||||
|  |       artist: 3, | ||||||
|  |       start: 1139, | ||||||
|  |       link: 'https://howlinbananarecords.bandcamp.com/track/in-it', | ||||||
|  |       coverId: 'a1720372066' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 5, | ||||||
|  |       boxId: 'ES00A', | ||||||
|  |       title: 'Bad michel', | ||||||
|  |       artist: 4, | ||||||
|  |       start: 1245, | ||||||
|  |       link: 'https://johnnymafia.bandcamp.com/track/bad-michel-3', | ||||||
|  |       coverId: 'a0984622869' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 6, | ||||||
|  |       boxId: 'ES00A', | ||||||
|  |       title: 'Overall', | ||||||
|  |       artist: 5, | ||||||
|  |       start: 1394, | ||||||
|  |       link: 'https://newcandys.bandcamp.com/track/overall', | ||||||
|  |       coverId: 'a0559661270' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 7, | ||||||
|  |       boxId: 'ES00A', | ||||||
|  |       title: 'Blowup', | ||||||
|  |       artist: 6, | ||||||
|  |       start: 1674, | ||||||
|  |       link: 'https://magicshoppe.bandcamp.com/track/blowup', | ||||||
|  |       coverId: 'a1444895293' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 8, | ||||||
|  |       boxId: 'ES00A', | ||||||
|  |       title: 'Guitar jet', | ||||||
|  |       artist: 7, | ||||||
|  |       start: 1880, | ||||||
|  |       link: 'https://radiomartiko.bandcamp.com/track/guitare-jet', | ||||||
|  |       coverId: 'a1494681687' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 9, | ||||||
|  |       boxId: 'ES00A', | ||||||
|  |       title: 'Intercontinental radio waves', | ||||||
|  |       artist: 8, | ||||||
|  |       start: 2024, | ||||||
|  |       link: 'https://traams.bandcamp.com/track/intercontinental-radio-waves', | ||||||
|  |       coverId: 'a0046738552' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 10, | ||||||
|  |       boxId: 'ES00A', | ||||||
|  |       title: 'Here comes the sun', | ||||||
|  |       artist: 9, | ||||||
|  |       start: 2211, | ||||||
|  |       link: 'https://blue-orchid.bandcamp.com/track/here-come-the-sun', | ||||||
|  |       coverId: 'a4102567047' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 11, | ||||||
|  |       boxId: 'ES00A', | ||||||
|  |       title: 'Like in the movies', | ||||||
|  |       artist: 10, | ||||||
|  |       start: 2560, | ||||||
|  |       link: 'https://bruitblanc.bandcamp.com/track/like-in-the-movies-2', | ||||||
|  |       coverId: 'a2203158939' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 1, | ||||||
|  |       boxId: 'ES00B', | ||||||
|  |       title: "Ce que révèle l'éclipse", | ||||||
|  |       artist: 0, | ||||||
|  |       start: 0, | ||||||
|  |       link: 'https://arakirecords.bandcamp.com/track/ce-que-r-v-le-l-clipse', | ||||||
|  |       coverId: 'a3236746052' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 2, | ||||||
|  |       boxId: 'ES00B', | ||||||
|  |       title: "Bleedin' Gums Mushrool", | ||||||
|  |       artist: 1, | ||||||
|  |       start: 263, | ||||||
|  |       link: 'https://the-kundalini-genie.bandcamp.com/track/bleedin-gums-mushroom', | ||||||
|  |       coverId: 'a1714786533' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 3, | ||||||
|  |       boxId: 'ES00B', | ||||||
|  |       title: 'A lucid dream', | ||||||
|  |       artist: 2, | ||||||
|  |       start: 554, | ||||||
|  |       link: 'https://fontainesdc.bandcamp.com/track/a-lucid-dream', | ||||||
|  |       coverId: 'a3772806156' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 4, | ||||||
|  |       boxId: 'ES00B', | ||||||
|  |       title: 'Lights off', | ||||||
|  |       artist: 3, | ||||||
|  |       start: 781, | ||||||
|  |       link: 'https://howlinbananarecords.bandcamp.com/track/lights-off', | ||||||
|  |       coverId: 'a1720372066' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 5, | ||||||
|  |       boxId: 'ES00B', | ||||||
|  |       title: "I'm sentimental", | ||||||
|  |       artist: 4, | ||||||
|  |       start: 969, | ||||||
|  |       link: 'https://johnnymafia.bandcamp.com/track/im-sentimental-2', | ||||||
|  |       coverId: 'a2333676849' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 6, | ||||||
|  |       boxId: 'ES00B', | ||||||
|  |       title: 'Thrill or trip', | ||||||
|  |       artist: 5, | ||||||
|  |       start: 1128, | ||||||
|  |       link: 'https://newcandys.bandcamp.com/track/thrill-or-trip', | ||||||
|  |       coverId: 'a0559661270' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 7, | ||||||
|  |       boxId: 'ES00B', | ||||||
|  |       title: 'Redhead', | ||||||
|  |       artist: 6, | ||||||
|  |       start: 1303, | ||||||
|  |       link: 'https://magicshoppe.bandcamp.com/track/redhead', | ||||||
|  |       coverId: 'a0594426943' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 8, | ||||||
|  |       boxId: 'ES00B', | ||||||
|  |       title: 'Supersonic twist', | ||||||
|  |       artist: 7, | ||||||
|  |       start: 1584, | ||||||
|  |       link: 'https://open.spotify.com/track/66voQIZAJ3zD3Eju2qtNjF', | ||||||
|  |       coverId: 'a1494681687' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 9, | ||||||
|  |       boxId: 'ES00B', | ||||||
|  |       title: 'Flowers', | ||||||
|  |       artist: 8, | ||||||
|  |       start: 1749, | ||||||
|  |       link: 'https://traams.bandcamp.com/track/flowers', | ||||||
|  |       coverId: 'a3644668199' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 10, | ||||||
|  |       boxId: 'ES00B', | ||||||
|  |       title: 'The shade', | ||||||
|  |       artist: 9, | ||||||
|  |       start: 1924, | ||||||
|  |       link: 'https://blue-orchid.bandcamp.com/track/the-shade', | ||||||
|  |       coverId: 'a0804204790' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 11, | ||||||
|  |       boxId: 'ES00B', | ||||||
|  |       title: 'Like in the movies', | ||||||
|  |       artist: 10, | ||||||
|  |       start: 2186, | ||||||
|  |       link: 'https://bruitblanc.bandcamp.com/track/like-in-the-movies', | ||||||
|  |       coverId: 'a3647322740' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 1, | ||||||
|  |       boxId: 'ES01A', | ||||||
|  |       title: 'He Walked In', | ||||||
|  |       artist: 11, | ||||||
|  |       start: 0, | ||||||
|  |       link: 'https://kidcongothepinkmonkeybirds.bandcamp.com/track/he-walked-in', | ||||||
|  |       coverId: 'a0336300523' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 2, | ||||||
|  |       boxId: 'ES01A', | ||||||
|  |       title: 'The Third Wave', | ||||||
|  |       artist: 12, | ||||||
|  |       start: 841, | ||||||
|  |       link: 'https://firefriend.bandcamp.com/track/the-third-wave', | ||||||
|  |       coverId: 'a2803689859' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 3, | ||||||
|  |       boxId: 'ES01A', | ||||||
|  |       title: 'Broadcaster', | ||||||
|  |       artist: 13, | ||||||
|  |       start: 1104.5, | ||||||
|  |       link: 'https://squiduk.bandcamp.com/track/broadcaster', | ||||||
|  |       coverId: 'a3391719769' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 4, | ||||||
|  |       boxId: 'ES01A', | ||||||
|  |       title: 'Mourn', | ||||||
|  |       artist: 14, | ||||||
|  |       start: 1441, | ||||||
|  |       link: 'https://lysistrata.bandcamp.com/track/mourn-2', | ||||||
|  |       coverId: 'a0872900041' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 5, | ||||||
|  |       boxId: 'ES01A', | ||||||
|  |       title: 'Let it Blow', | ||||||
|  |       artist: 15, | ||||||
|  |       start: 1844.8, | ||||||
|  |       link: 'https://pabloxbroadcastingservices.bandcamp.com/track/let-it-blow', | ||||||
|  |       coverId: 'a4000148031' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 6, | ||||||
|  |       boxId: 'ES01A', | ||||||
|  |       title: 'Sunday Mourning', | ||||||
|  |       artist: 16, | ||||||
|  |       start: 2091.7, | ||||||
|  |       link: 'https://nightbeats.bandcamp.com/track/sunday-mourning', | ||||||
|  |       coverId: 'a0031987121' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 7, | ||||||
|  |       boxId: 'ES01A', | ||||||
|  |       title: '3030 Instrumental', | ||||||
|  |       artist: 17, | ||||||
|  |       start: 2339.3, | ||||||
|  |       link: 'https://delthefunkyhomosapien.bandcamp.com/track/3030', | ||||||
|  |       coverId: 'a1948146136' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 8, | ||||||
|  |       boxId: 'ES01A', | ||||||
|  |       title: 'Immortality Break', | ||||||
|  |       artist: 18, | ||||||
|  |       start: 2530.5, | ||||||
|  |       link: 'https://theaa.bandcamp.com/track/immortality-break', | ||||||
|  |       coverId: 'a2749250329' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 9, | ||||||
|  |       boxId: 'ES01A', | ||||||
|  |       title: 'Lazy Bones', | ||||||
|  |       artist: 19, | ||||||
|  |       start: 2718, | ||||||
|  |       link: 'https://woodenshjips.bandcamp.com/track/lazy-bones', | ||||||
|  |       coverId: 'a1884221104' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 10, | ||||||
|  |       boxId: 'ES01A', | ||||||
|  |       title: 'On the Train of Aches', | ||||||
|  |       artist: 20, | ||||||
|  |       start: 2948, | ||||||
|  |       link: 'https://silasjdirge.bandcamp.com/track/on-the-train-of-aches', | ||||||
|  |       coverId: 'a1124177379' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 11, | ||||||
|  |       boxId: 'ES01A', | ||||||
|  |       title: 'Me', | ||||||
|  |       artist: 21, | ||||||
|  |       start: 3265, | ||||||
|  |       link: 'https://secretcolours.bandcamp.com/track/me', | ||||||
|  |       coverId: 'a1497022499' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 1, | ||||||
|  |       boxId: 'ES01B', | ||||||
|  |       title: 'Lady Hawke Blues', | ||||||
|  |       artist: 11, | ||||||
|  |       start: 0, | ||||||
|  |       link: 'https://kidcongothepinkmonkeybirds.bandcamp.com/track/lady-hawke-blues', | ||||||
|  |       coverId: 'a2532623230' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 2, | ||||||
|  |       boxId: 'ES01B', | ||||||
|  |       title: 'Dreamscapes', | ||||||
|  |       artist: 12, | ||||||
|  |       start: 235, | ||||||
|  |       link: 'https://littlecloudrecords.bandcamp.com/track/dreamscapes', | ||||||
|  |       coverId: 'a3498981203' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 3, | ||||||
|  |       boxId: 'ES01B', | ||||||
|  |       title: 'Crispy Skin', | ||||||
|  |       artist: 13, | ||||||
|  |       start: 644.2, | ||||||
|  |       link: 'https://squiduk.bandcamp.com/track/crispy-skin-2', | ||||||
|  |       coverId: 'a2516727021' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 4, | ||||||
|  |       boxId: 'ES01B', | ||||||
|  |       title: 'The Boy Who Stood Above The Earth', | ||||||
|  |       artist: 14, | ||||||
|  |       start: 1018, | ||||||
|  |       link: 'https://lysistrata.bandcamp.com/track/the-boy-who-stood-above-the-earth-2', | ||||||
|  |       coverId: 'a0350933426' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 5, | ||||||
|  |       boxId: 'ES01B', | ||||||
|  |       title: 'Better Off Alone', | ||||||
|  |       artist: 15, | ||||||
|  |       start: 1698, | ||||||
|  |       link: 'https://pabloxbroadcastingservices.bandcamp.com/track/better-off-alone', | ||||||
|  |       coverId: 'a4000148031' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 6, | ||||||
|  |       boxId: 'ES01B', | ||||||
|  |       title: 'Celebration #1', | ||||||
|  |       artist: 16, | ||||||
|  |       start: 2235, | ||||||
|  |       link: 'https://nightbeats.bandcamp.com/track/celebration-1', | ||||||
|  |       coverId: 'a0031987121' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 7, | ||||||
|  |       boxId: 'ES01B', | ||||||
|  |       title: '3030 Instrumental', | ||||||
|  |       artist: 17, | ||||||
|  |       start: 2458.3, | ||||||
|  |       link: 'https://delthefunkyhomosapien.bandcamp.com/track/3030', | ||||||
|  |       coverId: 'a1948146136' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 8, | ||||||
|  |       boxId: 'ES01B', | ||||||
|  |       title: 'The Emptiness Of Nothingness', | ||||||
|  |       artist: 18, | ||||||
|  |       start: 2864.5, | ||||||
|  |       link: 'https://theaa.bandcamp.com/track/the-emptiness-of-nothingness', | ||||||
|  |       coverId: 'a1053923875' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 9, | ||||||
|  |       boxId: 'ES01B', | ||||||
|  |       title: 'Rising', | ||||||
|  |       artist: 19, | ||||||
|  |       start: 3145, | ||||||
|  |       link: 'https://woodenshjips.bandcamp.com/track/rising', | ||||||
|  |       coverId: 'a1884221104' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 10, | ||||||
|  |       boxId: 'ES01B', | ||||||
|  |       title: 'The Last Time', | ||||||
|  |       artist: 22, | ||||||
|  |       start: 3447, | ||||||
|  |       link: 'https://www.discogs.com/release/12110815-Larry-McNeil-And-The-Blue-Knights-Jealous-Woman', | ||||||
|  |       coverId: | ||||||
|  |         'https://i.discogs.com/Yr05_neEXwzPwKlDeV7dimmTG34atkAMgpxbMBhHBkI/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTEyMTEw/ODE1LTE1Mjg1NjU1/NzQtMjcyOC5qcGVn.jpeg' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       order: 11, | ||||||
|  |       boxId: 'ES01B', | ||||||
|  |       title: 'Guajira Con Arpa', | ||||||
|  |       artist: 23, | ||||||
|  |       start: 3586, | ||||||
|  |       link: 'https://elpalmasmusic.bandcamp.com/track/guajira-con-arpa', | ||||||
|  |       coverId: 'a3463036407' | ||||||
|  |     } | ||||||
|  |   ] | ||||||
|  |  | ||||||
|  |   return tracks.map((track, index) => ({ | ||||||
|  |     id: index + 1, | ||||||
|  |     ...track, | ||||||
|  |     url: `https://files.erudi.fr/evilspins/${track.boxId}.mp3`, | ||||||
|  |     coverId: `https://f4.bcbits.com/img/${track.coverId}_4.jpg`, | ||||||
|  |     type: 'compilation', | ||||||
|  |   })) | ||||||
|  | }) | ||||||
							
								
								
									
										74
									
								
								server/api/tracks/playlist.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,74 @@ | |||||||
|  | import fs from 'fs' | ||||||
|  | import path from 'path' | ||||||
|  | import { eventHandler } from 'h3' | ||||||
|  | import { getCardFromDate } from '../../../utils/cards' | ||||||
|  |  | ||||||
|  | export default eventHandler(async (event) => { | ||||||
|  |   const basePath = path.join(process.cwd(), '/mnt/media/files/music') | ||||||
|  |  | ||||||
|  |   try { | ||||||
|  |     let allTracks: any[] = [] | ||||||
|  |  | ||||||
|  |     const dirPath = basePath | ||||||
|  |     const urlPrefix = `https://files.erudi.fr/music` | ||||||
|  |  | ||||||
|  |     let files = await fs.promises.readdir(dirPath) | ||||||
|  |     files = files.filter((f) => !f.startsWith('.') && !f.endsWith('.jpg')) | ||||||
|  |  | ||||||
|  |     const tracks = files.map((file, index) => { | ||||||
|  |       const EXT_RE = /\.(mp3|flac|wav|opus)$/i | ||||||
|  |       const nameWithoutExt = file.replace(EXT_RE, '') | ||||||
|  |  | ||||||
|  |       // On split sur __ | ||||||
|  |       const parts = nameWithoutExt.split('__') | ||||||
|  |       let stamp = parts[0] || '' | ||||||
|  |       let artist = parts[1] || '' | ||||||
|  |       let title = parts[2] || '' | ||||||
|  |  | ||||||
|  |       title = title.replaceAll('_', ' ') | ||||||
|  |       artist = artist.replaceAll('_', ' ') | ||||||
|  |  | ||||||
|  |       // Parser la date depuis le stamp | ||||||
|  |       let year = 2020, | ||||||
|  |         month = 1, | ||||||
|  |         day = 1, | ||||||
|  |         hour = 0 | ||||||
|  |       if (stamp.length === 10) { | ||||||
|  |         year = Number(stamp.slice(0, 4)) | ||||||
|  |         month = Number(stamp.slice(4, 6)) | ||||||
|  |         day = Number(stamp.slice(6, 8)) | ||||||
|  |         hour = Number(stamp.slice(8, 10)) | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       const date = new Date(year, month - 1, day, hour) | ||||||
|  |       const card = getCardFromDate(date) | ||||||
|  |       const url = `${urlPrefix}/${encodeURIComponent(file)}` | ||||||
|  |       const coverId = `${urlPrefix}/${encodeURIComponent(file).replace(EXT_RE, '.jpg')}` | ||||||
|  |  | ||||||
|  |       return { | ||||||
|  |         id: Number(`${year}${index + 1}`), | ||||||
|  |         boxId: `ES${year}`, | ||||||
|  |         date, | ||||||
|  |         title: title.trim(), | ||||||
|  |         artist: artist.trim(), | ||||||
|  |         url, | ||||||
|  |         coverId, | ||||||
|  |         card, | ||||||
|  |         order: 0, | ||||||
|  |         type: 'playlist', | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  |  | ||||||
|  |     tracks.sort((a, b) => b.date.getTime() - a.date.getTime()) | ||||||
|  |     // assign a stable order after sort (1..N) | ||||||
|  |     tracks.forEach((t, i) => (t.order = i + 1)) | ||||||
|  |     allTracks.push(...tracks) | ||||||
|  |  | ||||||
|  |     return allTracks | ||||||
|  |   } catch (error) { | ||||||
|  |     return { | ||||||
|  |       success: false, | ||||||
|  |       error: (error as Error).message | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | }) | ||||||
							
								
								
									
										80
									
								
								spade.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,80 @@ | |||||||
|  | <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||||
|  | <!-- Created with Inkscape (http://www.inkscape.org/) --> | ||||||
|  |  | ||||||
|  | <svg | ||||||
|  |    width="180mm" | ||||||
|  |    height="180mm" | ||||||
|  |    viewBox="0 0 180 180" | ||||||
|  |    version="1.1" | ||||||
|  |    id="svg1" | ||||||
|  |    inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)" | ||||||
|  |    sodipodi:docname="spade.svg" | ||||||
|  |    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||||
|  |    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||||
|  |    xmlns="http://www.w3.org/2000/svg" | ||||||
|  |    xmlns:svg="http://www.w3.org/2000/svg"> | ||||||
|  |   <sodipodi:namedview | ||||||
|  |      id="namedview1" | ||||||
|  |      pagecolor="#505050" | ||||||
|  |      bordercolor="#eeeeee" | ||||||
|  |      borderopacity="1" | ||||||
|  |      inkscape:showpageshadow="0" | ||||||
|  |      inkscape:pageopacity="0" | ||||||
|  |      inkscape:pagecheckerboard="0" | ||||||
|  |      inkscape:deskcolor="#505050" | ||||||
|  |      inkscape:document-units="mm" | ||||||
|  |      inkscape:zoom="0.396875" | ||||||
|  |      inkscape:cx="797.48031" | ||||||
|  |      inkscape:cy="452.28346" | ||||||
|  |      inkscape:window-width="1920" | ||||||
|  |      inkscape:window-height="1132" | ||||||
|  |      inkscape:window-x="0" | ||||||
|  |      inkscape:window-y="0" | ||||||
|  |      inkscape:window-maximized="1" | ||||||
|  |      inkscape:current-layer="layer1"> | ||||||
|  |     <inkscape:page | ||||||
|  |        x="0" | ||||||
|  |        y="0" | ||||||
|  |        width="180" | ||||||
|  |        height="180" | ||||||
|  |        id="page1" | ||||||
|  |        margin="0 0.625 0 0" | ||||||
|  |        bleed="0" /> | ||||||
|  |   </sodipodi:namedview> | ||||||
|  |   <defs | ||||||
|  |      id="defs1" /> | ||||||
|  |   <g | ||||||
|  |      inkscape:label="Layer 1" | ||||||
|  |      inkscape:groupmode="layer" | ||||||
|  |      id="layer1"> | ||||||
|  |     <path | ||||||
|  |        fill="#ff0000" | ||||||
|  |        d="m 90.078671,46.355618 q 1.66952,-1.568979 3.34698,-3.127375 7.781389,-7.228416 17.774709,-10.871729 c 1.62983,-0.595311 3.87879,-0.997478 5.77849,-1.113894 q 5.4954,-0.336021 10.70504,0.502708 c 13.57313,2.185457 24.52159,12.00679 28.21781,25.225373 4.10634,14.681729 -0.11377,29.458708 -9.41916,41.301458 q -4.50586,5.733521 -8.43756,9.477371 c -13.77686,13.10746 -29.337,24.08501 -42.410059,37.95447 -1.97909,2.09814 -4.07988,3.78619 -7.10936,2.96598 -1.91293,-0.51859 -3.487205,-2.17488 -4.894788,-3.67771 C 74.777814,135.54135 64.347939,127.26519 54.367856,118.82764 41.416502,107.87918 28.462502,96.512679 23.906377,79.386201 21.54894,70.522659 22.199815,60.198618 25.96219,51.800743 30.417773,41.844472 39.434773,34.682202 50.020752,32.155431 c 2.145771,-0.513291 4.915958,-0.928686 7.347479,-1.00277 4.513792,-0.132291 8.461375,0.06085 12.631208,1.664228 q 9.23925,3.550708 16.494122,10.371667 1.68275,1.582208 3.36815,3.167062 0.10848,0.100542 0.21696,0 z" | ||||||
|  |        id="path2" | ||||||
|  |        style="stroke-width:0.264583" /> | ||||||
|  |     <path | ||||||
|  |        fill="#000000" | ||||||
|  |        d="m 470.83582,48.369648 a 0.11377083,0.11377083 0 0 0 -0.17463,-0.121709 c -4.318,3.082396 -8.95085,5.574771 -14.05202,7.074959 -2.81252,0.828146 -6.18596,0.791104 -9.03817,0.690562 -14.65791,-0.523875 -27.23356,-10.091208 -30.90598,-24.43427 -4.73075,-18.473208 3.94494,-34.1841663 16.9889,-46.704249 7.05644,-6.773334 14.67644,-12.961938 22.20383,-19.198167 5.41073,-4.484688 10.49338,-9.339792 17.15559,-16.123708 0.87048,-0.886354 2.00289,-1.867959 3.27818,-2.12725 q 3.07711,-0.629709 5.50334,1.791229 c 4.92654,4.910666 9.16516,9.249833 15.06273,14.353646 9.05404,7.836958 16.97302,13.95677 25.05604,21.666729 q 6.31825,6.027208 11.00137,13.3508746 c 5.88698,9.2074997 8.01688,19.6982284 6.13834,30.4879364 -2.36273,13.559896 -12.94871,23.979188 -26.33927,26.381604 q -4.953,0.886354 -10.35315,0.486834 c -6.477,-0.478896 -12.38779,-4.103688 -17.69269,-7.548563 q -0.13494,-0.08731 -0.20373,-0.121708 a 0.1031875,0.1031875 0 0 0 -0.14287,0.121708 c 3.78089,12.181417 7.79198,22.918208 14.03085,33.535937 q 0.15346,0.261938 -0.15081,0.269875 -0.56885,0.01323 -1.28323,-0.08731 c -4.2836,-0.613834 -8.636,-1.113896 -12.96458,-1.275292 q -8.70215,-0.325437 -17.399,0.224896 -1.79917,0.113771 -8.79475,1.150937 -0.27781,0.03969 -0.84667,-0.03704 -0.14552,-0.01852 -0.0926,-0.156104 0.254,-0.674688 0.61648,-1.27 3.5216,-5.789084 6.19918,-12.014729 4.29155,-9.98273 7.19932,-20.367625 z" | ||||||
|  |        id="path1" | ||||||
|  |        style="stroke-width:0.264583" /> | ||||||
|  |     <path | ||||||
|  |        id="path3" | ||||||
|  |        style="fill:#000000;stroke-width:0.264583" | ||||||
|  |        d="m 689.88977,93.581748 a 32.819258,32.819258 0 0 0 -32.81918,32.819182 32.819258,32.819258 0 0 0 12.24214,25.56743 c 0.45986,0.67849 0.93396,1.34539 1.41956,1.99937 a 32.819258,32.819258 0 0 0 -16.14785,-4.24729 32.819258,32.819258 0 0 0 -32.81919,32.81918 32.819258,32.819258 0 0 0 32.81919,32.81919 32.819258,32.819258 0 0 0 31.46319,-23.48229 c 0.13863,-0.0901 0.27759,-0.17961 0.41703,-0.26871 a 0.06614583,0.06614583 0 0 1 0.10025,0.0713 c -1.82915,8.07332 -4.03647,16.0141 -6.62233,23.82283 -2.8284,8.54604 -6.18046,16.69282 -10.83448,24.02178 a 0.11641667,0.11641667 0 0 0 0.0713,0.17468 c 0.61736,0.14288 1.10161,0.18153 1.45262,0.11626 4.75192,-0.86077 9.86556,-1.33004 15.34067,-1.40766 8.12006,-0.11377 15.34544,0.0267 23.14536,1.4025 0.34043,0.06 0.79935,0.0246 1.37614,-0.10594 0.0952,-0.0212 0.11812,-0.074 0.0687,-0.15864 -4.21217,-7.23019 -7.6376,-14.81736 -10.27638,-22.76192 -2.41652,-7.27075 -4.64179,-15.08437 -6.67556,-23.43991 -0.11994,-0.49389 -0.17573,-0.9406 -0.16691,-1.341 a 0.12435417,0.12170833 16 0 1 0.1881,-0.10336 c 0.0396,0.0238 0.0789,0.0479 0.11834,0.0718 4.05187,13.52893 16.59657,23.38824 31.44408,23.38824 18.12557,0 32.81968,-14.69362 32.81968,-32.81919 0,-18.12557 -14.69411,-32.81918 -32.81968,-32.81918 -5.84735,0 -11.33713,1.52926 -16.09205,4.20956 0.51661,-0.6831 1.01243,-1.36973 1.48622,-2.06034 a 32.819258,32.819258 0 0 0 12.12018,-25.46873 32.819258,32.819258 0 0 0 -32.81918,-32.819182 z" /> | ||||||
|  |     <path | ||||||
|  |        fill="#0000ff" | ||||||
|  |        d="m 349.32373,119.55231 c 0.48419,0.17727 0.91546,0.37836 1.29646,0.762 q 1.95527,1.97909 2.92364,3.15648 22.41286,27.27854 46.46613,57.38019 c 1.05304,1.31762 2.37066,2.667 2.75166,4.18835 0.76465,3.03477 -0.59795,4.47411 -2.65641,7.05115 q -20.58988,25.79158 -41.48402,51.33975 -3.6539,4.46881 -7.48242,8.7921 c -2.02406,2.28336 -6.21242,2.42359 -8.28146,0.20902 q -2.45004,-2.61937 -3.86821,-4.34445 -23.88393,-29.09094 -47.3366,-58.53113 -0.11906,-0.15346 -0.27252,-0.43656 c -1.10596,-2.05581 -0.57944,-4.68048 0.7964,-6.45583 q 1.57162,-2.02936 3.17764,-4.01903 c 15.55221,-19.26166 31.14146,-38.50216 46.92385,-57.57597 1.7489,-2.11138 4.52703,-2.44475 7.04586,-1.51607 z" | ||||||
|  |        id="path4" | ||||||
|  |        style="fill:#ff0000;stroke-width:0.264583" /> | ||||||
|  |     <g | ||||||
|  |        id="g5" | ||||||
|  |        transform="matrix(0.26458333,0,0,0.26458333,273.92232,-63.930064)" /> | ||||||
|  |     <rect | ||||||
|  |        style="fill:#808080;stroke-width:0.7;stroke-linecap:square;stroke-miterlimit:2.3;paint-order:markers stroke fill" | ||||||
|  |        id="rect1" | ||||||
|  |        width="179.375" | ||||||
|  |        height="180" | ||||||
|  |        x="0" | ||||||
|  |        y="0" /> | ||||||
|  |   </g> | ||||||
|  | </svg> | ||||||
| After Width: | Height: | Size: 6.4 KiB | 
| @@ -1,22 +1,25 @@ | |||||||
| /** @type {import('tailwindcss').Config} */ | /** @type {import('tailwindcss').Config} */ | ||||||
| module.exports = { | module.exports = { | ||||||
|   content: [ |   content: [ | ||||||
|     "./components/**/*.{js,vue,ts}", |     './components/**/*.{js,vue,ts}', | ||||||
|     "./layouts/**/*.vue", |     './layouts/**/*.vue', | ||||||
|     "./pages/**/*.vue", |     './pages/**/*.vue', | ||||||
|     "./plugins/**/*.{js,ts}", |     './plugins/**/*.{js,ts}', | ||||||
|     "./app.vue", |     './app.vue', | ||||||
|     "./error.vue", |     './error.vue' | ||||||
|   ], |   ], | ||||||
|   theme: { |   theme: { | ||||||
|     extend: { |     extend: { | ||||||
|       colors: { |       colors: { | ||||||
|         esyellow: "#fdec50ff", |         esyellow: '#fdec50ff' | ||||||
|  |       }, | ||||||
|  |       fontSize: { | ||||||
|  |         xxs: '0.625rem' // 10px par exemple | ||||||
|       }, |       }, | ||||||
|       screens: { |       screens: { | ||||||
|         "2sm": "320px", |         '2sm': '320px' | ||||||
|       }, |       } | ||||||
|     }, |     } | ||||||
|   }, |   }, | ||||||
|   plugins: [], |   plugins: [] | ||||||
| }; | } | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								three.js
									
									
									
									
									
								
							
							
								
								
								
								
								
							
						
						| @@ -1,6 +1,7 @@ | |||||||
| // types.ts | // types.ts | ||||||
| export interface Compilation { | export interface Box { | ||||||
|   id: string |   id: string | ||||||
|  |   type: 'playlist' | 'compilation' | ||||||
|   name: string |   name: string | ||||||
|   duration: number |   duration: number | ||||||
|   tracks?: Track[] |   tracks?: Track[] | ||||||
| @@ -8,6 +9,7 @@ export interface Compilation { | |||||||
|   color2: string |   color2: string | ||||||
|   color1: string |   color1: string | ||||||
|   color3: string |   color3: string | ||||||
|  |   state: BoxState | ||||||
| } | } | ||||||
|  |  | ||||||
| export interface Artist { | export interface Artist { | ||||||
| @@ -16,28 +18,29 @@ export interface Artist { | |||||||
|   url: string |   url: string | ||||||
|   coverId: string |   coverId: string | ||||||
| } | } | ||||||
|  |  | ||||||
| export interface Track { | export interface Track { | ||||||
|   id: number |   id: number | ||||||
|   compilationId: string |   order?: number | ||||||
|  |   boxId: string | ||||||
|   title: string |   title: string | ||||||
|   artist: Artist |   artist?: Artist | number | string | ||||||
|   start: number |   start?: number | ||||||
|   url: string |   url: string | ||||||
|   coverId: string |   coverId?: string | ||||||
|  |   date?: Date | ||||||
|  |   card?: { suit: CardSuit; rank: CardRank } | ||||||
|  |   link?: string | ||||||
|  |   type: 'playlist' | 'compilation' | ||||||
| } | } | ||||||
|  |  | ||||||
| // pour une v2 | export interface Playlist { | ||||||
| export type BoxState = 'hide' | 'list' | 'selected' |   id: number | ||||||
|  |   date: Date | ||||||
| export interface BoxPosition { |   title: string | ||||||
|   x: number |   url: string | ||||||
|   y: number |   filename: string | ||||||
|   z: number |  | ||||||
| } | } | ||||||
|  |  | ||||||
| export interface BoxSize { | export type BoxState = 'box-hidden' | 'box-list' | 'box-selected' | ||||||
|   h: number | export type CardSuit = '♠' | '♣' | '♦' | '♥' | ||||||
|   w: number | export type CardRank = 'A' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | 'J' | 'Q' | 'K' | ||||||
|   d: number |  | ||||||
| } |  | ||||||
|   | |||||||
							
								
								
									
										21
									
								
								utils/cards.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | |||||||
|  | import type { CardSuit, CardRank } from '~/types/cards' | ||||||
|  |  | ||||||
|  | export function getCardFromDate(date: Date): { suit: CardSuit; rank: CardRank } { | ||||||
|  |   const month = date.getMonth() + 1 | ||||||
|  |   const day = date.getDate() | ||||||
|  |   const hour = date.getHours() | ||||||
|  |  | ||||||
|  |   const suit: CardSuit = | ||||||
|  |     month >= 12 || month <= 2 | ||||||
|  |       ? '♠' | ||||||
|  |       : month >= 3 && month <= 5 | ||||||
|  |       ? '♥' | ||||||
|  |       : month >= 6 && month <= 8 | ||||||
|  |       ? '♦' | ||||||
|  |       : '♣' | ||||||
|  |  | ||||||
|  |   const ranks: CardRank[] = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'] | ||||||
|  |   const rank = ranks[(day + hour) % ranks.length] | ||||||
|  |  | ||||||
|  |   return { suit, rank } | ||||||
|  | } | ||||||