Categorize components/ui into primitives, overlay, media, forms and layout

Splits the flat 38-component ui/ folder into primitives/ (generic
controls), overlay/ (menus/modals), media/ (music-domain display
components), forms/ (composite entity forms) and layout/ (page
scaffolding), and updates every import path accordingly.
This commit is contained in:
2026-09-22 14:16:25 +02:00
parent 50618865e7
commit 7fcf0645da
61 changed files with 131 additions and 131 deletions
@@ -0,0 +1,69 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { SessionUser } from '$lib/types';
import LogOut from '@lucide/svelte/icons/log-out';
import User from '@lucide/svelte/icons/user';
import Settings from '@lucide/svelte/icons/settings';
import Sun from '@lucide/svelte/icons/sun';
import Moon from '@lucide/svelte/icons/moon';
import MenuItem from './MenuItem.svelte';
import GlassMenu from './GlassMenu.svelte';
import { themeMode } from '$lib/theme/mode.svelte';
import { clickOutside } from '$lib/actions/clickOutside';
interface Props {
user: SessionUser;
accountUrl?: string | null;
width?: 'sm' | 'md';
extraItems?: Snippet<[{ close: () => void }]>;
trigger: Snippet<[{ toggle: () => void; open: boolean }]>;
}
let { user, accountUrl, width = 'sm', extraItems, trigger }: Props = $props();
const WIDTH: Record<'sm' | 'md', string> = { sm: 'w-52', md: 'w-56' };
let open = $state(false);
function toggle() {
open = !open;
}
function close() {
open = false;
}
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') close();
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
<div class="relative" use:clickOutside={close}>
{@render trigger({ toggle, open })}
{#if open}
<GlassMenu class="absolute top-full right-0 z-(--z-menu) mt-2 overflow-hidden {WIDTH[width]}">
<MenuItem icon={User} label="Ver perfil" href="/u/{user.sub}" onclick={close} />
{@render extraItems?.({ close })}
{#if accountUrl}
<MenuItem
icon={Settings}
label="Ajustes"
href={accountUrl}
target="_blank"
onclick={close}
/>
{/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}
</div>
@@ -0,0 +1,26 @@
<script lang="ts">
import Modal from './Modal.svelte';
import Button from '../primitives/Button.svelte';
interface Props {
open: boolean;
onClose: () => void;
title: string;
description: string;
confirmLabel: string;
action: string;
}
let { open, onClose, title, description, confirmLabel, action }: Props = $props();
</script>
<Modal {open} {onClose} maxWidth="max-w-sm">
<h2 class="text-display-3 text-fg">{title}</h2>
<p class="mt-2.5 text-body text-fg-2">{description}</p>
<div class="mt-6 flex items-center justify-end gap-3">
<Button variant="secondary" onclick={onClose}>Cancelar</Button>
<form method="POST" {action}>
<Button type="submit" variant="danger">{confirmLabel}</Button>
</form>
</div>
</Modal>
@@ -0,0 +1,112 @@
<script lang="ts" module>
import { MENU_WIDTH, SUBMENU_WIDTH } from '$lib/config';
export interface MenuPosition {
x: number;
y: number;
openLeft: boolean;
}
export function contextMenuPosition(event: MouseEvent): MenuPosition {
event.preventDefault();
return {
x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)),
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
};
}
</script>
<script lang="ts">
import { enhance } from '$app/forms';
import type { LucideIcon } from '@lucide/svelte';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import MediaIdentity from '../media/MediaIdentity.svelte';
import GlassMenu from './GlassMenu.svelte';
import MenuItem from './MenuItem.svelte';
interface Props {
x: number;
y: number;
openLeft: boolean;
onClose: () => void;
items: { icon: LucideIcon; label: string; onclick: () => void }[];
playlistAction?: { action: string; fields: Record<string, string>; label: string };
playlists?: { id: string; name: string }[];
}
let { x, y, openLeft, onClose, items, playlistAction, playlists = [] }: Props = $props();
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
<div
class="fixed inset-0 z-(--z-backdrop)"
role="presentation"
onclick={onClose}
oncontextmenu={(e) => {
e.preventDefault();
onClose();
}}
></div>
<GlassMenu class="fixed z-(--z-menu) w-(--mf-menu-w)" style="left:{x}px; top:{y}px;">
{#each items as item (item.label)}
<MenuItem icon={item.icon} label={item.label} onclick={item.onclick} />
{/each}
{#if playlistAction}
<div class="group/addmenu relative">
<div
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm font-medium text-fg-2 transition group-hover/addmenu:bg-hover"
>
<span class="flex-1 cursor-default">{playlistAction.label}</span>
<ChevronRight class="size-icon-sm shrink-0 text-fg-3" />
</div>
<div
class="invisible absolute top-0 z-(--z-submenu) opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 max-sm:top-full max-sm:right-0 max-sm:left-0 max-sm:px-0 max-sm:pt-1.5 {openLeft
? 'right-full pr-1.5'
: 'left-full pl-1.5'}"
>
<div
class="w-(--mf-submenu-w) overflow-hidden rounded-panel border border-line bg-elevated p-2 max-sm:w-full"
>
<div class="max-h-80 overflow-y-auto">
{#if playlists.length === 0}
<p class="px-2 py-2.5 text-sm text-fg-3">Todavía no tienes playlists.</p>
{/if}
{#each playlists as playlist (playlist.id)}
<form
method="POST"
action={playlistAction.action}
use:enhance={() =>
({ update }) => {
onClose();
return update({ reset: false, invalidateAll: false });
}}
>
<input type="hidden" name="playlistId" value={playlist.id} />
{#each Object.entries(playlistAction.fields) as [name, value] (name)}
<input type="hidden" {name} {value} />
{/each}
<button
type="submit"
class="flex w-full items-center rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover"
>
<MediaIdentity
title={playlist.name}
coverSrc="/api/playlists/{playlist.id}/cover?size=small"
size="xs"
titleClass="text-fg-2"
/>
</button>
</form>
{/each}
</div>
</div>
</div>
</div>
{/if}
</GlassMenu>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
style?: string;
children: Snippet;
}
let { class: klass = '', style = '', children }: Props = $props();
</script>
<div class="glass-panel animate-pop rounded-panel border border-hairline p-1.5 {klass}" {style}>
{@render children()}
</div>
@@ -0,0 +1,48 @@
<script lang="ts">
import type { LucideIcon } from '@lucide/svelte';
interface Props {
icon: LucideIcon;
label: string;
href?: string;
target?: string;
type?: 'button' | 'submit';
reload?: boolean;
onclick?: (event: MouseEvent) => void;
class?: string;
}
let {
icon: Icon,
label,
href,
target,
type = 'button',
reload = false,
onclick,
class: klass = ''
}: Props = $props();
const classes = $derived(
`flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg-2 transition hover:bg-hover ${klass}`
);
</script>
{#if href}
<a
{href}
{target}
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
data-sveltekit-reload={reload ? '' : undefined}
{onclick}
class={classes}
>
<Icon class="size-icon-md shrink-0" />
{label}
</a>
{:else}
<button {type} {onclick} class={classes}>
<Icon class="size-icon-md shrink-0" />
{label}
</button>
{/if}
@@ -0,0 +1,105 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import X from '@lucide/svelte/icons/x';
import IconButton from '../primitives/IconButton.svelte';
interface Props {
open: boolean;
onClose: () => void;
eyebrow?: string;
title?: string;
maxWidth?: string;
panelClass?: string;
children: Snippet;
}
let {
open,
onClose,
eyebrow,
title,
maxWidth = 'max-w-2xl',
panelClass = '',
children
}: Props = $props();
let panel: HTMLDivElement | undefined = $state();
$effect(() => {
if (!open) return;
const trigger = document.activeElement as HTMLElement | null;
panel?.focus();
return () => trigger?.focus();
});
function onBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) onClose();
}
function focusableElements(): HTMLElement[] {
if (!panel) return [];
return Array.from(
panel.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
);
}
function onWindowKeydown(event: KeyboardEvent) {
if (!open) return;
if (event.key === 'Escape') {
onClose();
return;
}
if (event.key !== 'Tab') return;
const elements = focusableElements();
if (elements.length === 0) return;
const first = elements[0];
const last = elements[elements.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
{#if open}
<div
class="animate-fade fixed inset-0 z-(--z-modal) grid place-items-center bg-scrim/70 p-4 backdrop-blur-sm"
role="presentation"
onclick={onBackdropClick}
>
<div
bind:this={panel}
class="glass-panel animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
>
{#if title}
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
{#if eyebrow}
<p class="mb-2.5 text-eyebrow text-muted">
{eyebrow}
</p>
{/if}
<h2 class="text-display-3 text-fg">
{title}
</h2>
</div>
<IconButton label="Cerrar" onclick={onClose} class="shrink-0">
<X class="size-4" />
</IconButton>
</div>
{/if}
{@render children()}
</div>
</div>
{/if}