forked from Keelrleek/SoulStream
follow: the artist page's heart becomes a real thing, stored and celebrated
Following an artist now writes a row: followed_artists (artist_id, name, portrait, followed_at) beside the playlists in the library db, reached through follow_artist / unfollow_artist / followed_artists, each emitting soulseek:follows-changed. Re-following refreshes the name and portrait — identity improves as pages are visited and a later portrait is usually the better one — but keeps the original followed_at, so "following since" stays honest. Name and portrait are stored on purpose rather than looked up: the home page's "new releases by artists you follow" can then draw a card from the row alone, with no catalog request per artist. The store is the UI truth and the chip never keeps its own copy. It is optimistic — the chip flips at the click and reverts if the write fails, rather than leaving the screen telling a lie. The chip itself (FB4) leads the stat row and wears the accent as ground with dark text, the Play pill's own trick, so its on-state is legible across the room. The lead slot is a fixed 14x14 in which the +, the equalizer bars and the check swap places, so nothing about the chip's box moves except the word — and the word morphs between its measured widths instead of snapping. Clicking runs the Encore: the + collapses, three bars dance three beats with a note rising per beat, they bow, and the check springs in as "Following" grows into place. The db write and the toast fire at the CLICK; the ceremony is presentation only, so navigating away mid-show still follows. Unfollow is an undo — the shrink morphs, nothing celebrates, and the toast says so. Every effect is template-owned with scoped keyframes. The first build spawned the glow and the notes imperatively and referenced -global- keyframes, which silently did not resolve: the glow ran no animation and sat at full opacity, the notes held opacity 0 and never appeared. The bars worked throughout for exactly the reason the others did not. The row around it is shorter too, at Kyle's call: the library and listening chips (progress meter, the disk popover, format, hours, last played, top track, genre) come out, leaving the identity trio and the socials. With nothing left to shed, the socials' scroll-driven fade and their layout drop at the park boundary come out with them, and the divider's clear space moves from the follow heart by name to whichever chip now closes the run. The JS that fed the removed chips stays — it is cheap, local, and the numbers still have a home coming.
This commit is contained in:
1 parent
5786269592
commit
520e2ad4e4
8 files changed
+763
-197
No files matched your search
@@ -0,0 +1,154 @@
|
||||
<script>
|
||||
// The follow confirmation — the approved T2: a dark glass pill above the
|
||||
// player bar with the artist's round portrait riding in it. "You're now
|
||||
// following {artist}" on follow; unfollow gets the same pill saying so,
|
||||
// with no celebration anywhere else (that is the follow chip's rule too).
|
||||
//
|
||||
// Driven by followToast.seq so two follows in a row re-show rather than
|
||||
// the second one landing on a toast that is already up. The element stays
|
||||
// mounted through its exit so the sink-out actually plays — Svelte's
|
||||
// outro would also work, but the mock's approved motion is a pair of CSS
|
||||
// keyframes and this keeps them verbatim.
|
||||
import Icon from "./Icon.svelte";
|
||||
import { followToast } from "../lib/state.svelte.js";
|
||||
|
||||
const HOLD_MS = 2200;
|
||||
const LEAVE_MS = 280;
|
||||
|
||||
let shown = $state(false);
|
||||
let leaving = $state(false);
|
||||
let holdTimer = 0;
|
||||
let leaveTimer = 0;
|
||||
|
||||
$effect(() => {
|
||||
const seq = followToast.seq;
|
||||
if (!seq) return;
|
||||
clearTimeout(holdTimer);
|
||||
clearTimeout(leaveTimer);
|
||||
// Retrigger cleanly: unmount for a frame so the enter animation
|
||||
// replays even when the previous toast is still on screen.
|
||||
shown = false;
|
||||
leaving = false;
|
||||
requestAnimationFrame(() => {
|
||||
shown = true;
|
||||
holdTimer = setTimeout(() => {
|
||||
leaving = true;
|
||||
leaveTimer = setTimeout(() => {
|
||||
shown = false;
|
||||
leaving = false;
|
||||
}, LEAVE_MS);
|
||||
}, HOLD_MS);
|
||||
});
|
||||
return () => {
|
||||
clearTimeout(holdTimer);
|
||||
clearTimeout(leaveTimer);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if shown}
|
||||
<div class="layer" aria-live="polite">
|
||||
<div class="toast" class:leaving>
|
||||
{#if followToast.portrait}
|
||||
<span class="art"><img src={followToast.portrait} alt="" draggable="false" /></span>
|
||||
{:else}
|
||||
<span class="check"><Icon name="check" size={15} /></span>
|
||||
{/if}
|
||||
{#if followToast.on}
|
||||
<span>You're now following <b>{followToast.name}</b></span>
|
||||
{:else}
|
||||
<span>Unfollowed <b>{followToast.name}</b></span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Above the player bar, centred on the window — the player's own height
|
||||
var keeps it clear of the transport in both collapsed and expanded
|
||||
states. The offset is three named parts:
|
||||
|
||||
var(--player-height) the bar itself (62 rest, 90 expanded)
|
||||
8px the app shell's bottom gutter under the bar
|
||||
26px air between the bar's top edge and the toast —
|
||||
at the mock's 16px total the pill hovered 8px
|
||||
off the bar and read as touching the window
|
||||
bottom in a real-height window (measured 78px
|
||||
up in a 977px viewport).
|
||||
|
||||
pointer-events none: a confirmation must never intercept a click aimed
|
||||
at the lane under it. */
|
||||
.layer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(var(--player-height, 62px) + 8px + 26px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 80;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 16px;
|
||||
border-radius: 99px;
|
||||
background: rgba(22, 23, 28, 0.94);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
animation: toast-in 340ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.toast.leaving {
|
||||
animation: toast-out 260ms ease both;
|
||||
}
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes toast-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.97);
|
||||
}
|
||||
}
|
||||
.art {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.art img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.check {
|
||||
display: inline-flex;
|
||||
color: var(--accent);
|
||||
}
|
||||
b {
|
||||
font-weight: 800;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast,
|
||||
.toast.leaving {
|
||||
animation-duration: 1ms;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user