feat(backend,frontend): copy liked songs into a shareable playlist

This commit is contained in:
hh committed 2026-10-15 13:19:38 +00:00
1 parent 09ca301d51
commit bf2d4f846a
20 files changed
+1194 -19

No files matched your search

+370 -7
View File
@@ -1,13 +1,376 @@
<script lang="ts">
import { onMount } from "svelte";
import { cubicOut } from "svelte/easing";
import { prefersReducedMotion, Tween } from "svelte/motion";
import { slide } from "svelte/transition";
import Sleeve from "#lib/components/Sleeve.svelte";
import { Session, type Stage } from "#lib/session.svelte.js";
const CODES = ["bg-ready", "bg-busy", "bg-ink", "bg-stop"];
const CODE: Partial<Record<Stage, number>> = {
copying: 1,
denied: 3,
done: 2,
ready: 0,
stopped: 3,
};
const FADE = [1, 0.62, 0.4, 0.22, 0];
const COPIED_MS = 1600;
const SPOTIFY =
"M12 0a12 12 0 1 0 0 24 12 12 0 0 0 0-24Zm5.5 17.3a.75.75 0 0 1-1 .25c-2.85-1.74-6.43-2.13-10.66-1.17a.75.75 0 1 1-.33-1.46c4.62-1.06 8.6-.6 11.74 1.33.35.21.46.67.25 1.05Zm1.47-3.27a.94.94 0 0 1-1.29.31c-3.26-2-8.23-2.58-12.09-1.41a.94.94 0 0 1-.54-1.8c4.41-1.34 9.89-.69 13.62 1.6.44.27.58.85.3 1.3Zm.13-3.4C15.18 8.3 8.73 8.08 5 9.22a1.13 1.13 0 1 1-.65-2.16c4.28-1.3 11.4-1.05 15.9 1.62a1.13 1.13 0 0 1-1.15 1.94Z";
const session = new Session();
const { format } = new Intl.NumberFormat("en-US");
const motion = prefersReducedMotion.current ? 0 : 1;
let copied = $state(false);
let field = $state<HTMLAnchorElement>();
const liked = $derived(session.me?.liked ?? 0);
const headline = $derived.by(() => {
switch (session.stage) {
case "ready":
return liked;
case "copying":
case "stopped":
return session.progress;
case "done":
return session.added;
default:
return null;
}
});
const caption = $derived.by(() => {
switch (session.stage) {
case "ready":
return "Liked songs";
case "copying":
return session.total ? `of ${format(session.total)}` : "";
case "done":
return session.playlist?.name ?? "";
case "stopped":
return `of ${format(session.total)}, stopped`;
case "denied":
return "Not on the list";
default:
return "";
}
});
const title = $derived.by(() => {
switch (session.stage) {
case "ready":
return `${format(liked)} liked songs`;
case "copying":
return `${format(session.progress)} / ${format(session.total)}`;
case "done":
return session.playlist?.name ?? "Liked songs";
default:
return "Liked songs";
}
});
const mode = $derived.by(() => {
if (session.stage === "done") {
return "full";
}
return session.stage === "copying" || session.stage === "stopped"
? "live"
: "flat";
});
const count = Tween.of(() => headline ?? 0, {
duration: 500 * motion,
easing: cubicOut,
});
const copy = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
} catch {
if (field) {
getSelection()?.selectAllChildren(field);
}
return;
}
copied = true;
setTimeout(() => {
copied = false;
}, COPIED_MS);
};
onMount(() => {
session.load();
});
</script>
<svelte:head>
<title>spotigrab</title>
<title>{title}</title>
</svelte:head>
{#snippet spotify(
size: string
)}
<svg aria-hidden="true" class={["shrink-0", size]} viewBox="0 0 24 24">
<path d={SPOTIFY} fill="currentColor" />
</svg>
{/snippet}
<main
class="flex min-h-dvh flex-col items-center justify-center gap-4 px-6 text-center bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-50"
class="mx-auto flex min-h-dvh max-w-[1180px] flex-col px-6 pt-[max(8px,env(safe-area-inset-top))] pb-[max(20px,env(safe-area-inset-bottom))] lg:px-12"
>
<h1 class="font-bold text-4xl tracking-tight sm:text-5xl">spotigrab</h1>
<p class="max-w-prose text-base text-neutral-500">
Edit
<code class="font-mono">src/routes/+page.svelte</code>.
</p>
<header class="flex h-14 shrink-0 items-center justify-between gap-4">
{#if session.me}
<div class="flex min-w-0 items-center gap-3">
{#if session.me.avatar}
<img
alt=""
class="size-7 rounded-full border border-rule object-cover"
height="28"
src={session.me.avatar}
width="28"
>
{:else}
<span
class="grid size-7 place-items-center rounded-full border border-mute text-[12px]"
>
{session.me.name.charAt(0).toUpperCase()}
</span>
{/if}
<span class="truncate text-[14px]">{session.me.name}</span>
</div>
<button
class="caps -mr-2 px-2 py-3 text-mute transition-colors hover:text-ink"
onclick={() => session.logout()}
type="button"
>
Log out
</button>
{/if}
</header>
<div
class="flex flex-1 flex-col gap-6 lg:grid lg:grid-cols-[minmax(0,420px)_1fr] lg:items-center lg:gap-20"
>
<div
class="mx-auto w-full max-w-[min(100%,40dvh)] border border-rule lg:order-2 lg:max-w-[min(72vh,600px)]"
>
<Sleeve bins={session.bins} {mode} row={session.row} />
</div>
<section class="flex flex-1 flex-col lg:order-1 lg:flex-none">
<div class="flex h-5 items-center justify-between">
<div
aria-hidden="true"
class={[
"flex gap-1",
CODE[session.stage] === undefined && "invisible",
]}
>
{#each CODES as code, index (code)}
<span
class={[
"h-2 w-5 transition-colors duration-500",
CODE[session.stage] === index ? code : "bg-rule",
]}
></span>
{/each}
</div>
{#if session.stage === "copying"}
<span title="Spotify">{@render spotify("size-[22px]")}</span>
{/if}
</div>
{#if headline !== null}
<p
class="wide mt-4 text-[clamp(56px,16vw,88px)] leading-[0.9] font-[450] tracking-[-0.02em]"
>
{format(Math.round(count.current))}
</p>
{/if}
{#if caption}
<p class="caps mt-3" role="status">{caption}</p>
{/if}
{#if session.stage === "copying"}
<ol class="mt-6 h-[164px] overflow-hidden">
{#each session.shown as track, index (track)}
<li
class="pb-2.5 transition-opacity duration-300"
style:opacity={FADE[index]}
in:slide={{ duration: 170 * motion }}
>
<a
class="flex min-w-0 items-center gap-3"
href={track.url}
rel="noopener"
target="_blank"
>
{#if track.cover}
<img
alt=""
class="size-8 shrink-0 bg-rule"
height="32"
src={track.cover}
width="32"
>
{/if}
<span class="truncate text-[15px]">
{track.title}
<span class="text-mute">· {track.artists}</span>
</span>
</a>
</li>
{/each}
</ol>
{/if}
{#if session.stage === "done" && session.playlist}
<a
class="mt-5 block truncate border border-rule px-4 py-3 text-[13px] text-mute transition-colors hover:border-mute hover:text-ink"
href={session.playlist.url}
rel="noopener"
target="_blank"
bind:this={field}
>
{session.playlist.url.replace("https://", "")}
</a>
{/if}
{#if session.stage === "stopped"}
<p class="mt-5 text-[14px] text-mute">Spotify keeps what was added.</p>
{/if}
{#if session.stage === "denied"}
<p class="mt-5 text-[14px] text-mute">
Ask to be added, or switch accounts.
</p>
{/if}
<div class="mt-auto flex flex-col gap-2.5 pt-6 lg:mt-8 lg:pt-0">
{#if session.stage === "out" || session.stage === "denied"}
<a
class="action"
data-sveltekit-reload
href={session.stage === "denied"
? "/api/login?switch=true"
: "/api/login"}
>
<span class="caps label">
{session.stage === "denied"
? "Use another account"
: "Continue with Spotify"}
</span>
<span class="end bg-ink text-ground">
{@render spotify("size-[22px]")}
</span>
</a>
{/if}
{#if session.stage === "ready" || session.stage === "stopped"}
<button
class="action"
disabled={liked === 0}
onclick={() => session.start()}
type="button"
>
<span class="caps label">
{session.stage === "stopped" ? "Try again" : "Make playlist"}
</span>
<span
class={[
"end",
session.stage === "stopped" ? "bg-stop" : "bg-ready",
]}
></span>
</button>
{/if}
{#if session.stage === "done" && session.playlist}
{@const url = session.playlist.url}
<button class="action" onclick={() => copy(url)} type="button">
<span class="caps label">{copied ? "Copied" : "Copy link"}</span>
<span class="end bg-ink text-ground">
<svg
aria-hidden="true"
class="size-4"
fill="none"
stroke="currentColor"
stroke-width="1.6"
viewBox="0 0 16 16"
>
{#if copied}
<path d="m3 8.5 3 3 7-7" />
{:else}
<path d="M5.5 5.5h8v8h-8z M10.5 5.5v-3h-8v8h3" />
{/if}
</svg>
</span>
</button>
<a class="action quiet" href={url} rel="noopener" target="_blank">
<span class="caps label">Open in Spotify</span>
<span class="end">{@render spotify("size-[22px]")}</span>
</a>
<button
class="caps mx-auto px-3 py-2.5 text-mute transition-colors hover:text-ink"
onclick={() => session.start()}
type="button"
>
Make another
</button>
{/if}
</div>
</section>
</div>
</main>
<style>
.action {
display: flex;
align-items: stretch;
width: 100%;
height: 52px;
color: var(--color-ink);
border: 1px solid var(--color-ink);
transition:
border-color 0.2s,
opacity 0.2s;
}
.label {
display: flex;
flex: 1;
align-items: center;
padding-inline: 18px;
transition: background-color 0.2s;
}
.end {
display: grid;
place-items: center;
width: 52px;
}
.action:active:not(:disabled) {
transform: translateY(1px);
}
.action:disabled {
cursor: default;
opacity: 0.35;
}
.quiet {
border-color: var(--color-rule);
}
.quiet .end {
border-left: 1px solid var(--color-rule);
}
@media (hover: hover) {
.action:hover:not(:disabled) .label {
background: color-mix(in srgb, var(--color-ink) 9%, transparent);
}
.quiet:hover {
border-color: var(--color-mute);
}
}
</style>