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.
209 lines
7.0 KiB
Svelte
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>
|