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
@@ -0,0 +1,71 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import {
createApiClient,
playlistCoverTrackIds,
requireAccessTokenAction,
requireUser,
unwrapOrError
} from '$lib/server/api';
import { uploadPresignedImage } from '$lib/server/upload';
const MAX_NAME = 100;
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const result = await api.GET('/playlists/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
});
const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.');
const trackIds = await playlistCoverTrackIds(
api,
data.items.map((p) => p.id)
);
return { playlists: data, trackIds };
};
export const actions: Actions = {
create: async ({ request, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').trim();
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
});
}
const api = createApiClient({ fetch, accessToken });
let pictureIntentId: string | null = null;
if (cover instanceof File && cover.size > 0) {
const result = await uploadPresignedImage(
(args) => api.POST('/playlists/upload-picture', { body: args }),
cover
);
if ('failMessage' in result) {
return fail(502, { message: result.failMessage, name, description, detail: result.detail });
}
pictureIntentId = result.intentId;
}
const { data, error: err } = await api.POST('/playlists', {
body: { name, description, pictureIntentId }
});
if (err || !data)
return fail(502, { message: 'No se pudo crear la playlist.', name, description });
redirect(303, `/playlists/${data.id}`);
}
};
@@ -0,0 +1,99 @@
<script lang="ts">
import Plus from '@lucide/svelte/icons/plus';
import ListMusic from '@lucide/svelte/icons/list-music';
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
import Button from '$lib/components/ui/Button.svelte';
const playlistNames = [
'Mi Playlist',
'Tus favoritos',
'Lo mejor de hoy',
'Para relajarse',
'Buena vibra',
'Modo estudio',
'Road Trip',
'Domingos',
'Late Night',
'Descubriendo música',
'Todo un poco',
'En bucle',
'Mi colección',
'Canciones para repetir',
'Hits del momento'
];
const namePlaceholder = playlistNames[Math.floor(Math.random() * playlistNames.length)];
let { data, form } = $props();
const items = $derived(data.playlists.items);
const trackIds = $derived(data.trackIds);
let creating = $state(false);
function openCreate() {
creating = true;
}
</script>
<svelte:head>
<title>Mis listas · Musify</title>
<meta name="description" content="Mis listas en Musify." />
</svelte:head>
<Page>
<PageHeader title="Mis listas" subtitle="Crea y organiza tus colecciones.">
{#snippet actions()}
{#if items.length > 0}
<Button onclick={openCreate}>
<Plus class="h-4 w-4" strokeWidth={2.5} />
Crear lista
</Button>
{/if}
{/snippet}
</PageHeader>
{#if items.length > 0}
<MediaGrid class="mt-9">
{#each items as playlist, i (playlist.id)}
<PlaylistCard
id={playlist.id}
name={playlist.name}
description={playlist.description}
trackIds={trackIds[playlist.id] ?? []}
updatedAt={playlist.updatedAt}
index={i}
/>
{/each}
</MediaGrid>
{:else}
<EmptyState
icon={ListMusic}
title="Todavía no tienes listas"
description="Crea la primera y añádele canciones de tu biblioteca."
>
{#snippet actions()}
<Button onclick={openCreate}>
<Plus class="h-4 w-4" strokeWidth={2} />
Crear lista
</Button>
{/snippet}
</EmptyState>
{/if}
</Page>
<Modal open={creating} onClose={() => (creating = false)} title="Nueva lista">
<PlaylistForm
action="?/create"
{namePlaceholder}
formMessage={form?.message}
submitLabel="Crear"
submittingLabel="Creando…"
onCancel={() => (creating = false)}
/>
</Modal>
@@ -0,0 +1,135 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import {
createApiClient,
requireAccessTokenAction,
requireUser,
unwrapOrError,
unwrapOrFail
} from '$lib/server/api';
import { uploadPresignedImage } from '$lib/server/upload';
import { fetchYoutubeFiller, shuffle } from '$lib/server/youtube';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import type { YouTubeSong } from '$lib/types';
const MAX_NAME = 100;
const SUGGESTIONS_LIMIT = 20;
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const [playlistRes, tracksRes, libraryRes, playlistsRes] = await Promise.all([
api.GET('/playlists/{id}', { params: { path: { id: params.id } } }),
api.GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } }
}),
api.GET('/tracks/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
}),
api.GET('/playlists/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
})
]);
const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404);
const tracks = tracksRes.data?.items ?? [];
const inPlaylist = new Set(tracks.map((t) => t.id));
const library = (libraryRes.data?.items ?? []).filter((t) => !inPlaylist.has(t.id));
const alreadyLinked = new Set(
tracks.filter((t) => t.source === 'YouTube' && t.externalId).map((t) => t.externalId)
);
const seeds = [...new Set(tracks.map((t) => t.artist).filter((a) => !!a))];
const seed = seeds.length > 0 ? shuffle(seeds)[0] : undefined;
const youtube = fetchYoutubeFiller(
api,
locals.accessToken,
SUGGESTIONS_LIMIT + alreadyLinked.size,
seed
)
.then((filler) => ({
query: filler.query,
items: (filler.items as YouTubeSong[])
.filter((song) => !alreadyLinked.has(song.videoId))
.slice(0, SUGGESTIONS_LIMIT)
}))
.catch(() => ({ query: '', items: [] as YouTubeSong[] }));
return {
playlist,
tracks,
library,
youtube,
playlists: playlistsRes.data?.items ?? []
};
};
export const actions: Actions = {
addTrack: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
removeTrack: async ({ request, params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', {
params: { path: { playlistId: params.id, trackId } }
});
const failure = unwrapOrFail(result, 'No se pudo quitar la canción.');
if (failure) return failure;
return { removed: true };
},
rename: async ({ request, params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').trim();
const cover = form.get('cover');
if (name === '' || name.length > MAX_NAME) {
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' });
}
const api = createApiClient({ fetch, accessToken });
let newPictureIntentId: string | null = null;
if (cover instanceof File && cover.size > 0) {
const result = await uploadPresignedImage(
(args) => api.POST('/playlists/upload-picture', { body: args }),
cover
);
if ('failMessage' in result) {
return fail(502, { message: result.failMessage, detail: result.detail });
}
newPictureIntentId = result.intentId;
}
const result = await api.PUT('/playlists/{playlistId}', {
params: { path: { playlistId: params.id } },
body: { newName: name, newDescription: description, newPictureIntentId }
});
const failure = unwrapOrFail(result, 'No se pudo actualizar la playlist.');
if (failure) return failure;
return { renamed: true };
},
delete: async ({ params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/playlists/{playlistId}', {
params: { path: { playlistId: params.id } }
});
const failure = unwrapOrFail(result, 'No se pudo eliminar la playlist.');
if (failure) return failure;
redirect(303, '/playlists');
}
};
@@ -0,0 +1,95 @@
<script lang="ts">
import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte';
import Page from '$lib/components/ui/Page.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
import Button from '$lib/components/ui/Button.svelte';
import PlaylistHeader from './components/PlaylistHeader.svelte';
import PlaylistTrackTable from './components/PlaylistTrackTable.svelte';
import SuggestionsTable from './components/SuggestionsTable.svelte';
import type { YouTubeSong } from '$lib/types';
let { data, form } = $props();
const playlist = $derived(data.playlist);
const tracks = $derived(data.tracks);
const library = $derived(data.library);
let editing = $state(false);
let confirmingDelete = $state(false);
let youtubeSuggestions = $state<YouTubeSong[]>([]);
$effect(() => {
youtubeSuggestions = [];
data.youtube.then((result) => {
youtubeSuggestions = result.items;
});
});
const isCurrentQueue = $derived(tracks.some((t) => queueIdForTrack(t) === player.current.id));
function playAll() {
if (tracks.length === 0) return;
if (isCurrentQueue) player.toggle();
else player.playQueue(toQueueItems(tracks), 0);
}
</script>
<svelte:head>
<title>{playlist.name} · Musify</title>
<meta name="description" content="Playlist {playlist.name} en Musify." />
</svelte:head>
<Page>
<PlaylistHeader
id={playlist.id}
name={playlist.name}
description={playlist.description}
trackIds={tracks.map((t) => t.id)}
updatedAt={playlist.updatedAt}
playing={isCurrentQueue && player.playing}
onPlayAll={playAll}
onEdit={() => (editing = true)}
onDelete={() => (confirmingDelete = true)}
/>
{#if tracks.length > 0}
<PlaylistTrackTable {tracks} />
{/if}
{#if library.length > 0 || youtubeSuggestions.length > 0}
<SuggestionsTable {library} youtube={youtubeSuggestions} playlists={data.playlists} />
{/if}
</Page>
<Modal open={editing} onClose={() => (editing = false)} title="Editar playlist">
<PlaylistForm
action="?/rename"
initialName={playlist.name}
initialDescription={playlist.description}
coverFallbackUrl="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
playlist.updatedAt
)}"
formMessage={form?.message}
submitLabel="Guardar"
submittingLabel="Guardando…"
onCancel={() => (editing = false)}
onSuccess={() => (editing = false)}
/>
</Modal>
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
<h2 class="text-lg font-bold tracking-tight text-fg">
¿Eliminar «{playlist.name}»?
</h2>
<p class="mt-2 text-base text-fg-3">
Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los
dispositivos donde la tengas guardada.
</p>
<div class="mt-6 flex items-center justify-end gap-3">
<Button variant="secondary" onclick={() => (confirmingDelete = false)}>Cancelar</Button>
<form method="POST" action="?/delete">
<Button type="submit" variant="danger">Eliminar</Button>
</form>
</div>
</Modal>
@@ -0,0 +1,77 @@
<script lang="ts">
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { hueFor } from '$lib/theme/color';
interface Props {
id: string;
name: string;
description?: string;
trackIds: (string | number)[];
updatedAt: string;
playing: boolean;
onPlayAll: () => void;
onEdit: () => void;
onDelete: () => void;
}
let { id, name, description, trackIds, updatedAt, playing, onPlayAll, onEdit, onDelete }: Props =
$props();
</script>
<a
href="/playlists"
class="group inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
>
<span class="grid h-8 w-8 place-items-center transition-colors group-hover:text-accent-soft">
<ArrowLeft class="h-4 w-4" />
</span>
Volver a tus listas
</a>
<div class="mt-6 flex flex-col gap-6 sm:flex-row sm:items-end">
<PlaylistArt
playlistId={id}
{trackIds}
hue={hueFor(id)}
size="large"
version={updatedAt}
class="h-44 w-44 flex-shrink-0 rounded-art-lg shadow-art-lg"
/>
<div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Lista</p>
<h1 class="mt-1.5 text-5xl font-bold tracking-tight text-fg sm:text-7xl">{name}</h1>
{#if description}
<p class="mt-3 max-w-2xl text-fg-2">{description}</p>
{/if}
<p class="mt-3 text-sm text-muted">
{trackIds.length}
{trackIds.length === 1 ? 'canción' : 'canciones'}
</p>
</div>
</div>
<div class="mt-7 flex items-center gap-2.5">
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
{#if playing}
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
<Button size="sm" variant="secondary" onclick={onEdit}>
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
Editar
</Button>
<Button size="sm" variant="secondary" onclick={onDelete}>
<Trash class="h-4 w-4" strokeWidth={1.5} />
Eliminar
</Button>
</div>
@@ -0,0 +1,85 @@
<script lang="ts">
import { enhance } from '$app/forms';
import X from '@lucide/svelte/icons/x';
import {
player,
isPendingYouTubeTrack,
queueIdForTrack,
toQueueItems,
youTubeThumbnailUrl,
type ApiTrackLike
} from '$lib/player/player.svelte';
import { hueFor, fmtTime, fmtDate } from '$lib/theme/color';
import Badge from '$lib/components/ui/Badge.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
type PlaylistTrack = ApiTrackLike & { createdAt: string; duration: number | string };
interface Props {
tracks: PlaylistTrack[];
}
let { tracks }: Props = $props();
function playFrom(index: number) {
player.playOrToggle(toQueueItems(tracks), index);
}
</script>
<TrackTable columns="32px 1fr 140px 64px 36px" class="mt-8">
{#snippet headers()}
<span class="text-center">#</span>
<span>Título</span>
<span class="text-center">Añadida</span>
<span class="text-center">Duración</span>
<span></span>
{/snippet}
{#each tracks as track, i (track.id)}
<TrackRow>
<TrackIndexCell
index={i}
active={player.current.id === queueIdForTrack(track)}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
<TrackTitleCell
trackId={track.id}
title={track.title}
artist={track.artist}
coverSrc={isPendingYouTubeTrack(track) && track.externalId
? youTubeThumbnailUrl(track.externalId)
: undefined}
hue={hueFor(track.id)}
explicit={track.isExplicit}
onClick={() => playFrom(i)}
>
{#snippet badge()}
{#if track.source === 'YouTube' && track.audioStatus === 'Failed'}
<Badge tone="danger">Error</Badge>
{:else if isPendingYouTubeTrack(track)}
<Badge tone="accent">Descargando</Badge>
{/if}
{/snippet}
</TrackTitleCell>
<TrackMeta>{fmtDate(track.createdAt)}</TrackMeta>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
<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" />
</IconButton>
</form>
</TrackRow>
{/each}
</TrackTable>
@@ -0,0 +1,157 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Plus from '@lucide/svelte/icons/plus';
import {
player,
queueIdForTrack,
type ApiTrackLike,
type QueueItem
} from '$lib/player/player.svelte';
import { hueFor, fmtTime } from '$lib/theme/color';
import type { YouTubeSong } from '$lib/types';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import TrackContextMenu, {
contextMenuStateFor,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
type LibraryTrack = ApiTrackLike & { duration: number | string };
type Suggestion = { kind: 'local'; track: LibraryTrack } | { kind: 'youtube'; song: YouTubeSong };
interface Props {
library: LibraryTrack[];
youtube: YouTubeSong[];
playlists: { id: string; name: string }[];
}
let { library, youtube, playlists }: Props = $props();
let contextMenu = $state<ContextMenuState | null>(null);
function openContextMenu(event: MouseEvent, item: Suggestion) {
contextMenu = contextMenuStateFor(event, item);
}
function closeContextMenu() {
contextMenu = null;
}
function addToQueue() {
if (!contextMenu) return;
player.addToQueue(queueItemFor(contextMenu as Suggestion));
closeContextMenu();
}
const suggestions = $derived<Suggestion[]>([
...library.map((track) => ({ kind: 'local' as const, track })),
...youtube.map((song) => ({ kind: 'youtube' as const, song }))
]);
function idFor(item: Suggestion) {
return item.kind === 'youtube' ? item.song.videoId : queueIdForTrack(item.track);
}
function queueItemFor(item: Suggestion): QueueItem {
return item.kind === 'youtube'
? {
id: item.song.videoId,
title: item.song.title,
artist: item.song.artist,
source: 'youtube',
coverUrl: item.song.thumbnailUrl,
explicit: item.song.isExplicit
}
: {
id: queueIdForTrack(item.track),
title: item.track.title,
artist: item.track.artist,
source: 'local',
coverUrl: `/api/tracks/${item.track.id}/cover?size=small`
};
}
function playFrom(index: number) {
player.playOrToggle(suggestions.map(queueItemFor), index);
}
</script>
<div class="mt-12">
<SectionHeading title="Sugerencias" />
<TrackTable columns="1fr 64px 36px">
{#snippet headers()}
<span>Título</span>
<span class="text-center">Duración</span>
<span></span>
{/snippet}
{#each suggestions as item, i (idFor(item))}
<TrackRow oncontextmenu={(e) => openContextMenu(e, item)}>
<TrackTitleCell
trackId={item.kind === 'youtube' ? item.song.videoId : item.track.id}
title={item.kind === 'youtube' ? item.song.title : item.track.title}
artist={item.kind === 'youtube' ? item.song.artist : item.track.artist}
coverSrc={item.kind === 'youtube' ? item.song.thumbnailUrl : undefined}
hue={hueFor(idFor(item))}
explicit={item.kind === 'youtube' && item.song.isExplicit}
onClick={() => playFrom(i)}
>
{#snippet overlay()}
{#if player.current.id === idFor(item)}
<NowPlaying paused={!player.playing} />
{/if}
{/snippet}
</TrackTitleCell>
<TrackMeta>
{fmtTime(
item.kind === 'youtube' ? item.song.durationSeconds : Number(item.track.duration)
)}
</TrackMeta>
{#if item.kind === 'youtube'}
<form
method="POST"
action="?/addYouTubeToPlaylist"
use:enhance={() =>
async ({ update }) =>
update()}
>
<input type="hidden" name="videoId" value={item.song.videoId} />
<input type="hidden" name="title" value={item.song.title} />
<input type="hidden" name="artist" value={item.song.artist} />
<input type="hidden" name="durationSeconds" value={item.song.durationSeconds} />
<input type="hidden" name="thumbnailUrl" value={item.song.thumbnailUrl} />
<IconButton type="submit" label="Añadir a la playlist" size="sm">
<Plus class="h-4 w-4" />
</IconButton>
</form>
{:else}
<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" />
</IconButton>
</form>
{/if}
</TrackRow>
{/each}
</TrackTable>
</div>
{#if contextMenu}
<TrackContextMenu
menu={contextMenu}
{playlists}
onClose={closeContextMenu}
onAddToQueue={addToQueue}
/>
{/if}