12 changed files with 498 additions and 43 deletions

No files matched your search

+1
View File
@@ -129,6 +129,7 @@ watch(
<Checkbox <Checkbox
v-else-if="field.type == 'checkbox'" v-else-if="field.type == 'checkbox'"
:label="field.label || field.key" :label="field.label || field.key"
:model-value="localItem[field.key]"
@change="(v) => (localItem[field.key] = v)" @change="(v) => (localItem[field.key] = v)"
/> />
<Input <Input
+2
View File
@@ -131,6 +131,8 @@ const fullscreen = useFullscreen()
border-radius: $border-radius-lg; border-radius: $border-radius-lg;
border: 1px solid $color-border; border: 1px solid $color-border;
background-color: $color-background;
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 1rem; gap: 1rem;
+25 -6
View File
@@ -1,22 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
interface SliderProps { interface SliderProps {
title?: string title?: string
scrollWidth?: () => number
sliderClass?: string
} }
const props = defineProps<SliderProps>() const props = defineProps<SliderProps>()
const slider = ref<HTMLDivElement>() const slider = useTemplateRef<HTMLDivElement>("slider")
const scrollLeft = (num: number) => { const scrollLeft = (num: number) => {
if (slider.value) slider.value.scrollLeft += num if (!slider.value) return
const maxScroll = slider.value.scrollWidth - slider.value.clientWidth
slider.value.scrollLeft = Math.max(
0,
Math.min(slider.value?.scrollLeft + num, maxScroll),
)
} }
const left = () => { const left = () => {
if (slider.value) scrollLeft(-slider.value.offsetWidth) if (slider.value)
scrollLeft(
props.scrollWidth
? -props.scrollWidth()
: -slider.value.offsetWidth,
)
} }
const right = () => { const right = () => {
if (slider.value) scrollLeft(slider.value.offsetWidth) if (slider.value)
scrollLeft(
props.scrollWidth ? props.scrollWidth() : slider.value.offsetWidth,
)
} }
const doesScroll = ref<boolean>(false) const doesScroll = ref<boolean>(false)
@@ -48,7 +65,9 @@ onMounted(() => {
class="left" class="left"
name="material-symbols:chevron-left-rounded" name="material-symbols:chevron-left-rounded"
/> />
<section class="slider" ref="slider"><slot /></section> <section class="slider" :class="[props.sliderClass]" ref="slider">
<slot />
</section>
<Icon <Icon
v-if="doesScroll" v-if="doesScroll"
@click="right" @click="right"
@@ -85,7 +104,7 @@ onMounted(() => {
& > * { & > * {
flex: 0 0 auto; flex: 0 0 auto;
scroll-snap-align: center; scroll-snap-align: start;
} }
} }
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
import type { DropdownMenuItem } from "~/ui/DropdownMenu.vue"
interface TrackRowProps {
track: Track
}
const { track } = defineProps<TrackRowProps>()
const emit = defineEmits<{ (e: "playAlbumAtIndex"): void }>()
const hovering = ref(false)
const toast = useToast()
const dropdownMenuItems: DropdownMenuItem[][] = [
[
{
label: "Play only this",
icon: "lucide:play",
onSelect() {},
},
{
label: "Play next",
icon: "lucide:list-start",
onSelect() {
toast.show("Playing next")
},
},
{
label: "Add to queue",
icon: "lucide:list-end",
onSelect() {
toast.show("Added to queue")
},
},
],
]
</script>
<template>
<article
class="track"
@mouseover="hovering = true"
@mouseleave="hovering = false"
>
<div
class="index"
:class="{ hovering }"
@click="emit('playAlbumAtIndex')"
>
<Icon name="lucide:play" />
<NuxtImg
:src="GET_FILE(track.album.file)"
width="50"
height="50"
placeholder
/>
</div>
<div>
<p>{{ track.name }}</p>
<ArtistName :artists="track.artists" class="artists" />
</div>
<DropdownMenu :items="dropdownMenuItems">
<Icon name="lucide:ellipsis" class="dropdown-icon" />
</DropdownMenu>
</article>
</template>
<style lang="scss" scoped>
@use "@/styles/variables" as *;
@use "@/styles/util" as *;
@use "sass:color";
.track {
display: grid;
grid-template-columns: 40px 1fr 30px;
gap: 0.5rem;
align-items: center;
padding: 0.6rem 1rem;
border-radius: $border-radius-standard;
background-color: $color-background;
line-height: 1;
width: 100%;
p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.index {
display: flex;
justify-content: center;
align-items: center;
font-size: 24px;
position: relative;
cursor: pointer;
img {
border-radius: $border-radius-standard;
}
span {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
}
img,
span {
transition: opacity 0.15s ease;
}
&.hovering {
img {
opacity: 0.4;
}
span {
opacity: 1;
}
}
}
.artists {
@include fontSize(14px);
}
.dropdown-icon {
font-size: 20px;
cursor: pointer;
}
</style>
+21 -18
View File
@@ -14,26 +14,28 @@ const hovering = ref(false)
const toast = useToast() const toast = useToast()
const dropdownMenuItems: DropdownMenuItem[] = [ const dropdownMenuItems: DropdownMenuItem[][] = [
{ [
label: "Play only this", {
icon: "lucide:play", label: "Play only this",
onSelect() {}, icon: "lucide:play",
}, onSelect() {},
{
label: "Play next",
icon: "lucide:list-start",
onSelect() {
toast.show("Playing next")
}, },
}, {
{ label: "Play next",
label: "Add to queue", icon: "lucide:list-start",
icon: "lucide:list-end", onSelect() {
onSelect() { toast.show("Playing next")
toast.show("Added to queue") },
}, },
}, {
label: "Add to queue",
icon: "lucide:list-end",
onSelect() {
toast.show("Added to queue")
},
},
],
] ]
const durationFormatted = computed(() => const durationFormatted = computed(() =>
@@ -120,5 +122,6 @@ const durationFormatted = computed(() =>
.dropdown-icon { .dropdown-icon {
font-size: 20px; font-size: 20px;
cursor: pointer;
} }
</style> </style>
+4
View File
@@ -131,6 +131,10 @@ export const useAuth = () => {
return refreshPromise return refreshPromise
} }
onMounted(() => {
if (refreshToken.value && !sessionToken.value) refreshSessionToken()
})
return { return {
refreshToken, refreshToken,
sessionToken, sessionToken,
+150 -1
View File
@@ -1 +1,150 @@
<template></template> <script setup lang="ts">
const route = useRoute()
const id = computed(() => route.params.id)
const { data: artistData } = await useApiFetch<Album>(`/artists/${id.value}`)
const { data: albumsData } = await useApiFetch<Album[]>(
`/artists/${id.value}/albums`,
)
const { data: tracksData } = await useApiFetch<Track[]>(
`/artists/${id.value}/tracks`,
)
const chunkedTracks = computed(() => {
const SIZE = 3
const chunks = []
for (let i = 0; i < tracksData.value.length; i += SIZE) {
chunks.push(tracksData.value.slice(i, i + SIZE))
}
return chunks
})
const slider = useTemplateRef("slider")
const scrollWidth = () => {
if (!slider.value) return 0
const el = Array.isArray(slider.value) ? slider.value[0] : slider.value
if (!el) return 0
return el.offsetWidth
}
const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
</script>
<template>
<section class="info" v-if="artistData">
<NuxtImg
:src="GET_FILE(artistData.file)"
width="300"
height="300"
placeholder
/>
<div class="details">
<h1>{{ artistData.name }}</h1>
<div class="buttons">
<Button
icon-name="lucide:play"
@click="() => playAlbum(tracksData)"
>Play</Button
>
<Button
icon-name="lucide:shuffle"
@click="() => playShuffledAlbum(tracksData)"
>Shuffle</Button
>
</div>
</div>
</section>
<Slider slider-class="slider" :scroll-width="scrollWidth" title="Tracks">
<section
class="tracks"
v-for="(chunk, pI) in chunkedTracks"
:key="`chunk-${pI}`"
ref="slider"
>
<TrackRow
v-for="(track, i) in chunk"
:key="`track-${track._id}`"
:track="track"
:index="i + pI * 3"
@play-album-at-index="
() => playAlbumAtIndex(tracksData, i + pI * 3)
"
/>
</section>
</Slider>
<Slider title="Albums">
<AlbumCard
v-for="album in albumsData"
:key="album._id"
:name="album.name"
:file="`${BACKEND_SERVE}${album.file}`"
:artists="album.artists"
:_id="album._id"
/>
</Slider>
</template>
<style lang="scss" scoped>
@use "@/styles/variables" as *;
@use "@/styles/util";
.info {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
& img {
border-radius: $border-radius-standard;
}
.details {
padding-top: 3.5rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
& h1 {
@include util.fontSize(32px);
font-weight: 700;
line-height: 2.4rem;
}
.artist {
@include util.fontSize(18px);
}
.buttons {
margin-top: auto;
display: flex;
gap: 1rem;
}
}
}
$gap: 12px;
:deep(.slider) {
gap: $gap;
}
.tracks {
display: grid;
grid-template-rows: repeat(3, 1fr);
gap: 0.4rem;
flex: 0 0 calc((100% - 3 * $gap) / 4);
}
</style>
+108
View File
@@ -0,0 +1,108 @@
<script setup lang="ts">
const route = useRoute()
const id = computed(() => route.params.id)
const { data: artistData } = await useApiFetch<Artist>(`/artists/${id.value}`)
const { data: tracksData } = await useApiFetch<Track[]>(
`/artists/${id.value}/tracks`,
)
const { playAlbum, playAlbumAtIndex } = usePlayer()
</script>
<template>
<section class="info" v-if="artistData">
<NuxtImg
:src="GET_FILE(artistData.file)"
width="300"
height="300"
placeholder
/>
<div class="details">
<h1>{{ artistData.name }}</h1>
<div class="buttons">
<Button
icon-name="lucide:play"
@click="() => playAlbum(tracksData)"
>Play</Button
>
</div>
</div>
</section>
<section class="tracks-container">
<NuxtLink :to="`/artist/${id}/tracks`">
Tracks
<Icon name="lucide:chevron-right" />
</NuxtLink>
<Slider>
<div class="tracks">
<template v-for="j in 25">
<TrackImageRow
v-for="(track, i) in tracksData"
:key="track._id"
:track="track"
:index="i"
@play-album-at-index="
() => playAlbumAtIndex(tracksData, i)
"
/>
</template>
</div>
</Slider>
</section>
<section class="albums"></section>
</template>
<style lang="scss" scoped>
@use "@/styles/variables" as *;
@use "@/styles/util";
.info {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
img {
border-radius: $border-radius-standard;
}
.details {
padding-top: 3.5rem;
display: flex;
flex-direction: column;
& h1 {
@include util.fontSize(32px);
font-weight: 700;
line-height: 2.4rem;
}
.buttons {
margin-top: auto;
}
}
}
.tracks-container {
a {
display: inline-flex;
align-items: center;
@include util.fontSize(24px);
font-weight: 600;
}
.tracks {
display: grid;
grid-template-rows: repeat(3, 1fr);
grid-template-columns: repeat(auto-fit, minmax(300px, 350px));
grid-auto-flow: column;
}
}
</style>
+3
View File
@@ -0,0 +1,3 @@
<template>
<h1>Tracks!</h1>
</template>
+1
View File
@@ -9,6 +9,7 @@ $color-button-hover: rgba(255, 255, 255, 0.16);
$color-attention: #fc8181; $color-attention: #fc8181;
$color-slider-monochrome: white; $color-slider-monochrome: white;
$border-radius-sm: 0.15rem;
$border-radius-standard: 0.3rem; $border-radius-standard: 0.3rem;
$border-radius-lg: 0.6rem; $border-radius-lg: 0.6rem;
+41 -17
View File
@@ -6,7 +6,7 @@ export interface DropdownMenuItem {
} }
interface DropdownMenuProps { interface DropdownMenuProps {
items: DropdownMenuItem[] items: DropdownMenuItem[][]
} }
const props = defineProps<DropdownMenuProps>() const props = defineProps<DropdownMenuProps>()
@@ -85,20 +85,26 @@ onBeforeMount(() => {
<Transition name="dropdown"> <Transition name="dropdown">
<div v-show="open" class="menu" :class="placement"> <div v-show="open" class="menu" :class="placement">
<button <div
v-for="(item, i) in items" v-for="(group, i) in items"
:key="i" :key="`group-${i}`"
class="item" class="group"
@click="
() => {
item.onSelect?.()
close()
}
"
> >
<Icon :name="item.icon" class="icon" /> <button
<span>{{ item.label }}</span> v-for="(item, i) in group"
</button> :key="`item-${i}`"
class="item"
@click="
() => {
item.onSelect?.()
close()
}
"
>
<Icon :name="item.icon" class="icon" />
<span>{{ item.label }}</span>
</button>
</div>
</div> </div>
</Transition> </Transition>
</div> </div>
@@ -113,11 +119,10 @@ onBeforeMount(() => {
.menu { .menu {
position: absolute; position: absolute;
right: 0; right: 0;
min-width: 180px;
padding: 0.5rem; padding: 0.5rem;
min-width: 180px;
background-color: $color-secondary-background; background-color: $color-secondary-background;
border-radius: $border-radius-standard; border-radius: $border-radius-standard;
@@ -125,7 +130,6 @@ onBeforeMount(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px;
z-index: 1000; z-index: 1000;
@@ -140,6 +144,26 @@ onBeforeMount(() => {
} }
} }
.group {
width: 100%;
display: flex;
flex-direction: column;
gap: 4px;
padding: 0.4rem 0;
border-bottom: 1px solid $color-border;
&:first-child {
padding-top: 0;
}
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
.item { .item {
display: flex; display: flex;
align-items: center; align-items: center;
+1 -1
View File
@@ -38,7 +38,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
padding: 2rem 3rem; padding: 2rem 3rem calc(2rem + $player-height);
} }
} }
</style> </style>