Files
SoulStream/src/components/WhoHasSheet.svelte
T

132 lines
6.0 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
// Ask shape A: the sheet off a card. One for the whole app, portalled to
// body (the pane is a containing block and would clip a fixed child),
// anchored beside whichever card asked, closed by Escape, the ×, or a
// click anywhere else. Shape B — the section inside an open album — is
// WhoHas.svelte placed inline; this is the same rows in a dark glass
// sheet, the drawer's material.
import Icon from "./Icon.svelte";
import WhoHas from "./WhoHas.svelte";
import { whoHas, closeWhoHas } from "../lib/whoHas.svelte.js";
const W = 440;
const MARGIN = 12;
let el = $state(null);
let people = $state(null);
function portal(node) {
document.body.appendChild(node);
return { destroy: () => node.remove() };
}
/** Beside the card when there is room to its right, under it otherwise,
* and never off the viewport. */
const place = $derived.by(() => {
const a = whoHas.anchor;
const vw = window.innerWidth;
const vh = window.innerHeight;
if (!a) return { left: Math.max(MARGIN, (vw - W) / 2), top: Math.max(MARGIN, vh * 0.2) };
let left = a.x + a.w + 10;
let top = a.y;
if (left + W + MARGIN > vw) left = Math.max(MARGIN, a.x - W - 10);
if (left < MARGIN) {
left = Math.min(Math.max(MARGIN, a.x), vw - W - MARGIN);
top = a.y + a.h + 8;
}
const h = el?.offsetHeight ?? 320;
if (top + h + MARGIN > vh) top = Math.max(MARGIN, vh - h - MARGIN);
return { left, top };
});
$effect(() => {
if (!whoHas.open) return;
people = null;
const onKey = (e) => {
if (e.key === "Escape") closeWhoHas();
};
const onDown = (e) => {
if (el && !el.contains(e.target)) closeWhoHas();
};
// The sheet is placed ONCE from the card's box and is position:fixed,
// so a scroll anywhere under it slides the card away and leaves the
// sheet floating beside nothing (measured 2026-09-09: the pane moved
// 120px, the sheet stayed). Scroll events do not bubble, so this is
// a capture listener on window; the sheet's own overflow scroll is
// the one scroller it must ignore.
const onScroll = (e) => {
if (el && e.target instanceof Node && el.contains(e.target)) return;
closeWhoHas();
};
window.addEventListener("keydown", onKey);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onScroll);
// Deferred a tick so the click that opened the sheet does not close it.
const t = setTimeout(() => window.addEventListener("pointerdown", onDown, true), 0);
return () => {
clearTimeout(t);
window.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onScroll);
window.removeEventListener("pointerdown", onDown, true);
};
});
</script>
{#if whoHas.open}
<div class="sheet" use:portal bind:this={el} style:left="{place.left}px" style:top="{place.top}px" role="dialog" aria-label="Who has {whoHas.album}">
<div class="hd">
{#if whoHas.cover}
<img class="cv" src={whoHas.cover} alt="" draggable="false" />
{:else}
<span class="cv ph"><Icon name="disc" size={20} /></span>
{/if}
<div class="copy">
<div class="t" title={whoHas.album}>{whoHas.album}</div>
<div class="a">{whoHas.artist}</div>
</div>
<div class="q">{#if people != null}<b>{people} {people === 1 ? "person" : "people"}</b>{people === 1 ? "has it" : people ? "have it" : "in your rooms"}{/if}</div>
<button class="x" onclick={closeWhoHas} title="Close" aria-label="Close"><Icon name="x" size={14} /></button>
</div>
{#key whoHas.artist + "" + whoHas.album}
<WhoHas artist={whoHas.artist} album={whoHas.album} mbid={whoHas.mbid} compact onanswer={(h) => (people = new Set((h ?? []).map((x) => (x.username ?? x.member).toLowerCase())).size)} />
{/key}
</div>
{/if}
<style>
.sheet {
position: fixed;
z-index: 95;
width: 440px;
max-width: calc(100vw - 24px);
max-height: calc(100vh - 24px);
overflow: auto;
background: rgba(22, 23, 28, 0.97);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 14px;
padding: 12px 12px 10px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
color: var(--text-primary);
animation: sheet-in 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes sheet-in {
from { opacity: 0; transform: translateY(6px) scale(0.98); }
to { opacity: 1; transform: none; }
}
.hd { display: flex; gap: 12px; align-items: center; padding-bottom: 10px; border-bottom: 1px solid var(--hair, rgba(255, 255, 255, 0.07)); }
.cv { width: 48px; height: 48px; border-radius: 6px; flex: none; object-fit: cover; }
.cv.ph { display: grid; place-items: center; background: var(--bg-highlight); color: var(--text-muted); }
.copy { min-width: 0; flex: 1; }
.t { font-size: 14px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.a { font-size: 12px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.q { font-size: 11px; color: var(--text-muted); text-align: right; line-height: 1.4; flex: none; }
.q b { display: block; color: var(--text-secondary); font-size: 13px; }
.x { width: 26px; height: 26px; border-radius: 99px; display: grid; place-items: center; color: var(--text-secondary); flex: none; }
.x:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
@media (prefers-reduced-motion: reduce) {
.sheet { animation-duration: 1ms; }
}
</style>