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:
2026-07-20 07:35:01 +02:00
parent 05514a1c6e
commit 97daa13ebd
104 changed files with 134 additions and 2457 deletions
+1 -1
View File
@@ -234,7 +234,7 @@ services:
frontend: frontend:
build: build:
context: ../frontend context: ../web-player
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: musify-frontend container_name: musify-frontend
depends_on: depends_on:
-6
View File
@@ -1,6 +0,0 @@
{
"recommendations": [
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss"
]
}
-2349
View File
File diff suppressed because it is too large Load Diff
-34
View File
@@ -1,34 +0,0 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"gen:api": "npx -y openapi-typescript@7 http://localhost:5111/openapi/v1.json -o src/lib/api/schema.d.ts"
},
"devDependencies": {
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^22.20.1",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"vite": "^8.0.16"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@lucide/svelte": "^1.23.0",
"@sveltejs/adapter-node": "^5.5.7",
"jose": "^6.2.3",
"openapi-fetch": "^0.17.0",
"openid-client": "^6.8.4"
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss"]
}
+5 -2
View File
@@ -1,6 +1,6 @@
# Musify — Frontend web # Musify — web-player
Web pública de Musify. Para diseño y recetas de UI usa el skill **`musify-web`**. Web pública de Musify (carpeta `web-player/`). Para diseño y recetas de UI usa el skill **`musify-web`**.
## Stack ## Stack
@@ -14,11 +14,14 @@ No hay `svelte.config.js` (config inline en `vite.config.ts`) ni `tailwind.confi
npm run dev # servidor de desarrollo (puerto 5173) npm run dev # servidor de desarrollo (puerto 5173)
npm run build npm run build
npm run check # svelte-check — debe salir 0 errores antes de cerrar un cambio npm run check # svelte-check — debe salir 0 errores antes de cerrar un cambio
npm run format # prettier --write .
npm run lint # prettier --check . && eslint .
``` ```
## Reglas ## Reglas
- **Sin comentarios** en el código salvo que sean imprescindibles. - **Sin comentarios** en el código salvo que sean imprescindibles.
- `npm run lint` y `npm run check` deben salir limpios antes de cerrar un cambio.
- **Svelte 5 runes siempre:** `$props()` con `interface Props`, `$state`, `$derived`, `$effect`. Nada de `export let` ni `$:`. `{#each}` siempre con clave. - **Svelte 5 runes siempre:** `$props()` con `interface Props`, `$state`, `$derived`, `$effect`. Nada de `export let` ni `$:`. `{#each}` siempre con clave.
- **Server-only bajo `$lib/server/`**; nunca importado desde cliente. - **Server-only bajo `$lib/server/`**; nunca importado desde cliente.
- Datos SSR vía `load` en `+page.server.ts`/`+layout.server.ts` → prop `data`. - Datos SSR vía `load` en `+page.server.ts`/`+layout.server.ts` → prop `data`.
@@ -1,4 +1,4 @@
# Build context: frontend/ # Build context: web-player/
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /src WORKDIR /src
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
+1 -1
View File
@@ -15,7 +15,7 @@ To recreate this project with the same configuration:
```sh ```sh
# recreate this project # recreate this project
npx sv@0.16.2 create --template minimal --types ts --add tailwindcss="plugins:none" --no-download-check --install npm frontend npx sv@0.16.2 create --template minimal --types ts --add tailwindcss="plugins:none" --no-download-check --install npm web-player
``` ```
## Developing ## Developing
@@ -86,6 +86,7 @@ Descubrimiento y búsqueda de canciones del catálogo.
## 4. Subir música `/upload` 🟡 maqueta (app) ## 4. Subir música `/upload` 🟡 maqueta (app)
Alta de una canción nueva. Flujo real de backend (para cuando se cablee): Alta de una canción nueva. Flujo real de backend (para cuando se cablee):
1. `POST /tracks/upload-urls` → URLs presignadas para portada y audio + `intentId`s. 1. `POST /tracks/upload-urls` → URLs presignadas para portada y audio + `intentId`s.
2. `PUT` de cada archivo a su URL presignada (subida directa al almacenamiento). 2. `PUT` de cada archivo a su URL presignada (subida directa al almacenamiento).
3. `POST /tracks` con `title`, `pictureIntentId`, `audioIntentId`. 3. `POST /tracks` con `title`, `pictureIntentId`, `audioIntentId`.
@@ -149,7 +150,7 @@ desde cualquier grid (`player.playQueue`). Reproduce DASH vía el proxy
## Backend disponible (resumen) ## Backend disponible (resumen)
| Recurso | Endpoints | | Recurso | Endpoints |
| --- | --- | | --------- | ----------------------------------------------------------------------- |
| Tracks | listar/buscar, detalle, por usuario, stream, crear, borrar, upload-urls | | Tracks | listar/buscar, detalle, por usuario, stream, crear, borrar, upload-urls |
| Playlists | CRUD, tracks de la playlist, add/remove track, upload-picture | | Playlists | CRUD, tracks de la playlist, add/remove track, upload-picture |
| Users | listar/buscar, detalle, crear | | Users | listar/buscar, detalle, crear |
View File
@@ -11,10 +11,17 @@ import { authConfig } from '$lib/server/config';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
let session = await decodeSession(event.cookies.get(SESSION_COOKIE)); let session = await decodeSession(event.cookies.get(SESSION_COOKIE));
if (session && session.expiresAt - authConfig.refreshThresholdSeconds <= Math.floor(Date.now() / 1000)) { if (
session &&
session.expiresAt - authConfig.refreshThresholdSeconds <= Math.floor(Date.now() / 1000)
) {
session = await refreshSession(session); session = await refreshSession(session);
if (session) { if (session) {
event.cookies.set(SESSION_COOKIE, await encodeSession(session), sessionCookieOptions(event.url)); event.cookies.set(
SESSION_COOKIE,
await encodeSession(session),
sessionCookieOptions(event.url)
);
} else { } else {
event.cookies.delete(SESSION_COOKIE, { path: '/' }); event.cookies.delete(SESSION_COOKIE, { path: '/' });
} }

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -69,10 +69,7 @@
? 'text-fg' ? 'text-fg'
: 'text-fg-3 hover:bg-hover hover:text-fg'}" : 'text-fg-3 hover:bg-hover hover:text-fg'}"
> >
<link.icon <link.icon class="h-6 w-6 shrink-0" style={active ? 'color:var(--mf-accent-60)' : ''} />
class="h-6 w-6 shrink-0"
style={active ? 'color:var(--mf-accent-60)' : ''}
/>
{link.label} {link.label}
</a> </a>
{/snippet} {/snippet}
@@ -90,7 +87,10 @@
: ''}" : ''}"
style="top:{pillTop}px; height:{pillHeight}px; opacity:{pillVisible ? 1 : 0}" style="top:{pillTop}px; height:{pillHeight}px; opacity:{pillVisible ? 1 : 0}"
> >
<span class="absolute inset-y-0 left-0 w-[3px] rounded-r-full" style="background:var(--mf-accent-60)"></span> <span
class="absolute inset-y-0 left-0 w-[3px] rounded-r-full"
style="background:var(--mf-accent-60)"
></span>
</div> </div>
<nav class="relative z-10 flex flex-col gap-1"> <nav class="relative z-10 flex flex-col gap-1">
@@ -138,7 +138,7 @@
{user.name.charAt(0)} {user.name.charAt(0)}
</span> </span>
{/if} {/if}
<span class="truncate text-base font-semibold leading-[1.35]">{user.name}</span> <span class="truncate text-base leading-[1.35] font-semibold">{user.name}</span>
</button> </button>
</div> </div>
</aside> </aside>
@@ -62,7 +62,7 @@
{#if explicit} {#if explicit}
<ExplicitBadge /> <ExplicitBadge />
{/if} {/if}
<span class="truncate text-fg" title={title}>{title}</span> <span class="truncate text-fg" {title}>{title}</span>
</div> </div>
{#if badge || artist} {#if badge || artist}
<div class="mt-0.5 flex min-w-0 items-center gap-1.5"> <div class="mt-0.5 flex min-w-0 items-center gap-1.5">
@@ -6,7 +6,7 @@
</script> </script>
<div <div
class="animate-enter fixed bottom-0 right-0 z-50 h-[92px] border-t border-line bg-bg" class="animate-enter fixed right-0 bottom-0 z-50 h-[92px] border-t border-line bg-bg"
style="left:var(--mf-sidebar-w)" style="left:var(--mf-sidebar-w)"
> >
<div <div
@@ -9,7 +9,7 @@
</script> </script>
<div class="flex w-full max-w-172 items-center gap-3"> <div class="flex w-full max-w-172 items-center gap-3">
<span class="text-right text-xs tabular-nums text-muted"> <span class="text-right text-xs text-muted tabular-nums">
{fmtTime(player.progress)} {fmtTime(player.progress)}
</span> </span>
<button <button
@@ -27,7 +27,7 @@
style="left:{player.progressPercent}%" style="left:{player.progressPercent}%"
></span> ></span>
</button> </button>
<span class="text-xs tabular-nums text-(--mf-text-4)"> <span class="text-xs text-(--mf-text-4) tabular-nums">
{fmtTime(player.current.duration)} {fmtTime(player.current.duration)}
</span> </span>
</div> </div>
@@ -12,7 +12,15 @@
children?: Snippet; children?: Snippet;
} }
let { trackId, hue, size = 'medium', src, alt = '', class: klass = '', children }: Props = $props(); let {
trackId,
hue,
size = 'medium',
src,
alt = '',
class: klass = '',
children
}: Props = $props();
let failed = $state(false); let failed = $state(false);
let loaded = $state(false); let loaded = $state(false);
@@ -9,5 +9,5 @@
<span <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}" 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" title="Contenido explícito"
aria-label="Contenido explícito" aria-label="Contenido explícito">E</span
>E</span> >
@@ -22,7 +22,9 @@
} }
</script> </script>
<label class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {klass}"> <label
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {klass}"
>
<input <input
type="file" type="file"
{name} {name}
@@ -22,7 +22,9 @@
{#if playlistId && !coverFailed} {#if playlistId && !coverFailed}
<div class="relative overflow-hidden {klass}" style="background:{gradientForHue(hue)}"> <div class="relative overflow-hidden {klass}" style="background:{gradientForHue(hue)}">
<img <img
src="/api/playlists/{playlistId}/cover?size={size}{version ? `&v=${encodeURIComponent(version)}` : ''}" src="/api/playlists/{playlistId}/cover?size={size}{version
? `&v=${encodeURIComponent(version)}`
: ''}"
alt="" alt=""
loading="lazy" loading="lazy"
onerror={() => (coverFailed = true)} onerror={() => (coverFailed = true)}
@@ -24,5 +24,5 @@
{placeholder} {placeholder}
{maxlength} {maxlength}
bind:value bind:value
class="w-full min-h-0 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}" 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> ></textarea>
@@ -9,6 +9,6 @@
let { class: klass = '', children }: Props = $props(); let { class: klass = '', children }: Props = $props();
</script> </script>
<span class="truncate text-center text-sm tabular-nums text-muted {klass}"> <span class="truncate text-center text-sm text-muted tabular-nums {klass}">
{@render children()} {@render children()}
</span> </span>
@@ -34,7 +34,7 @@
<button type="button" onclick={onClick} class="flex min-w-0 items-center gap-3.5 text-left"> <button type="button" onclick={onClick} class="flex min-w-0 items-center gap-3.5 text-left">
<Cover <Cover
trackId={trackId} {trackId}
src={coverSrc} src={coverSrc}
{hue} {hue}
size="small" size="small"
@@ -76,7 +76,14 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
const RECENT_LIMIT = 15; const RECENT_LIMIT = 15;
const EMPTY: PlayerTrack = { id: '', title: '', artist: '', duration: 0, hue: HUES[0], source: 'local' }; const EMPTY: PlayerTrack = {
id: '',
title: '',
artist: '',
duration: 0,
hue: HUES[0],
source: 'local'
};
function toTrack(item: QueueItem): PlayerTrack { function toTrack(item: QueueItem): PlayerTrack {
return { return {
@@ -96,9 +103,7 @@ class PlayerState {
currentId = $state<string | number | null>(null); currentId = $state<string | number | null>(null);
playing = $state(false); playing = $state(false);
progress = $state(0); progress = $state(0);
volume = $state( volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100);
browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100
);
loading = $state(false); loading = $state(false);
error = $state(''); error = $state('');
recentlyPlayed = $state<PlayerTrack[]>([]); recentlyPlayed = $state<PlayerTrack[]>([]);
@@ -214,9 +219,7 @@ class PlayerState {
#syncDuration(value: number) { #syncDuration(value: number) {
if (!Number.isFinite(value) || value <= 0) return; if (!Number.isFinite(value) || value <= 0) return;
const id = this.currentId; const id = this.currentId;
this.tracks = this.tracks.map((t) => this.tracks = this.tracks.map((t) => (t.id === id ? { ...t, duration: Math.round(value) } : t));
t.id === id ? { ...t, duration: Math.round(value) } : t
);
} }
#index() { #index() {
@@ -18,7 +18,8 @@ export function createApiClient({ fetch, accessToken }: ApiClientOptions) {
} }
export function requireUser(locals: App.Locals, url: URL): SessionUser { export function requireUser(locals: App.Locals, url: URL): SessionUser {
if (!locals.user) redirect(302, `/login?returnTo=${encodeURIComponent(url.pathname + url.search)}`); if (!locals.user)
redirect(302, `/login?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
return locals.user; return locals.user;
} }
@@ -73,7 +73,10 @@ function claimString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined; return typeof value === 'string' && value.length > 0 ? value : undefined;
} }
export function toSessionUser(claims: Record<string, unknown>, fallback?: SessionUser): SessionUser { export function toSessionUser(
claims: Record<string, unknown>,
fallback?: SessionUser
): SessionUser {
const sub = claimString(claims.sub) ?? fallback?.sub ?? ''; const sub = claimString(claims.sub) ?? fallback?.sub ?? '';
return { return {
sub, sub,
@@ -3,7 +3,10 @@ const GREETINGS_BY_HOUR: { until: number; pool: string[] }[] = [
until: 6, until: 6,
pool: ['¿Aún despierto?', 'La noche también tiene banda sonora', 'Un ratito más, con música'] pool: ['¿Aún despierto?', 'La noche también tiene banda sonora', 'Un ratito más, con música']
}, },
{ until: 12, pool: ['Buenos días', 'Que empiece bien la mañana', 'Un nuevo día, una nueva lista'] }, {
until: 12,
pool: ['Buenos días', 'Que empiece bien la mañana', 'Un nuevo día, una nueva lista']
},
{ until: 14, pool: ['Buenos días', 'El mediodía sabe mejor con música'] }, { until: 14, pool: ['Buenos días', 'El mediodía sabe mejor con música'] },
{ {
until: 19, until: 19,
@@ -14,7 +17,8 @@ const GREETINGS_BY_HOUR: { until: number; pool: string[] }[] = [
]; ];
export function pickGreeting(hour: number): string { export function pickGreeting(hour: number): string {
const pool = GREETINGS_BY_HOUR.find((slot) => hour < slot.until)?.pool ?? GREETINGS_BY_HOUR[0].pool; const pool =
GREETINGS_BY_HOUR.find((slot) => hour < slot.until)?.pool ?? GREETINGS_BY_HOUR[0].pool;
const greeting = pool[Math.floor(Math.random() * pool.length)]; const greeting = pool[Math.floor(Math.random() * pool.length)];
return /[.!?]$/.test(greeting) ? greeting : `${greeting}.`; return /[.!?]$/.test(greeting) ? greeting : `${greeting}.`;
} }
@@ -28,12 +28,7 @@ export async function addTrackAction({ request, params, locals, fetch }: Request
return { added: true, trackId, playlistId }; return { added: true, trackId, playlistId };
} }
export async function addYouTubeToPlaylistAction({ export async function addYouTubeToPlaylistAction({ request, params, locals, fetch }: RequestEvent) {
request,
params,
locals,
fetch
}: RequestEvent) {
const accessToken = requireAccessTokenAction(locals); const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken; if (typeof accessToken !== 'string') return accessToken;
@@ -80,11 +80,7 @@ export async function putPresigned(
export type UploadImageResult = { intentId: string } | { failMessage: string; detail?: string }; export type UploadImageResult = { intentId: string } | { failMessage: string; detail?: string };
export async function uploadPresignedImage( export async function uploadPresignedImage(
reserve: (args: { reserve: (args: { fileType: string; contentType: string; expectedSizeBytes: number }) => Promise<{
fileType: string;
contentType: string;
expectedSizeBytes: number;
}) => Promise<{
data?: { uploadUrl: string; contentType: string; intentId: string } | null; data?: { uploadUrl: string; contentType: string; intentId: string } | null;
error?: unknown; error?: unknown;
}>, }>,
@@ -8,8 +8,7 @@ import {
import type { YouTubeSong } from '$lib/types'; import type { YouTubeSong } from '$lib/types';
export type TrackTarget = export type TrackTarget =
| { kind: 'youtube'; song: YouTubeSong } { kind: 'youtube'; song: YouTubeSong } | { kind: 'local'; track: ApiTrackLike };
| { kind: 'local'; track: ApiTrackLike };
export function targetId(target: TrackTarget): string | number { export function targetId(target: TrackTarget): string | number {
return target.kind === 'youtube' ? target.song.videoId : target.track.id; return target.kind === 'youtube' ? target.song.videoId : target.track.id;
@@ -183,7 +183,11 @@
{/if} {/if}
{#if nothingFound} {#if nothingFound}
<EmptyState icon={Music} title="Sin resultados" description="No hay resultados para «{data.query}»." /> <EmptyState
icon={Music}
title="Sin resultados"
description="No hay resultados para «{data.query}»."
/>
{/if} {/if}
{#if items.length > 0} {#if items.length > 0}
@@ -222,5 +226,10 @@
</Page> </Page>
{#if contextMenu} {#if contextMenu}
<TrackContextMenu menu={contextMenu} playlists={data.playlists} onClose={closeContextMenu} onAddToQueue={addToQueue} /> <TrackContextMenu
menu={contextMenu}
playlists={data.playlists}
onClose={closeContextMenu}
onAddToQueue={addToQueue}
/>
{/if} {/if}
@@ -73,7 +73,13 @@
<TrackMeta>{fmtDate(track.createdAt)}</TrackMeta> <TrackMeta>{fmtDate(track.createdAt)}</TrackMeta>
<TrackMeta>{Number(track.listensCount)}</TrackMeta> <TrackMeta>{Number(track.listensCount)}</TrackMeta>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta> <TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
<form method="POST" action="?/deleteTrack" use:enhance={() => async ({ update }) => update()}> <form
method="POST"
action="?/deleteTrack"
use:enhance={() =>
async ({ update }) =>
update()}
>
<input type="hidden" name="trackId" value={track.id} /> <input type="hidden" name="trackId" value={track.id} />
<IconButton type="submit" label="Borrar canción" size="sm" revealOnHover> <IconButton type="submit" label="Borrar canción" size="sm" revealOnHover>
<X class="h-3.5 w-3.5" /> <X class="h-3.5 w-3.5" />
@@ -38,7 +38,11 @@ export const actions: Actions = {
const cover = form.get('cover'); const cover = form.get('cover');
if (name === '' || name.length > MAX_NAME) { if (name === '' || name.length > MAX_NAME) {
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).', name, description }); return fail(400, {
message: 'El nombre es obligatorio (máx. 100 caracteres).',
name,
description
});
} }
const api = createApiClient({ fetch, accessToken }); const api = createApiClient({ fetch, accessToken });
@@ -59,7 +63,8 @@ export const actions: Actions = {
body: { name, description, pictureIntentId } body: { name, description, pictureIntentId }
}); });
if (err || !data) return fail(502, { message: 'No se pudo crear la playlist.', name, description }); if (err || !data)
return fail(502, { message: 'No se pudo crear la playlist.', name, description });
redirect(303, `/playlists/${data.id}`); redirect(303, `/playlists/${data.id}`);
} }
@@ -67,7 +67,9 @@
action="?/rename" action="?/rename"
initialName={playlist.name} initialName={playlist.name}
initialDescription={playlist.description} initialDescription={playlist.description}
coverFallbackUrl="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(playlist.updatedAt)}" coverFallbackUrl="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
playlist.updatedAt
)}"
formMessage={form?.message} formMessage={form?.message}
submitLabel="Guardar" submitLabel="Guardar"
submittingLabel="Guardando…" submittingLabel="Guardando…"
@@ -68,7 +68,13 @@
</TrackTitleCell> </TrackTitleCell>
<TrackMeta>{fmtDate(track.createdAt)}</TrackMeta> <TrackMeta>{fmtDate(track.createdAt)}</TrackMeta>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta> <TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
<form method="POST" action="?/removeTrack" use:enhance={() => async ({ update }) => update()}> <form
method="POST"
action="?/removeTrack"
use:enhance={() =>
async ({ update }) =>
update()}
>
<input type="hidden" name="trackId" value={track.id} /> <input type="hidden" name="trackId" value={track.id} />
<IconButton type="submit" label="Quitar de la playlist" size="sm" revealOnHover> <IconButton type="submit" label="Quitar de la playlist" size="sm" revealOnHover>
<X class="h-3.5 w-3.5" /> <X class="h-3.5 w-3.5" />
@@ -22,9 +22,7 @@
} from '$lib/components/TrackContextMenu.svelte'; } from '$lib/components/TrackContextMenu.svelte';
type LibraryTrack = ApiTrackLike & { duration: number | string }; type LibraryTrack = ApiTrackLike & { duration: number | string };
type Suggestion = type Suggestion = { kind: 'local'; track: LibraryTrack } | { kind: 'youtube'; song: YouTubeSong };
| { kind: 'local'; track: LibraryTrack }
| { kind: 'youtube'; song: YouTubeSong };
interface Props { interface Props {
library: LibraryTrack[]; library: LibraryTrack[];
@@ -117,7 +115,9 @@
<form <form
method="POST" method="POST"
action="?/addYouTubeToPlaylist" action="?/addYouTubeToPlaylist"
use:enhance={() => async ({ update }) => update()} use:enhance={() =>
async ({ update }) =>
update()}
> >
<input type="hidden" name="videoId" value={item.song.videoId} /> <input type="hidden" name="videoId" value={item.song.videoId} />
<input type="hidden" name="title" value={item.song.title} /> <input type="hidden" name="title" value={item.song.title} />
@@ -129,7 +129,13 @@
</IconButton> </IconButton>
</form> </form>
{:else} {:else}
<form method="POST" action="?/addTrack" use:enhance={() => async ({ update }) => update()}> <form
method="POST"
action="?/addTrack"
use:enhance={() =>
async ({ update }) =>
update()}
>
<input type="hidden" name="trackId" value={item.track.id} /> <input type="hidden" name="trackId" value={item.track.id} />
<IconButton type="submit" label="Añadir a la playlist" size="sm"> <IconButton type="submit" label="Añadir a la playlist" size="sm">
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
@@ -168,7 +168,7 @@
<meta name="description" content="Tu música, sin límites." /> <meta name="description" content="Tu música, sin límites." />
</svelte:head> </svelte:head>
<section class="page-x relative overflow-hidden pt-11 pb-16"> <section class="relative overflow-hidden page-x pt-11 pb-16">
<div <div
class="pointer-events-none absolute inset-0 blur-[70px] saturate-150" class="pointer-events-none absolute inset-0 blur-[70px] saturate-150"
style="background:linear-gradient(135deg, var(--mf-accent), color-mix(in oklch, var(--mf-accent), black 55%));animation:breathe 9s ease-in-out infinite" style="background:linear-gradient(135deg, var(--mf-accent), color-mix(in oklch, var(--mf-accent), black 55%));animation:breathe 9s ease-in-out infinite"
@@ -269,5 +269,10 @@
</section> </section>
{#if contextMenu} {#if contextMenu}
<TrackContextMenu menu={contextMenu} {playlists} onClose={closeContextMenu} onAddToQueue={addToQueue} /> <TrackContextMenu
menu={contextMenu}
{playlists}
onClose={closeContextMenu}
onAddToQueue={addToQueue}
/>
{/if} {/if}

Some files were not shown because too many files have changed in this diff Show More