feat(backend,frontend): copy liked songs into a shareable playlist
This commit is contained in:
1 parent
09ca301d51
commit
bf2d4f846a
20 files changed
+1194
-19
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user