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:
build:
context: ../frontend
context: ../web-player
dockerfile: Dockerfile
container_name: musify-frontend
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
@@ -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 ./
+1 -1
View File
@@ -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 |
View File
@@ -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

@@ -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>
@@ -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">
@@ -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
@@ -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>
@@ -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);
@@ -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
>
@@ -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}
@@ -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)}
@@ -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>
@@ -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>
@@ -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"
@@ -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}.`;
}
@@ -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;
@@ -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}
@@ -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" />
@@ -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}`);
}
@@ -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…"
@@ -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" />
@@ -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