Design Prot Merge #1
@@ -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:
|
||||||
|
|||||||
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
|
## 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 ./
|
||||||
@@ -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 |
|
||||||
@@ -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 |
+6
-6
@@ -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>
|
||||||
+1
-1
@@ -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">
|
||||||
+1
-1
@@ -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
|
||||||
+2
-2
@@ -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>
|
||||||
+9
-1
@@ -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);
|
||||||
+2
-2
@@ -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>
|
>
|
||||||
+3
-1
@@ -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}
|
||||||
+3
-1
@@ -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)}
|
||||||
+1
-1
@@ -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>
|
||||||
+1
-1
@@ -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>
|
||||||
+1
-1
@@ -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"
|
||||||
+10
-7
@@ -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}.`;
|
||||||
}
|
}
|
||||||
+1
-6
@@ -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;
|
||||||
+11
-2
@@ -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}
|
||||||
+7
-1
@@ -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" />
|
||||||
+7
-2
@@ -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}`);
|
||||||
}
|
}
|
||||||
+3
-1
@@ -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…"
|
||||||
+7
-1
@@ -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" />
|
||||||
+11
-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
Reference in New Issue
Block a user