Files
jellytau/src/lib/components/account/AccountMenu.svelte
T
dtourolle f902caa07f feat(profiles): switch profile from the account menu
Switching was reachable only from Settings, which is the wrong place for
it: on a shared device changing who is watching is a frequent, front-door
action, not a configuration change buried three screens deep.

It sits directly under the identity block rather than among the
destinations (Downloads/Settings/Display), because it answers "who is
this?" and not "where do I go?". Shown even with one account, since the
picker is also where a second is added -- gating it on a second profile
existing would leave no way in from here.

The picker also gains a Back affordance when a session is already live.
Reaching it from the menu and changing your mind -- or failing a PIN on
someone else's tile -- previously had no way back to the session you
still had. At startup there is nothing behind it, so it stays hidden.
2026-08-30 19:38:53 +02:00

209 lines
7.0 KiB
Svelte

<!--
Shared account menu — one component for both breakpoints. Anchored to the
user's name/avatar, it groups the account-level destinations (Downloads,
Settings, Display) and Sign out. Available on every authenticated,
non-immersive screen via the shared AppHeader.
TRACES: UR-054, UR-082 | DR-075, DR-276
-->
<script lang="ts">
import { tick } from "svelte";
import { goto } from "$app/navigation";
import { auth, currentUser, serverName, serverUrl } from "$lib/stores/auth";
import { library } from "$lib/stores/library";
let open = $state(false);
let triggerEl = $state<HTMLButtonElement | null>(null);
// Prefer the human server name; fall back to the bare host of the URL so the
// identity block always shows *something* server-identifying.
const serverHost = $derived.by(() => {
if ($serverName) return $serverName;
if (!$serverUrl) return "";
try {
return new URL($serverUrl).host;
} catch {
return $serverUrl;
}
});
const displayName = $derived($currentUser?.name ?? "Account");
const initial = $derived((displayName[0] ?? "?").toUpperCase());
async function close(returnFocus = true) {
open = false;
if (returnFocus) {
await tick();
triggerEl?.focus();
}
}
function toggle() {
open = !open;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape" && open) {
e.stopPropagation();
close();
}
}
async function handleLogout() {
await close(false);
await auth.logout();
library.reset();
goto("/");
}
</script>
<svelte:window onkeydown={onKeydown} />
<div class="relative">
<button
bind:this={triggerEl}
onclick={toggle}
class="flex items-center gap-2 rounded-full py-1 pl-1 pr-1 md:pr-3 text-gray-300 hover:text-white hover:bg-[var(--color-surface)] transition-colors"
aria-haspopup="menu"
aria-expanded={open}
aria-label="Account menu"
>
<span
class="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--color-jellyfin)] text-sm font-semibold text-white"
aria-hidden="true"
>
{initial}
</span>
<span class="hidden md:inline text-sm">{displayName}</span>
</button>
{#if open}
<!-- Backdrop closes the menu on any outside click. -->
<div
class="fixed inset-0 z-40"
onclick={() => close()}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") close();
}}
role="button"
tabindex="-1"
aria-label="Close account menu"
></div>
<div
class="absolute right-0 top-full mt-2 w-56 bg-[var(--color-surface)] rounded-lg shadow-lg border border-gray-700 py-1 z-50"
role="menu"
>
<!-- Identity block — not interactive. -->
<div class="px-4 py-3">
<p class="text-xs text-gray-500">Signed in as</p>
<p class="text-sm font-semibold text-white truncate">{displayName}</p>
{#if serverHost}
<p class="text-xs text-gray-400 truncate">{serverHost}</p>
{/if}
</div>
<div class="border-t border-gray-700 my-1"></div>
<!--
Grouped with the identity block above rather than with the destinations
below: this changes *who* the app is, not where it goes. Shown even on a
device with one account, because the picker is also where a second one is
added — hiding it until a second profile exists would leave no way in
from here. (DR-276)
-->
<a
href="/profiles"
role="menuitem"
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
onclick={() => close(false)}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
Switch profile
</a>
<div class="border-t border-gray-700 my-1"></div>
<a
href="/downloads"
role="menuitem"
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
onclick={() => close(false)}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Downloads
</a>
<a
href="/settings"
role="menuitem"
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
onclick={() => close(false)}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Settings
</a>
<a
href="/settings#display"
role="menuitem"
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
onclick={() => close(false)}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v10a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM8 20h8"
/>
</svg>
Display
</a>
<div class="border-t border-gray-700 my-1"></div>
<button
onclick={handleLogout}
role="menuitem"
class="w-full flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
Sign out
</button>
</div>
{/if}
</div>