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:
@@ -234,7 +234,7 @@ services:
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ../frontend
|
||||
context: ../web-player
|
||||
dockerfile: Dockerfile
|
||||
container_name: musify-frontend
|
||||
depends_on:
|
||||
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"svelte.svelte-vscode",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
Generated
-2349
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss"]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 build
|
||||
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
|
||||
|
||||
- **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.
|
||||
- **Server-only bajo `$lib/server/`**; nunca importado desde cliente.
|
||||
- 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
|
||||
WORKDIR /src
|
||||
COPY package.json package-lock.json ./
|
||||
@@ -15,7 +15,7 @@ To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# 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
|
||||
@@ -86,6 +86,7 @@ Descubrimiento y búsqueda de canciones del catálogo.
|
||||
## 4. Subir música `/upload` 🟡 maqueta (app)
|
||||
|
||||
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.
|
||||
2. `PUT` de cada archivo a su URL presignada (subida directa al almacenamiento).
|
||||
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)
|
||||
|
||||
| Recurso | Endpoints |
|
||||
| --- | --- |
|
||||
| --------- | ----------------------------------------------------------------------- |
|
||||
| Tracks | listar/buscar, detalle, por usuario, stream, crear, borrar, upload-urls |
|
||||
| Playlists | CRUD, tracks de la playlist, add/remove track, upload-picture |
|
||||
| Users | listar/buscar, detalle, crear |
|
||||
@@ -11,10 +11,17 @@ import { authConfig } from '$lib/server/config';
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
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);
|
||||
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 {
|
||||
event.cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
+6
-6
@@ -69,10 +69,7 @@
|
||||
? 'text-fg'
|
||||
: 'text-fg-3 hover:bg-hover hover:text-fg'}"
|
||||
>
|
||||
<link.icon
|
||||
class="h-6 w-6 shrink-0"
|
||||
style={active ? 'color:var(--mf-accent-60)' : ''}
|
||||
/>
|
||||
<link.icon class="h-6 w-6 shrink-0" style={active ? 'color:var(--mf-accent-60)' : ''} />
|
||||
{link.label}
|
||||
</a>
|
||||
{/snippet}
|
||||
@@ -90,7 +87,10 @@
|
||||
: ''}"
|
||||
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>
|
||||
|
||||
<nav class="relative z-10 flex flex-col gap-1">
|
||||
@@ -138,7 +138,7 @@
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
{/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>
|
||||
</div>
|
||||
</aside>
|
||||
+1
-1
@@ -62,7 +62,7 @@
|
||||
{#if explicit}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-fg" title={title}>{title}</span>
|
||||
<span class="truncate text-fg" {title}>{title}</span>
|
||||
</div>
|
||||
{#if badge || artist}
|
||||
<div class="mt-0.5 flex min-w-0 items-center gap-1.5">
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
</script>
|
||||
|
||||
<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)"
|
||||
>
|
||||
<div
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
</script>
|
||||
|
||||
<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)}
|
||||
</span>
|
||||
<button
|
||||
@@ -27,7 +27,7 @@
|
||||
style="left:{player.progressPercent}%"
|
||||
></span>
|
||||
</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)}
|
||||
</span>
|
||||
</div>
|
||||
+9
-1
@@ -12,7 +12,15 @@
|
||||
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 loaded = $state(false);
|
||||
+2
-2
@@ -9,5 +9,5 @@
|
||||
<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>
|
||||
aria-label="Contenido explícito">E</span
|
||||
>
|
||||
+3
-1
@@ -22,7 +22,9 @@
|
||||
}
|
||||
</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
|
||||
type="file"
|
||||
{name}
|
||||
+3
-1
@@ -22,7 +22,9 @@
|
||||
{#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)}` : ''}"
|
||||
src="/api/playlists/{playlistId}/cover?size={size}{version
|
||||
? `&v=${encodeURIComponent(version)}`
|
||||
: ''}"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onerror={() => (coverFailed = true)}
|
||||
+1
-1
@@ -24,5 +24,5 @@
|
||||
{placeholder}
|
||||
{maxlength}
|
||||
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>
|
||||
+1
-1
@@ -9,6 +9,6 @@
|
||||
let { class: klass = '', children }: Props = $props();
|
||||
</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()}
|
||||
</span>
|
||||
+1
-1
@@ -34,7 +34,7 @@
|
||||
|
||||
<button type="button" onclick={onClick} class="flex min-w-0 items-center gap-3.5 text-left">
|
||||
<Cover
|
||||
trackId={trackId}
|
||||
{trackId}
|
||||
src={coverSrc}
|
||||
{hue}
|
||||
size="small"
|
||||
+10
-7
@@ -76,7 +76,14 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
|
||||
|
||||
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 {
|
||||
return {
|
||||
@@ -96,9 +103,7 @@ class PlayerState {
|
||||
currentId = $state<string | number | null>(null);
|
||||
playing = $state(false);
|
||||
progress = $state(0);
|
||||
volume = $state(
|
||||
browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100
|
||||
);
|
||||
volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100);
|
||||
loading = $state(false);
|
||||
error = $state('');
|
||||
recentlyPlayed = $state<PlayerTrack[]>([]);
|
||||
@@ -214,9 +219,7 @@ class PlayerState {
|
||||
#syncDuration(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return;
|
||||
const id = this.currentId;
|
||||
this.tracks = this.tracks.map((t) =>
|
||||
t.id === id ? { ...t, duration: Math.round(value) } : t
|
||||
);
|
||||
this.tracks = this.tracks.map((t) => (t.id === id ? { ...t, duration: Math.round(value) } : t));
|
||||
}
|
||||
|
||||
#index() {
|
||||
@@ -18,7 +18,8 @@ export function createApiClient({ fetch, accessToken }: ApiClientOptions) {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,10 @@ function claimString(value: unknown): string | 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 ?? '';
|
||||
return {
|
||||
sub,
|
||||
@@ -3,7 +3,10 @@ const GREETINGS_BY_HOUR: { until: number; pool: string[] }[] = [
|
||||
until: 6,
|
||||
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: 19,
|
||||
@@ -14,7 +17,8 @@ const GREETINGS_BY_HOUR: { until: number; pool: 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)];
|
||||
return /[.!?]$/.test(greeting) ? greeting : `${greeting}.`;
|
||||
}
|
||||
+1
-6
@@ -28,12 +28,7 @@ export async function addTrackAction({ request, params, locals, fetch }: Request
|
||||
return { added: true, trackId, playlistId };
|
||||
}
|
||||
|
||||
export async function addYouTubeToPlaylistAction({
|
||||
request,
|
||||
params,
|
||||
locals,
|
||||
fetch
|
||||
}: RequestEvent) {
|
||||
export async function addYouTubeToPlaylistAction({ request, params, locals, fetch }: RequestEvent) {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
@@ -80,11 +80,7 @@ export async function putPresigned(
|
||||
export type UploadImageResult = { intentId: string } | { failMessage: string; detail?: string };
|
||||
|
||||
export async function uploadPresignedImage(
|
||||
reserve: (args: {
|
||||
fileType: string;
|
||||
contentType: string;
|
||||
expectedSizeBytes: number;
|
||||
}) => Promise<{
|
||||
reserve: (args: { fileType: string; contentType: string; expectedSizeBytes: number }) => Promise<{
|
||||
data?: { uploadUrl: string; contentType: string; intentId: string } | null;
|
||||
error?: unknown;
|
||||
}>,
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
|
||||
export type TrackTarget =
|
||||
| { kind: 'youtube'; song: YouTubeSong }
|
||||
| { kind: 'local'; track: ApiTrackLike };
|
||||
{ kind: 'youtube'; song: YouTubeSong } | { kind: 'local'; track: ApiTrackLike };
|
||||
|
||||
export function targetId(target: TrackTarget): string | number {
|
||||
return target.kind === 'youtube' ? target.song.videoId : target.track.id;
|
||||
+11
-2
@@ -183,7 +183,11 @@
|
||||
{/if}
|
||||
|
||||
{#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 items.length > 0}
|
||||
@@ -222,5 +226,10 @@
|
||||
</Page>
|
||||
|
||||
{#if contextMenu}
|
||||
<TrackContextMenu menu={contextMenu} playlists={data.playlists} onClose={closeContextMenu} onAddToQueue={addToQueue} />
|
||||
<TrackContextMenu
|
||||
menu={contextMenu}
|
||||
playlists={data.playlists}
|
||||
onClose={closeContextMenu}
|
||||
onAddToQueue={addToQueue}
|
||||
/>
|
||||
{/if}
|
||||
+7
-1
@@ -73,7 +73,13 @@
|
||||
<TrackMeta>{fmtDate(track.createdAt)}</TrackMeta>
|
||||
<TrackMeta>{Number(track.listensCount)}</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} />
|
||||
<IconButton type="submit" label="Borrar canción" size="sm" revealOnHover>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
+7
-2
@@ -38,7 +38,11 @@ export const actions: Actions = {
|
||||
const cover = form.get('cover');
|
||||
|
||||
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 });
|
||||
@@ -59,7 +63,8 @@ export const actions: Actions = {
|
||||
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}`);
|
||||
}
|
||||
+3
-1
@@ -67,7 +67,9 @@
|
||||
action="?/rename"
|
||||
initialName={playlist.name}
|
||||
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}
|
||||
submitLabel="Guardar"
|
||||
submittingLabel="Guardando…"
|
||||
+7
-1
@@ -68,7 +68,13 @@
|
||||
</TrackTitleCell>
|
||||
<TrackMeta>{fmtDate(track.createdAt)}</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} />
|
||||
<IconButton type="submit" label="Quitar de la playlist" size="sm" revealOnHover>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
+11
-5
@@ -22,9 +22,7 @@
|
||||
} from '$lib/components/TrackContextMenu.svelte';
|
||||
|
||||
type LibraryTrack = ApiTrackLike & { duration: number | string };
|
||||
type Suggestion =
|
||||
| { kind: 'local'; track: LibraryTrack }
|
||||
| { kind: 'youtube'; song: YouTubeSong };
|
||||
type Suggestion = { kind: 'local'; track: LibraryTrack } | { kind: 'youtube'; song: YouTubeSong };
|
||||
|
||||
interface Props {
|
||||
library: LibraryTrack[];
|
||||
@@ -117,7 +115,9 @@
|
||||
<form
|
||||
method="POST"
|
||||
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="title" value={item.song.title} />
|
||||
@@ -129,7 +129,13 @@
|
||||
</IconButton>
|
||||
</form>
|
||||
{: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} />
|
||||
<IconButton type="submit" label="Añadir a la playlist" size="sm">
|
||||
<Plus class="h-4 w-4" />
|
||||
@@ -168,7 +168,7 @@
|
||||
<meta name="description" content="Tu música, sin límites." />
|
||||
</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
|
||||
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"
|
||||
@@ -269,5 +269,10 @@
|
||||
</section>
|
||||
|
||||
{#if contextMenu}
|
||||
<TrackContextMenu menu={contextMenu} {playlists} onClose={closeContextMenu} onAddToQueue={addToQueue} />
|
||||
<TrackContextMenu
|
||||
menu={contextMenu}
|
||||
{playlists}
|
||||
onClose={closeContextMenu}
|
||||
onAddToQueue={addToQueue}
|
||||
/>
|
||||
{/if}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user