Design Prot Merge #1

Merged
NotS1mp merged 217 commits from design-prot into master 2026-09-21 15:48:16 +00:00
10 changed files with 3844 additions and 4965 deletions
Showing only changes of commit 7861600081 - Show all commits
@@ -0,0 +1,183 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box}
html,body{margin:0;padding:0;background:#050507}
a{color:#b4a4ea;text-decoration:none}
a:hover{color:#cdc0f5}
img{display:block}
input,textarea{font-family:inherit}
input::placeholder,textarea::placeholder{color:#5d5d63}
@keyframes modalIn{from{opacity:0;transform:translateY(14px) scale(.985)}to{opacity:1;transform:none}}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
</style>
</helmet>
<div style="position:relative;min-height:100vh;display:grid;place-items:center;padding:40px 20px;background:radial-gradient(120% 90% at 50% 0%,#15131c,#08080b 58%,#050507);font-family:'Plus Jakarta Sans',system-ui,sans-serif;color:#c9c8cc;-webkit-font-smoothing:antialiased">
<div style="position:absolute;inset:0;background:rgba(4,4,6,.55);backdrop-filter:blur(3px)"></div>
<div data-screen-label="Crear playlist" style="position:relative;width:100%;max-width:560px;border-radius:24px;overflow:hidden;border:1px solid rgba(255,255,255,.075);background:linear-gradient(180deg,#141419,#0d0d11 62%,#0b0b0e);box-shadow:0 32px 90px rgba(0,0,0,.66),0 2px 0 rgba(255,255,255,.035) inset;animation:modalIn .28s cubic-bezier(.2,.8,.3,1)">
<div style="position:absolute;inset:0;pointer-events:none;background:{{ auraTop }};transition:background .5s ease"></div>
<!-- HEADER -->
<div style="position:relative;display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:24px 26px 0">
<div>
<div style="font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;color:#8f8f93;font-weight:600;margin-bottom:9px">Tu biblioteca</div>
<div style="font-family:'Sora',sans-serif;font-size:21px;font-weight:600;letter-spacing:-.03em;color:#d8d7db">Crear playlist</div>
</div>
<div onClick="{{ close }}" title="Cerrar" style="flex:0 0 auto;width:32px;height:32px;border-radius:10px;display:grid;place-items:center;cursor:pointer;color:#87878c;border:1px solid rgba(255,255,255,.06);background:rgba(255,255,255,.025);transition:background .18s ease,color .18s ease" style-hover="background:rgba(255,255,255,.07);color:#d0cfd4">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"></path></svg>
</div>
</div>
<!-- BODY -->
<div style="position:relative;padding:22px 26px 0;display:flex;gap:20px;align-items:flex-start">
<!-- COVER -->
<div style="flex:0 0 auto;width:124px">
<div style="position:relative;width:124px;height:124px;border-radius:16px;overflow:hidden;border:1px solid rgba(255,255,255,.07);background:{{ coverGrad }};box-shadow:{{ coverGlow }};transition:background .45s ease,box-shadow .45s ease;display:grid;place-items:center">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="rgba(255,255,255,.62)" stroke-width="1.5" stroke-linecap="round"><path d="M9 18V6l10-2v12"></path><circle cx="6.5" cy="18" r="2.5"></circle><circle cx="16.5" cy="16" r="2.5"></circle></svg>
</div>
<div style="display:flex;gap:6px;margin-top:11px">
<sc-for list="{{ swatches }}" as="s" hint-placeholder-count="5">
<div onClick="{{ s.pick }}" title="{{ s.name }}" style="flex:1;height:20px;border-radius:7px;cursor:pointer;background:{{ s.dot }};box-shadow:{{ s.ring }};transition:box-shadow .18s ease"></div>
</sc-for>
</div>
<div onClick="{{ upload }}" style="margin-top:9px;text-align:center;font-size:11px;font-weight:500;color:#8a8a8f;padding:7px 0;border-radius:9px;border:1px dashed rgba(255,255,255,.1);cursor:pointer;transition:color .18s ease,border-color .18s ease" style-hover="color:#cfccd9;border-color:rgba(255,255,255,.2)">Subir imagen</div>
</div>
<!-- FIELDS -->
<div style="flex:1;min-width:0">
<label style="display:block;font-size:11px;font-weight:600;letter-spacing:.03em;color:#87878c;margin-bottom:8px">Nombre</label>
<input value="{{ name }}" onChange="{{ onName }}" maxLength="60" placeholder="Playlist sin título" style="width:100%;padding:12px 14px;border-radius:12px;background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.075);outline:none;color:#dcdbe0;font-family:'Sora',sans-serif;font-size:16px;font-weight:600;letter-spacing:-.02em" style-focus="border-color:rgba(255,255,255,.2);background:rgba(255,255,255,.055)">
<div style="display:flex;justify-content:flex-end;font-size:10.5px;color:#65656a;margin-top:6px">{{ counter }}</div>
<label style="display:block;font-size:11px;font-weight:600;letter-spacing:.03em;color:#87878c;margin:10px 0 8px">Descripción <span style="font-weight:400;color:#5f5f65">· opcional</span></label>
<textarea value="{{ desc }}" onChange="{{ onDesc }}" rows="3" placeholder="Para qué sirve esta playlist, cuándo la escuchas…" style="width:100%;resize:none;padding:11px 14px;border-radius:12px;background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.075);outline:none;color:#c4c3c9;font-size:12.5px;line-height:1.6" style-focus="border-color:rgba(255,255,255,.2);background:rgba(255,255,255,.055)"></textarea>
</div>
</div>
<!-- OPTIONS -->
<div style="position:relative;padding:20px 26px 0">
<div style="display:flex;align-items:center;justify-content:space-between;gap:14px;padding:13px 15px;border-radius:13px;background:rgba(255,255,255,.026);border:1px solid rgba(255,255,255,.055)">
<div style="min-width:0">
<div style="font-size:12.5px;font-weight:600;color:#cfccd9">Visibilidad</div>
<div style="font-size:11px;color:#7b7b81;margin-top:3px">{{ visHint }}</div>
</div>
<div style="flex:0 0 auto;display:flex;gap:3px;padding:3px;border-radius:10px;background:rgba(0,0,0,.34);border:1px solid rgba(255,255,255,.05)">
<sc-for list="{{ visOptions }}" as="v" hint-placeholder-count="2">
<div onClick="{{ v.pick }}" style="padding:6px 14px;border-radius:8px;font-size:11.5px;font-weight:600;cursor:pointer;background:{{ v.bg }};color:{{ v.fg }};transition:background .18s ease,color .18s ease">{{ v.label }}</div>
</sc-for>
</div>
</div>
<div onClick="{{ toggleCollab }}" style="display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:9px;padding:13px 15px;border-radius:13px;cursor:pointer;background:rgba(255,255,255,.026);border:1px solid rgba(255,255,255,.055);transition:background .18s ease" style-hover="background:rgba(255,255,255,.045)">
<div style="min-width:0">
<div style="font-size:12.5px;font-weight:600;color:#cfccd9">Colaborativa</div>
<div style="font-size:11px;color:#7b7b81;margin-top:3px">Quien tenga el enlace puede añadir canciones</div>
</div>
<div style="flex:0 0 auto;width:40px;height:23px;border-radius:20px;padding:3px;background:{{ collabTrack }};transition:background .2s ease">
<div style="width:17px;height:17px;border-radius:50%;background:#0c0c10;transform:{{ collabKnob }};transition:transform .2s cubic-bezier(.3,.8,.3,1)"></div>
</div>
</div>
</div>
<!-- FOOTER -->
<div style="position:relative;display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;margin-top:22px;padding:17px 26px 20px;border-top:1px solid rgba(255,255,255,.055);background:rgba(0,0,0,.2)">
<div style="font-size:11px;color:#6e6e74;max-width:230px;line-height:1.5">Podrás añadir canciones desde cualquier pantalla.</div>
<div style="display:flex;gap:9px;margin-left:auto">
<div onClick="{{ close }}" style="padding:11px 19px;border-radius:11px;border:1px solid rgba(255,255,255,.1);font-size:12.5px;font-weight:500;cursor:pointer;color:#a9a7b2;transition:background .18s ease,color .18s ease" style-hover="background:rgba(255,255,255,.05);color:#d3d2d8">Cancelar</div>
<div onClick="{{ create }}" style="display:flex;align-items:center;gap:8px;padding:11px 21px;border-radius:11px;font-size:12.5px;font-weight:600;background:{{ ctaBg }};color:{{ ctaFg }};cursor:{{ ctaCursor }};opacity:{{ ctaOpacity }};transition:background .18s ease,opacity .18s ease">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"></path></svg>
Crear playlist
</div>
</div>
</div>
</div>
<sc-if value="{{ created }}" hint-placeholder-val="{{ false }}">
<div style="position:fixed;left:50%;bottom:34px;transform:translateX(-50%);display:flex;align-items:center;gap:11px;padding:12px 18px;border-radius:13px;background:#16161c;border:1px solid rgba(255,255,255,.09);box-shadow:0 18px 46px rgba(0,0,0,.6);animation:fadeIn .22s ease">
<div style="width:26px;height:26px;border-radius:8px;background:{{ coverGrad }};flex:0 0 auto"></div>
<div style="font-size:12.5px;color:#cfccd9;font-weight:500">Playlist «{{ createdName }}» creada</div>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:900,&quot;height&quot;:760}}">
const HUES = [
{ name: 'Ámbar', h: 68 },
{ name: 'Violeta', h: 288 },
{ name: 'Azul', h: 244 },
{ name: 'Verde', h: 158 },
{ name: 'Rojo', h: 22 },
];
class Component extends DCLogic {
state = { name: '', desc: '', hue: 1, vis: 'Privada', collab: false, created: false, createdName: '' };
renderVals() {
const h = HUES[this.state.hue].h;
const name = this.state.name;
const ready = name.trim().length > 0;
return {
name, desc: this.state.desc,
counter: name.length + '/60',
onName: e => this.setState({ name: e.target.value }),
onDesc: e => this.setState({ desc: e.target.value }),
coverGrad: `linear-gradient(150deg,oklch(.66 .16 ${h}),oklch(.34 .11 ${h}) 58%,#0e0e13)`,
coverGlow: `0 14px 34px oklch(.5 .13 ${h} / .22)`,
auraTop: `radial-gradient(110% 60% at 18% -10%,oklch(.55 .13 ${h} / .16),transparent 62%)`,
swatches: HUES.map((s, i) => ({
name: s.name,
dot: `linear-gradient(145deg,oklch(.66 .16 ${s.h}),oklch(.38 .11 ${s.h}))`,
ring: i === this.state.hue
? '0 0 0 1.5px rgba(255,255,255,.85), 0 0 0 4px rgba(0,0,0,.5)'
: '0 0 0 1px rgba(255,255,255,.07)',
pick: () => this.setState({ hue: i }),
})),
visOptions: ['Privada', 'Pública'].map(label => ({
label,
bg: this.state.vis === label ? '#cfccd9' : 'transparent',
fg: this.state.vis === label ? '#0a0a0d' : '#8e8e94',
pick: () => this.setState({ vis: label }),
})),
visHint: this.state.vis === 'Privada'
? 'Solo tú la ves en tu biblioteca'
: 'Aparece en tu perfil y se puede compartir',
collabTrack: this.state.collab ? `oklch(.68 .15 ${h})` : 'rgba(255,255,255,.12)',
collabKnob: this.state.collab ? 'translateX(17px)' : 'translateX(0)',
toggleCollab: () => this.setState(s => ({ collab: !s.collab })),
ctaBg: ready ? '#c6c4cb' : 'rgba(255,255,255,.08)',
ctaFg: ready ? '#0a0a0d' : '#6c6c72',
ctaCursor: ready ? 'pointer' : 'not-allowed',
ctaOpacity: ready ? 1 : 0.85,
create: () => {
if (!ready) return;
this.setState({ created: true, createdName: name.trim() });
clearTimeout(this._t);
this._t = setTimeout(() => this.setState({ created: false }), 2600);
},
close: () => this.setState({ name: '', desc: '', collab: false, created: false }),
upload: () => {},
created: this.state.created,
createdName: this.state.createdName,
};
}
componentWillUnmount() { clearTimeout(this._t); }
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+126 -52
View File
@@ -39,11 +39,11 @@ Altura fija de viewport, sin scroll de página; solo el área de contenido y la
### Breakpoints ### Breakpoints
| Ancho | Comportamiento | | Ancho | Comportamiento |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | |---|---|
| `>= 1180px` | Sidebar visible (254px), cola 304px, player completo | | `>= 1180px` | Sidebar visible (254px), cola 304px, player completo |
| `< 1180px` (tablet) | Sidebar oculto → barra de pestañas bajo el player; cola 258px; padding inferior 206px | | `< 1180px` (tablet) | Sidebar oculto → barra de pestañas bajo el player; cola 258px; padding inferior 206px |
| `< 760px` (móvil) | Cola oculta; se ocultan shuffle/repeat/volumen/letra/cola del player (queda carátula + título + transporte + progreso); el bloque de info del player pasa a `width:auto` | | `< 760px` (móvil) | Cola oculta; se ocultan shuffle/repeat/volumen/letra/cola del player (queda carátula + título + transporte + progreso); el bloque de info del player pasa a `width:auto` |
--- ---
@@ -84,17 +84,17 @@ Los elementos que cambian de color reciben `transition: background .6s ease` (y
### Grises fijos (no dependen del hue) ### Grises fijos (no dependen del hue)
| Rol | Hex | | Rol | Hex |
| ------------------------------------------- | ------------------------------------------------------------------------------------ | |---|---|
| Texto principal (títulos de sección, h1/h2) | `#d3d2d6` – `#d9d8dc` | | Texto principal (títulos de sección, h1/h2) | `#d3d2d6` – `#d9d8dc` |
| Texto de tarjeta / fila | `#cbcacf` / `#cdccd1` | | Texto de tarjeta / fila | `#cbcacf` / `#cdccd1` |
| Texto base del documento | `#c9c8cc` | | Texto base del documento | `#c9c8cc` |
| Secundario | `#8a8a8e`, `#8f8f93`, `#919195` | | Secundario | `#8a8a8e`, `#8f8f93`, `#919195` |
| Terciario | `#7f7f83`, `#7d7d81`, `#848488` | | Terciario | `#7f7f83`, `#7d7d81`, `#848488` |
| Cuaternario / contadores | `#6c6c70`, `#696a6d`, `#646468`, `#616165`, `#5c5c60` | | Cuaternario / contadores | `#6c6c70`, `#696a6d`, `#646468`, `#616165`, `#5c5c60` |
| Iconos inactivos player | `#747478` | | Iconos inactivos player | `#747478` |
| Botón primario (fondo) | `#c6c4cb`, hover `#d8d7dc`; texto `#0a0a0d` | | Botón primario (fondo) | `#c6c4cb`, hover `#d8d7dc`; texto `#0a0a0d` |
| Superficies neutras | `rgba(255,255,255,.022)` base, `.045` hover; bordes `rgba(255,255,255,.04)` / `.075` | | Superficies neutras | `rgba(255,255,255,.022)` base, `.045` hover; bordes `rgba(255,255,255,.04)` / `.075` |
Los grises son **neutros a propósito** (sin tinte), para que solo el acento lleve color. Los grises son **neutros a propósito** (sin tinte), para que solo el acento lleve color.
@@ -106,19 +106,19 @@ Los grises son **neutros a propósito** (sin tinte), para que solo el acento lle
- UI / texto: **Plus Jakarta Sans** 400/500/600/700. - UI / texto: **Plus Jakarta Sans** 400/500/600/700.
- `-webkit-font-smoothing: antialiased`. - `-webkit-font-smoothing: antialiased`.
| Uso | Fuente | Tamaño | Peso | Tracking | | Uso | Fuente | Tamaño | Peso | Tracking |
| ------------------------------------------ | ------- | ---------------- | ---- | ------------------------------------ | |---|---|---|---|---|
| H1 hero | Sora | 36px / 1.1 | 600 | -0.035em | | H1 hero | Sora | 36px / 1.1 | 600 | -0.035em |
| H1 vista (Descubrir, Playlists, Tracklist) | Sora | 28–33px | 600 | -0.03 / -0.035em | | H1 vista (Descubrir, Playlists, Tracklist) | Sora | 28–33px | 600 | -0.03 / -0.035em |
| H2 sección | Sora | 17.5px | 600 | -0.025em | | H2 sección | Sora | 17.5px | 600 | -0.025em |
| Título spotlight | Sora | 23px | 600 | -0.03em | | Título spotlight | Sora | 23px | 600 | -0.03em |
| Wordmark | Sora | 17.5px | 600 | -0.02em | | Wordmark | Sora | 17.5px | 600 | -0.02em |
| Subtítulo de sección | Jakarta | 12px | 400 | — | | Subtítulo de sección | Jakarta | 12px | 400 | — |
| Eyebrow / cabecera de columna | Jakarta | 10.5px uppercase | 600 | 0.12–0.17em | | Eyebrow / cabecera de columna | Jakarta | 10.5px uppercase | 600 | 0.12–0.17em |
| Título de tarjeta | Jakarta | 13–13.5px | 600 | -0.01em | | Título de tarjeta | Jakarta | 13–13.5px | 600 | -0.01em |
| Fila de canción (título) | Jakarta | 12.5–13px | 500 | — | | Fila de canción (título) | Jakarta | 12.5–13px | 500 | — |
| Meta / artista | Jakarta | 11–11.5px | 400 | — | | Meta / artista | Jakarta | 11–11.5px | 400 | — |
| Números (duración, índice, reproducciones) | Jakarta | 11–12px | 400 | `font-variant-numeric: tabular-nums` | | Números (duración, índice, reproducciones) | Jakarta | 11–12px | 400 | `font-variant-numeric: tabular-nums` |
Todo texto de una línea en tarjetas y filas usa `white-space:nowrap; overflow:hidden; text-overflow:ellipsis` con `min-width:0` en el contenedor flex. Los titulares llevan `text-wrap: pretty`. Todo texto de una línea en tarjetas y filas usa `white-space:nowrap; overflow:hidden; text-overflow:ellipsis` con `min-width:0` en el contenedor flex. Los titulares llevan `text-wrap: pretty`.
@@ -166,8 +166,7 @@ Todo texto de una línea en tarjetas y filas usa `white-space:nowrap; overflow:h
Ritmo editorial deliberado — no filas iguales encadenadas: Ritmo editorial deliberado — no filas iguales encadenadas:
**a) Hero** — `border-radius:24px; margin-bottom:42px; background: heroBg`, sin borde, `padding:38px 36px 34px; max-width:620px`. **a) Hero** — `border-radius:24px; margin-bottom:42px; background: heroBg`, sin borde, `padding:38px 36px 34px; max-width:620px`.
- Saludo H1 dinámico por hora y día (ver *Copy dinámico*).
- Saludo H1 dinámico por hora y día (ver _Copy dinámico_).
- Blurb 13.5px `#8a8a8e`, `line-height:1.65`, `max-width:440px`. - Blurb 13.5px `#8a8a8e`, `line-height:1.65`, `max-width:440px`.
- Botones: primario "Reproducir" (`#c6c4cb` sobre `#0a0a0d`, 12px 21px, radio 12px, icono play 14px) + secundario "Guardar" (borde `rgba(255,255,255,.11)`), y un meta a la derecha en 12px. - Botones: primario "Reproducir" (`#c6c4cb` sobre `#0a0a0d`, 12px 21px, radio 12px, icono play 14px) + secundario "Guardar" (borde `rgba(255,255,255,.11)`), y un meta a la derecha en 12px.
- Tres métricas: valor Sora 26px/600 y etiqueta 11.5px — "Canciones esta semana", "Tiempo de escucha", "Artistas nuevos". - Tres métricas: valor Sora 26px/600 y etiqueta 11.5px — "Canciones esta semana", "Tiempo de escucha", "Artistas nuevos".
@@ -175,7 +174,6 @@ Ritmo editorial deliberado — no filas iguales encadenadas:
**b) Continuar escuchando** — grid `auto-fill minmax(268px,1fr)`, gap 10px. Fila-tarjeta: `padding:9px 14px 9px 9px; radius:13px`, carátula 46px radio 9px, título 13px/600, sub 11.5px, y un icono play 15px en `accent` que aparece en hover. La pista en curso usa `accentSoft` de fondo, `accentHair` de borde y `accentMuted` en el título. **b) Continuar escuchando** — grid `auto-fill minmax(268px,1fr)`, gap 10px. Fila-tarjeta: `padding:9px 14px 9px 9px; radius:13px`, carátula 46px radio 9px, título 13px/600, sub 11.5px, y un icono play 15px en `accent` que aparece en hover. La pista en curso usa `accentSoft` de fondo, `accentHair` de borde y `accentMuted` en el título.
**c) Playlist destacada (spotlight)** — grid de dos columnas `auto-fit minmax(290px,1fr)`, `radius:22px`, `background: spotlightBg`, sin borde ni divisor central. **c) Playlist destacada (spotlight)** — grid de dos columnas `auto-fit minmax(290px,1fr)`, `radius:22px`, `background: spotlightBg`, sin borde ni divisor central.
- Izquierda: portada 88px radio 14px, eyebrow "PLAYLIST DESTACADA", nombre Sora 23px, meta 12px, blurb 13px/1.7, botón "Reproducir" (`accentBtnBg` + `accentLine` + `accentTitle`, hover `accentBtnBgHover`) y "Mezclar" secundario. - Izquierda: portada 88px radio 14px, eyebrow "PLAYLIST DESTACADA", nombre Sora 23px, meta 12px, blurb 13px/1.7, botón "Reproducir" (`accentBtnBg` + `accentLine` + `accentTitle`, hover `accentBtnBgHover`) y "Mezclar" secundario.
- Derecha: 5 canciones con índice 20px, título 12.5px, artista 11px, duración tabular; fondo `rgba(0,0,0,.26)` que se desvanece a transparente hacia la derecha. - Derecha: 5 canciones con índice 20px, título 12.5px, artista 11px, duración tabular; fondo `rgba(0,0,0,.26)` que se desvanece a transparente hacia la derecha.
@@ -191,18 +189,54 @@ Ritmo editorial deliberado — no filas iguales encadenadas:
H1 28px + sub. Mosaico de géneros `minmax(228px,1fr)` gap 14px: tarjetas de 112px, radio 16px, fondo `linear-gradient(140deg, oklch(0.24 0.07 Hgénero), rgba(9,9,12,.95))`, borde `rgba(255,255,255,.05)`, nombre Sora 15.5px, meta 11.5px, y una miniatura de 80px rotada 20° asomando en la esquina inferior derecha (`opacity:.82`). Debajo, "Fuera de tu burbuja" con la misma tarjeta cuadrada de Mixes. H1 28px + sub. Mosaico de géneros `minmax(228px,1fr)` gap 14px: tarjetas de 112px, radio 16px, fondo `linear-gradient(140deg, oklch(0.24 0.07 Hgénero), rgba(9,9,12,.95))`, borde `rgba(255,255,255,.05)`, nombre Sora 15.5px, meta 11.5px, y una miniatura de 80px rotada 20° asomando en la esquina inferior derecha (`opacity:.82`). Debajo, "Fuera de tu burbuja" con la misma tarjeta cuadrada de Mixes.
### 5. Tracklist (Me gusta / Reciente / Playlist / Resultados) ### 5. Tracklist (Me gusta / Reciente / Playlist)
Cabecera: portada 164px radio 18px + eyebrow (`Colección` / `Historial` / `Playlist` / `Búsqueda`), H1 33px, meta 12.5px, botón "Reproducir" y un botón cuadrado 40px de "aleatorio". Cabecera: portada 164px radio 18px + eyebrow (`Colección` / `Historial` / `Playlist`), H1 33px, meta 12.5px, botón "Reproducir" y un botón cuadrado 40px de "aleatorio".
Tabla: `grid-template-columns: 32px 1fr 1fr 92px; gap:16px`. Cabecera 10.5px uppercase `#616165` con borde inferior `rgba(255,255,255,.05)`. Fila `padding:10px 14px; radius:12px`: Tabla: `grid-template-columns: 32px 1fr 1fr 92px; gap:16px`. Cabecera 10.5px uppercase `#616165` con borde inferior `rgba(255,255,255,.05)`. Fila `padding:10px 14px; radius:12px`:
- Col 1: número o, si es la pista activa, EQ de 3 barras de 2.5px en `accent`. - Col 1: número o, si es la pista activa, EQ de 3 barras de 2.5px en `accent`.
- Col 2: carátula 40px radio 9px + título 13px/500 + artista 11.5px. - Col 2: carátula 40px radio 9px + título 13px/500 + artista 11.5px.
- Col 3: álbum (o `—` si falta). - Col 3: álbum (o `—` si falta).
- Col 4: corazón (14px; visible siempre si está marcado, si no solo en hover) + duración tabular. - Col 4: corazón (14px; visible siempre si está marcado, si no solo en hover) + duración tabular.
- Activa: fondo `accentSoft`, título `accentTitle`. Hover: `rgba(255,255,255,.032)`. - Activa: fondo `accentSoft`, título `accentTitle`. Hover: `rgba(255,255,255,.032)`.
### 5b. Resultados de búsqueda
**Vista propia, no una tracklist.** La búsqueda devuelve canciones, álbumes, artistas y playlists, así que no lleva portada-hero ni columnas de tabla: es una lista agrupada donde cada tipo se reconoce a simple vista.
**Cabecera** (`margin:12px 0 18px`): eyebrow `RESULTADOS DE BÚSQUEDA` (10.5px uppercase, tracking .16em, `#8a8a8e`, 600) · H1 Sora 26px/600 tracking -0.03em `#d9d8dc` con el texto `Resultados para «{query}»` · meta 12.5px `#8a8a8e`: `{n} coincidencias en canciones, álbumes, artistas y playlists` (singular "coincidencia" con 1).
**Chips de filtro** (`display:flex; gap:7px; flex-wrap:wrap; margin-bottom:26px`): `Todo`, `Canciones`, `Álbumes`, `Artistas`, `Playlists`. Cada chip `padding:7px 14px; radius:999px; font-size:12px/500` y lleva a su derecha el contador en 10.5px tabular-nums.
- Activo: fondo `#cfccd9`, texto `#0a0a0d`, contador `rgba(10,10,13,.55)`, sin borde.
- Inactivo: fondo `rgba(255,255,255,.03)`, texto `#9b9b9f`, contador `#65656a`, borde `1px solid rgba(255,255,255,.07)`.
- Transición `background .16s, color .16s`.
**Mejor resultado** (solo con el filtro `Todo`): fila-tarjeta `display:flex; gap:20px; padding:18px; radius:18px; border:1px solid rgba(255,255,255,.06)`, fondo `rgba(255,255,255,.025)` → `.05` en hover, `margin-bottom:34px`. Miniatura 88px con el radio del tipo y `box-shadow:0 14px 34px rgba(0,0,0,.5)`. Eyebrow `Mejor resultado · {Tipo}` 10.5px uppercase tracking .14em en el **tinte del tipo**; título Sora 22px/600 tracking -0.03em `#dcdbe0`; subtítulo 12.5px `#84848a`. A la derecha, botón circular 46px `#c6c4cb` / icono `#0a0a0d`, `opacity .55` → `1` en hover.
Selección: el primer elemento cuyo nombre **empieza** por la consulta, recorriendo Artista → Canción → Álbum → Playlist; si ninguno empieza por ella, el primer resultado del primer grupo no vacío.
**Grupos** (`margin-bottom:30px` cada uno). Cabecera de grupo: título Sora 14.5px/600 `#cfccd9` + regla `1px rgba(255,255,255,.06)` que ocupa el espacio libre + contador 11px `#6e6e74` (`{n} resultados`). Con filtro `Todo` cada grupo muestra **máximo 4** elementos; al filtrar por un tipo se muestran todos los de ese tipo.
**Fila de resultado:** `grid-template-columns: 52px 1fr auto; gap:15px; padding:9px 12px; radius:12px; border-bottom:1px solid rgba(255,255,255,.035)`; fondo transparente → `rgba(255,255,255,.035)` en hover (`.15s ease`), sin elevación.
- Miniatura 52px con degradado de respaldo del hue del elemento y overlay `rgba(6,6,9,.55)` + play 15px `#e2e1e6` que aparece en hover (`opacity 0 → 1`).
- Título 13.5px/500 `#cbcacf` → `#e0dfe4` en hover, seguido del **badge de tipo**: 9.5px uppercase, tracking .1em, 600, `padding:3px 7px; radius:5px`, color del tinte del tipo sobre el mismo tinte al 11%.
- Subtítulo 11.5px `#7f7f83` (una línea, ellipsis).
- Meta a la derecha 11.5px `#76767c` tabular-nums.
**Diferenciación por tipo** — la forma de la miniatura es la señal primaria; el badge la confirma. El tinte se deriva del hue de la canción en reproducción con un desplazamiento fijo, así que la vista sigue siendo cromáticamente coherente con el resto de la app:
| Tipo | Radio de miniatura | Tinte | Subtítulo | Meta |
|---|---|---|---|---|
| Canción | `9px` (cuadrado suave) | `oklch(0.80 0.10 H)` | `artista · álbum` | duración |
| Álbum | `5px` (casi recto) | `oklch(0.80 0.10 H+46)` | `artista · año` | `Álbum` |
| Artista | `50%` (círculo) | `oklch(0.80 0.10 H-52)` | oyentes | `Artista` |
| Playlist | `13px` (muy redondeado) | `oklch(0.80 0.10 H+92)` | artistas principales | `{n} canciones` |
Orden de los grupos: Canciones, Álbumes, Artistas, Playlists. Los grupos vacíos no se renderizan.
**Coincidencias:** canciones por título + artista + álbum; álbumes por título + `artista · año`; artistas por nombre; playlists por nombre + artistas principales. Todo en minúsculas, `includes`.
**Acciones:** canción → fija la pista y reproduce; álbum y artista → reproducen; playlist → abre la vista de playlist con su contexto y limpia la búsqueda.
### 6. Índice de playlists ### 6. Índice de playlists
H1 "Tus playlists" + meta agregada, botón "Nueva playlist" secundario, grid `minmax(196px,1fr)` gap 22px de tarjetas cuadradas con nombre, nº de canciones y artistas principales. H1 "Tus playlists" + meta agregada, botón "Nueva playlist" secundario, grid `minmax(196px,1fr)` gap 22px de tarjetas cuadradas con nombre, nº de canciones y artistas principales.
@@ -210,7 +244,6 @@ H1 "Tus playlists" + meta agregada, botón "Nueva playlist" secundario, grid `mi
### 7. Cola (aside derecho) ### 7. Cola (aside derecho)
`border-left:1px solid hairline; background: panelBg; padding:20px 16px` + padding inferior dinámico. `border-left:1px solid hairline; background: panelBg; padding:20px 16px` + padding inferior dinámico.
- Cabecera "En cola" (Sora 14.5px) + cerrar "×". - Cabecera "En cola" (Sora 14.5px) + cerrar "×".
- "REPRODUCIENDO": tarjeta con fondo `accentSoft`, borde `accentLine`, carátula 40px, título en `accentTitle`. - "REPRODUCIENDO": tarjeta con fondo `accentSoft`, borde `accentLine`, carátula 40px, título en `accentTitle`.
- Cabecera "A CONTINUACIÓN · {playlist}" con `gap:12px`; la etiqueta trunca con elipsis (`flex:1 1 auto; min-width:0; nowrap`) y "Vaciar" es `flex:0 0 auto`. - Cabecera "A CONTINUACIÓN · {playlist}" con `gap:12px`; la etiqueta trunca con elipsis (`flex:1 1 auto; min-width:0; nowrap`) y "Vaciar" es `flex:0 0 auto`.
@@ -236,29 +269,30 @@ Bajo el player, `margin-top:10px; padding:7px; radius:18px`, mismo `barBg` y blu
- **Loading (skeleton):** bloque de hero de 260px + 2 secciones de 6 tarjetas. Gradiente `linear-gradient(90deg, rgba(255,255,255,.025), .055, .025)` con `background-size:420px 100%` y `animation: shim 1.25s linear infinite` (`-420px 0` → `420px 0`). Se muestra 900ms al arrancar. - **Loading (skeleton):** bloque de hero de 260px + 2 secciones de 6 tarjetas. Gradiente `linear-gradient(90deg, rgba(255,255,255,.025), .055, .025)` con `background-size:420px 100%` y `animation: shim 1.25s linear infinite` (`-420px 0` → `420px 0`). Se muestra 900ms al arrancar.
- **Error:** cuadro 52px con "!" (`rgba(226,130,130,.08)` / borde `.18` / texto `#e09292`), título 20px, cuerpo 13px, botones "Reintentar" (primario) y "Ver descargas". Copy: "No se pudo cargar tu música" / "La conexión se interrumpió. Lo que tengas descargado sigue disponible sin conexión." Reintentar vuelve al skeleton 800ms. - **Error:** cuadro 52px con "!" (`rgba(226,130,130,.08)` / borde `.18` / texto `#e09292`), título 20px, cuerpo 13px, botones "Reintentar" (primario) y "Ver descargas". Copy: "No se pudo cargar tu música" / "La conexión se interrumpió. Lo que tengas descargado sigue disponible sin conexión." Reintentar vuelve al skeleton 800ms.
- **Vacío:** icono 70px radio 19px con degradado tenue, título 20px, cuerpo 13px, CTA primario + "Volver al inicio". Dos variantes de copy: playlists sin crear y "me gusta" sin canciones. - **Vacío:** icono 70px radio 19px con degradado tenue, título 20px, cuerpo 13px, CTA primario + "Volver al inicio". Dos variantes de copy: playlists sin crear y "me gusta" sin canciones.
- **Sin resultados:** título `Sin resultados para "{query}"`, cuerpo de ayuda y 4 chips de sugerencia (radio 999px, borde `rgba(255,255,255,.08)`, 12px). - **Sin resultados:** se muestra cuando **ningún** tipo tiene coincidencias (canciones + álbumes + artistas + playlists = 0). Título `Sin resultados para "{query}"`, cuerpo de ayuda y 4 chips de sugerencia (radio 999px, borde `rgba(255,255,255,.08)`, 12px).
- **Datos incompletos:** si falta el álbum, la columna muestra `—` y el player omite el `· álbum`; si falta el subtítulo de una tarjeta, "Sin información". Toda carátula tiene un degradado de respaldo detrás de la imagen. - **Datos incompletos:** si falta el álbum, la columna muestra `—` y el player omite el `· álbum`; si falta el subtítulo de una tarjeta, "Sin información". Toda carátula tiene un degradado de respaldo detrás de la imagen.
--- ---
## Interacciones ## Interacciones
| Elemento | Comportamiento | | Elemento | Comportamiento |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------- | |---|---|
| Nav / tabs | Cambia de vista y limpia la búsqueda | | Nav / tabs | Cambia de vista y limpia la búsqueda |
| Playlist (sidebar, tarjeta, índice, spotlight) | Abre la vista de playlist con su contexto | | Playlist (sidebar, tarjeta, índice, spotlight) | Abre la vista de playlist con su contexto |
| Tarjeta / fila de canción | Fija la pista, `pos = 0`, `playing = true` | | Tarjeta / fila de canción | Fija la pista, `pos = 0`, `playing = true` |
| Play/pausa | Alterna reproducción (el reloj avanza 1s por segundo) | | Play/pausa | Alterna reproducción (el reloj avanza 1s por segundo) |
| Siguiente / anterior | Cíclicos sobre el array de pistas, resetean la posición | | Siguiente / anterior | Cíclicos sobre el array de pistas, resetean la posición |
| Pista de progreso | Clic → `pos = (x / ancho) * duración` | | Pista de progreso | Clic → `pos = (x / ancho) * duración` |
| Pista de volumen | Clic → `vol` entre 0 y 1 | | Pista de volumen | Clic → `vol` entre 0 y 1 |
| Corazón (fila o player) | Alterna en el mapa `liked` | | Corazón (fila o player) | Alterna en el mapa `liked` |
| Shuffle / repeat | Alternan, se tiñen de `accent` | | Shuffle / repeat | Alternan, se tiñen de `accent` |
| Botón "Cola" | Abre/cierra el aside | | Botón "Cola" | Abre/cierra el aside |
| Buscar | Filtra por título, artista y álbum; vacío → vista normal; sin coincidencias → estado "sin resultados" | | Buscar | Filtra canciones, álbumes, artistas y playlists → vista de resultados agrupada; vacío → vuelve a la vista normal; 0 coincidencias en total → estado "sin resultados" |
| Chip de filtro de resultados | Fija `sfilter`; `Todo` recorta cada grupo a 4, un tipo concreto muestra todos |
| Nueva playlist (sidebar / índice) | Abre el modal de crear playlist |
**Animación — reglas:** **Animación — reglas:**
- **Nada de elevación en hover.** Se eliminó a propósito: el feedback es solo un cambio de fondo y la aparición del botón de play. - **Nada de elevación en hover.** Se eliminó a propósito: el feedback es solo un cambio de fondo y la aparición del botón de play.
- Hover de fondo/color: `.15–.18s ease`. Play del player: `.14s` solo de color, sin `scale`. - Hover de fondo/color: `.15–.18s ease`. Play del player: `.14s` solo de color, sin `scale`.
- Cambio de tinte por canción: `.6s ease`. - Cambio de tinte por canción: `.6s ease`.
@@ -278,6 +312,7 @@ pos segundos transcurridos (intervalo de 1s, vuelve a 0 al llegar a la d
vol 0..1 vol 0..1
queueOpen boolean (forzado a false por debajo de 760px) queueOpen boolean (forzado a false por debajo de 760px)
query texto de búsqueda query texto de búsqueda
sfilter 'Todo' | 'Canciones' | 'Álbumes' | 'Artistas' | 'Playlists' (filtro de resultados)
liked { [trackId]: true } liked { [trackId]: true }
shuffle / repeat boolean shuffle / repeat boolean
hover clave del elemento con hover (o null) hover clave del elemento con hover (o null)
@@ -296,6 +331,43 @@ Todos los campos opcionales deben tener degradado o texto de respaldo. El `hue`
--- ---
## Modal: crear playlist
Diseño aparte en `Crear playlist.dc.html`. Se invoca desde "Nueva playlist" (sidebar e índice de playlists).
**Scrim:** `rgba(4,4,6,.55)` + `backdrop-filter: blur(3px)` sobre el fondo `radial-gradient(120% 90% at 50% 0%, #15131c, #08080b 58%, #050507)`. Modal centrado con `display:grid; place-items:center; padding:40px 20px`.
**Contenedor:** `max-width:560px; radius:24px; border:1px solid rgba(255,255,255,.075)`, fondo `linear-gradient(180deg,#141419,#0d0d11 62%,#0b0b0e)`, `box-shadow:0 32px 90px rgba(0,0,0,.66)` + `inset 0 2px 0 rgba(255,255,255,.035)`. Entrada `modalIn .28s cubic-bezier(.2,.8,.3,1)` (`opacity 0 → 1`, `translateY(14px) scale(.985)` → sin transformar).
**Aura:** capa absoluta no interactiva `radial-gradient(110% 60% at 18% -10%, oklch(.55 .13 H / .16), transparent 62%)`, con `transition: background .5s` — vira con el acento elegido.
**Header** (`padding:24px 26px 0`): eyebrow `TU BIBLIOTECA` 10.5px uppercase tracking .16em `#8f8f93`; título Sora 21px/600 tracking -0.03em `#d8d7db` "Crear playlist". Cerrar: 32px, radio 10px, borde `rgba(255,255,255,.06)`, fondo `rgba(255,255,255,.025)` → `.07` y color `#87878c` → `#d0cfd4` en hover.
**Portada** (columna izquierda, 124px):
- Cuadrado 124px radio 16px, `linear-gradient(150deg, oklch(.66 .16 H), oklch(.34 .11 H) 58%, #0e0e13)`, borde `rgba(255,255,255,.07)`, `box-shadow:0 14px 34px oklch(.5 .13 H / .22)`, con el icono de nota 26px `rgba(255,255,255,.62)` centrado. Transición `.45s` al cambiar de acento.
- **Selector de acento:** 5 franjas `flex:1; height:20px; radius:7px` (gap 6px, `margin-top:11px`), cada una `linear-gradient(145deg, oklch(.66 .16 h), oklch(.38 .11 h))`. Hues: Ámbar 68, Violeta 288 (por defecto), Azul 244, Verde 158, Rojo 22. Seleccionada: `box-shadow:0 0 0 1.5px rgba(255,255,255,.85), 0 0 0 4px rgba(0,0,0,.5)`; el resto `0 0 0 1px rgba(255,255,255,.07)`.
- **Subir imagen:** botón de texto 11px `#8a8a8f`, `padding:7px 0; radius:9px`, borde `1px dashed rgba(255,255,255,.1)`; hover texto `#cfccd9` y borde `rgba(255,255,255,.2)`.
**Campos** (columna derecha, `gap:20px` respecto a la portada):
- Etiquetas 11px/600 `#87878c`.
- Nombre: `padding:12px 14px; radius:12px`, fondo `rgba(255,255,255,.035)`, borde `rgba(255,255,255,.075)`, texto **Sora 16px/600** tracking -0.02em `#dcdbe0`, placeholder "Playlist sin título" `#5d5d63`, `maxlength 60`. Foco: borde `rgba(255,255,255,.2)`, fondo `.055`. Contador `{n}/60` 10.5px `#65656a` alineado a la derecha.
- Descripción (opcional): `textarea` 3 filas, `resize:none`, 12.5px/1.6 `#c4c3c9`, mismo tratamiento de superficie y foco. Placeholder "Para qué sirve esta playlist, cuándo la escuchas…".
**Opciones** (`padding:20px 26px 0`, dos bloques `radius:13px; padding:13px 15px`, fondo `rgba(255,255,255,.026)`, borde `rgba(255,255,255,.055)`, gap 9px):
- **Visibilidad** — segmentado de 2 (`Privada` por defecto / `Pública`) en un riel `radius:10px` `rgba(0,0,0,.34)`; opción activa fondo `#cfccd9` texto `#0a0a0d`, inactiva `#8e8e94`. El texto de ayuda cambia: "Solo tú la ves en tu biblioteca" / "Aparece en tu perfil y se puede compartir".
- **Colaborativa** — toggle 40×23px, riel `rgba(255,255,255,.12)` → `oklch(.68 .15 H)` al activar, perilla 17px `#0c0c10` con `translateX(17px)`, `.2s cubic-bezier(.3,.8,.3,1)`. Ayuda: "Quien tenga el enlace puede añadir canciones". Toda la fila es clicable.
**Footer** (`border-top:1px solid rgba(255,255,255,.055)`, fondo `rgba(0,0,0,.2)`, `padding:17px 26px 20px`): nota 11px `#6e6e74` "Podrás añadir canciones desde cualquier pantalla."; a la derecha "Cancelar" (secundario, borde `rgba(255,255,255,.1)`, `#a9a7b2`) y "Crear playlist" (primario con icono +).
**Validación:** el CTA solo se habilita con nombre no vacío (`trim`). Deshabilitado: fondo `rgba(255,255,255,.08)`, texto `#6c6c72`, `cursor:not-allowed`. Habilitado: `#c6c4cb` sobre `#0a0a0d`.
**Confirmación:** al crear, toast fijo abajo-centro (`bottom:34px`, `radius:13px`, fondo `#16161c`, borde `rgba(255,255,255,.09)`, `box-shadow:0 18px 46px rgba(0,0,0,.6)`, `fadeIn .22s`) con un cuadrado 26px del degradado elegido y el texto «{nombre}» creada. Se oculta a los 2.6s.
**Estado:** `{ name, desc, hue: 0..4, vis: 'Privada'|'Pública', collab: boolean, created: boolean, createdName }`.
**Salida esperada:** `POST /playlists { name, description, coverHue, visibility, collaborative }` → redirigir a la playlist recién creada.
---
## Copy dinámico ## Copy dinámico
Saludo del hero según hora y día de la semana: Saludo del hero según hora y día de la semana:
@@ -317,9 +389,11 @@ Saludo del hero según hora y día de la semana:
## Files ## Files
- `Crear playlist.dc.html` — modal de creación de playlist (diseño aparte).
- `Musify.dc.html` — prototipo completo (plantilla + lógica + datos de ejemplo). Contiene todas las vistas y estados; el selector `demoState` permite ver loading / error / empty. - `Musify.dc.html` — prototipo completo (plantilla + lógica + datos de ejemplo). Contiene todas las vistas y estados; el selector `demoState` permite ver loading / error / empty.
- `support.js` — runtime del prototipo. **No portar**: solo hace funcionar el HTML de referencia. - `support.js` — runtime del prototipo. **No portar**: solo hace funcionar el HTML de referencia.
- `screenshots/` — capturas de referencia (viewport ~924px, es decir en modo tablet: sidebar plegado a barra de pestañas inferior): - `screenshots/` — capturas de referencia (viewport ~924px, es decir en modo tablet: sidebar plegado a barra de pestañas inferior):
- `01-home-hero.png` … `04-home-populares-playlists.png` — recorrido vertical de Home - `01-home-hero.png` … `04-home-populares-playlists.png` — recorrido vertical de Home
- `05-descubrir.png`, `06-playlists.png`, `07-tracklist-me-gusta.png`, `08-sin-resultados.png` - `05-descubrir.png`, `06-playlists.png`, `07-tracklist-me-gusta.png`, `08-sin-resultados.png`
Todas con "Yankee" sonando, de ahí el tinte ámbar: con otra pista el mismo layout se ve en otro color. Nota: las capturas son anteriores al rediseño de la vista de resultados de búsqueda (5b) y al modal de crear playlist; para esos dos, la referencia son los archivos HTML y este documento.
Todas con "Yankee" sonando, de ahí el tinte ámbar: con otra pista el mismo layout se ve en otro color.
File diff suppressed because it is too large Load Diff
@@ -10,7 +10,6 @@
import Moon from '@lucide/svelte/icons/moon'; import Moon from '@lucide/svelte/icons/moon';
import MenuItem from './ui/MenuItem.svelte'; import MenuItem from './ui/MenuItem.svelte';
import GlassMenu from './ui/GlassMenu.svelte'; import GlassMenu from './ui/GlassMenu.svelte';
import SearchBox from './SearchBox.svelte';
import { themeMode } from '$lib/theme/mode.svelte'; import { themeMode } from '$lib/theme/mode.svelte';
interface Props { interface Props {
@@ -29,92 +28,86 @@
</script> </script>
<header <header
class="sticky top-0 z-40 flex flex-col gap-2.5 border-b border-hairline bg-bg/85 page-x pt-3 pb-3 backdrop-blur-md sm:hidden" class="sticky top-0 z-40 flex h-14 items-center justify-between border-b border-hairline bg-bg/85 page-x backdrop-blur-md sm:hidden"
> >
<div class="flex h-9 items-center justify-between"> <a
<a href="/"
href="/" class="flex items-center gap-2 font-display text-[17.5px] font-semibold tracking-[-0.02em] text-fg"
class="flex items-center gap-2 font-display text-[17.5px] font-semibold tracking-[-0.02em] text-fg" >
> <span
<span class="h-5.5 w-5.5 rounded-[7px] bg-[image:var(--mf-logo-grad)] shadow-[var(--mf-logo-glow)]"
class="h-5.5 w-5.5 rounded-[7px] bg-[image:var(--mf-logo-grad)] shadow-[var(--mf-logo-glow)]" ></span>
></span> Musify
Musify </a>
</a>
<div class="relative" bind:this={menuRef}> <div class="relative" bind:this={menuRef}>
{#if user} {#if user}
<button <button
type="button" type="button"
onclick={() => (menuOpen = !menuOpen)} onclick={() => (menuOpen = !menuOpen)}
aria-label="Tu cuenta" aria-label="Tu cuenta"
aria-expanded={menuOpen} aria-expanded={menuOpen}
class="flex items-center rounded-full" class="flex items-center rounded-full"
> >
{#if user.picture} {#if user.picture}
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" /> <img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
{:else} {:else}
<span <span class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-sm uppercase">
class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-sm uppercase" {user.name.charAt(0)}
> </span>
{user.name.charAt(0)}
</span>
{/if}
</button>
{#if menuOpen}
<GlassMenu class="absolute top-full right-0 mt-2 w-56 overflow-hidden">
<MenuItem
icon={User}
label="Ver perfil"
href="/u/{user.sub}"
onclick={() => (menuOpen = false)}
/>
<MenuItem
icon={Folder}
label="Canciones subidas"
href="/library"
onclick={() => (menuOpen = false)}
/>
<MenuItem
icon={Upload}
label="Subir música"
href="/upload"
onclick={() => (menuOpen = false)}
/>
{#if accountUrl}
<MenuItem
icon={Settings}
label="Ajustes"
href={accountUrl}
target="_blank"
onclick={() => (menuOpen = false)}
/>
{/if}
<MenuItem
icon={themeMode.current === 'dark' ? Sun : Moon}
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
onclick={() => themeMode.toggle()}
/>
<form method="POST" action="/logout" data-sveltekit-reload>
<MenuItem icon={LogOut} label="Cerrar sesión" type="submit" />
</form>
</GlassMenu>
{/if} {/if}
{:else} </button>
<a
href="/auth/login"
data-sveltekit-reload
aria-label="Iniciar sesión"
class="flex items-center rounded-full p-1.5 text-fg-3 transition hover:bg-hover hover:text-fg"
>
<LogIn class="h-6 w-6" strokeWidth={2} />
</a>
{/if}
</div>
</div>
<SearchBox class="w-full" /> {#if menuOpen}
<GlassMenu class="absolute top-full right-0 mt-2 w-56 overflow-hidden">
<MenuItem
icon={User}
label="Ver perfil"
href="/u/{user.sub}"
onclick={() => (menuOpen = false)}
/>
<MenuItem
icon={Folder}
label="Canciones subidas"
href="/library"
onclick={() => (menuOpen = false)}
/>
<MenuItem
icon={Upload}
label="Subir música"
href="/upload"
onclick={() => (menuOpen = false)}
/>
{#if accountUrl}
<MenuItem
icon={Settings}
label="Ajustes"
href={accountUrl}
target="_blank"
onclick={() => (menuOpen = false)}
/>
{/if}
<MenuItem
icon={themeMode.current === 'dark' ? Sun : Moon}
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
onclick={() => themeMode.toggle()}
/>
<form method="POST" action="/logout" data-sveltekit-reload>
<MenuItem icon={LogOut} label="Cerrar sesión" type="submit" />
</form>
</GlassMenu>
{/if}
{:else}
<a
href="/auth/login"
data-sveltekit-reload
aria-label="Iniciar sesión"
class="flex items-center rounded-full p-1.5 text-fg-3 transition hover:bg-hover hover:text-fg"
>
<LogIn class="h-6 w-6" strokeWidth={2} />
</a>
{/if}
</div>
</header> </header>
<svelte:window onclick={onDocumentClick} /> <svelte:window onclick={onDocumentClick} />
@@ -1,86 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
interface Props {
class?: string;
placeholder?: string;
}
let { class: klass = '', placeholder = 'Canciones, artistas, álbumes o playlists' }: Props =
$props();
let query = $state('');
let searchInput: HTMLInputElement | undefined = $state();
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
function navigateSearch() {
const term = query.trim();
goto(term ? `/explore?q=${encodeURIComponent(term)}` : '/explore', {
replaceState: page.url.pathname === '/explore',
keepFocus: true,
noScroll: true
});
}
function onInput() {
clearTimeout(searchTimeout);
const term = query.trim();
const onExplore = page.url.pathname === '/explore';
if (term.length > 0 && term.length < SEARCH_MIN_LENGTH) return;
if (term.length === 0 && !onExplore) return;
searchTimeout = setTimeout(navigateSearch, SEARCH_DEBOUNCE_MS);
}
function clearQuery() {
clearTimeout(searchTimeout);
query = '';
if (page.url.pathname === '/explore') navigateSearch();
searchInput?.focus();
}
function onSubmit(event: SubmitEvent) {
event.preventDefault();
clearTimeout(searchTimeout);
navigateSearch();
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter') return;
event.preventDefault();
clearTimeout(searchTimeout);
navigateSearch();
}
</script>
<div class="relative min-w-0 {klass}">
<form onsubmit={onSubmit}>
<label
class="flex h-9.5 w-full items-center gap-2.5 rounded-[11px] bg-white/[3.5%] px-3.5 transition focus-within:bg-white/[6%]"
>
<Search class="h-[15px] w-[15px] shrink-0 text-fg-3" strokeWidth={1.8} />
<input
type="text"
bind:this={searchInput}
bind:value={query}
oninput={onInput}
onkeydown={onKeydown}
{placeholder}
class="w-full min-w-0 bg-transparent text-[13px] text-fg placeholder:text-muted focus:outline-none"
/>
{#if query}
<button
type="button"
onclick={clearQuery}
aria-label="Borrar búsqueda"
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-muted transition hover:text-fg"
>
<X class="h-3.5 w-3.5" />
</button>
{/if}
</label>
</form>
</div>
+66 -2
View File
@@ -1,5 +1,10 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right'; import ChevronRight from '@lucide/svelte/icons/chevron-right';
import LogOut from '@lucide/svelte/icons/log-out'; import LogOut from '@lucide/svelte/icons/log-out';
@@ -9,7 +14,6 @@
import Moon from '@lucide/svelte/icons/moon'; import Moon from '@lucide/svelte/icons/moon';
import MenuItem from './ui/MenuItem.svelte'; import MenuItem from './ui/MenuItem.svelte';
import GlassMenu from './ui/GlassMenu.svelte'; import GlassMenu from './ui/GlassMenu.svelte';
import SearchBox from './SearchBox.svelte';
import { themeMode } from '$lib/theme/mode.svelte'; import { themeMode } from '$lib/theme/mode.svelte';
interface Props { interface Props {
@@ -19,8 +23,44 @@
let { user, accountUrl }: Props = $props(); let { user, accountUrl }: Props = $props();
let query = $derived(
page.url.pathname === '/explore' ? (page.url.searchParams.get('q') ?? '') : ''
);
let menuOpen = $state(false); let menuOpen = $state(false);
let menuRef: HTMLDivElement | undefined = $state(); let menuRef: HTMLDivElement | undefined = $state();
let searchInput: HTMLInputElement | undefined = $state();
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
function buildHref(term: string) {
return term ? `/explore?q=${encodeURIComponent(term)}` : '/explore';
}
function runSearch(term: string) {
if (term.length > 0 && term.length < SEARCH_MIN_LENGTH) return;
goto(buildHref(term), {
replaceState: page.url.pathname === '/explore',
keepFocus: true,
noScroll: true
});
}
function onInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => runSearch(query.trim()), SEARCH_DEBOUNCE_MS);
}
function clearQuery() {
query = '';
clearTimeout(searchTimeout);
runSearch('');
searchInput?.focus();
}
function onSubmit(event: SubmitEvent) {
event.preventDefault();
clearTimeout(searchTimeout);
runSearch(query.trim());
}
function onDocumentClick(event: MouseEvent) { function onDocumentClick(event: MouseEvent) {
if (menuRef && !menuRef.contains(event.target as Node)) menuOpen = false; if (menuRef && !menuRef.contains(event.target as Node)) menuOpen = false;
@@ -45,7 +85,31 @@
</span> </span>
</div> </div>
<SearchBox class="max-w-[480px] flex-1" /> <form onsubmit={onSubmit} class="max-w-[480px] min-w-0 flex-1">
<label
class="flex h-9.5 w-full items-center gap-2.5 rounded-[11px] bg-white/[3.5%] px-3.5 transition focus-within:bg-white/[6%]"
>
<Search class="h-[15px] w-[15px] shrink-0 text-fg-3" strokeWidth={1.8} />
<input
type="text"
bind:this={searchInput}
bind:value={query}
oninput={onInput}
placeholder="Canciones, artistas, álbumes o playlists"
class="w-full min-w-0 bg-transparent text-[13px] text-fg placeholder:text-muted focus:outline-none"
/>
{#if query}
<button
type="button"
onclick={clearQuery}
aria-label="Borrar búsqueda"
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-muted transition hover:text-fg"
>
<X class="h-3.5 w-3.5" />
</button>
{/if}
</label>
</form>
<div class="flex-1"></div> <div class="flex-1"></div>
@@ -0,0 +1,80 @@
<script lang="ts" module>
export type SearchResultKind = 'track' | 'album' | 'playlist' | 'user';
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import ExplicitBadge from './ExplicitBadge.svelte';
const RADIUS: Record<SearchResultKind, string> = {
track: 'rounded-control',
album: 'rounded-[6px]',
playlist: 'rounded-[13px]',
user: 'rounded-full'
};
interface Props {
kind: SearchResultKind;
title: string;
subtitle?: string | null;
meta?: string;
explicit?: boolean;
href?: string;
onclick?: () => void;
oncontextmenu?: (event: MouseEvent) => void;
art: Snippet<[string]>;
}
let {
kind,
title,
subtitle,
meta,
explicit = false,
href,
onclick,
oncontextmenu,
art
}: Props = $props();
const artClass = $derived(
`relative h-13 w-13 shrink-0 overflow-hidden shadow-art ${RADIUS[kind]}`
);
</script>
{#snippet content()}
{@render art(artClass)}
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.75">
{#if explicit}
<ExplicitBadge />
{/if}
<span class="truncate text-[13px] font-medium text-fg">{title}</span>
</div>
{#if subtitle}
<div class="mt-0.75 truncate text-[11.5px] text-fg-3">{subtitle}</div>
{/if}
</div>
{#if meta}
<span class="shrink-0 text-[11.5px] text-muted tabular-nums">{meta}</span>
{/if}
{/snippet}
{#if href}
<a
{href}
{oncontextmenu}
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-surface"
>
{@render content()}
</a>
{:else}
<button
type="button"
{onclick}
{oncontextmenu}
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-surface focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
>
{@render content()}
</button>
{/if}
@@ -12,7 +12,7 @@
</script> </script>
{#snippet heading()} {#snippet heading()}
<h2 class="font-display text-[17.5px] font-semibold tracking-[-0.025em] text-fg"> <h2 class="font-display text-[17.5px] font-medium tracking-[-0.025em] text-fg">
{title} {title}
</h2> </h2>
{#if subtitle} {#if subtitle}
+398 -111
View File
@@ -1,20 +1,18 @@
<script lang="ts"> <script lang="ts">
import Music from '@lucide/svelte/icons/music'; import Music from '@lucide/svelte/icons/music';
import Play from '@lucide/svelte/icons/play';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { fetchAlbumQueueItems } from '$lib/albums'; import { fetchAlbumQueueItems } from '$lib/albums';
import { fmtTime } from '$lib/format'; import { fmtTime } from '$lib/format';
import Page from '$lib/components/ui/Page.svelte'; import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.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 Alert from '$lib/components/ui/Alert.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte'; import EmptyState from '$lib/components/ui/EmptyState.svelte';
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte'; import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
import Cover from '$lib/components/ui/Cover.svelte'; import Cover from '$lib/components/ui/Cover.svelte';
import AlbumCard from '$lib/components/ui/AlbumCard.svelte'; import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import ArtistAvatar from '$lib/components/ui/ArtistAvatar.svelte';
import Rail from '$lib/components/ui/Rail.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte'; import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte'; import SearchResultRow from '$lib/components/ui/SearchResultRow.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte'; import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import { EXPLORE_ALBUMS_LIMIT, EXPLORE_PAGE_SIZE } from '$lib/config'; import { EXPLORE_ALBUMS_LIMIT, EXPLORE_PAGE_SIZE } from '$lib/config';
import type { YouTubeSong } from '$lib/types'; import type { YouTubeSong } from '$lib/types';
@@ -38,6 +36,13 @@
type TrackTarget type TrackTarget
} from '$lib/tracks'; } from '$lib/tracks';
const TOP_RESULT_KIND_LABEL: Record<TopResult['kind'], string> = {
track: 'Canción',
album: 'Álbum',
playlist: 'Playlist',
user: 'Usuario'
};
const GENRE_INFO: Record<string, { label: string; tagline: string }> = { const GENRE_INFO: Record<string, { label: string; tagline: string }> = {
pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' }, pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' }, rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
@@ -57,10 +62,15 @@
let { data, form } = $props(); let { data, form } = $props();
const SEARCH_FILTERS = ['Todo', 'Canciones', 'Álbumes', 'Playlists', 'Usuarios'] as const;
type SearchFilter = (typeof SEARCH_FILTERS)[number];
const SEARCH_GROUP_PREVIEW = 4;
let ytItems = $state<YouTubeSong[]>([]); let ytItems = $state<YouTubeSong[]>([]);
let ytUnavailable = $state(false); let ytUnavailable = $state(false);
let contextMenu = $state<ContextMenuState | null>(null); let contextMenu = $state<ContextMenuState | null>(null);
let albumMenu = $state<AlbumMenuState | null>(null); let albumMenu = $state<AlbumMenuState | null>(null);
let sfilter = $state<SearchFilter>('Todo');
type LocalTrack = NonNullable<(typeof data.tracks.items)[number]['track']>; type LocalTrack = NonNullable<(typeof data.tracks.items)[number]['track']>;
@@ -73,6 +83,7 @@
$effect(() => { $effect(() => {
contextMenu = null; contextMenu = null;
albumMenu = null; albumMenu = null;
sfilter = 'Todo';
localItems = data.tracks.items.flatMap((item) => (item.track ? [item.track] : [])); localItems = data.tracks.items.flatMap((item) => (item.track ? [item.track] : []));
localPage = Number(data.tracks.pageNumber); localPage = Number(data.tracks.pageNumber);
ytContinuation = data.tracks.nextYoutubeContinuationToken ?? ''; ytContinuation = data.tracks.nextYoutubeContinuationToken ?? '';
@@ -132,14 +143,88 @@
const hasAlbums = $derived(albumEntries.length > 0); const hasAlbums = $derived(albumEntries.length > 0);
const hasUsers = $derived(users.length > 0); const hasUsers = $derived(users.length > 0);
const nothingFound = $derived( const playlistMatches = $derived(
!!data.query && !ytUnavailable && items.length === 0 && !hasAlbums && !hasUsers data.query
? data.playlists.filter((p) => p.name.toLowerCase().includes(data.query.toLowerCase()))
: []
); );
const hasPlaylists = $derived(playlistMatches.length > 0);
const nothingFound = $derived(
!!data.query && !ytUnavailable && items.length === 0 && !hasAlbums && !hasUsers && !hasPlaylists
);
const searchCounts = $derived({
Canciones: songRows.length,
Álbumes: albumEntries.length,
Playlists: playlistMatches.length,
Usuarios: users.length
});
const totalHits = $derived(
searchCounts.Canciones + searchCounts.Álbumes + searchCounts.Playlists + searchCounts.Usuarios
);
const searchChips = $derived(
SEARCH_FILTERS.map((label) => ({
label,
count: label === 'Todo' ? totalHits : searchCounts[label]
}))
);
type TopResult =
| { kind: 'track'; target: TrackTarget }
| { kind: 'album'; entry: AlbumEntry }
| { kind: 'playlist'; playlist: (typeof playlistMatches)[number] }
| { kind: 'user'; user: (typeof users)[number] };
const topResult = $derived.by((): TopResult | null => {
if (sfilter !== 'Todo' || !data.query) return null;
const q = data.query.trim().toLowerCase();
const startsWithQuery = (value: string) => value.toLowerCase().startsWith(q);
const userHit = users.find((u) => startsWithQuery(u.name));
if (userHit) return { kind: 'user', user: userHit };
const trackHit = items.find((target) => startsWithQuery(targetTitle(target)));
if (trackHit) return { kind: 'track', target: trackHit };
const albumHit = albumEntries.find((entry) => startsWithQuery(entry.album.title));
if (albumHit) return { kind: 'album', entry: albumHit };
const playlistHit = playlistMatches.find((p) => startsWithQuery(p.name));
if (playlistHit) return { kind: 'playlist', playlist: playlistHit };
if (users.length) return { kind: 'user', user: users[0] };
if (items.length) return { kind: 'track', target: items[0] };
if (albumEntries.length) return { kind: 'album', entry: albumEntries[0] };
if (playlistMatches.length) return { kind: 'playlist', playlist: playlistMatches[0] };
return null;
});
function showGroup(label: Exclude<SearchFilter, 'Todo'>) {
return sfilter === 'Todo' || sfilter === label;
}
function capped<T>(list: T[]) {
return sfilter === 'Todo' ? list.slice(0, SEARCH_GROUP_PREVIEW) : list;
}
function buildHref(query: string) { function buildHref(query: string) {
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore'; return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
} }
function playTopResult() {
if (topResult?.kind !== 'track') return;
player.playOrToggle([queueItemForTarget(topResult.target)], 0);
}
const topResultHref = $derived.by(() => {
if (!topResult || topResult.kind === 'track') return undefined;
if (topResult.kind === 'album') {
return topResult.entry.kind === 'local'
? `/albums/${topResult.entry.album.id}`
: `/albums/external/youtube/${topResult.entry.album.albumId}`;
}
if (topResult.kind === 'playlist') return `/playlists/${topResult.playlist.id}`;
return `/u/${topResult.user.id}`;
});
function togglePlay(index: number) { function togglePlay(index: number) {
player.playOrToggle(items.map(queueItemForTarget), index); player.playOrToggle(items.map(queueItemForTarget), index);
} }
@@ -223,7 +308,143 @@
subtitle="Encuentra tu próxima canción favorita explorando por género." subtitle="Encuentra tu próxima canción favorita explorando por género."
/> />
{:else if !nothingFound} {:else if !nothingFound}
<PageHeader title="Resultados para «{data.query}»" /> <div>
<div class="mb-2.5 text-sm font-semibold tracking-[0.16em] text-fg-3 uppercase">
Resultados de búsqueda
</div>
<h1
class="font-display text-2xl font-semibold tracking-[-0.03em] text-pretty text-fg sm:text-[28px]"
>
Resultados para «{data.query}»
</h1>
<p class="mt-1.75 text-sm text-fg-2">
{totalHits}
{totalHits === 1 ? 'Coincidencia' : 'Coincidencias'} en canciones, álbumes, playlists y usuarios
</p>
</div>
<div class="mt-5 flex flex-wrap gap-1.75">
{#each searchChips as chip (chip.label)}
<button
type="button"
onclick={() => (sfilter = chip.label)}
class="flex items-center gap-1.75 rounded-full px-3.5 py-1.75 text-xs font-medium transition {sfilter ===
chip.label
? 'bg-cta-strong text-ink'
: 'bg-white/[3.5%] text-fg-2 hover:bg-white/7 hover:text-fg'}"
>
<span>{chip.label}</span>
<span
class="text-[10.5px] tabular-nums {sfilter === chip.label
? 'opacity-55'
: 'text-muted'}">{chip.count}</span
>
</button>
{/each}
</div>
{#if topResult}
{#snippet topResultBody()}
{#if topResult.kind === 'track'}
<Cover
trackId={targetId(topResult.target)}
src={targetCoverSrc(topResult.target)}
size="large"
alt={targetTitle(topResult.target)}
class="h-22 w-22 shrink-0 rounded-2xl shadow-art"
/>
{:else if topResult.kind === 'album'}
{#if topResult.entry.kind === 'local'}
<PlaylistArt
trackIds={topResult.entry.album.coverTrackIds}
class="h-22 w-22 shrink-0 rounded-2xl shadow-art"
/>
{:else}
<Cover
trackId={topResult.entry.album.albumId}
src={topResult.entry.album.thumbnailUrl}
size="large"
alt={topResult.entry.album.title}
class="h-22 w-22 shrink-0 rounded-2xl shadow-art"
/>
{/if}
{:else if topResult.kind === 'playlist'}
<PlaylistArt
playlistId={topResult.playlist.id}
trackIds={topResult.playlist.coverTrackIds}
version={topResult.playlist.updatedAt}
class="h-22 w-22 shrink-0 rounded-2xl shadow-art"
/>
{:else}
<div
class="grid h-22 w-22 shrink-0 place-items-center overflow-hidden rounded-full bg-surface-2 shadow-art"
>
{#if topResult.user.profilePictureUrl}
<img
src={topResult.user.profilePictureUrl}
alt=""
class="h-full w-full object-cover"
/>
{:else}
<span class="text-xl font-semibold text-fg uppercase"
>{topResult.user.name.charAt(0)}</span
>
{/if}
</div>
{/if}
<div class="flex h-22 min-w-0 flex-1 flex-col justify-center gap-3">
<div
class="text-xs leading-none font-semibold tracking-[0.14em] text-fg-3 uppercase"
>
Mejor resultado · {TOP_RESULT_KIND_LABEL[topResult.kind]}
</div>
<div
class="truncate font-display text-lg leading-none font-medium tracking-[-0.02em] text-fg"
>
{topResult.kind === 'track'
? targetTitle(topResult.target)
: topResult.kind === 'album'
? topResult.entry.album.title
: topResult.kind === 'playlist'
? topResult.playlist.name
: topResult.user.name}
</div>
{#if topResult.kind === 'track' && targetArtist(topResult.target)}
<div class="truncate text-sm leading-none text-fg-3">
{targetArtist(topResult.target)}
</div>
{:else if topResult.kind === 'album' && topResult.entry.kind === 'youtube' && topResult.entry.album.artist}
<div class="truncate text-sm leading-none text-fg-3">
{topResult.entry.album.artist}
</div>
{/if}
</div>
<span
class="grid h-11.5 w-11.5 shrink-0 place-items-center rounded-full bg-cta-strong text-ink transition group-hover/top:brightness-110"
>
<Play class="h-4 w-4" fill="currentColor" strokeWidth={0} />
</span>
{/snippet}
{#if topResult.kind === 'track'}
<button
type="button"
onclick={playTopResult}
class="group/top animate-pop mt-6.5 flex w-full items-center gap-5 rounded-panel-lg bg-surface p-4.5 text-left transition hover:bg-surface-hover"
>
{@render topResultBody()}
</button>
{:else}
<a
href={topResultHref}
class="group/top animate-pop mt-6.5 flex w-full items-center gap-5 rounded-panel-lg bg-surface p-4.5 text-left transition hover:bg-surface-hover"
>
{@render topResultBody()}
</a>
{/if}
{/if}
{/if} {/if}
{#if form?.message} {#if form?.message}
@@ -271,114 +492,180 @@
</div> </div>
{/if} {/if}
{#if hasAlbums} {#if data.query && !nothingFound}
<div class="mt-9"> <div class="mt-9 flex flex-col gap-9">
<SectionHeading title="Álbumes" /> {#if showGroup('Canciones')}
<MediaGrid as="ul" min="168px" minMobile="150px"> <div>
{#each albumEntries as entry, i (entry.kind === 'local' ? entry.album.id : entry.album.albumId)} {#if songRows.length > 0}
<li> <SectionHeading title="Canciones">
{#if entry.kind === 'local'} {#snippet actions()}
<AlbumCard <div class="h-px flex-1 self-center bg-line"></div>
id={entry.album.id} <span class="shrink-0 text-[11px] text-muted tabular-nums">
title={entry.album.title} {searchCounts.Canciones}
releaseYear={entry.album.releaseYear === null {searchCounts.Canciones === 1 ? 'Resultado' : 'Resultados'}
? undefined </span>
: Number(entry.album.releaseYear)} {/snippet}
trackCount={Number(entry.album.trackCount)} </SectionHeading>
trackIds={entry.album.coverTrackIds} <ul class="flex flex-col gap-1">
index={i} {#each capped(songRows) as { target: item, seconds }, i (targetId(item))}
onContextMenu={(e) => openAlbumMenu(e, 'local', entry.album.id)} <li>
/> <SearchResultRow
{:else} kind="track"
<AlbumCard title={targetTitle(item)}
id={entry.album.albumId} subtitle={targetArtist(item)}
href="/albums/external/youtube/{entry.album.albumId}" meta={fmtTime(seconds)}
title={entry.album.title} explicit={targetExplicit(item)}
subtitle={entry.album.artist} onclick={() => togglePlay(i)}
releaseYear={entry.album.releaseYear === null oncontextmenu={(e) => openContextMenu(e, item)}
? undefined >
: Number(entry.album.releaseYear)} {#snippet art(artClass)}
coverSrc={entry.album.thumbnailUrl} <Cover
index={i} trackId={targetId(item)}
onContextMenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)} src={targetCoverSrc(item)}
/> size="small"
alt={targetTitle(item)}
class="{artClass} ring-1 ring-line ring-inset"
>
{#if isTargetCurrent(item)}
<NowPlaying paused={!player.playing} />
{/if}
</Cover>
{/snippet}
</SearchResultRow>
</li>
{/each}
</ul>
{#if sfilter === 'Canciones' && hasMore}
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
{/if} {/if}
</li> {:else if !ytUnavailable}
{/each} <EmptyState
</MediaGrid> icon={Music}
</div> description="No hay canciones que coincidan con «{data.query}»."
{/if} />
{/if}
</div>
{/if}
{#if hasUsers} {#if hasAlbums && showGroup('Álbumes')}
<div class="mt-9"> <div>
<SectionHeading title="Usuarios" /> <SectionHeading title="Álbumes">
<Rail> {#snippet actions()}
{#each users as u, i (u.id)} <div class="h-px flex-1 self-center bg-line"></div>
<a <span class="shrink-0 text-[11px] text-muted tabular-nums">
href="/u/{u.id}" {searchCounts.Álbumes}
class="group/artist animate-enter shrink-0" {searchCounts.Álbumes === 1 ? 'Resultado' : 'Resultados'}
style="animation-delay:{Math.min(i, 10) * 45}ms" </span>
> {/snippet}
<ArtistAvatar name={u.name} imageUrl={u.profilePictureUrl} size={96} /> </SectionHeading>
</a> <ul class="flex flex-col gap-1">
{/each} {#each capped(albumEntries) as entry (entry.kind === 'local' ? entry.album.id : entry.album.albumId)}
</Rail> <li>
</div> {#if entry.kind === 'local'}
{/if} <SearchResultRow
kind="album"
title={entry.album.title}
meta="{Number(entry.album.trackCount)} canciones"
href="/albums/{entry.album.id}"
oncontextmenu={(e) => openAlbumMenu(e, 'local', entry.album.id)}
>
{#snippet art(artClass)}
<PlaylistArt trackIds={entry.album.coverTrackIds} class={artClass} />
{/snippet}
</SearchResultRow>
{:else}
<SearchResultRow
kind="album"
title={entry.album.title}
subtitle={entry.album.artist}
meta={entry.album.releaseYear ? String(entry.album.releaseYear) : undefined}
href="/albums/external/youtube/{entry.album.albumId}"
oncontextmenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
>
{#snippet art(artClass)}
<Cover
trackId={entry.album.albumId}
src={entry.album.thumbnailUrl}
size="small"
alt={entry.album.title}
class={artClass}
/>
{/snippet}
</SearchResultRow>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
{#if data.query} {#if hasUsers && showGroup('Usuarios')}
<div class="mt-9"> <div>
{#if songRows.length > 0} <SectionHeading title="Usuarios">
<SectionHeading title="Canciones" /> {#snippet actions()}
<ul class="flex flex-col gap-1"> <div class="h-px flex-1 self-center bg-line"></div>
{#each songRows as { target: item, seconds }, i (targetId(item))} <span class="shrink-0 text-[11px] text-muted tabular-nums">
<li> {searchCounts.Usuarios}
<button {searchCounts.Usuarios === 1 ? 'Resultado' : 'Resultados'}
type="button" </span>
onclick={() => togglePlay(i)} {/snippet}
oncontextmenu={(e) => openContextMenu(e, item)} </SectionHeading>
aria-label="Reproducir {targetTitle(item)}" <ul class="flex flex-col gap-1">
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-surface focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none" {#each capped(users) as u (u.id)}
> <li>
<Cover <SearchResultRow kind="user" title={u.name} href="/u/{u.id}">
trackId={targetId(item)} {#snippet art(artClass)}
src={targetCoverSrc(item)} <div class="{artClass} grid place-items-center bg-surface-2">
size="small" {#if u.profilePictureUrl}
alt={targetTitle(item)} <img src={u.profilePictureUrl} alt="" class="h-full w-full object-cover" />
class="h-11 w-11 shrink-0 rounded-control shadow-art ring-1 ring-line ring-inset" {:else}
> <span class="text-sm font-semibold text-fg uppercase">
{#if isTargetCurrent(item)} {u.name.charAt(0)}
<NowPlaying paused={!player.playing} /> </span>
{/if} {/if}
</Cover>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.5">
{#if targetExplicit(item)}
<ExplicitBadge />
{/if}
<span class="truncate text-[13px] font-medium text-fg">
{targetTitle(item)}
</span>
</div>
{#if targetArtist(item)}
<div class="truncate text-[11.5px] text-fg-3">
Canción · {targetArtist(item)}
</div> </div>
{/if} {/snippet}
</div> </SearchResultRow>
<span class="shrink-0 text-[11.5px] text-muted tabular-nums" </li>
>{fmtTime(seconds)}</span {/each}
> </ul>
</button> </div>
</li> {/if}
{/each}
</ul>
{#if hasMore} {#if hasPlaylists && showGroup('Playlists')}
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} /> <div>
{/if} <SectionHeading title="Playlists">
{:else if !ytUnavailable && !nothingFound} {#snippet actions()}
<EmptyState icon={Music} description="No hay canciones que coincidan con «{data.query}»." /> <div class="h-px flex-1 self-center bg-line"></div>
<span class="shrink-0 text-[11px] text-muted tabular-nums">
{searchCounts.Playlists}
{searchCounts.Playlists === 1 ? 'Resultado' : 'Resultados'}
</span>
{/snippet}
</SectionHeading>
<ul class="flex flex-col gap-1">
{#each capped(playlistMatches) as playlist (playlist.id)}
<li>
<SearchResultRow
kind="playlist"
title={playlist.name}
subtitle={playlist.description}
href="/playlists/{playlist.id}"
>
{#snippet art(artClass)}
<PlaylistArt
playlistId={playlist.id}
trackIds={playlist.coverTrackIds}
version={playlist.updatedAt}
class={artClass}
/>
{/snippet}
</SearchResultRow>
</li>
{/each}
</ul>
</div>
{/if} {/if}
</div> </div>
{/if} {/if}