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:
@@ -0,0 +1,52 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
import { EXPLORE_PAGE_SIZE as PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
const query = url.searchParams.get('q')?.trim() ?? '';
|
||||
const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
|
||||
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
const tracksPromise = api.GET('/tracks', {
|
||||
params: { query: { name: query || undefined, pageNumber: page, pageSize: PAGE_SIZE } }
|
||||
});
|
||||
|
||||
const searchPromise =
|
||||
query && locals.accessToken
|
||||
? api.GET('/youtube/search', { params: { query: { query } } })
|
||||
: Promise.resolve(null);
|
||||
|
||||
const playlistsPromise = locals.user
|
||||
? api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
|
||||
const [tracksRes, searchRes, playlistsRes] = await Promise.all([
|
||||
tracksPromise,
|
||||
searchPromise,
|
||||
playlistsPromise
|
||||
]);
|
||||
|
||||
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
|
||||
|
||||
return {
|
||||
query,
|
||||
tracks,
|
||||
ytResults: searchRes?.data ?? null,
|
||||
ytError: Boolean(searchRes?.error),
|
||||
youtubeFiller: query
|
||||
? Promise.resolve(null)
|
||||
: fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT),
|
||||
playlists: playlistsRes?.data?.items ?? [],
|
||||
needsAuth: !locals.user
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addTrack: addTrackAction,
|
||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
<script lang="ts">
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
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 Alert from '$lib/components/ui/Alert.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
|
||||
import { EXPLORE_PAGE_SIZE } from '$lib/config';
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
import TrackTile from '$lib/components/TrackTile.svelte';
|
||||
import TrackContextMenu, {
|
||||
contextMenuStateFor,
|
||||
type ContextMenuState
|
||||
} from '$lib/components/TrackContextMenu.svelte';
|
||||
import { appendUnique } from '$lib/collections';
|
||||
import {
|
||||
isTargetCurrent,
|
||||
queueItemForTarget,
|
||||
targetArtist,
|
||||
targetCoverSrc,
|
||||
targetExplicit,
|
||||
targetId,
|
||||
targetTitle,
|
||||
type TrackTarget
|
||||
} from '$lib/tracks';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let searchValue = $state(untrack(() => data.query));
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
let ytItems = $state<YouTubeSong[]>([]);
|
||||
let ytContinuation = $state('');
|
||||
let ytSearchTerm = $state('');
|
||||
let contextMenu = $state<ContextMenuState | null>(null);
|
||||
|
||||
type LocalTrack = (typeof data.tracks.items)[number];
|
||||
|
||||
let localItems = $state<LocalTrack[]>([]);
|
||||
let localPage = $state(1);
|
||||
let localHasNext = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
contextMenu = null;
|
||||
localItems = data.tracks.items;
|
||||
localPage = Number(data.tracks.pageNumber);
|
||||
localHasNext = Boolean(data.tracks.hasNextPage);
|
||||
|
||||
if (data.query) {
|
||||
ytItems = appendUnique([], (data.ytResults?.items ?? []) as YouTubeSong[], (i) => i.videoId);
|
||||
ytContinuation = data.ytResults?.continuationToken ?? '';
|
||||
ytSearchTerm = data.query;
|
||||
return;
|
||||
}
|
||||
|
||||
ytItems = [];
|
||||
ytContinuation = '';
|
||||
ytSearchTerm = '';
|
||||
|
||||
let cancelled = false;
|
||||
data.youtubeFiller?.then((filler) => {
|
||||
if (cancelled || !filler) return;
|
||||
ytItems = appendUnique([], filler.items, (i) => i.videoId);
|
||||
ytContinuation = filler.continuationToken;
|
||||
ytSearchTerm = filler.query;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const items = $derived<TrackTarget[]>([
|
||||
...localItems.map((track) => ({ kind: 'local' as const, track })),
|
||||
...ytItems.map((song) => ({ kind: 'youtube' as const, song }))
|
||||
]);
|
||||
|
||||
const hasMore = $derived(localHasNext || ytContinuation !== '');
|
||||
|
||||
const nothingFound = $derived(
|
||||
!!data.query && items.length === 0 && !data.needsAuth && !data.ytError
|
||||
);
|
||||
|
||||
function buildHref(query: string) {
|
||||
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
|
||||
}
|
||||
|
||||
function onSearchInput(event: Event) {
|
||||
const value = (event.currentTarget as HTMLInputElement).value;
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
goto(buildHref(value.trim()), {
|
||||
keepFocus: true,
|
||||
replaceState: true,
|
||||
noScroll: true
|
||||
});
|
||||
}, 350);
|
||||
}
|
||||
|
||||
function togglePlay(index: number) {
|
||||
player.playOrToggle(items.map(queueItemForTarget), index);
|
||||
}
|
||||
|
||||
function openContextMenu(event: MouseEvent, target: TrackTarget) {
|
||||
contextMenu = contextMenuStateFor(event, target);
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu = null;
|
||||
}
|
||||
|
||||
function addToQueue() {
|
||||
if (!contextMenu) return;
|
||||
player.addToQueue(queueItemForTarget(contextMenu));
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
async function loadMoreLocal() {
|
||||
const params = new URLSearchParams({
|
||||
pageNumber: String(localPage + 1),
|
||||
pageSize: String(EXPLORE_PAGE_SIZE)
|
||||
});
|
||||
if (data.query) params.set('name', data.query);
|
||||
const res = await fetch(`/api/tracks?${params}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const next = (await res.json()) as typeof data.tracks;
|
||||
localItems = appendUnique(localItems, next.items, (track) => track.id);
|
||||
localPage = Number(next.pageNumber);
|
||||
localHasNext = Boolean(next.hasNextPage);
|
||||
}
|
||||
|
||||
async function loadMoreYouTube() {
|
||||
const params = new URLSearchParams({ query: ytSearchTerm, continuation: ytContinuation });
|
||||
const res = await fetch(`/api/youtube/search?${params}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const next = (await res.json()) as { items: YouTubeSong[]; continuationToken: string };
|
||||
ytItems = appendUnique(ytItems, next.items, (i) => i.videoId);
|
||||
ytContinuation = next.continuationToken;
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore) return;
|
||||
loadingMore = true;
|
||||
try {
|
||||
if (localHasNext) await loadMoreLocal();
|
||||
else if (ytContinuation && ytSearchTerm) await loadMoreYouTube();
|
||||
} catch {
|
||||
if (localHasNext) localHasNext = false;
|
||||
else ytContinuation = '';
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Explorar · Musify</title>
|
||||
<meta name="description" content="Explora tu música y busca canciones en YouTube Music." />
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<PageHeader title="Explorar" subtitle="Busca a la vez en tu música y en YouTube Music." />
|
||||
|
||||
<div class="mt-8">
|
||||
<Input
|
||||
type="search"
|
||||
icon={Search}
|
||||
bind:value={searchValue}
|
||||
oninput={onSearchInput}
|
||||
placeholder="Buscar en tu música y en YouTube Music..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if form?.message}
|
||||
<Alert class="mt-4">{form.message}</Alert>
|
||||
{/if}
|
||||
|
||||
{#if nothingFound}
|
||||
<EmptyState
|
||||
icon={Music}
|
||||
title="Sin resultados"
|
||||
description="No hay resultados para «{data.query}»."
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if items.length > 0}
|
||||
<MediaGrid as="ul" class="mt-6">
|
||||
{#each items as item, i (targetId(item))}
|
||||
<TrackTile
|
||||
id={targetId(item)}
|
||||
title={targetTitle(item)}
|
||||
artist={targetArtist(item)}
|
||||
coverSrc={targetCoverSrc(item)}
|
||||
hue={hueFor(targetId(item))}
|
||||
explicit={targetExplicit(item)}
|
||||
active={isTargetCurrent(item)}
|
||||
playing={player.isPlaying}
|
||||
index={i}
|
||||
onClick={() => togglePlay(i)}
|
||||
onContextMenu={(e) => openContextMenu(e, item)}
|
||||
/>
|
||||
{/each}
|
||||
</MediaGrid>
|
||||
|
||||
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
|
||||
{/if}
|
||||
|
||||
{#if data.needsAuth}
|
||||
<EmptyState
|
||||
icon={Music}
|
||||
description="Inicia sesión para ver también resultados de YouTube Music."
|
||||
/>
|
||||
{:else if data.ytError}
|
||||
<EmptyState
|
||||
icon={Music}
|
||||
description="YouTube Music no está disponible ahora mismo. Inténtalo de nuevo."
|
||||
/>
|
||||
{/if}
|
||||
</Page>
|
||||
|
||||
{#if contextMenu}
|
||||
<TrackContextMenu
|
||||
menu={contextMenu}
|
||||
playlists={data.playlists}
|
||||
onClose={closeContextMenu}
|
||||
onAddToQueue={addToQueue}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import {
|
||||
createApiClient,
|
||||
requireAccessTokenAction,
|
||||
requireUser,
|
||||
unwrapOrError,
|
||||
unwrapOrFail
|
||||
} from '$lib/server/api';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
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('/tracks/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PAGE_SIZE } }
|
||||
});
|
||||
|
||||
return { tracks: unwrapOrError(result, 'No se pudo cargar tu biblioteca.') };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
deleteTrack: async ({ request, 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('/tracks/{trackId}', {
|
||||
params: { path: { trackId } }
|
||||
});
|
||||
const failure = unwrapOrFail(result, 'No se pudo borrar la canción.');
|
||||
if (failure) return failure;
|
||||
return { deleted: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import { player, toQueueItems } from '$lib/player/player.svelte';
|
||||
import { hueFor, fmtTime, fmtDate } from '$lib/theme/color';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Button from '$lib/components/ui/Button.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';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const items = $derived(data.tracks.items);
|
||||
const total = $derived(Number(data.tracks.totalItemCount));
|
||||
|
||||
function togglePlay(index: number) {
|
||||
player.playOrToggle(toQueueItems(items), index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Tus canciones subidas · Musify</title>
|
||||
<meta name="description" content="Todas tus canciones subidas a Musify." />
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<PageHeader
|
||||
title="Tus canciones subidas"
|
||||
subtitle="{total} {total === 1 ? 'canción' : 'canciones'} subida{total === 1 ? '' : 's'}."
|
||||
>
|
||||
{#snippet actions()}
|
||||
{#if items.length > 0}
|
||||
<Button href="/upload">
|
||||
<Upload class="h-4 w-4" />
|
||||
Subir canción
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
{#if items.length > 0}
|
||||
<TrackTable columns="32px 1fr 100px 100px 64px 36px" class="mt-8">
|
||||
{#snippet headers()}
|
||||
<span class="text-center">#</span>
|
||||
<span>Título</span>
|
||||
<span class="text-center">Subida</span>
|
||||
<span class="text-center">Escuchas</span>
|
||||
<span class="text-center">Duración</span>
|
||||
<span></span>
|
||||
{/snippet}
|
||||
{#each items as track, i (track.id)}
|
||||
<TrackRow>
|
||||
<TrackIndexCell
|
||||
index={i}
|
||||
active={player.current.id === track.id}
|
||||
playing={player.playing}
|
||||
onToggle={() => togglePlay(i)}
|
||||
/>
|
||||
<TrackTitleCell
|
||||
trackId={track.id}
|
||||
title={track.title}
|
||||
hue={hueFor(track.id)}
|
||||
onClick={() => togglePlay(i)}
|
||||
/>
|
||||
<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()}
|
||||
>
|
||||
<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" />
|
||||
</IconButton>
|
||||
</form>
|
||||
</TrackRow>
|
||||
{/each}
|
||||
</TrackTable>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={Music}
|
||||
title="Todavía no tienes canciones"
|
||||
description="Sube la primera y aparecerá aquí lista para reproducir."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button href="/upload">
|
||||
<Upload class="h-4 w-4" />
|
||||
Sube la primera
|
||||
</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</Page>
|
||||
@@ -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}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import {
|
||||
createApiClient,
|
||||
requireAccessTokenAction,
|
||||
requireUser,
|
||||
unwrapOrFail
|
||||
} from '$lib/server/api';
|
||||
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
|
||||
|
||||
const MAX_TITLE = 100;
|
||||
|
||||
export const load: PageServerLoad = ({ locals, url }) => {
|
||||
requireUser(locals, url);
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals, 'Inicia sesión para subir música.');
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const form = await request.formData();
|
||||
const title = String(form.get('title') ?? '').trim();
|
||||
const audio = form.get('audio');
|
||||
const cover = form.get('cover');
|
||||
|
||||
if (title === '' || title.length > MAX_TITLE) {
|
||||
return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' });
|
||||
}
|
||||
if (!(audio instanceof File) || audio.size === 0) {
|
||||
return fail(400, { message: 'Selecciona un archivo de audio.' });
|
||||
}
|
||||
if (!(cover instanceof File) || cover.size === 0) {
|
||||
return fail(400, { message: 'Selecciona una portada.' });
|
||||
}
|
||||
|
||||
const audioContentType = contentTypeOf(audio, AUDIO_TYPES);
|
||||
const pictureContentType = contentTypeOf(cover, IMAGE_TYPES);
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
|
||||
const urlsResult = await api.POST('/tracks/upload-urls', {
|
||||
body: {
|
||||
pictureFileType: extOf(cover.name) || 'jpg',
|
||||
pictureContentType,
|
||||
audioFileType: extOf(audio.name) || 'mp3',
|
||||
audioContentType,
|
||||
expectedPictureSizeBytes: cover.size,
|
||||
expectedAudioSizeBytes: audio.size
|
||||
}
|
||||
});
|
||||
|
||||
const urlsFailure = unwrapOrFail(urlsResult, 'No se pudieron reservar las URLs de subida.');
|
||||
if (urlsFailure) return urlsFailure;
|
||||
const urls = urlsResult.data!;
|
||||
|
||||
try {
|
||||
const [audioBuffer, coverBuffer] = await Promise.all([
|
||||
audio.arrayBuffer(),
|
||||
cover.arrayBuffer()
|
||||
]);
|
||||
await Promise.all([
|
||||
putPresigned(urls.audioUploadUrl, Buffer.from(audioBuffer), urls.audioContentType),
|
||||
putPresigned(urls.pictureUploadUrl, Buffer.from(coverBuffer), urls.pictureContentType)
|
||||
]);
|
||||
} catch (err) {
|
||||
return fail(502, {
|
||||
message: 'Falló la subida del archivo al almacenamiento.',
|
||||
detail: err instanceof Error ? err.message : undefined
|
||||
});
|
||||
}
|
||||
|
||||
const trackResult = await api.POST('/tracks', {
|
||||
body: {
|
||||
title,
|
||||
pictureIntentId: urls.pictureIntentId,
|
||||
audioIntentId: urls.audioIntentId
|
||||
}
|
||||
});
|
||||
|
||||
const trackFailure = unwrapOrFail(trackResult, 'No se pudo crear la pista tras la subida.');
|
||||
if (trackFailure) return trackFailure;
|
||||
const track = trackResult.data!;
|
||||
|
||||
return { success: true, trackId: track.id, title: track.title };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,283 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import Headphones from '@lucide/svelte/icons/headphones';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||
import Surface from '$lib/components/ui/Surface.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import Field from '$lib/components/ui/Field.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Checkbox from '$lib/components/ui/Checkbox.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
type Status = 'idle' | 'uploading' | 'done' | 'error';
|
||||
type StepState = 'idle' | 'active' | 'done';
|
||||
|
||||
const MAX_TITLE = 100;
|
||||
|
||||
let title = $state('');
|
||||
let audioName = $state('');
|
||||
let audioSize = $state(0);
|
||||
let coverPreview = $state('');
|
||||
let dragging = $state(false);
|
||||
let status = $state<Status>('idle');
|
||||
let acceptedTerms = $state(false);
|
||||
let errorMsg = $state('');
|
||||
let publishedTitle = $state('');
|
||||
let audioInput = $state<HTMLInputElement>();
|
||||
|
||||
const steps = [
|
||||
{ key: 'upload', label: 'Subir', hint: 'Portada y audio al almacenamiento' },
|
||||
{ key: 'process', label: 'Procesar', hint: 'Transcodificado para streaming' },
|
||||
{ key: 'publish', label: 'Publicar', hint: 'Disponible en el catálogo' }
|
||||
];
|
||||
|
||||
const busy = $derived(status === 'uploading');
|
||||
|
||||
const canPublish = $derived(
|
||||
title.trim() !== '' && audioName !== '' && coverPreview !== '' && acceptedTerms && !busy
|
||||
);
|
||||
|
||||
const stepStates = $derived.by<StepState[]>(() => {
|
||||
if (status === 'done') return ['done', 'done', 'done'];
|
||||
if (status === 'uploading') return ['active', 'idle', 'idle'];
|
||||
return ['idle', 'idle', 'idle'];
|
||||
});
|
||||
|
||||
const dropClass = $derived(
|
||||
dragging || audioName !== '' ? 'border-accent/60' : 'border-line hover:border-accent/60'
|
||||
);
|
||||
|
||||
function stepClass(s: StepState) {
|
||||
if (s === 'active') return 'border-accent/40 bg-accent/5';
|
||||
if (s === 'done') return 'border-accent/40';
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (bytes === 0) return '';
|
||||
const mb = bytes / (1024 * 1024);
|
||||
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
|
||||
}
|
||||
|
||||
function pickAudio(file: File) {
|
||||
audioName = file.name;
|
||||
audioSize = file.size;
|
||||
if (title.trim() === '') title = file.name.replace(/\.[^.]+$/, '');
|
||||
}
|
||||
|
||||
function onAudioInput(event: Event) {
|
||||
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (file) pickAudio(file);
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
dragging = false;
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (!file || !audioInput) return;
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
audioInput.files = dt.files;
|
||||
pickAudio(file);
|
||||
}
|
||||
|
||||
function onCoverInput(event: Event) {
|
||||
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (file) coverPreview = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
title = '';
|
||||
audioName = '';
|
||||
audioSize = 0;
|
||||
coverPreview = '';
|
||||
status = 'idle';
|
||||
acceptedTerms = false;
|
||||
errorMsg = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Subir música · Musify</title>
|
||||
<meta name="description" content="Sube tus canciones a Musify: portada, audio y título." />
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<PageHeader
|
||||
title="Subir música"
|
||||
subtitle="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
|
||||
/>
|
||||
|
||||
<ol class="mt-10 grid gap-4 sm:grid-cols-3">
|
||||
{#each steps as step, i (step.key)}
|
||||
<li>
|
||||
<Surface class={stepClass(stepStates[i])}>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="grid h-8 w-8 place-items-center rounded-full bg-accent-soft text-sm font-bold text-on-accent"
|
||||
>
|
||||
{#if stepStates[i] === 'done'}
|
||||
<Check class="h-4 w-4" strokeWidth={2.5} />
|
||||
{:else}
|
||||
{i + 1}
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-base font-semibold text-fg">{step.label}</span>
|
||||
</div>
|
||||
<p class="mt-3 text-base text-muted">{step.hint}</p>
|
||||
</Surface>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
{#if status === 'done'}
|
||||
<Surface padding="lg" class="mt-10 border-accent/30 bg-accent/10 text-center">
|
||||
<span
|
||||
class="mx-auto grid h-16 w-16 place-items-center rounded-full bg-accent-soft text-on-accent"
|
||||
>
|
||||
<Check class="h-8 w-8" strokeWidth={2.5} />
|
||||
</span>
|
||||
<h2 class="mt-6 text-2xl font-bold tracking-tight text-fg">¡Subida!</h2>
|
||||
<p class="mt-3 text-fg-2">
|
||||
«{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en
|
||||
unos momentos.
|
||||
</p>
|
||||
<div class="mt-8 flex items-center justify-center gap-4">
|
||||
<Button href="/library" size="lg">Ir a la biblioteca</Button>
|
||||
<Button variant="secondary" size="lg" onclick={reset}>Subir otra</Button>
|
||||
</div>
|
||||
</Surface>
|
||||
{:else}
|
||||
<form
|
||||
method="POST"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => {
|
||||
status = 'uploading';
|
||||
errorMsg = '';
|
||||
return async ({ result }) => {
|
||||
if (result.type === 'success') {
|
||||
publishedTitle = title;
|
||||
status = 'done';
|
||||
} else if (result.type === 'failure') {
|
||||
status = 'error';
|
||||
errorMsg = (result.data?.message as string) ?? 'No se pudo subir la canción.';
|
||||
} else {
|
||||
status = 'error';
|
||||
errorMsg = 'Error inesperado durante la subida.';
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="mt-10 space-y-8"
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
dragging = true;
|
||||
}}
|
||||
ondragleave={() => (dragging = false)}
|
||||
ondrop={onDrop}
|
||||
class="relative flex min-h-72 flex-col items-center justify-center rounded-panel border border-dashed bg-surface p-10 text-center transition {dropClass}"
|
||||
>
|
||||
<input
|
||||
bind:this={audioInput}
|
||||
type="file"
|
||||
name="audio"
|
||||
accept="audio/*"
|
||||
onchange={onAudioInput}
|
||||
class="absolute inset-0 cursor-pointer opacity-0"
|
||||
aria-label="Seleccionar archivo de audio"
|
||||
/>
|
||||
<span class="grid h-20 w-20 place-items-center rounded-full bg-accent-soft text-on-accent">
|
||||
{#if audioName === ''}
|
||||
<Headphones class="h-9 w-9" />
|
||||
{:else}
|
||||
<Music class="h-9 w-9" />
|
||||
{/if}
|
||||
</span>
|
||||
{#if audioName === ''}
|
||||
<p class="mt-5 text-lg font-medium text-fg">
|
||||
Arrastra tu audio aquí o haz clic para elegir
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-muted">MP3, FLAC, WAV…</p>
|
||||
{:else}
|
||||
<p class="mt-5 max-w-full truncate text-lg font-medium text-fg">{audioName}</p>
|
||||
<p class="mt-2 text-sm text-muted">{formatSize(audioSize)} · Listo para subir</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid items-start gap-8 sm:grid-cols-[auto_1fr]">
|
||||
<Field label="Portada">
|
||||
<label
|
||||
class="relative grid h-44 w-44 cursor-pointer place-items-center overflow-hidden rounded-control border border-line bg-surface transition hover:border-accent/50"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
name="cover"
|
||||
accept="image/*"
|
||||
onchange={onCoverInput}
|
||||
class="absolute inset-0 cursor-pointer opacity-0"
|
||||
aria-label="Seleccionar portada"
|
||||
/>
|
||||
{#if coverPreview !== ''}
|
||||
<img src={coverPreview} alt="Portada" class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<ImageIcon class="h-12 w-12 text-muted" />
|
||||
{/if}
|
||||
</label>
|
||||
</Field>
|
||||
|
||||
<Field label="Título" for="title">
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
bind:value={title}
|
||||
maxlength={MAX_TITLE}
|
||||
placeholder="Nombre de la canción"
|
||||
/>
|
||||
{#snippet hint()}
|
||||
<p>Se usa como nombre público de la pista en el catálogo.</p>
|
||||
<span class="shrink-0 tabular-nums">{title.length}/{MAX_TITLE}</span>
|
||||
{/snippet}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Checkbox bind:checked={acceptedTerms}>
|
||||
Acepto los
|
||||
<a
|
||||
href="/terms"
|
||||
class="text-accent-soft underline underline-offset-2 transition hover:brightness-110"
|
||||
>
|
||||
términos y condiciones
|
||||
</a>
|
||||
al subir {title.trim() !== '' ? `«${title}»` : 'esta canción'}.
|
||||
</Checkbox>
|
||||
|
||||
{#if status === 'uploading'}
|
||||
<Surface>
|
||||
<div class="flex items-center justify-between text-base">
|
||||
<span class="font-medium text-fg">Subiendo y creando la pista…</span>
|
||||
<span class="text-muted">un momento</span>
|
||||
</div>
|
||||
<div class="mt-4 h-2.5 overflow-hidden rounded-full bg-surface-2">
|
||||
<div class="h-full w-full animate-pulse rounded-full bg-accent-soft"></div>
|
||||
</div>
|
||||
</Surface>
|
||||
{/if}
|
||||
|
||||
{#if status === 'error'}
|
||||
<Alert tone="danger">{errorMsg}</Alert>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<Button href="/explore" variant="secondary" size="lg">Cancelar</Button>
|
||||
<Button type="submit" size="lg" disabled={!canPublish}>Publicar</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</Page>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
const PUBLIC_PATHS = new Set(['/login']);
|
||||
|
||||
export const load: LayoutServerLoad = ({ locals, url }) => {
|
||||
if (!locals.user && !PUBLIC_PATHS.has(url.pathname)) {
|
||||
redirect(302, `/login?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
|
||||
}
|
||||
return { user: locals.user };
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import '@fontsource-variable/inter/index.css';
|
||||
import '$lib/theme/theme.css';
|
||||
import './layout.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import PlayerBar from '$lib/components/player/PlayerBar.svelte';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { ACCENT_LIGHTNESS, ACCENT_CHROMA, ACCENT_HUE } from '$lib/theme/color';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { children, data } = $props();
|
||||
|
||||
const isAuthPage = $derived(page.url.pathname === '/login');
|
||||
const hasTrack = $derived(player.currentId !== null);
|
||||
|
||||
$effect(() => {
|
||||
document.documentElement.classList.toggle('home', page.url.pathname === '/');
|
||||
});
|
||||
|
||||
function parseAccent(value: string): { c: number; h: number } | null {
|
||||
const m = /oklch\(\s*[\d.]+%?\s+([\d.]+)\s+([\d.]+)/.exec(value);
|
||||
return m ? { c: parseFloat(m[1]), h: parseFloat(m[2]) } : null;
|
||||
}
|
||||
|
||||
let displayed = { c: ACCENT_CHROMA, h: ACCENT_HUE };
|
||||
let rafId = 0;
|
||||
|
||||
$effect(() => {
|
||||
const target = parseAccent(player.accent);
|
||||
if (!target) {
|
||||
document.documentElement.style.setProperty('--mf-accent', player.accent);
|
||||
return;
|
||||
}
|
||||
cancelAnimationFrame(rafId);
|
||||
const from = { ...displayed };
|
||||
let dh = target.h - from.h;
|
||||
if (dh > 180) dh -= 360;
|
||||
else if (dh < -180) dh += 360;
|
||||
const start = performance.now();
|
||||
const duration = 350;
|
||||
const tick = (now: number) => {
|
||||
const t = Math.min(1, (now - start) / duration);
|
||||
const e = 1 - Math.pow(1 - t, 3);
|
||||
const c = from.c + (target.c - from.c) * e;
|
||||
const h = (((from.h + dh * e) % 360) + 360) % 360;
|
||||
displayed = { c, h };
|
||||
document.documentElement.style.setProperty(
|
||||
'--mf-accent',
|
||||
`oklch(${ACCENT_LIGHTNESS}% ${c.toFixed(3)} ${h.toFixed(1)})`
|
||||
);
|
||||
if (t < 1) rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
|
||||
{#if isAuthPage}
|
||||
<div class="min-h-screen bg-bg text-fg antialiased">
|
||||
{@render children()}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-screen bg-bg text-fg antialiased">
|
||||
<Sidebar user={data.user!} />
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<main class="flex-1 transition-[padding] {hasTrack ? 'pb-[92px]' : 'pb-0'}">
|
||||
{#key page.url.pathname}
|
||||
<div class="animate-fade">{@render children()}</div>
|
||||
{/key}
|
||||
</main>
|
||||
{#if hasTrack}
|
||||
<PlayerBar />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, playlistCoverTrackIds } from '$lib/server/api';
|
||||
import { fetchYoutubeFiller, shuffle } from '$lib/server/youtube';
|
||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
import { HOME_LATEST_PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config';
|
||||
import { pickGreeting } from '$lib/server/greeting';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
const latestPromise = api.GET('/tracks', {
|
||||
params: { query: { pageNumber: 1, pageSize: HOME_LATEST_PAGE_SIZE } }
|
||||
});
|
||||
const playlistsPromise = locals.user
|
||||
? api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 12 } }
|
||||
})
|
||||
: Promise.resolve({ data: undefined });
|
||||
const recentlyPlayedPromise = locals.user
|
||||
? api.GET('/users/{id}/listening-history', { params: { path: { id: locals.user.sub } } })
|
||||
: Promise.resolve({ data: undefined });
|
||||
|
||||
const [latest, playlists, recentlyPlayed] = await Promise.all([
|
||||
latestPromise,
|
||||
playlistsPromise,
|
||||
recentlyPlayedPromise
|
||||
]);
|
||||
const playlistItems = playlists.data?.items ?? [];
|
||||
const trackIds = await playlistCoverTrackIds(
|
||||
api,
|
||||
playlistItems.map((p) => p.id)
|
||||
);
|
||||
|
||||
return {
|
||||
greeting: pickGreeting(new Date().getHours()),
|
||||
latest: shuffle((latest.data?.items ?? []).map((track) => ({ kind: 'local' as const, track }))),
|
||||
latestHasNext: Boolean(latest.data?.hasNextPage),
|
||||
youtubeFiller: fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT),
|
||||
playlists: playlistItems,
|
||||
trackIds,
|
||||
recentlyPlayed: recentlyPlayed.data ?? []
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addTrack: addTrackAction,
|
||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction
|
||||
};
|
||||
@@ -0,0 +1,278 @@
|
||||
<script lang="ts">
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import { player, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
|
||||
import {
|
||||
isTargetCurrent,
|
||||
queueItemForTarget,
|
||||
targetArtist,
|
||||
targetCoverSrc,
|
||||
targetExplicit,
|
||||
targetForQueueItem,
|
||||
targetId,
|
||||
targetTitle,
|
||||
type TrackTarget
|
||||
} from '$lib/tracks';
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
|
||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
||||
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
|
||||
import { HOME_LATEST_PAGE_SIZE } from '$lib/config';
|
||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||
import TrackTile from '$lib/components/TrackTile.svelte';
|
||||
import TrackContextMenu, {
|
||||
contextMenuStateFor,
|
||||
type ContextMenuState
|
||||
} from '$lib/components/TrackContextMenu.svelte';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
import { appendUnique } from '$lib/collections';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let youtubeFillerItems = $state<YouTubeSong[]>([]);
|
||||
let ytContinuation = $state('');
|
||||
let ytQuery = $state('');
|
||||
let contextMenu = $state<ContextMenuState | null>(null);
|
||||
|
||||
type LocalLatest = (typeof data.latest)[number];
|
||||
|
||||
let localLatest = $state<LocalLatest[]>([]);
|
||||
let localPage = $state(1);
|
||||
let localHasNext = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
youtubeFillerItems = [];
|
||||
ytContinuation = '';
|
||||
ytQuery = '';
|
||||
localLatest = data.latest;
|
||||
localPage = 1;
|
||||
localHasNext = data.latestHasNext;
|
||||
|
||||
let cancelled = false;
|
||||
data.youtubeFiller.then((filler) => {
|
||||
if (cancelled) return;
|
||||
youtubeFillerItems = filler.items;
|
||||
ytContinuation = filler.continuationToken;
|
||||
ytQuery = filler.query;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const latest = $derived<TrackTarget[]>([
|
||||
...localLatest,
|
||||
...youtubeFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
|
||||
]);
|
||||
|
||||
const hasMoreLatest = $derived(localHasNext || ytContinuation !== '');
|
||||
|
||||
async function loadMoreLatest() {
|
||||
if (loadingMore) return;
|
||||
loadingMore = true;
|
||||
try {
|
||||
if (localHasNext) {
|
||||
const params = new URLSearchParams({
|
||||
pageNumber: String(localPage + 1),
|
||||
pageSize: String(HOME_LATEST_PAGE_SIZE)
|
||||
});
|
||||
const res = await fetch(`/api/tracks?${params}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const next = await res.json();
|
||||
localLatest = appendUnique(
|
||||
localLatest,
|
||||
next.items.map((track: LocalLatest['track']) => ({ kind: 'local' as const, track })),
|
||||
(item) => item.track.id
|
||||
);
|
||||
localPage = Number(next.pageNumber);
|
||||
localHasNext = Boolean(next.hasNextPage);
|
||||
} else if (ytContinuation && ytQuery) {
|
||||
const params = new URLSearchParams({ query: ytQuery, continuation: ytContinuation });
|
||||
const res = await fetch(`/api/youtube/search?${params}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const next = (await res.json()) as { items: YouTubeSong[]; continuationToken: string };
|
||||
youtubeFillerItems = appendUnique(youtubeFillerItems, next.items, (i) => i.videoId);
|
||||
ytContinuation = next.continuationToken;
|
||||
}
|
||||
} catch {
|
||||
if (localHasNext) localHasNext = false;
|
||||
else ytContinuation = '';
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
const playlists = $derived(data.playlists);
|
||||
const trackIds = $derived(data.trackIds);
|
||||
|
||||
function playLatest(index: number) {
|
||||
player.playOrToggle(latest.map(queueItemForTarget), index);
|
||||
}
|
||||
|
||||
function openContextMenu(event: MouseEvent, target: TrackTarget) {
|
||||
contextMenu = contextMenuStateFor(event, target);
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu = null;
|
||||
}
|
||||
|
||||
function addToQueue() {
|
||||
if (!contextMenu) return;
|
||||
player.addToQueue(queueItemForTarget(contextMenu));
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
const recentlyPlayed = $derived.by(() => {
|
||||
const seen = new Set<string>();
|
||||
const merged: QueueItem[] = [];
|
||||
for (const t of player.recentlyPlayed) {
|
||||
const id = String(t.id);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
merged.push({
|
||||
id,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
source: t.source,
|
||||
coverUrl: t.coverUrl,
|
||||
explicit: t.explicit
|
||||
});
|
||||
}
|
||||
for (const item of toQueueItems(data.recentlyPlayed)) {
|
||||
const id = String(item.id);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
merged.push({
|
||||
id,
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
source: item.source ?? 'local',
|
||||
coverUrl: item.coverUrl,
|
||||
explicit: item.explicit
|
||||
});
|
||||
}
|
||||
return merged.slice(0, 15);
|
||||
});
|
||||
|
||||
function playRecent(index: number) {
|
||||
player.playOrToggle(recentlyPlayed, index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Musify</title>
|
||||
<meta name="description" content="Tu música, sin límites." />
|
||||
</svelte:head>
|
||||
|
||||
<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"
|
||||
></div>
|
||||
<div class="animate-enter relative flex flex-col gap-2.5">
|
||||
<p class="tracking-[0.14em] text-fg-2 uppercase">{data.greeting}</p>
|
||||
<h1 class="max-w-160 text-5xl leading-[1.05] font-extrabold text-fg">
|
||||
Tu música. Sin límites.
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if recentlyPlayed.length > 0}
|
||||
<section class="page-x pt-6 pb-3">
|
||||
<SectionHeading title="Escuchado recientemente" />
|
||||
<MediaGrid min="300px">
|
||||
{#each recentlyPlayed as track, i (track.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playRecent(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(track))}
|
||||
aria-label="{player.current.id === track.id && player.playing
|
||||
? 'Pausar'
|
||||
: 'Reproducir'} {track.title}"
|
||||
class="flex min-w-0 items-center gap-3 rounded-control bg-surface p-2 text-left transition hover:bg-surface-hover"
|
||||
>
|
||||
<Cover
|
||||
trackId={track.id}
|
||||
src={track.coverUrl}
|
||||
hue={hueFor(track.id)}
|
||||
size="small"
|
||||
alt={track.title}
|
||||
class="h-14 w-14 flex-shrink-0 rounded-control"
|
||||
>
|
||||
{#if player.current.id === track.id}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
</Cover>
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if track.explicit}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-fg">{track.title}</span>
|
||||
</div>
|
||||
{#if track.artist}
|
||||
<div class="truncate text-sm text-muted">{track.artist}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</MediaGrid>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if playlists.length > 0}
|
||||
<section class="page-x pt-6 pb-3">
|
||||
<SectionHeading title="Mis listas" />
|
||||
<MediaGrid>
|
||||
{#each playlists 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>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="page-x pt-6 pb-10">
|
||||
<SectionHeading title="Novedades" />
|
||||
{#if latest.length > 0}
|
||||
<MediaGrid as="ul">
|
||||
{#each latest as item, i (targetId(item))}
|
||||
<TrackTile
|
||||
id={targetId(item)}
|
||||
title={targetTitle(item)}
|
||||
artist={targetArtist(item)}
|
||||
coverSrc={targetCoverSrc(item)}
|
||||
hue={hueFor(targetId(item))}
|
||||
explicit={targetExplicit(item)}
|
||||
active={isTargetCurrent(item)}
|
||||
playing={player.isPlaying}
|
||||
index={i}
|
||||
onClick={() => playLatest(i)}
|
||||
onContextMenu={(e) => openContextMenu(e, item)}
|
||||
/>
|
||||
{/each}
|
||||
</MediaGrid>
|
||||
<InfiniteScroll onLoadMore={loadMoreLatest} hasMore={hasMoreLatest} loading={loadingMore} />
|
||||
{:else}
|
||||
<EmptyState icon={Music} title="Todavía no hay música" />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if contextMenu}
|
||||
<TrackContextMenu
|
||||
menu={contextMenu}
|
||||
{playlists}
|
||||
onClose={closeContextMenu}
|
||||
onAddToQueue={addToQueue}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { proxyCoverImage } from '$lib/server/image';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, fetch }) => {
|
||||
return proxyCoverImage(fetch, 'playlists', params.id, url.searchParams.get('size'));
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||
import { EXPLORE_PAGE_SIZE } from '$lib/config';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
|
||||
const name = url.searchParams.get('name')?.trim() || undefined;
|
||||
const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1);
|
||||
const pageSize = Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE);
|
||||
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const result = await api.GET('/tracks', { params: { query: { name, pageNumber, pageSize } } });
|
||||
|
||||
return json(unwrapOrError(result, 'No se pudieron cargar las canciones.'));
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { proxyCoverImage } from '$lib/server/image';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, fetch }) => {
|
||||
return proxyCoverImage(fetch, 'tracks', params.id, url.searchParams.get('size'));
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createApiClient, requireAccessToken, unwrapOrError } from '$lib/server/api';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
|
||||
const accessToken = requireAccessToken(locals, 'Inicia sesión para reproducir.');
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const result = await api.GET('/tracks/{id}/stream', {
|
||||
params: { path: { id: params.id } }
|
||||
});
|
||||
|
||||
return json(unwrapOrError(result, 'No se pudo obtener el stream de la pista.'));
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { createApiClient, requireAccessToken, unwrapOrError } from '$lib/server/api';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
|
||||
const accessToken = requireAccessToken(locals, 'Inicia sesión para buscar.');
|
||||
|
||||
const query = url.searchParams.get('query')?.trim() ?? '';
|
||||
if (!query) {
|
||||
error(400, 'Falta el término de búsqueda.');
|
||||
}
|
||||
|
||||
const continuation = url.searchParams.get('continuation') ?? undefined;
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const result = await api.GET('/youtube/search', {
|
||||
params: { query: { query, continuation } }
|
||||
});
|
||||
|
||||
return json(unwrapOrError(result, 'No se pudo buscar en YouTube Music.'));
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createApiClient, requireAccessToken, unwrapOrError } from '$lib/server/api';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
|
||||
const accessToken = requireAccessToken(locals, 'Inicia sesión para reproducir.');
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const result = await api.GET('/youtube/tracks/{videoId}/stream', {
|
||||
params: { path: { videoId: params.videoId } }
|
||||
});
|
||||
|
||||
return json(unwrapOrError(result, 'No se pudo obtener el stream de YouTube.'));
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
import * as client from 'openid-client';
|
||||
import type { RequestHandler } from './$types';
|
||||
import {
|
||||
getOidcConfig,
|
||||
encodeSession,
|
||||
sessionCookieOptions,
|
||||
toSessionUser,
|
||||
type Session,
|
||||
SESSION_COOKIE,
|
||||
STATE_COOKIE,
|
||||
VERIFIER_COOKIE,
|
||||
NONCE_COOKIE,
|
||||
RETURN_COOKIE
|
||||
} from '$lib/server/auth';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const codeVerifier = cookies.get(VERIFIER_COOKIE);
|
||||
const state = cookies.get(STATE_COOKIE);
|
||||
const nonce = cookies.get(NONCE_COOKIE);
|
||||
const returnTo = cookies.get(RETURN_COOKIE) ?? '/';
|
||||
|
||||
if (!codeVerifier || !state) {
|
||||
error(400, 'Estado de autenticación inválido. Vuelve a iniciar sesión.');
|
||||
}
|
||||
|
||||
const config = await getOidcConfig();
|
||||
|
||||
let tokens;
|
||||
try {
|
||||
tokens = await client.authorizationCodeGrant(config, url, {
|
||||
pkceCodeVerifier: codeVerifier,
|
||||
expectedState: state,
|
||||
expectedNonce: nonce,
|
||||
idTokenExpected: true
|
||||
});
|
||||
} catch (exception) {
|
||||
console.error('Token exchange failed', exception);
|
||||
error(401, 'La autenticación ha fallado.');
|
||||
}
|
||||
|
||||
const claims = tokens.claims();
|
||||
if (!claims || !tokens.id_token) {
|
||||
error(401, 'El proveedor no devolvió el token de identidad.');
|
||||
}
|
||||
if (!tokens.refresh_token) {
|
||||
error(401, 'El proveedor no devolvió un refresh token; revisa el scope offline_access.');
|
||||
}
|
||||
|
||||
const session: Session = {
|
||||
...toSessionUser(claims),
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
idToken: tokens.id_token,
|
||||
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
|
||||
};
|
||||
|
||||
cookies.set(SESSION_COOKIE, await encodeSession(session), sessionCookieOptions(url));
|
||||
|
||||
for (const name of [STATE_COOKIE, VERIFIER_COOKIE, NONCE_COOKIE, RETURN_COOKIE]) {
|
||||
cookies.delete(name, { path: '/' });
|
||||
}
|
||||
|
||||
redirect(302, returnTo);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import * as client from 'openid-client';
|
||||
import type { RequestHandler } from './$types';
|
||||
import {
|
||||
getOidcConfig,
|
||||
STATE_COOKIE,
|
||||
VERIFIER_COOKIE,
|
||||
NONCE_COOKIE,
|
||||
RETURN_COOKIE
|
||||
} from '$lib/server/auth';
|
||||
import { authConfig } from '$lib/server/config';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const config = await getOidcConfig();
|
||||
|
||||
const codeVerifier = client.randomPKCECodeVerifier();
|
||||
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
|
||||
const state = client.randomState();
|
||||
const nonce = client.randomNonce();
|
||||
|
||||
const register = url.searchParams.get('mode') === 'register';
|
||||
const returnTo = url.searchParams.get('returnTo') ?? '/';
|
||||
|
||||
const parameters: Record<string, string> = {
|
||||
redirect_uri: authConfig.redirectUri,
|
||||
scope: authConfig.scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
nonce
|
||||
};
|
||||
if (register) parameters.prompt = 'create';
|
||||
|
||||
const authorizationUrl = client.buildAuthorizationUrl(config, parameters);
|
||||
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax' as const,
|
||||
secure: url.protocol === 'https:',
|
||||
path: '/',
|
||||
maxAge: authConfig.flowTtlSeconds
|
||||
};
|
||||
cookies.set(VERIFIER_COOKIE, codeVerifier, cookieOptions);
|
||||
cookies.set(STATE_COOKIE, state, cookieOptions);
|
||||
cookies.set(NONCE_COOKIE, nonce, cookieOptions);
|
||||
cookies.set(RETURN_COOKIE, returnTo, cookieOptions);
|
||||
|
||||
redirect(302, authorizationUrl.href);
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--color-neutral-950: #08080a;
|
||||
|
||||
--color-bg: var(--mf-bg);
|
||||
--color-elevated: var(--mf-elevated);
|
||||
--color-surface: var(--mf-surface);
|
||||
--color-surface-2: var(--mf-surface-2);
|
||||
--color-surface-hover: var(--mf-surface-hover);
|
||||
--color-hover: rgba(255, 255, 255, 0.05);
|
||||
--color-line: var(--mf-border);
|
||||
--color-line-strong: var(--mf-border-strong);
|
||||
--color-track: var(--mf-track);
|
||||
--color-fg: var(--mf-text);
|
||||
--color-fg-2: var(--mf-text-2);
|
||||
--color-fg-3: var(--mf-text-3);
|
||||
--color-muted: var(--mf-text-4);
|
||||
--color-accent: var(--mf-accent);
|
||||
--color-accent-soft: var(--mf-accent-60);
|
||||
--color-on-accent: #ffffff;
|
||||
--color-danger: oklch(63.7% 0.237 25.331);
|
||||
--color-danger-fg: oklch(88.5% 0.062 18.334);
|
||||
|
||||
--radius-control: 0.625rem;
|
||||
--radius-panel: 1rem;
|
||||
--radius-art: 0.75rem;
|
||||
--radius-art-lg: 1rem;
|
||||
|
||||
--shadow-art: 0 12px 28px -10px rgba(0, 0, 0, 0.6);
|
||||
--shadow-art-lg: 0 20px 45px -15px rgba(0, 0, 0, 0.7);
|
||||
--shadow-menu: 0 12px 28px -10px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
@utility page-x {
|
||||
padding-inline: --spacing(8);
|
||||
}
|
||||
|
||||
@utility scrollbar-stable {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role='button'],
|
||||
label:has(input[type='file']),
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.75rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96) translateY(0.5rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes equalize {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.35);
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.78;
|
||||
transform: scale(1.32);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sheen {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-enter {
|
||||
animation: fade-up 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.animate-pop {
|
||||
animation: pop-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.animate-fade {
|
||||
animation: fade-in 0.4s ease both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-delay: 0ms !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
|
||||
background-color: var(--mf-bg);
|
||||
}
|
||||
|
||||
html.home {
|
||||
background-image: linear-gradient(to bottom, var(--mf-accent), transparent 340px);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.18);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.32);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = ({ locals, url }) => {
|
||||
if (locals.user) {
|
||||
redirect(302, url.searchParams.get('returnTo') ?? '/');
|
||||
}
|
||||
return { returnTo: url.searchParams.get('returnTo') ?? '/' };
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const returnParam = $derived(`returnTo=${encodeURIComponent(data.returnTo)}`);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Iniciar sesión · Musify</title>
|
||||
<meta name="description" content="Inicia sesión o crea una cuenta en Musify." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="grid min-h-screen place-items-center bg-bg px-6 text-fg">
|
||||
<div class="flex w-full max-w-[440px] flex-col items-center gap-9 text-center">
|
||||
<div class="text-3xl font-extrabold tracking-tight">Musify</div>
|
||||
|
||||
<div>
|
||||
<h1 class="text-4xl font-extrabold tracking-tight">Tu música. Sin límites.</h1>
|
||||
<p class="mt-3 text-base text-fg-2">Inicia sesión para continuar.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-5">
|
||||
<Button href={`/auth/login?${returnParam}`} reload size="lg" class="w-full">
|
||||
Iniciar Sesión
|
||||
</Button>
|
||||
<Button
|
||||
href={`/auth/login?mode=register&${returnParam}`}
|
||||
reload
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
>
|
||||
Crear Cuenta
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import * as client from 'openid-client';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getOidcConfig, decodeSession, SESSION_COOKIE } from '$lib/server/auth';
|
||||
import { authConfig } from '$lib/server/config';
|
||||
|
||||
const handleLogout: RequestHandler = async ({ cookies }) => {
|
||||
const session = await decodeSession(cookies.get(SESSION_COOKIE));
|
||||
cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||
|
||||
const config = await getOidcConfig();
|
||||
const endSessionUrl = client.buildEndSessionUrl(config, {
|
||||
post_logout_redirect_uri: authConfig.postLogoutUri,
|
||||
...(session?.idToken ? { id_token_hint: session.idToken } : {})
|
||||
});
|
||||
|
||||
redirect(302, endSessionUrl.href);
|
||||
};
|
||||
|
||||
export const GET = handleLogout;
|
||||
export const POST = handleLogout;
|
||||
Reference in New Issue
Block a user