Merge branch 'master' into feat/album-page

This commit is contained in:
lucast committed 2026-02-22 15:26:21 +01:00
commit b7262be0a6
4 files changed
+286 -10

No files matched your search

+8 -10
View File
@@ -12,7 +12,13 @@ const props = defineProps<AlbumProps>()
<template> <template>
<article class="card"> <article class="card">
<NuxtLink :to="`/album/${props._id}`"> <NuxtLink :to="`/album/${props._id}`">
<NuxtImg :src="props.file" :alt="props.name" /> <NuxtImg
:src="props.file"
:alt="props.name"
placeholder
width="240"
height="240"
/>
<p>{{ props.name }}</p> <p>{{ props.name }}</p>
</NuxtLink> </NuxtLink>
<ArtistName :artists="props.artists" class="artist" /> <ArtistName :artists="props.artists" class="artist" />
@@ -23,12 +29,10 @@ const props = defineProps<AlbumProps>()
@use "@/styles/util" as *; @use "@/styles/util" as *;
@use "@/styles/variables" as *; @use "@/styles/variables" as *;
$width: 240px;
.card { .card {
@include fontSize(14px); @include fontSize(14px);
max-width: $width; max-width: 240px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -37,8 +41,6 @@ $width: 240px;
& > a { & > a {
& img { & img {
border-radius: $border-radius-standard; border-radius: $border-radius-standard;
width: $width;
} }
& p { & p {
@@ -46,8 +48,4 @@ $width: 240px;
} }
} }
} }
.artist {
opacity: 0.6;
}
</style> </style>
+1
View File
@@ -41,6 +41,7 @@ const suffix = (index: number) => {
.artist-name { .artist-name {
display: inline-flex; display: inline-flex;
flex-wrap: wrap; flex-wrap: wrap;
opacity: 0.6;
& span { & span {
white-space: pre; white-space: pre;
+196
View File
@@ -0,0 +1,196 @@
<script setup lang="ts">
export interface DropdownMenuItem {
label: string
icon: string
onSelect?: () => void
}
interface DropdownMenuProps {
items: DropdownMenuItem[]
}
const props = defineProps<DropdownMenuProps>()
const open = ref(false)
const placement = ref<"bottom" | "top">("bottom")
const root = useTemplateRef("root")
const toggle = async () => {
open.value = !open.value
if (open.value) {
await nextTick()
updatePlacement()
}
}
const close = () => (open.value = false)
const updatePlacement = () => {
const el = root.value
if (!el) return
const rect = el.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top
// TODO: Calculate height instead
const menuHeight = 220
console.log(
spaceBelow,
menuHeight,
spaceAbove,
spaceBelow < menuHeight && spaceAbove > spaceBelow,
spaceBelow < menuHeight && spaceAbove > spaceBelow ? "top" : "bottom",
)
placement.value =
spaceBelow < menuHeight && spaceAbove > spaceBelow ? "top" : "bottom"
}
const handleOutsideClick = (e: MouseEvent) => {
if (!root.value) return
if (!root.value.contains(e.target as Node)) close()
}
const handleKey = (e: KeyboardEvent) => {
if (e.key == "Escape") close()
}
const handleReposition = () => {
if (open.value) updatePlacement()
}
onMounted(() => {
document.addEventListener("click", handleOutsideClick)
document.addEventListener("keydown", handleKey)
window.addEventListener("resize", handleReposition)
window.addEventListener("scroll", handleReposition, true)
})
onBeforeMount(() => {
document.removeEventListener("click", handleOutsideClick)
document.removeEventListener("keydown", handleKey)
window.removeEventListener("resize", handleReposition)
window.removeEventListener("scroll", handleReposition, true)
})
</script>
<template>
<div ref="root" class="dropdown">
<div @click="toggle" class="trigger">
<slot />
</div>
<Transition name="dropdown">
<div v-show="open" class="menu" :class="placement">
<button
v-for="(item, i) in items"
:key="i"
class="item"
@click="
() => {
item.onSelect?.()
close()
}
"
>
<Icon :name="item.icon" class="icon" />
<span>{{ item.label }}</span>
</button>
</div>
</Transition>
</div>
</template>
<style lang="scss" scoped>
@use "@/styles/variables" as *;
.dropdown {
position: relative;
display: inline-block;
.menu {
position: absolute;
right: 0;
min-width: 180px;
padding: 0.5rem;
background-color: $color-secondary-background;
border-radius: $border-radius-standard;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
gap: 2px;
z-index: 1000;
&.bottom {
top: calc(100% + 0.5rem);
transform-origin: top right;
}
&.top {
bottom: calc(100% + 0.5rem);
transform-origin: bottom right;
}
}
.item {
display: flex;
align-items: center;
gap: 8px;
padding: 0.5rem;
border-radius: $border-radius-standard;
background: transparent;
border: none;
cursor: pointer;
transition: background-color 0.2s ease;
}
.item:hover {
background-color: $color-button-hover;
}
.icon {
font-size: 16px;
}
}
.dropdown-enter-active,
.dropdown-leave-active {
transition:
opacity 0.14s ease-out,
transform 0.14s ease-out;
}
/* Starting state */
.dropdown-enter-from {
opacity: 0;
transform: translateY(-6px) scale(0.96);
}
/* End state */
.dropdown-enter-to {
opacity: 1;
transform: translateY(0) scale(1);
}
/* Leaving */
.dropdown-leave-from {
opacity: 1;
transform: translateY(0) scale(1);
}
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
</style>
+81
View File
@@ -0,0 +1,81 @@
<script setup lang="ts">
interface SliderProps {
min?: number
max?: number
step?: number
}
const props = withDefaults(defineProps<SliderProps>(), {
min: 0,
max: 100,
step: 1,
})
const value = defineModel<number>()
const sliderRef = useTemplateRef("slider-ref")
const percentage = computed(() => {
const range = props.max - props.min
return (((value.value ?? 0) - props.min) / range) * 100
})
const updateFromEvent = (event: MouseEvent) => {
if (!sliderRef.value) return
const rect = sliderRef.value.getBoundingClientRect()
const x = event.clientX - rect.left
const ratio = Math.min(Math.max(x / rect.width, 0), 1)
value.value = props.min + ratio * (props.max - props.min)
}
const onMouseDown = (event: MouseEvent) => {
updateFromEvent(event)
const move = (e: MouseEvent) => updateFromEvent(e)
const up = () => {
window.removeEventListener("mousemove", move)
window.removeEventListener("mouseup", up)
}
window.addEventListener("mousemove", move)
window.addEventListener("mouseup", up)
}
</script>
<template>
<div ref="slider-ref" class="slider" @mousedown="onMouseDown">
<div class="track">
<div class="fill" :style="{ width: percentage + '%' }" />
</div>
</div>
</template>
<style lang="scss" scoped>
@use "@/styles/variables" as *;
.slider {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
}
.slider .track {
position: relative;
width: 100%;
height: 6px;
border-radius: 999px;
background: $color-border;
overflow: hidden;
}
.slider .fill {
position: absolute;
inset: 0 auto 0 0;
height: 100%;
background: $color-accent;
border-radius: 999px;
transition: width 0.08s linear;
}
</style>