refactor: rename frontend/ to web-player/ and format with prettier
Updates the compose build context, the Dockerfile header, the package name and the docs. The compose service and container keep the name frontend/musify-frontend, since nginx routes to http://frontend:3000. The source files also carry the first prettier pass, so the rename and the reformat land together for them.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
tone?: 'neutral' | 'danger';
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { tone = 'neutral', class: klass = '', children }: Props = $props();
|
||||
|
||||
const tones = {
|
||||
neutral: 'border-line bg-surface text-fg-2',
|
||||
danger: 'border-danger/40 bg-danger/10 text-danger-fg'
|
||||
};
|
||||
</script>
|
||||
|
||||
<p class="rounded-control border px-4 py-3 text-base {tones[tone]} {klass}">
|
||||
{@render children()}
|
||||
</p>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
tone?: 'neutral' | 'accent' | 'danger';
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { tone = 'neutral', class: klass = '', children }: Props = $props();
|
||||
|
||||
const tones = {
|
||||
neutral: 'bg-surface-2 text-fg',
|
||||
accent: 'bg-accent/20 text-accent',
|
||||
danger: 'bg-danger/10 text-danger-fg'
|
||||
};
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="shrink-0 rounded-full px-2 py-0.5 text-sm leading-none font-semibold {tones[tone]} {klass}"
|
||||
>
|
||||
{@render children()}
|
||||
</span>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary' | 'subtle' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
type?: 'button' | 'submit';
|
||||
href?: string;
|
||||
reload?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
type = 'button',
|
||||
href,
|
||||
reload = false,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
onclick,
|
||||
class: klass = '',
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-4 py-2 text-base',
|
||||
md: 'px-5 py-2.5 text-base',
|
||||
lg: 'px-8 py-3.5 text-base'
|
||||
};
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-accent-soft font-semibold text-on-accent active:scale-[0.97]',
|
||||
secondary: 'border border-line text-fg hover:bg-hover',
|
||||
subtle: 'bg-surface-2 font-semibold text-fg hover:bg-surface-hover',
|
||||
danger: 'bg-danger font-semibold text-white hover:brightness-110'
|
||||
};
|
||||
|
||||
const classes = $derived(
|
||||
`inline-flex items-center justify-center gap-2 rounded-control transition disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a {href} data-sveltekit-reload={reload ? '' : undefined} class={classes}>
|
||||
{@render children()}
|
||||
</a>
|
||||
{:else}
|
||||
<button {type} disabled={disabled || loading} {onclick} class={classes}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
{@render children()}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
|
||||
interface Props {
|
||||
checked?: boolean;
|
||||
name?: string;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { checked = $bindable(false), name, class: klass = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<label class="flex items-center gap-3 text-base text-fg-2 {klass}">
|
||||
<span class="relative inline-grid h-5 w-5 shrink-0 place-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
{name}
|
||||
bind:checked
|
||||
class="peer h-5 w-5 cursor-pointer appearance-none rounded border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
|
||||
/>
|
||||
<Check
|
||||
class="pointer-events-none absolute h-3.5 w-3.5 text-on-accent opacity-0 transition peer-checked:opacity-100"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</span>
|
||||
<span class="cursor-pointer">{@render children()}</span>
|
||||
</label>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { gradientForHue } from '$lib/theme/color';
|
||||
|
||||
interface Props {
|
||||
trackId: string | number;
|
||||
hue: number;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
src?: string;
|
||||
alt?: string;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
trackId,
|
||||
hue,
|
||||
size = 'medium',
|
||||
src,
|
||||
alt = '',
|
||||
class: klass = '',
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
let failed = $state(false);
|
||||
let loaded = $state(false);
|
||||
|
||||
const imageSrc = $derived(src ?? `/api/tracks/${trackId}/cover?size=${size}`);
|
||||
|
||||
$effect(() => {
|
||||
imageSrc;
|
||||
failed = false;
|
||||
loaded = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative overflow-hidden {klass}"
|
||||
style={loaded && !failed ? undefined : `background:${gradientForHue(hue)}`}
|
||||
>
|
||||
{#if !failed}
|
||||
<img
|
||||
src={imageSrc}
|
||||
{alt}
|
||||
loading="lazy"
|
||||
onload={() => (loaded = true)}
|
||||
onerror={() => (failed = true)}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { LucideIcon } from '@lucide/svelte';
|
||||
|
||||
interface Props {
|
||||
icon?: LucideIcon;
|
||||
title?: string;
|
||||
description?: string;
|
||||
class?: string;
|
||||
actions?: Snippet;
|
||||
}
|
||||
|
||||
let { icon: Icon, title, description, class: klass = '', actions }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center px-6 py-14 text-center {klass}">
|
||||
{#if Icon}
|
||||
<span class="grid h-14 w-14 place-items-center rounded-full bg-accent-soft text-on-accent">
|
||||
<Icon class="h-6 w-6" />
|
||||
</span>
|
||||
{/if}
|
||||
{#if title}
|
||||
<h3 class="mt-5 text-2xl font-bold tracking-tight text-fg">{title}</h3>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p class="mt-2 text-fg-2">{description}</p>
|
||||
{/if}
|
||||
{#if actions}
|
||||
<div class="mt-6 flex items-center gap-3">{@render actions()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: klass = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="grid h-4 w-4 shrink-0 place-items-center rounded-sm bg-fg-3 text-[11px] leading-4 font-bold text-bg {klass}"
|
||||
title="Contenido explícito"
|
||||
aria-label="Contenido explícito">E</span
|
||||
>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
for?: string;
|
||||
optional?: boolean;
|
||||
class?: string;
|
||||
hint?: Snippet;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { label, for: forId, optional = false, class: klass = '', hint, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-0 flex-col {klass}">
|
||||
<label for={forId} class="mb-2 block text-base font-medium text-fg-2">
|
||||
{label}
|
||||
{#if optional}<span class="text-muted">(Opcional)</span>{/if}
|
||||
</label>
|
||||
{@render children()}
|
||||
{#if hint}
|
||||
<div class="mt-2 flex items-center justify-between gap-3 text-sm text-muted">
|
||||
{@render hint()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
type?: 'button' | 'submit';
|
||||
size?: 'sm' | 'md';
|
||||
revealOnHover?: boolean;
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
type = 'button',
|
||||
size = 'md',
|
||||
revealOnHover = false,
|
||||
onclick,
|
||||
class: klass = '',
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
const sizes = { sm: 'h-7 w-7', md: 'h-8 w-8' };
|
||||
</script>
|
||||
|
||||
<button
|
||||
{type}
|
||||
{onclick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
class="grid place-items-center rounded-control text-muted transition hover:bg-hover hover:text-fg {sizes[
|
||||
size
|
||||
]} {revealOnHover ? 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100' : ''} {klass}"
|
||||
>
|
||||
{@render children()}
|
||||
</button>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
fallbackUrl?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
name,
|
||||
fallbackUrl,
|
||||
class: klass = 'h-52 w-52 rounded-control border border-line bg-surface hover:border-accent/50'
|
||||
}: Props = $props();
|
||||
|
||||
let preview = $state('');
|
||||
let fallbackFailed = $state(false);
|
||||
|
||||
function onInput(event: Event) {
|
||||
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (file) preview = URL.createObjectURL(file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<label
|
||||
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {klass}"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
{name}
|
||||
accept="image/*"
|
||||
onchange={onInput}
|
||||
class="absolute inset-0 cursor-pointer opacity-0"
|
||||
aria-label="Seleccionar portada"
|
||||
/>
|
||||
{#if preview !== ''}
|
||||
<img src={preview} alt="Portada" class="h-full w-full object-cover" />
|
||||
{:else if fallbackUrl && !fallbackFailed}
|
||||
<img
|
||||
src={fallbackUrl}
|
||||
alt="Portada actual"
|
||||
loading="lazy"
|
||||
onerror={() => (fallbackFailed = true)}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<ImageIcon class="h-12 w-12 text-muted" />
|
||||
{/if}
|
||||
</label>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||
|
||||
interface Props {
|
||||
onLoadMore: () => void;
|
||||
hasMore: boolean;
|
||||
loading: boolean;
|
||||
rootMargin?: string;
|
||||
}
|
||||
|
||||
let { onLoadMore, hasMore, loading, rootMargin = '600px' }: Props = $props();
|
||||
|
||||
let sentinel = $state<HTMLDivElement>();
|
||||
let visible = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const node = sentinel;
|
||||
if (!node) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
visible = entries[0].isIntersecting;
|
||||
},
|
||||
{ rootMargin }
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (visible && hasMore && !loading) onLoadMore();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
|
||||
{#if loading}
|
||||
<LoaderCircle class="h-5 w-5 animate-spin text-muted" />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import type { LucideIcon } from '@lucide/svelte';
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
type?: 'text' | 'search';
|
||||
placeholder?: string;
|
||||
maxlength?: number;
|
||||
required?: boolean;
|
||||
autocomplete?: 'on' | 'off';
|
||||
icon?: LucideIcon;
|
||||
oninput?: (event: Event) => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
id,
|
||||
name,
|
||||
type = 'text',
|
||||
placeholder,
|
||||
maxlength,
|
||||
required = false,
|
||||
autocomplete = 'off',
|
||||
icon: Icon,
|
||||
oninput,
|
||||
class: klass = ''
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative w-full">
|
||||
{#if Icon}
|
||||
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted">
|
||||
<Icon class="h-4 w-4" />
|
||||
</span>
|
||||
{/if}
|
||||
<input
|
||||
{id}
|
||||
{name}
|
||||
{type}
|
||||
{placeholder}
|
||||
{maxlength}
|
||||
{required}
|
||||
{autocomplete}
|
||||
{oninput}
|
||||
bind:value
|
||||
class="w-full rounded-control border border-line bg-surface py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {Icon
|
||||
? 'pr-4 pl-11'
|
||||
: 'px-4'} {klass}"
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
as?: 'ul' | 'div';
|
||||
min?: string;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { as = 'div', min = '180px', class: klass = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={as}
|
||||
class="grid gap-4 {klass}"
|
||||
style="grid-template-columns:repeat(auto-fill,minmax({min},1fr))"
|
||||
>
|
||||
{@render children()}
|
||||
</svelte:element>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import IconButton from './IconButton.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
maxWidth?: string;
|
||||
panelClass?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
maxWidth = 'max-w-2xl',
|
||||
panelClass = 'border border-line bg-elevated',
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
let panel: HTMLDivElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const trigger = document.activeElement as HTMLElement | null;
|
||||
panel?.focus();
|
||||
return () => trigger?.focus();
|
||||
});
|
||||
|
||||
function onBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}
|
||||
|
||||
function focusableElements(): HTMLElement[] {
|
||||
if (!panel) return [];
|
||||
return Array.from(
|
||||
panel.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function onWindowKeydown(event: KeyboardEvent) {
|
||||
if (!open) return;
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const elements = focusableElements();
|
||||
if (elements.length === 0) return;
|
||||
const first = elements[0];
|
||||
const last = elements[elements.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onWindowKeydown} />
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-black/70 p-4 backdrop-blur-sm"
|
||||
role="presentation"
|
||||
onclick={onBackdropClick}
|
||||
>
|
||||
<div
|
||||
bind:this={panel}
|
||||
class="animate-pop w-full {maxWidth} rounded-panel p-6 shadow-2xl outline-none {panelClass}"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabindex="-1"
|
||||
>
|
||||
{#if title}
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold tracking-tight text-fg">{title}</h2>
|
||||
<IconButton label="Cerrar" onclick={onClose}>
|
||||
<X class="h-4 w-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
let { paused = false }: Props = $props();
|
||||
|
||||
const bars = [
|
||||
{ delay: 0, dur: 0.9 },
|
||||
{ delay: 0.25, dur: 0.7 },
|
||||
{ delay: 0.1, dur: 1.05 },
|
||||
{ delay: 0.4, dur: 0.8 }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="absolute inset-0 flex items-end justify-center gap-[2.5px] bg-black/45 pb-2">
|
||||
{#each bars as bar, i (i)}
|
||||
<span
|
||||
class="w-[3px] origin-bottom rounded-full bg-white"
|
||||
style="height:14px; animation:equalize {bar.dur}s ease-in-out {bar.delay}s infinite; animation-play-state:{paused
|
||||
? 'paused'
|
||||
: 'running'}"
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { class: klass = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section class="page-x pt-11 pb-10 {klass}">
|
||||
{@render children()}
|
||||
</section>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
class?: string;
|
||||
actions?: Snippet;
|
||||
}
|
||||
|
||||
let { title, subtitle, class: klass = '', actions }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-end justify-between gap-4 {klass}">
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-3xl font-bold tracking-tight text-fg sm:text-4xl">{title}</h1>
|
||||
{#if subtitle}
|
||||
<p class="mt-2 text-fg-2">{subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if actions}
|
||||
<div class="flex shrink-0 items-center gap-3">{@render actions()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
|
||||
interface Props {
|
||||
active: boolean;
|
||||
playing: boolean;
|
||||
}
|
||||
|
||||
let { active, playing }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 translate-y-2 place-items-center rounded-full bg-accent-soft text-on-accent opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
|
||||
class:!opacity-100={active}
|
||||
class:!translate-y-0={active}
|
||||
>
|
||||
{#if active && playing}
|
||||
<Pause class="h-5 w-5" fill="currentColor" />
|
||||
{:else}
|
||||
<Play class="h-5 w-5" fill="currentColor" />
|
||||
{/if}
|
||||
</span>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import Cover from './Cover.svelte';
|
||||
import { gradientForHue, hueFor } from '$lib/theme/color';
|
||||
|
||||
interface Props {
|
||||
playlistId?: string;
|
||||
trackIds: (string | number)[];
|
||||
hue: number;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
version?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { playlistId, trackIds, hue, size = 'medium', version, class: klass = '' }: Props = $props();
|
||||
|
||||
let coverFailed = $state(false);
|
||||
|
||||
const mosaic = $derived(trackIds.length >= 4);
|
||||
const single = $derived(trackIds.length >= 1 && trackIds.length < 4 ? trackIds[0] : null);
|
||||
</script>
|
||||
|
||||
{#if playlistId && !coverFailed}
|
||||
<div class="relative overflow-hidden {klass}" style="background:{gradientForHue(hue)}">
|
||||
<img
|
||||
src="/api/playlists/{playlistId}/cover?size={size}{version
|
||||
? `&v=${encodeURIComponent(version)}`
|
||||
: ''}"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onerror={() => (coverFailed = true)}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{:else if mosaic}
|
||||
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
|
||||
{#each trackIds.slice(0, 4) as id (id)}
|
||||
<Cover trackId={id} hue={hueFor(id)} size="small" class="h-full w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if single !== null}
|
||||
<Cover trackId={single} hue={hueFor(single)} size="medium" class={klass} />
|
||||
{:else}
|
||||
<div class={klass} style="background:{gradientForHue(hue)}"></div>
|
||||
{/if}
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import PlaylistArt from './PlaylistArt.svelte';
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
trackIds: (string | number)[];
|
||||
updatedAt: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
let { id, name, description, trackIds, updatedAt, index = 0 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/playlists/{id}"
|
||||
class="group animate-enter min-w-0 text-left"
|
||||
style="animation-delay:{index * 45}ms"
|
||||
>
|
||||
<PlaylistArt
|
||||
playlistId={id}
|
||||
{trackIds}
|
||||
hue={hueFor(id)}
|
||||
version={updatedAt}
|
||||
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg"
|
||||
/>
|
||||
<div class="mt-2.5 truncate text-fg">{name}</div>
|
||||
{#if description}
|
||||
<div class="mt-0.5 truncate text-sm text-fg-3">{description}</div>
|
||||
{/if}
|
||||
</a>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import ImageDropzone from './ImageDropzone.svelte';
|
||||
import Button from './Button.svelte';
|
||||
import Field from './Field.svelte';
|
||||
import Input from './Input.svelte';
|
||||
import Textarea from './Textarea.svelte';
|
||||
import Alert from './Alert.svelte';
|
||||
|
||||
interface Props {
|
||||
action: string;
|
||||
initialName?: string;
|
||||
initialDescription?: string;
|
||||
namePlaceholder?: string;
|
||||
coverFallbackUrl?: string;
|
||||
formMessage?: string;
|
||||
submitLabel: string;
|
||||
submittingLabel: string;
|
||||
onCancel: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
action,
|
||||
initialName = '',
|
||||
initialDescription = '',
|
||||
namePlaceholder = '',
|
||||
coverFallbackUrl,
|
||||
formMessage,
|
||||
submitLabel,
|
||||
submittingLabel,
|
||||
onCancel,
|
||||
onSuccess
|
||||
}: Props = $props();
|
||||
|
||||
let name = $state(untrack(() => initialName));
|
||||
let description = $state(untrack(() => initialDescription));
|
||||
let submitting = $state(false);
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
{action}
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return async ({ update, result }) => {
|
||||
await update({ reset: false });
|
||||
submitting = false;
|
||||
if (result.type === 'success') onSuccess?.();
|
||||
};
|
||||
}}
|
||||
class="mt-4 w-full max-w-4xl space-y-4"
|
||||
>
|
||||
<div class="flex items-stretch gap-8">
|
||||
<Field label="Portada" optional>
|
||||
<ImageDropzone name="cover" fallbackUrl={coverFallbackUrl} />
|
||||
</Field>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-4">
|
||||
<Field label="Nombre" for="playlist-name">
|
||||
<Input
|
||||
id="playlist-name"
|
||||
name="name"
|
||||
maxlength={100}
|
||||
required
|
||||
bind:value={name}
|
||||
placeholder={namePlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Descripción" for="playlist-description" optional class="min-h-0 flex-1">
|
||||
<Textarea
|
||||
id="playlist-description"
|
||||
name="description"
|
||||
maxlength={300}
|
||||
bind:value={description}
|
||||
placeholder="¿De qué va esta lista?"
|
||||
class="flex-1"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if formMessage}
|
||||
<Alert tone="danger">{formMessage}</Alert>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
|
||||
<Button type="submit" disabled={submitting || name.trim() === ''}>
|
||||
{submitting ? submittingLabel : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { title, class: klass = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<h2 class="mb-6 text-2xl font-bold text-fg {klass}">{title}</h2>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
padding?: 'sm' | 'md' | 'lg';
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { padding = 'md', class: klass = '', children }: Props = $props();
|
||||
|
||||
const paddings = { sm: 'p-4', md: 'p-6', lg: 'p-12' };
|
||||
</script>
|
||||
|
||||
<div class="rounded-panel border border-line bg-surface transition {paddings[padding]} {klass}">
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
maxlength?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
id,
|
||||
name,
|
||||
placeholder,
|
||||
maxlength,
|
||||
class: klass = ''
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<textarea
|
||||
{id}
|
||||
{name}
|
||||
{placeholder}
|
||||
{maxlength}
|
||||
bind:value
|
||||
class="min-h-0 w-full resize-none rounded-control border border-line bg-surface px-4 py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {klass}"
|
||||
></textarea>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import NowPlaying from './NowPlaying.svelte';
|
||||
|
||||
interface Props {
|
||||
index: number;
|
||||
active: boolean;
|
||||
playing: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
let { index, active, playing, onToggle }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={onToggle}
|
||||
aria-label={active && playing ? 'Pausar' : 'Reproducir'}
|
||||
class="relative grid h-8 w-8 place-items-center overflow-hidden rounded text-sm text-muted"
|
||||
>
|
||||
{#if active}
|
||||
<NowPlaying paused={!playing} />
|
||||
{:else}
|
||||
<span class="group-hover:hidden">{index + 1}</span>
|
||||
<Play class="hidden h-3.5 w-3.5 text-fg group-hover:block" fill="currentColor" />
|
||||
{/if}
|
||||
</button>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { class: klass = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="truncate text-center text-sm text-muted tabular-nums {klass}">
|
||||
{@render children()}
|
||||
</span>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { trackTableKey, type TrackTableContext } from './TrackTable.svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
oncontextmenu?: (event: MouseEvent) => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { class: klass = '', oncontextmenu, children }: Props = $props();
|
||||
|
||||
const table = getContext<TrackTableContext>(trackTableKey);
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="group grid items-center gap-8 rounded-control px-3 py-2.5 transition hover:bg-surface {klass}"
|
||||
style="grid-template-columns:{table.columns}"
|
||||
{oncontextmenu}
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" module>
|
||||
export const trackTableKey = Symbol('track-table');
|
||||
|
||||
export interface TrackTableContext {
|
||||
readonly columns: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { setContext } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
columns: string;
|
||||
class?: string;
|
||||
headers: Snippet;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { columns, class: klass = '', headers, children }: Props = $props();
|
||||
|
||||
setContext<TrackTableContext>(trackTableKey, {
|
||||
get columns() {
|
||||
return columns;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={klass}>
|
||||
<div
|
||||
class="grid items-center gap-8 border-b border-line px-3 pb-2 text-sm font-medium tracking-wide text-muted uppercase"
|
||||
style="grid-template-columns:{columns}"
|
||||
>
|
||||
{@render headers()}
|
||||
</div>
|
||||
<div class="mt-1 flex flex-col">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Cover from './Cover.svelte';
|
||||
import ExplicitBadge from './ExplicitBadge.svelte';
|
||||
|
||||
interface Props {
|
||||
trackId: string | number;
|
||||
title: string;
|
||||
artist?: string;
|
||||
coverSrc?: string;
|
||||
coverSize?: string;
|
||||
hue: number;
|
||||
explicit?: boolean;
|
||||
titleClass?: string;
|
||||
onClick: () => void;
|
||||
overlay?: Snippet;
|
||||
badge?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
trackId,
|
||||
title,
|
||||
artist,
|
||||
coverSrc,
|
||||
coverSize = 'h-[42px] w-[42px]',
|
||||
hue,
|
||||
explicit = false,
|
||||
titleClass = '',
|
||||
onClick,
|
||||
overlay,
|
||||
badge
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button type="button" onclick={onClick} class="flex min-w-0 items-center gap-3.5 text-left">
|
||||
<Cover
|
||||
{trackId}
|
||||
src={coverSrc}
|
||||
{hue}
|
||||
size="small"
|
||||
alt={title}
|
||||
class="{coverSize} flex-shrink-0 rounded-control"
|
||||
>
|
||||
{@render overlay?.()}
|
||||
</Cover>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
{#if explicit}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="min-w-0 truncate text-fg {titleClass}">{title}</span>
|
||||
{@render badge?.()}
|
||||
</div>
|
||||
{#if artist}
|
||||
<div class="truncate text-left text-sm text-fg-3">{artist}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
Reference in New Issue
Block a user