feat: add mobile navigation component and queue panel
- Implemented MobileNav.vue for mobile navigation with links to Home, Library, Playlist, and Profile. - Added QueuePanel.vue to display the current queue of tracks with functionality to jump to a specific track. - Created ArtistView.vue to show artist details and their tracks, including loading states and play functionality. - Introduced NotFoundPage.vue for handling 404 errors with a user-friendly message and a link to return home.
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 585 B |
|
Before Width: | Height: | Size: 566 B |
@@ -1,26 +0,0 @@
|
||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
@media (prefers-color-scheme: light) {
|
||||
.background { fill: black; }
|
||||
.foreground { fill: white; }
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.background { fill: white; }
|
||||
.foreground { fill: black; }
|
||||
}
|
||||
</style>
|
||||
<g clip-path="url(#clip0_7960_43945)">
|
||||
<rect class="background" width="180" height="180" rx="37" />
|
||||
<g style="transform: scale(95%); transform-origin: center">
|
||||
<path class="foreground"
|
||||
d="M101.141 53H136.632C151.023 53 162.689 64.6662 162.689 79.0573V112.904H148.112V79.0573C148.112 78.7105 148.098 78.3662 148.072 78.0251L112.581 112.898C112.701 112.902 112.821 112.904 112.941 112.904H148.112V126.672H112.941C98.5504 126.672 86.5638 114.891 86.5638 100.5V66.7434H101.141V100.5C101.141 101.15 101.191 101.792 101.289 102.422L137.56 66.7816C137.255 66.7563 136.945 66.7434 136.632 66.7434H101.141V53Z" />
|
||||
<path class="foreground"
|
||||
d="M65.2926 124.136L14 66.7372H34.6355L64.7495 100.436V66.7372H80.1365V118.47C80.1365 126.278 70.4953 129.958 65.2926 124.136Z" />
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_7960_43945">
|
||||
<rect width="180" height="180" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -2,12 +2,18 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Noctune - Music That Hits Different</title>
|
||||
<meta name="description" content="Noctune — Music That Hits Different. Stream and discover music." />
|
||||
<meta property="og:title" content="Noctune — Music That Hits Different" />
|
||||
<meta property="og:description" content="Stream and discover music on Noctune." />
|
||||
<meta property="og:image" content="/og_image.png" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||
<script type="module" crossorigin src="/assets/index-BxVozdka.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CuqcJYD7.css">
|
||||
<script type="module" crossorigin src="/assets/index-CNvoD_zC.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-iQ9-4Dbt.css">
|
||||
</head>
|
||||
<body class="bg-noctune-cream">
|
||||
<div id="app"></div>
|
||||
|
||||
|
After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 568 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="215" height="48" fill="none"><path fill="#000" d="M57.588 9.6h6L73.828 38h-5.2l-2.36-6.88h-11.36L52.548 38h-5.2l10.24-28.4Zm7.16 17.16-4.16-12.16-4.16 12.16h8.32Zm23.694-2.24c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.486-7.72.12 3.4c.534-1.227 1.307-2.173 2.32-2.84 1.04-.693 2.267-1.04 3.68-1.04 1.494 0 2.76.387 3.8 1.16 1.067.747 1.827 1.813 2.28 3.2.507-1.44 1.294-2.52 2.36-3.24 1.094-.747 2.414-1.12 3.96-1.12 1.414 0 2.64.307 3.68.92s1.84 1.52 2.4 2.72c.56 1.2.84 2.667.84 4.4V38h-4.96V25.92c0-1.813-.293-3.187-.88-4.12-.56-.96-1.413-1.44-2.56-1.44-.906 0-1.68.213-2.32.64-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.84-.48 3.04V38h-4.56V25.92c0-1.2-.133-2.213-.4-3.04-.24-.827-.626-1.453-1.16-1.88-.506-.427-1.133-.64-1.88-.64-.906 0-1.68.227-2.32.68-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.827-.48 3V38h-4.96V16.8h4.48Zm26.723 10.6c0-2.24.427-4.187 1.28-5.84.854-1.68 2.067-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.84 0 3.494.413 4.96 1.24 1.467.827 2.64 2.08 3.52 3.76.88 1.653 1.347 3.693 1.4 6.12v1.32h-15.08c.107 1.813.614 3.227 1.52 4.24.907.987 2.134 1.48 3.68 1.48.987 0 1.88-.253 2.68-.76a4.803 4.803 0 0 0 1.84-2.2l5.08.36c-.64 2.027-1.84 3.64-3.6 4.84-1.733 1.173-3.733 1.76-6 1.76-2.08 0-3.906-.453-5.48-1.36-1.573-.907-2.786-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84Zm15.16-2.04c-.213-1.733-.76-3.013-1.64-3.84-.853-.827-1.893-1.24-3.12-1.24-1.44 0-2.6.453-3.48 1.36-.88.88-1.44 2.12-1.68 3.72h9.92ZM163.139 9.6V38h-5.04V9.6h5.04Zm8.322 7.2.24 5.88-.64-.36c.32-2.053 1.094-3.56 2.32-4.52 1.254-.987 2.787-1.48 4.6-1.48 2.32 0 4.107.733 5.36 2.2 1.254 1.44 1.88 3.387 1.88 5.84V38h-4.96V25.92c0-1.253-.12-2.28-.36-3.08-.24-.8-.64-1.413-1.2-1.84-.533-.427-1.253-.64-2.16-.64-1.44 0-2.573.48-3.4 1.44-.8.933-1.2 2.307-1.2 4.12V38h-4.96V16.8h4.48Zm30.003 7.72c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.443 8.16V38h-5.6v-5.32h5.6Z"/><path fill="#171717" fill-rule="evenodd" d="m7.839 40.783 16.03-28.054L20 6 0 40.783h7.839Zm8.214 0H40L27.99 19.894l-4.02 7.032 3.976 6.914H20.02l-3.967 6.943Z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -2,9 +2,15 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Noctune - Music That Hits Different</title>
|
||||
<meta name="description" content="Noctune — Music That Hits Different. Stream and discover music." />
|
||||
<meta property="og:title" content="Noctune — Music That Hits Different" />
|
||||
<meta property="og:description" content="Stream and discover music on Noctune." />
|
||||
<meta property="og:image" content="/og_image.png" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-noctune-cream">
|
||||
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 585 B |
|
Before Width: | Height: | Size: 566 B |
@@ -1,26 +0,0 @@
|
||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
@media (prefers-color-scheme: light) {
|
||||
.background { fill: black; }
|
||||
.foreground { fill: white; }
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.background { fill: white; }
|
||||
.foreground { fill: black; }
|
||||
}
|
||||
</style>
|
||||
<g clip-path="url(#clip0_7960_43945)">
|
||||
<rect class="background" width="180" height="180" rx="37" />
|
||||
<g style="transform: scale(95%); transform-origin: center">
|
||||
<path class="foreground"
|
||||
d="M101.141 53H136.632C151.023 53 162.689 64.6662 162.689 79.0573V112.904H148.112V79.0573C148.112 78.7105 148.098 78.3662 148.072 78.0251L112.581 112.898C112.701 112.902 112.821 112.904 112.941 112.904H148.112V126.672H112.941C98.5504 126.672 86.5638 114.891 86.5638 100.5V66.7434H101.141V100.5C101.141 101.15 101.191 101.792 101.289 102.422L137.56 66.7816C137.255 66.7563 136.945 66.7434 136.632 66.7434H101.141V53Z" />
|
||||
<path class="foreground"
|
||||
d="M65.2926 124.136L14 66.7372H34.6355L64.7495 100.436V66.7372H80.1365V118.47C80.1365 126.278 70.4953 129.958 65.2926 124.136Z" />
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_7960_43945">
|
||||
<rect width="180" height="180" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 568 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="215" height="48" fill="none"><path fill="#000" d="M57.588 9.6h6L73.828 38h-5.2l-2.36-6.88h-11.36L52.548 38h-5.2l10.24-28.4Zm7.16 17.16-4.16-12.16-4.16 12.16h8.32Zm23.694-2.24c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.486-7.72.12 3.4c.534-1.227 1.307-2.173 2.32-2.84 1.04-.693 2.267-1.04 3.68-1.04 1.494 0 2.76.387 3.8 1.16 1.067.747 1.827 1.813 2.28 3.2.507-1.44 1.294-2.52 2.36-3.24 1.094-.747 2.414-1.12 3.96-1.12 1.414 0 2.64.307 3.68.92s1.84 1.52 2.4 2.72c.56 1.2.84 2.667.84 4.4V38h-4.96V25.92c0-1.813-.293-3.187-.88-4.12-.56-.96-1.413-1.44-2.56-1.44-.906 0-1.68.213-2.32.64-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.84-.48 3.04V38h-4.56V25.92c0-1.2-.133-2.213-.4-3.04-.24-.827-.626-1.453-1.16-1.88-.506-.427-1.133-.64-1.88-.64-.906 0-1.68.227-2.32.68-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.827-.48 3V38h-4.96V16.8h4.48Zm26.723 10.6c0-2.24.427-4.187 1.28-5.84.854-1.68 2.067-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.84 0 3.494.413 4.96 1.24 1.467.827 2.64 2.08 3.52 3.76.88 1.653 1.347 3.693 1.4 6.12v1.32h-15.08c.107 1.813.614 3.227 1.52 4.24.907.987 2.134 1.48 3.68 1.48.987 0 1.88-.253 2.68-.76a4.803 4.803 0 0 0 1.84-2.2l5.08.36c-.64 2.027-1.84 3.64-3.6 4.84-1.733 1.173-3.733 1.76-6 1.76-2.08 0-3.906-.453-5.48-1.36-1.573-.907-2.786-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84Zm15.16-2.04c-.213-1.733-.76-3.013-1.64-3.84-.853-.827-1.893-1.24-3.12-1.24-1.44 0-2.6.453-3.48 1.36-.88.88-1.44 2.12-1.68 3.72h9.92ZM163.139 9.6V38h-5.04V9.6h5.04Zm8.322 7.2.24 5.88-.64-.36c.32-2.053 1.094-3.56 2.32-4.52 1.254-.987 2.787-1.48 4.6-1.48 2.32 0 4.107.733 5.36 2.2 1.254 1.44 1.88 3.387 1.88 5.84V38h-4.96V25.92c0-1.253-.12-2.28-.36-3.08-.24-.8-.64-1.413-1.2-1.84-.533-.427-1.253-.64-2.16-.64-1.44 0-2.573.48-3.4 1.44-.8.933-1.2 2.307-1.2 4.12V38h-4.96V16.8h4.48Zm30.003 7.72c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.443 8.16V38h-5.6v-5.32h5.6Z"/><path fill="#171717" fill-rule="evenodd" d="m7.839 40.783 16.03-28.054L20 6 0 40.783h7.839Zm8.214 0H40L27.99 19.894l-4.02 7.032 3.976 6.914H20.02l-3.967 6.943Z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -4,16 +4,17 @@
|
||||
<AppHeader class="sticky top-0 z-30" />
|
||||
<div class="flex">
|
||||
<Sidebar
|
||||
class="fixed left-0 top-[57px] bottom-0 w-64 border-r-4 border-black overflow-y-auto z-20"
|
||||
class="fixed left-0 top-[57px] bottom-0 w-64 border-r-4 border-black overflow-y-auto z-20 hidden lg:block"
|
||||
/>
|
||||
<main
|
||||
class="ml-64 flex-1 min-h-[calc(100vh-57px)]"
|
||||
:class="{ 'pb-20': hasPlayer }"
|
||||
class="flex-1 min-h-[calc(100vh-57px)] ml-0 lg:ml-64"
|
||||
:class="{ 'pb-20': hasPlayer, 'pb-16 md:pb-0': !hasPlayer }"
|
||||
>
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
<PlayerBar />
|
||||
<MobileNav />
|
||||
</div>
|
||||
<router-view v-else />
|
||||
<div class="fixed top-4 right-4 z-[100] space-y-2">
|
||||
@@ -46,6 +47,7 @@ import { usePlayerStore } from './stores/playerStore.js';
|
||||
import AppHeader from './components/AppHeader.vue';
|
||||
import Sidebar from './components/Sidebar.vue';
|
||||
import PlayerBar from './components/PlayerBar.vue';
|
||||
import MobileNav from './components/MobileNav.vue';
|
||||
import { useKeyboard } from './composables/useKeyboard.js';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
:class="savingId === pl._id ? 'opacity-50 pointer-events-none' : ''"
|
||||
>
|
||||
<div class="w-12 h-12 rounded-lg overflow-hidden bg-gray-300 border border-black flex-shrink-0">
|
||||
<img v-if="pl.coverUrl" :src="formatUrl(pl.coverUrl)" class="w-full h-full object-cover" />
|
||||
<img v-if="pl.coverUrl" :src="formatUrl(pl.coverUrl)" class="w-full h-full object-cover" loading="lazy" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center bg-gradient-to-br from-noctune-teal/20 to-noctune-yellow/20">
|
||||
<svg class="w-6 h-5 text-gray-400" fill="currentColor" viewBox="0 0 24 24"><path d="M3 10h11v2H3v-2zm0-4h11v2H3V6zm0 8h7v2H3v-2zm13-1v8l6-4-6-4z"/></svg>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<p class="text-xs font-bold text-gray-500 px-2 py-1">TRACKS</p>
|
||||
<div v-for="track in searchResults.tracks.slice(0, 5)" :key="track._id" @click="playSearchTrack(track)" class="flex items-center gap-3 px-2 py-2 hover:bg-gray-100 rounded cursor-pointer">
|
||||
<div class="w-8 h-8 bg-gray-300 rounded border border-black flex-shrink-0 overflow-hidden">
|
||||
<img v-if="track.coverUrl" :src="formatUrl(track.coverUrl)" class="w-full h-full object-cover" />
|
||||
<img v-if="track.coverUrl" :src="formatUrl(track.coverUrl)" class="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-black truncate">{{ track.title }}</p>
|
||||
@@ -43,7 +43,7 @@
|
||||
</button>
|
||||
<button @click="goToProfile" class="text-black hover:opacity-80">
|
||||
<div class="w-8 h-8 rounded-full bg-noctune-dark overflow-hidden border-2 border-black">
|
||||
<img v-if="authStore.user?.avatarUrl" :src="formatUrl(authStore.user.avatarUrl)" class="w-full h-full object-cover" />
|
||||
<img v-if="authStore.user?.avatarUrl" :src="formatUrl(authStore.user.avatarUrl)" class="w-full h-full object-cover" loading="lazy" @error="onImgError" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
||||
</div>
|
||||
@@ -69,6 +69,10 @@ const playerStore = usePlayerStore()
|
||||
const queueStore = useQueueStore()
|
||||
const { requireAuth } = useAuthGate()
|
||||
|
||||
function onImgError(e) {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
|
||||
const query = ref('')
|
||||
const searchResults = ref(null)
|
||||
let searchTimeout = null
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<nav class="fixed bottom-0 left-0 right-0 bg-noctune-cream border-t-4 border-black z-30 md:hidden" :class="{ 'pb-0': !hasPlayer }">
|
||||
<div class="flex items-center justify-around py-2">
|
||||
<router-link to="/home"
|
||||
class="flex flex-col items-center gap-0.5 px-3 py-1"
|
||||
:class="isActive('/home') ? 'text-noctune-teal' : 'text-gray-600'">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>
|
||||
<span class="text-[10px] font-bold">HOME</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/library"
|
||||
class="flex flex-col items-center gap-0.5 px-3 py-1"
|
||||
:class="isActive('/library') ? 'text-noctune-teal' : 'text-gray-600'">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-3 2H5c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/></svg>
|
||||
<span class="text-[10px] font-bold">LIBRARY</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/playlist"
|
||||
class="flex flex-col items-center gap-0.5 px-3 py-1"
|
||||
:class="isActive('/playlist') || isActivePrefix('/playlist/') ? 'text-noctune-teal' : 'text-gray-600'">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M3 10h11v2H3v-2zm0-4h11v2H3V6zm0 8h7v2H3v-2zm13-1v8l6-4-6-4z"/></svg>
|
||||
<span class="text-[10px] font-bold">PLAYLIST</span>
|
||||
</router-link>
|
||||
|
||||
<button @click="goToProfile"
|
||||
class="flex flex-col items-center gap-0.5 px-3 py-1"
|
||||
:class="isActive('/profile') ? 'text-noctune-teal' : 'text-gray-600'">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
||||
<span class="text-[10px] font-bold">PROFILE</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore.js'
|
||||
import { useAuthGate } from '../composables/useAuthGate.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { requireAuth } = useAuthGate()
|
||||
|
||||
const isActive = (path) => route.path === path
|
||||
const isActivePrefix = (prefix) => route.path.startsWith(prefix)
|
||||
|
||||
function goToProfile() {
|
||||
requireAuth({
|
||||
redirectTo: '/login',
|
||||
onAuthenticated: () => router.push('/profile')
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,80 +0,0 @@
|
||||
<template>
|
||||
<div class="bg-noctune-orange rounded-xl p-4 border-2 border-black">
|
||||
<!-- Album Art & Info -->
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<div class="w-16 h-16 bg-black rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<!-- Waveform icon -->
|
||||
<svg class="w-12 h-8 text-white" viewBox="0 0 50 30">
|
||||
<rect x="2" y="10" width="3" height="10" fill="currentColor"/>
|
||||
<rect x="8" y="5" width="3" height="20" fill="currentColor"/>
|
||||
<rect x="14" y="8" width="3" height="14" fill="currentColor"/>
|
||||
<rect x="20" y="3" width="3" height="24" fill="currentColor"/>
|
||||
<rect x="26" y="7" width="3" height="16" fill="currentColor"/>
|
||||
<rect x="32" y="5" width="3" height="20" fill="currentColor"/>
|
||||
<rect x="38" y="9" width="3" height="12" fill="currentColor"/>
|
||||
<rect x="44" y="11" width="3" height="8" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-bold text-sm text-black truncate">No. 1 Party Anthem</h4>
|
||||
<p class="text-xs text-black/70">Arctic Monkeys</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="mb-3">
|
||||
<input
|
||||
type="range"
|
||||
:value="progress"
|
||||
@input="updateProgress"
|
||||
min="0"
|
||||
max="100"
|
||||
class="w-full h-1.5 bg-black/30 rounded-full appearance-none cursor-pointer"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-black mt-1">
|
||||
<span>1:30</span>
|
||||
<span>3:00</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex items-center justify-center gap-6">
|
||||
<button class="text-black hover:opacity-70 transition-opacity">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="w-12 h-12 bg-noctune-yellow rounded-full flex items-center justify-center border-2 border-black hover:bg-yellow-400 transition-colors">
|
||||
<svg class="w-6 h-6 ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="text-black hover:opacity-70 transition-opacity">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const progress = ref(50)
|
||||
|
||||
const updateProgress = (e) => {
|
||||
progress.value = e.target.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #1a1a1a;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,21 +1,27 @@
|
||||
<template>
|
||||
<div v-if="playerStore.currentTrack" class="fixed bottom-0 left-0 right-0 bg-noctune-dark border-t-4 border-black z-50">
|
||||
<div class="flex items-center px-4 py-2 max-w-screen-2xl mx-auto">
|
||||
<div class="flex items-center gap-3 w-80">
|
||||
<div class="w-12 h-12 bg-black rounded-lg overflow-hidden flex-shrink-0 border border-white/20">
|
||||
<img v-if="playerStore.currentTrack.coverUrl" :src="coverSrc" alt="" class="w-full h-full object-cover" />
|
||||
<div class="flex items-center gap-3 w-56 lg:w-80">
|
||||
<div class="w-12 h-12 bg-black rounded-lg overflow-hidden flex-shrink-0 border border-white/20 relative">
|
||||
<img v-if="playerStore.currentTrack.coverUrl" :src="coverSrc" alt="" class="w-full h-full object-cover" loading="lazy" @error="onImgError" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<svg class="w-8 h-6 text-white/60" viewBox="0 0 50 30"><rect x="2" y="10" width="3" height="10" fill="currentColor"/><rect x="8" y="5" width="3" height="20" fill="currentColor"/><rect x="14" y="8" width="3" height="14" fill="currentColor"/><rect x="20" y="3" width="3" height="24" fill="currentColor"/><rect x="26" y="7" width="3" height="16" fill="currentColor"/><rect x="32" y="5" width="3" height="20" fill="currentColor"/><rect x="38" y="9" width="3" height="12" fill="currentColor"/><rect x="44" y="11" width="3" height="8" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<div v-if="playerStore.isPlaying" class="absolute inset-0 flex items-end justify-center gap-[2px] pb-1">
|
||||
<span class="w-[3px] bg-noctune-teal rounded-full equalizer-bar" style="animation-delay: 0s" />
|
||||
<span class="w-[3px] bg-noctune-teal rounded-full equalizer-bar" style="animation-delay: 0.15s" />
|
||||
<span class="w-[3px] bg-noctune-teal rounded-full equalizer-bar" style="animation-delay: 0.3s" />
|
||||
<span class="w-[3px] bg-noctune-teal rounded-full equalizer-bar" style="animation-delay: 0.45s" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
</div>
|
||||
<div class="min-w-0 hidden lg:block">
|
||||
<p class="text-sm font-bold text-white truncate">{{ playerStore.currentTrack.title }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ playerStore.currentTrack.artist }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<div class="flex items-center gap-4 mb-1">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<button @click="queueStore.toggleShuffle()" :class="['text-sm transition-colors', queueStore.shuffle ? 'text-noctune-teal' : 'text-gray-400 hover:text-white']">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/></svg>
|
||||
</button>
|
||||
@@ -29,35 +35,53 @@
|
||||
<button @click="playNext()" class="text-gray-400 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||
</button>
|
||||
<button @click="queueStore.cycleRepeat()" :class="['text-sm transition-colors', queueStore.repeat !== 'off' ? 'text-noctune-teal' : 'text-gray-400 hover:text-white', queueStore.repeat === 'one' ? 'relative' : '']">
|
||||
<button @click="queueStore.cycleRepeat()" :class="['text-sm transition-colors relative', queueStore.repeat !== 'off' ? 'text-noctune-teal' : 'text-gray-400 hover:text-white']">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
|
||||
<span v-if="queueStore.repeat === 'one'" class="absolute -top-2 -right-2 text-[9px] font-bold">1</span>
|
||||
</button>
|
||||
<button @click="showQueuePanel = true" class="text-gray-400 hover:text-white transition-colors">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 w-full max-w-lg">
|
||||
<span class="text-xs text-gray-400 w-10 text-right">{{ playerStore.formattedProgress }}</span>
|
||||
<div class="relative flex-1" @mousemove="onSeekHover" @mouseleave="seekTooltipVal = null">
|
||||
<div v-if="seekTooltipVal !== null" class="absolute -top-6 left-1/2 -translate-x-1/2 bg-black text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10"
|
||||
:style="{ left: `${(seekTooltipVal / (playerStore.duration || 1)) * 100}%` }">
|
||||
<div class="flex items-center gap-2 w-full max-w-lg">
|
||||
<span class="text-xs text-gray-400 w-8 text-right font-mono">{{ playerStore.formattedProgress }}</span>
|
||||
<div class="relative flex-1 h-3" @mousemove="onSeekHover" @mouseleave="seekTooltipVal = null" @click="onSeekClick">
|
||||
<div v-if="seekTooltipVal !== null" class="absolute -top-6 pointer-events-none bg-black text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10"
|
||||
:style="{ left: `${(seekTooltipVal / (playerStore.duration || 1)) * 100}%`, transform: 'translateX(-50%)' }">
|
||||
{{ formatSeekTime(seekTooltipVal) }}
|
||||
</div>
|
||||
<input type="range" :value="playerStore.progress" @input="onSeek" min="0" :max="playerStore.duration || 1" step="0.1" class="w-full h-1 bg-gray-600 rounded-full appearance-none cursor-pointer range-player" />
|
||||
<div class="w-full h-full bg-gray-700 border border-gray-500 rounded cursor-pointer overflow-hidden">
|
||||
<div class="h-full bg-noctune-teal rounded transition-[width] duration-200" :style="{ width: `${(playerStore.progress / (playerStore.duration || 1)) * 100}%` }" />
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 w-10">{{ playerStore.formattedDuration }}</span>
|
||||
<div class="absolute inset-0 flex items-center pointer-events-none">
|
||||
<div class="w-3 h-3 bg-white border-2 border-black rounded-full shadow-[1px_1px_0px_0px_#000] -ml-1.5" :style="{ marginLeft: `${(playerStore.progress / (playerStore.duration || 1)) * 100}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 w-8 font-mono">{{ playerStore.formattedDuration }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-80 flex items-center justify-end gap-3">
|
||||
<div class="w-56 lg:w-80 flex items-center justify-end gap-2">
|
||||
<span class="text-xs text-gray-500 font-mono hidden lg:inline">{{ queuePosition }}</span>
|
||||
<button @click="toggleLike" class="transition-colors">
|
||||
<svg class="w-5 h-5" :class="isLiked ? 'fill-red-400 text-red-400' : 'text-gray-400 hover:text-red-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/></svg>
|
||||
<input type="range" :value="playerStore.volume" @input="onVolume" min="0" max="100" class="w-20 h-1 bg-gray-600 rounded-full appearance-none cursor-pointer range-vol" />
|
||||
<div class="relative w-16 h-2" @click="onVolumeClick">
|
||||
<div class="w-full h-full bg-gray-700 border border-gray-500 rounded cursor-pointer">
|
||||
<div class="h-full bg-white rounded" :style="{ width: `${playerStore.volume}%` }" />
|
||||
</div>
|
||||
<div class="absolute inset-0 flex items-center pointer-events-none">
|
||||
<div class="w-2.5 h-2.5 bg-white border border-black rounded-full -ml-1.25" :style="{ marginLeft: `${playerStore.volume}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<QueuePanel :show="showQueuePanel" @close="showQueuePanel = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -67,6 +91,7 @@ import { useQueueStore } from '../stores/queueStore.js'
|
||||
import { useAuthStore } from '../stores/authStore.js'
|
||||
import { formatUrl } from '../lib/utils.js'
|
||||
import { useAuthGate } from '../composables/useAuthGate.js'
|
||||
import QueuePanel from './QueuePanel.vue'
|
||||
|
||||
const playerStore = usePlayerStore()
|
||||
const queueStore = useQueueStore()
|
||||
@@ -75,6 +100,7 @@ const { requireAuth } = useAuthGate()
|
||||
|
||||
const isLiked = ref(false)
|
||||
const seekTooltipVal = ref(null)
|
||||
const showQueuePanel = ref(false)
|
||||
|
||||
function formatSeekTime(seconds) {
|
||||
const m = Math.floor(seconds / 60)
|
||||
@@ -85,7 +111,20 @@ function formatSeekTime(seconds) {
|
||||
function onSeekHover(e) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const pct = (e.clientX - rect.left) / rect.width
|
||||
seekTooltipVal.value = pct * playerStore.duration
|
||||
seekTooltipVal.value = Math.max(0, Math.min(pct * playerStore.duration, playerStore.duration))
|
||||
}
|
||||
|
||||
function onSeekClick(e) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const pct = (e.clientX - rect.left) / rect.width
|
||||
const time = Math.max(0, Math.min(pct * playerStore.duration, playerStore.duration))
|
||||
playerStore.seek(time)
|
||||
}
|
||||
|
||||
function onVolumeClick(e) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const pct = (e.clientX - rect.left) / rect.width
|
||||
playerStore.setVolume(Math.round(Math.max(0, Math.min(pct * 100, 100))))
|
||||
}
|
||||
|
||||
function onTrackEnd() {
|
||||
@@ -102,13 +141,12 @@ onMounted(() => {
|
||||
|
||||
const coverSrc = computed(() => formatUrl(playerStore.currentTrack?.coverUrl))
|
||||
|
||||
function onSeek(e) {
|
||||
playerStore.seek(parseFloat(e.target.value))
|
||||
}
|
||||
|
||||
function onVolume(e) {
|
||||
playerStore.setVolume(parseInt(e.target.value))
|
||||
}
|
||||
const queuePosition = computed(() => {
|
||||
const idx = queueStore.currentIndex
|
||||
const total = queueStore.queue.length
|
||||
if (total === 0) return ''
|
||||
return `${idx + 1}/${total}`
|
||||
})
|
||||
|
||||
function playNext() {
|
||||
const next = queueStore.nextTrack()
|
||||
@@ -135,7 +173,10 @@ function toggleLike() {
|
||||
})
|
||||
}
|
||||
|
||||
// Sync isLiked ref reactively from authStore
|
||||
function onImgError(e) {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
|
||||
watch(() => playerStore.currentTrack?._id, (id) => {
|
||||
isLiked.value = id ? authStore.likedTrackIds.has(id) : false
|
||||
}, { immediate: true })
|
||||
@@ -147,13 +188,13 @@ watch(() => authStore.user?.likedTracks?.length, () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.range-player::-webkit-slider-thumb,
|
||||
.range-vol::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
@keyframes equalizer {
|
||||
0%, 100% { height: 4px; }
|
||||
25% { height: 14px; }
|
||||
50% { height: 8px; }
|
||||
75% { height: 18px; }
|
||||
}
|
||||
.equalizer-bar {
|
||||
animation: equalizer 0.8s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div v-if="show" class="fixed inset-0 z-40">
|
||||
<div class="absolute inset-0 bg-black/30" @click="$emit('close')" />
|
||||
<div class="absolute right-0 top-0 bottom-0 w-80 bg-noctune-cream border-l-4 border-black shadow-[-4px_0_0_0_#000] p-4 overflow-y-auto flex flex-col">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-black text-lg text-black">UP NEXT</h3>
|
||||
<button @click="$emit('close')" class="text-gray-500 hover:text-black text-2xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!queue.length" class="flex-1 flex items-center justify-center text-gray-500 text-center py-8">
|
||||
<div>
|
||||
<svg class="w-16 h-16 mx-auto mb-3 text-gray-300" fill="currentColor" viewBox="0 0 24 24"><path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/></svg>
|
||||
<p>Queue is empty</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-1">
|
||||
<div v-for="(track, i) in queue" :key="track._id + '-' + i"
|
||||
@click="jumpTo(i)"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer hover:bg-gray-200 transition-colors"
|
||||
:class="i === currentIndex ? 'bg-noctune-teal/20 border border-noctune-teal' : 'border border-transparent'"
|
||||
>
|
||||
<span class="text-sm text-gray-500 w-6 text-center font-mono">{{ i + 1 }}</span>
|
||||
<div class="w-10 h-10 rounded-lg overflow-hidden bg-gray-300 border border-black flex-shrink-0">
|
||||
<img v-if="track.coverUrl" :src="formatUrl(track.coverUrl)" class="w-full h-full object-cover" loading="lazy" @error="onImgError" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-black truncate" :class="i === currentIndex ? 'text-noctune-teal' : ''">{{ track.title }}</p>
|
||||
<p class="text-xs text-gray-500 truncate">{{ track.artist }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { usePlayerStore } from '../stores/playerStore.js'
|
||||
import { useQueueStore } from '../stores/queueStore.js'
|
||||
import { formatUrl } from '../lib/utils.js'
|
||||
|
||||
defineProps({ show: Boolean })
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const playerStore = usePlayerStore()
|
||||
const queueStore = useQueueStore()
|
||||
|
||||
const queue = computed(() => queueStore.queue)
|
||||
const currentIndex = computed(() => queueStore.currentIndex)
|
||||
|
||||
function jumpTo(index) {
|
||||
const track = queue.value[index]
|
||||
if (!track) return
|
||||
queueStore.currentIndex = index
|
||||
playerStore.setTrack(track)
|
||||
playerStore.play()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onImgError(e) {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
</script>
|
||||
@@ -1,62 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-4 px-6 py-4 border-b border-gray-200 hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
<div :class="[iconColor, 'w-12 h-12 rounded-xl flex items-center justify-center border-2 border-black']">
|
||||
<!-- User Icon -->
|
||||
<svg v-if="icon === 'user'" class="w-6 h-6 text-black" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
<!-- Lock Icon -->
|
||||
<svg v-else-if="icon === 'lock'" class="w-6 h-6 text-black" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/>
|
||||
</svg>
|
||||
<!-- Shield Icon -->
|
||||
<svg v-else-if="icon === 'shield'" class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/>
|
||||
</svg>
|
||||
<!-- Card Icon -->
|
||||
<svg v-else-if="icon === 'card'" class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z"/>
|
||||
</svg>
|
||||
<!-- Bell Icon -->
|
||||
<svg v-else-if="icon === 'bell'" class="w-6 h-6 text-black" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
|
||||
</svg>
|
||||
<!-- Moon Icon -->
|
||||
<svg v-else-if="icon === 'moon'" class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-2.98 0-5.4-2.42-5.4-5.4 0-1.81.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1z"/>
|
||||
</svg>
|
||||
<!-- Globe Icon -->
|
||||
<svg v-else-if="icon === 'globe'" class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-bold text-black">{{ title }}</h3>
|
||||
<p class="text-sm text-gray-500">{{ subtitle }}</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
iconColor: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -84,6 +84,10 @@ const showCreateModal = ref(false)
|
||||
|
||||
const { requireAuth } = useAuthGate()
|
||||
|
||||
function onImgError(e) {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
|
||||
const isActive = (path) => route.path === path
|
||||
const isActivePrefix = (prefix) => route.path.startsWith(prefix)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001/api',
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api',
|
||||
withCredentials: true,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
@@ -9,6 +9,8 @@ import PlaylistPage from '../views/PlaylistPage.vue'
|
||||
import PlaylistDetail from '../views/PlaylistDetail.vue'
|
||||
import ProfilePage from '../views/ProfilePage.vue'
|
||||
import AdminPage from '../views/AdminPage.vue'
|
||||
import ArtistView from '../views/ArtistView.vue'
|
||||
import NotFoundPage from '../views/NotFoundPage.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', redirect: '/home' },
|
||||
@@ -19,7 +21,9 @@ const routes = [
|
||||
{ path: '/playlist', name: 'Playlist', component: PlaylistPage },
|
||||
{ path: '/playlist/:id', name: 'PlaylistDetail', component: PlaylistDetail },
|
||||
{ path: '/profile', name: 'Profile', component: ProfilePage },
|
||||
{ path: '/artist/:name', name: 'Artist', component: ArtistView },
|
||||
{ path: '/admin/tracks', name: 'AdminTracks', component: AdminPage, meta: { admin: true } },
|
||||
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFoundPage },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
@@ -41,4 +45,8 @@ router.beforeEach((to, from, next) => {
|
||||
next()
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
window.scrollTo(0, 0)
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="p-8">
|
||||
<div v-if="loading" class="animate-pulse space-y-6">
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="w-32 h-32 rounded-full bg-gray-300 border-4 border-black" />
|
||||
<div class="space-y-3">
|
||||
<div class="h-6 bg-gray-200 rounded w-24" />
|
||||
<div class="h-10 bg-gray-200 rounded w-48" />
|
||||
<div class="h-4 bg-gray-200 rounded w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div v-for="i in 4" :key="i" class="h-14 bg-gray-200 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-6 mb-8">
|
||||
<div class="w-32 h-32 rounded-full overflow-hidden border-4 border-black bg-noctune-yellow flex-shrink-0">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<svg class="w-16 h-16 text-black/30" fill="currentColor" viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs font-bold text-gray-500 tracking-wider">ARTIST</span>
|
||||
<h1 class="text-4xl font-black text-black">{{ artistName }}</h1>
|
||||
<p class="text-gray-500 mt-1">{{ tracks.length }} tracks</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl border-4 border-black overflow-hidden">
|
||||
<div class="bg-noctune-teal px-6 py-3 flex items-center text-black font-bold border-b-4 border-black">
|
||||
<span class="w-12 text-center">#</span>
|
||||
<span class="flex-1">TITLE</span>
|
||||
<span class="w-48 text-center">ALBUM</span>
|
||||
<span class="w-12 text-center"></span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200">
|
||||
<div v-for="(track, index) in tracks" :key="track._id"
|
||||
@click="playTrack(index)"
|
||||
class="px-6 py-3 flex items-center hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
:class="isCurrentTrack(track) ? 'bg-noctune-teal/10' : ''"
|
||||
>
|
||||
<span class="w-12 text-center text-sm text-gray-500">
|
||||
<svg v-if="isCurrentTrack(track) && playerStore.isPlaying" class="w-4 h-4 text-noctune-teal mx-auto" fill="currentColor" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||
<span v-else>{{ index + 1 }}</span>
|
||||
</span>
|
||||
<div class="flex-1 flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg overflow-hidden bg-gray-300 border border-black flex-shrink-0">
|
||||
<img v-if="track.coverUrl" :src="formatUrl(track.coverUrl)" class="w-full h-full object-cover" loading="lazy" @error="onImgError" />
|
||||
</div>
|
||||
<span class="font-medium text-sm truncate text-black">{{ track.title }}</span>
|
||||
</div>
|
||||
<span class="w-48 text-center text-gray-600 text-sm truncate">{{ track.album || '-' }}</span>
|
||||
<button @click.stop="toggleLike(track)" class="w-12 flex justify-center transition-colors">
|
||||
<svg class="w-5 h-5" :class="isLiked(track) ? 'fill-red-400 text-red-400' : 'text-gray-400 hover:text-red-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!tracks.length" class="p-8 text-center text-gray-500">No tracks found for this artist</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { usePlayerStore } from '../stores/playerStore.js'
|
||||
import { useQueueStore } from '../stores/queueStore.js'
|
||||
import { useAuthStore } from '../stores/authStore.js'
|
||||
import api from '../lib/api.js'
|
||||
import { formatUrl } from '../lib/utils.js'
|
||||
import { useAuthGate } from '../composables/useAuthGate.js'
|
||||
|
||||
const route = useRoute()
|
||||
const playerStore = usePlayerStore()
|
||||
const queueStore = useQueueStore()
|
||||
const authStore = useAuthStore()
|
||||
const { requireAuth } = useAuthGate()
|
||||
|
||||
const tracks = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const artistName = computed(() => decodeURIComponent(route.params.name))
|
||||
|
||||
function isCurrentTrack(track) {
|
||||
return playerStore.currentTrack?._id === track._id
|
||||
}
|
||||
|
||||
function isLiked(track) {
|
||||
return authStore.likedTrackIds?.has(track._id)
|
||||
}
|
||||
|
||||
function playTrack(index) {
|
||||
if (!tracks.value.length) return
|
||||
const track = tracks.value[index]
|
||||
if (isCurrentTrack(track)) {
|
||||
playerStore.togglePlay()
|
||||
return
|
||||
}
|
||||
queueStore.setQueue(tracks.value, index)
|
||||
playerStore.setTrack(track)
|
||||
playerStore.play()
|
||||
}
|
||||
|
||||
function toggleLike(track) {
|
||||
requireAuth({
|
||||
redirectTo: '/login',
|
||||
onAuthenticated: () => authStore.toggleLike(track._id)
|
||||
})
|
||||
}
|
||||
|
||||
function onImgError(e) {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get(`/tracks?artist=${encodeURIComponent(artistName.value)}`)
|
||||
if (data.success) tracks.value = data.data
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -13,10 +13,31 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-80 h-80 rounded-2xl overflow-hidden border-4 border-black flex-shrink-0">
|
||||
<img src="https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop" alt="Music illustration" class="w-full h-full object-cover" />
|
||||
<img src="https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop" alt="Music illustration" class="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="recentTracks.length" class="mb-10">
|
||||
<h2 class="text-2xl font-black text-black mb-6 flex items-center">
|
||||
RECENTLY PLAYED
|
||||
<svg class="w-5 h-5 ml-2" fill="currentColor" viewBox="0 0 24 24"><path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm0 16c-3.86 0-7-3.14-7-7s3.14-7 7-7 7 3.14 7 7-3.14 7-7 7zm2-11H11v6l5.25 3.15.75-1.23-4.5-2.67z"/></svg>
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
<div v-for="track in recentTracks" :key="track._id"
|
||||
@click="playTrack(track, true)"
|
||||
class="bg-white rounded-xl border-2 border-black overflow-hidden hover:scale-[1.02] transition-transform cursor-pointer group"
|
||||
>
|
||||
<div class="aspect-square bg-gray-300 relative overflow-hidden">
|
||||
<img v-if="track.coverUrl" :src="formatUrl(track.coverUrl)" class="w-full h-full object-cover" loading="lazy" @error="onImgError" />
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<p class="text-xs font-bold text-black truncate">{{ track.title }}</p>
|
||||
<p class="text-[10px] text-gray-500 truncate">{{ track.artist }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-2xl font-black text-black mb-6 flex items-center">
|
||||
TRENDING NOW
|
||||
@@ -44,7 +65,7 @@
|
||||
:class="isCurrentTrack(track) ? 'border-4 border-noctune-teal' : 'border-2 border-black'"
|
||||
>
|
||||
<div class="aspect-square bg-gray-300 relative overflow-hidden">
|
||||
<img v-if="track.coverUrl" :src="formatUrl(track.coverUrl)" class="w-full h-full object-cover" />
|
||||
<img v-if="track.coverUrl" :src="formatUrl(track.coverUrl)" class="w-full h-full object-cover" loading="lazy" @error="onImgError" />
|
||||
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center border-2 border-black opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
:class="isCurrentTrack(track) && playerStore.isPlaying ? 'bg-white' : 'bg-noctune-yellow'">
|
||||
@@ -94,6 +115,40 @@ const tracks = ref([])
|
||||
const loading = ref(true)
|
||||
const trackToAdd = ref(null)
|
||||
const showAddModal = ref(false)
|
||||
const recentTracks = ref([])
|
||||
|
||||
const RECENT_KEY = 'noctune_recently_played'
|
||||
|
||||
function loadRecent() {
|
||||
try {
|
||||
const stored = localStorage.getItem(RECENT_KEY)
|
||||
return stored ? JSON.parse(stored) : []
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
function saveRecent(ids) {
|
||||
try { localStorage.setItem(RECENT_KEY, JSON.stringify(ids)) } catch {}
|
||||
}
|
||||
|
||||
function addToRecent(trackId) {
|
||||
let ids = loadRecent()
|
||||
ids = ids.filter((id) => id !== trackId)
|
||||
ids.unshift(trackId)
|
||||
if (ids.length > 10) ids = ids.slice(0, 10)
|
||||
saveRecent(ids)
|
||||
}
|
||||
|
||||
async function fetchRecentTracks() {
|
||||
const ids = loadRecent()
|
||||
if (!ids.length) return
|
||||
try {
|
||||
const { data } = await api.post('/tracks/by-ids', { ids })
|
||||
if (data.success) {
|
||||
const map = new Map(data.data.map((t) => [t._id, t]))
|
||||
recentTracks.value = ids.map((id) => map.get(id)).filter(Boolean)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function openAddToPlaylist(track) {
|
||||
trackToAdd.value = track
|
||||
@@ -111,19 +166,24 @@ function isLiked(track) {
|
||||
return authStore.likedTrackIds?.has(track._id)
|
||||
}
|
||||
|
||||
function playTrack(track) {
|
||||
function playTrack(track, fromRecent = false) {
|
||||
if (isCurrentTrack(track)) {
|
||||
playerStore.togglePlay()
|
||||
return
|
||||
}
|
||||
const idx = tracks.value.findIndex((t) => t._id === track._id)
|
||||
queueStore.setQueue(tracks.value, idx)
|
||||
addToRecent(track._id)
|
||||
const source = fromRecent ? recentTracks.value : tracks.value
|
||||
const idx = source.findIndex((t) => t._id === track._id)
|
||||
queueStore.setQueue(source, idx)
|
||||
playerStore.setTrack(track)
|
||||
playerStore.play()
|
||||
}
|
||||
|
||||
function playFirstTrack() {
|
||||
if (tracks.value.length) playTrack(tracks.value[0])
|
||||
if (tracks.value.length) {
|
||||
addToRecent(tracks.value[0]._id)
|
||||
playTrack(tracks.value[0], false)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLike(track) {
|
||||
@@ -133,6 +193,10 @@ function toggleLike(track) {
|
||||
})
|
||||
}
|
||||
|
||||
function onImgError(e) {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/tracks')
|
||||
@@ -142,5 +206,6 @@ onMounted(async () => {
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
fetchRecentTracks()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
class="group cursor-pointer bg-white rounded-2xl border-2 border-black overflow-hidden hover:scale-[1.02] transition-transform"
|
||||
>
|
||||
<div class="aspect-square bg-gray-300 relative overflow-hidden">
|
||||
<img v-if="track.coverUrl" :src="formatUrl(track.coverUrl)" class="w-full h-full object-cover" />
|
||||
<img v-if="track.coverUrl" :src="formatUrl(track.coverUrl)" class="w-full h-full object-cover" loading="lazy" @error="onImgError" />
|
||||
<div class="absolute bottom-2 right-2 w-10 h-10 rounded-full flex items-center justify-center border-2 border-black opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
:class="isCurrentTrack(track) && playerStore.isPlaying ? 'bg-white' : 'bg-noctune-yellow'">
|
||||
<svg v-if="isCurrentTrack(track) && playerStore.isPlaying" class="w-5 h-5 text-black" fill="currentColor" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||
@@ -72,7 +72,7 @@
|
||||
<span v-else>{{ index + 1 }}</span>
|
||||
</span>
|
||||
<div class="w-10 h-10 rounded-lg flex-shrink-0 overflow-hidden bg-gray-300 border border-black">
|
||||
<img v-if="track.coverUrl" :src="formatUrl(track.coverUrl)" class="w-full h-full object-cover" />
|
||||
<img v-if="track.coverUrl" :src="formatUrl(track.coverUrl)" class="w-full h-full object-cover" loading="lazy" @error="onImgError" />
|
||||
</div>
|
||||
<span class="font-medium text-sm truncate" :class="isCurrentTrack(track) ? 'text-noctune-teal' : 'text-black'">{{ track.title }}</span>
|
||||
</div>
|
||||
@@ -143,6 +143,10 @@ function playTrack(track, fromLiked = false, idx) {
|
||||
playerStore.play()
|
||||
}
|
||||
|
||||
function onImgError(e) {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
|
||||
function toggleLike(track) {
|
||||
const wasLiked = isLiked(track)
|
||||
requireAuth({
|
||||
|
||||
@@ -14,23 +14,7 @@
|
||||
<span v-for="i in 12" :key="i" class="w-2 h-2 bg-black/40 rounded-full"></span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center h-full">
|
||||
<div class="relative">
|
||||
<div class="w-48 h-48 flex items-center justify-center">
|
||||
<svg viewBox="0 0 120 120" class="w-full h-full">
|
||||
<polygon points="40,35 80,35 90,50 50,50" fill="#ff6b35"/>
|
||||
<polygon points="40,35 50,50 50,90 40,75" fill="#e85a2a"/>
|
||||
<polygon points="50,50 90,50 90,90 50,90" fill="#ff8c5a"/>
|
||||
<polygon points="40,75 50,90 50,70 40,55" fill="#1a1a1a"/>
|
||||
<polygon points="70,50 90,50 90,70 70,70" fill="#1a1a1a"/>
|
||||
<polygon points="50,70 70,70 70,90 50,90" fill="#1a1a1a"/>
|
||||
<rect x="95" y="35" width="5" height="5" fill="#ff6b35"/>
|
||||
<rect x="100" y="30" width="4" height="4" fill="#ff6b35"/>
|
||||
<rect x="105" y="38" width="3" height="3" fill="#ff6b35"/>
|
||||
<rect x="98" y="45" width="4" height="4" fill="#ff6b35"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-4xl font-mono font-bold text-noctune-dark tracking-wider mt-4">noctune</h1>
|
||||
<img src="/logo_transparent.png" alt="Noctune" class="w-48 h-48 object-contain" loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-noctune-cream flex items-center justify-center p-8">
|
||||
<div class="text-center max-w-lg">
|
||||
<div class="text-9xl font-black text-noctune-teal mb-4 font-mono">404</div>
|
||||
<h1 class="text-4xl font-black text-black mb-4">PAGE NOT FOUND</h1>
|
||||
<p class="text-gray-600 mb-8 text-lg">The page you're looking for doesn't exist or has been moved.</p>
|
||||
<router-link to="/home"
|
||||
class="inline-block bg-noctune-yellow border-2 border-black px-8 py-3 font-bold rounded-lg hover:bg-yellow-400 transition-colors shadow-[4px_4px_0px_0px_#000] hover:shadow-none hover:translate-x-1 hover:translate-y-1">
|
||||
GO HOME
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -22,7 +22,7 @@
|
||||
<template v-else-if="playlist">
|
||||
<div class="flex items-start gap-8 mb-8">
|
||||
<div class="w-56 h-56 rounded-2xl overflow-hidden border-4 border-black flex-shrink-0 bg-black">
|
||||
<img v-if="playlist.coverUrl" :src="formatUrl(playlist.coverUrl)" class="w-full h-full object-cover" />
|
||||
<img v-if="playlist.coverUrl" :src="formatUrl(playlist.coverUrl)" class="w-full h-full object-cover" loading="lazy" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<svg class="w-40 h-24 text-white/60" viewBox="0 0 100 50">
|
||||
<rect x="5" y="20" width="4" height="15" fill="currentColor"/>
|
||||
@@ -54,6 +54,10 @@
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
PLAY
|
||||
</button>
|
||||
<button @click="handleDeletePlaylist" class="bg-red-400 border-2 border-black px-4 py-2 font-bold flex items-center gap-2 rounded-lg hover:bg-red-500 transition-colors cursor-pointer text-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
DELETE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,7 +67,7 @@
|
||||
<span class="w-12 text-center">#</span>
|
||||
<span class="flex-1 text-center">TITLE</span>
|
||||
<span class="w-48 text-center">ARTIST</span>
|
||||
<span class="w-12 text-center"></span>
|
||||
<span class="w-24 text-center"></span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200">
|
||||
<div v-for="(track, index) in playlist.tracks" :key="track._id"
|
||||
@@ -77,7 +81,7 @@
|
||||
</span>
|
||||
<div class="flex-1 flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-lg flex-shrink-0 overflow-hidden bg-gray-300 border border-black">
|
||||
<img v-if="track.coverUrl" :src="formatUrl(track.coverUrl)" class="w-full h-full object-cover" />
|
||||
<img v-if="track.coverUrl" :src="formatUrl(track.coverUrl)" class="w-full h-full object-cover" loading="lazy" @error="onImgError" />
|
||||
</div>
|
||||
<span class="font-medium text-sm truncate" :class="isCurrentTrack(track) ? 'text-noctune-teal' : 'text-black'">{{ track.title }}</span>
|
||||
</div>
|
||||
@@ -88,6 +92,9 @@
|
||||
<button @click.stop="openAddToPlaylist(track)" class="w-8 flex justify-center transition-colors text-gray-400 hover:text-noctune-teal cursor-pointer">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
</button>
|
||||
<button @click.stop="handleRemoveTrack(track)" class="w-8 flex justify-center transition-colors text-red-400 hover:text-red-600 cursor-pointer">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!playlist.tracks?.length" class="p-8 text-center text-gray-500">No tracks in this playlist yet</div>
|
||||
</div>
|
||||
@@ -101,7 +108,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { usePlayerStore } from '../stores/playerStore.js'
|
||||
import { useQueueStore } from '../stores/queueStore.js'
|
||||
import { useAuthStore } from '../stores/authStore.js'
|
||||
@@ -109,11 +116,14 @@ import api from '../lib/api.js'
|
||||
import { formatUrl } from '../lib/utils.js'
|
||||
import { useAuthGate } from '../composables/useAuthGate.js'
|
||||
import AddToPlaylistModal from '../components/AddToPlaylistModal.vue'
|
||||
import { useUiStore } from '../stores/uiStore.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const playerStore = usePlayerStore()
|
||||
const queueStore = useQueueStore()
|
||||
const authStore = useAuthStore()
|
||||
const uiStore = useUiStore()
|
||||
const { requireAuth } = useAuthGate()
|
||||
|
||||
const playlist = ref(null)
|
||||
@@ -129,6 +139,10 @@ function openAddToPlaylist(track) {
|
||||
})
|
||||
}
|
||||
|
||||
function onImgError(e) {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
|
||||
function isCurrentTrack(track) {
|
||||
return playerStore.currentTrack?._id === track._id
|
||||
}
|
||||
@@ -161,6 +175,34 @@ function toggleLike(track) {
|
||||
})
|
||||
}
|
||||
|
||||
async function handleRemoveTrack(track) {
|
||||
if (!playlist.value) return
|
||||
try {
|
||||
const { data } = await api.delete(`/playlists/${route.params.id}/tracks`, {
|
||||
data: { trackId: track._id }
|
||||
})
|
||||
if (data.success) {
|
||||
playlist.value.tracks = playlist.value.tracks.filter((t) => t._id !== track._id)
|
||||
uiStore.showToast('Removed from playlist', 'info')
|
||||
}
|
||||
} catch {
|
||||
uiStore.showToast('Failed to remove track', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePlaylist() {
|
||||
if (!confirm('Delete this playlist forever?')) return
|
||||
try {
|
||||
const { data } = await api.delete(`/playlists/${route.params.id}`)
|
||||
if (data.success) {
|
||||
uiStore.showToast('Playlist deleted', 'success')
|
||||
router.push('/playlist')
|
||||
}
|
||||
} catch {
|
||||
uiStore.showToast('Failed to delete playlist', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const id = route.params.id
|
||||
try {
|
||||
|
||||
@@ -29,11 +29,14 @@
|
||||
class="bg-white rounded-2xl border-2 border-black overflow-hidden hover:scale-[1.02] transition-transform cursor-pointer group"
|
||||
>
|
||||
<div class="aspect-square bg-gray-300 relative overflow-hidden">
|
||||
<img v-if="pl.coverUrl" :src="formatUrl(pl.coverUrl)" class="w-full h-full object-cover" />
|
||||
<img v-if="pl.coverUrl" :src="formatUrl(pl.coverUrl)" class="w-full h-full object-cover" loading="lazy" @error="onImgError" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center bg-gradient-to-br from-noctune-teal/20 to-noctune-yellow/20">
|
||||
<svg class="w-20 h-16 text-gray-400" fill="currentColor" viewBox="0 0 24 24"><path d="M3 10h11v2H3v-2zm0-4h11v2H3V6zm0 8h7v2H3v-2zm13-1v8l6-4-6-4z"/></svg>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
|
||||
<button @click.stop="handleDelete(pl)" class="absolute top-2 right-2 w-7 h-7 bg-red-400 border-2 border-black rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500">
|
||||
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<p class="font-bold text-sm truncate text-black">{{ pl.name }}</p>
|
||||
@@ -54,15 +57,21 @@ import api from '../lib/api.js'
|
||||
import { formatUrl } from '../lib/utils.js'
|
||||
import { useAuthGate } from '../composables/useAuthGate.js'
|
||||
import CreatePlaylistModal from '../components/CreatePlaylistModal.vue'
|
||||
import { useUiStore } from '../stores/uiStore.js'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const uiStore = useUiStore()
|
||||
const { requireAuth } = useAuthGate()
|
||||
|
||||
const playlists = ref([])
|
||||
const loading = ref(true)
|
||||
const showCreateModal = ref(false)
|
||||
|
||||
function onImgError(e) {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
requireAuth({
|
||||
redirectTo: '/login',
|
||||
@@ -75,6 +84,19 @@ function onPlaylistCreated(playlist) {
|
||||
playlists.value.unshift(playlist)
|
||||
}
|
||||
|
||||
async function handleDelete(pl) {
|
||||
if (!confirm(`Delete "${pl.name}" forever?`)) return
|
||||
try {
|
||||
const { data } = await api.delete(`/playlists/${pl._id}`)
|
||||
if (data.success) {
|
||||
playlists.value = playlists.value.filter((p) => p._id !== pl._id)
|
||||
uiStore.showToast('Playlist deleted', 'success')
|
||||
}
|
||||
} catch {
|
||||
uiStore.showToast('Failed to delete playlist', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!authStore.isLoggedIn) {
|
||||
loading.value = false
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="p-8">
|
||||
<div class="bg-white rounded-2xl border-4 border-black p-6 mb-6 flex items-center gap-8">
|
||||
<div class="w-40 h-40 rounded-full border-4 border-black overflow-hidden flex-shrink-0 bg-gray-300">
|
||||
<img v-if="authStore.user?.avatarUrl" :src="formatUrl(authStore.user.avatarUrl)" alt="Profile" class="w-full h-full object-cover" />
|
||||
<img v-if="authStore.user?.avatarUrl" :src="formatUrl(authStore.user.avatarUrl)" alt="Profile" class="w-full h-full object-cover" loading="lazy" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<svg class="w-16 h-16 text-gray-400" fill="currentColor" viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
||||
</div>
|
||||
|
||||
@@ -6,19 +6,7 @@
|
||||
<span v-for="i in 16" :key="i" class="w-2 h-2 bg-black/40 rounded-full"></span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center h-full">
|
||||
<div class="relative">
|
||||
<div class="w-48 h-48 flex items-center justify-center">
|
||||
<svg viewBox="0 0 120 120" class="w-full h-full">
|
||||
<polygon points="40,35 80,35 90,50 50,50" fill="#ff6b35"/>
|
||||
<polygon points="40,35 50,50 50,90 40,75" fill="#e85a2a"/>
|
||||
<polygon points="50,50 90,50 90,90 50,90" fill="#ff8c5a"/>
|
||||
<polygon points="40,75 50,90 50,70 40,55" fill="#1a1a1a"/>
|
||||
<polygon points="70,50 90,50 90,70 70,70" fill="#1a1a1a"/>
|
||||
<polygon points="50,70 70,70 70,90 50,90" fill="#1a1a1a"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-4xl font-mono font-bold text-noctune-dark tracking-wider mt-4">noctune</h1>
|
||||
<img src="/logo_transparent.png" alt="Noctune" class="w-48 h-48 object-contain" loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-1/2 bg-white rounded-3xl m-4 p-10">
|
||||
|
||||