🏗️ Build and Test JellyTau / Run Tests (push) Failing after 2m4s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Failing after 2m45s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped
212 lines
9.1 KiB
Svelte
212 lines
9.1 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy, setContext } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import { page } from "$app/stores";
|
|
import { commands } from "$lib/api/bindings";
|
|
import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
|
|
import { library } from "$lib/stores/library";
|
|
import { bottomUiHeight } from "$lib/stores/appState";
|
|
import { reservedBottomPadding } from "$lib/utils/layoutShell";
|
|
import { useScrollGuard } from "$lib/composables/useScrollGuard";
|
|
import Search from "$lib/components/Search.svelte";
|
|
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
|
|
|
// Scroll guard prevents accidental taps on library cards during/after scrolling (Android)
|
|
const scrollGuard = useScrollGuard(300);
|
|
setContext("scrollGuard", scrollGuard);
|
|
|
|
let { children } = $props();
|
|
|
|
let searchQuery = $state("");
|
|
let showOverflowMenu = $state(false);
|
|
let showSleepTimerModal = $state(false);
|
|
|
|
onMount(() => {
|
|
return () => {
|
|
scrollGuard.cleanup();
|
|
};
|
|
});
|
|
|
|
// Redirect to login if not authenticated
|
|
$effect(() => {
|
|
if (!$isAuthLoading && !$isAuthenticated) {
|
|
goto("/");
|
|
}
|
|
});
|
|
|
|
async function handleLogout() {
|
|
await auth.logout();
|
|
library.reset();
|
|
goto("/");
|
|
}
|
|
|
|
async function handleSearch(query: string) {
|
|
if (query.trim()) {
|
|
await library.search(query);
|
|
} else {
|
|
library.clearSearch();
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#if $isAuthLoading}
|
|
<div class="min-h-screen flex items-center justify-center">
|
|
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
|
</div>
|
|
{:else if $isAuthenticated}
|
|
<div class="h-screen flex flex-col overflow-hidden">
|
|
<!-- Header -->
|
|
<header class="sticky top-0 z-50 bg-[var(--color-background)]/95 backdrop-blur border-b border-gray-800 flex-shrink-0">
|
|
<div class="px-4 py-3 flex items-center gap-4">
|
|
<!-- Logo -->
|
|
<a href="/library" class="text-xl font-bold text-[var(--color-jellyfin)]">
|
|
JellyTau
|
|
</a>
|
|
|
|
<!-- Desktop Navigation -->
|
|
<nav class="hidden md:flex items-center gap-1">
|
|
<a
|
|
href="/"
|
|
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {$page.url.pathname === '/' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
|
>
|
|
Home
|
|
</a>
|
|
<a
|
|
href="/library"
|
|
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {$page.url.pathname.startsWith('/library') ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
|
>
|
|
Library
|
|
</a>
|
|
<a
|
|
href="/downloads"
|
|
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {$page.url.pathname === '/downloads' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
|
>
|
|
Downloads
|
|
</a>
|
|
<a
|
|
href="/settings"
|
|
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {$page.url.pathname === '/settings' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
|
>
|
|
Settings
|
|
</a>
|
|
</nav>
|
|
|
|
<!-- Search (desktop only) -->
|
|
<div class="flex-1 max-w-md hidden md:block">
|
|
<Search
|
|
bind:value={searchQuery}
|
|
placeholder="Search your library..."
|
|
onSearch={handleSearch}
|
|
/>
|
|
</div>
|
|
|
|
<!-- User menu -->
|
|
<div class="ml-auto flex items-center gap-3">
|
|
<span class="text-sm text-gray-400 hidden md:inline">{$currentUser?.name}</span>
|
|
|
|
<!-- Desktop: Downloads icon -->
|
|
<a
|
|
href="/downloads"
|
|
class="hidden md:block text-gray-400 hover:text-white transition-colors"
|
|
title="Downloads"
|
|
>
|
|
<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>
|
|
</a>
|
|
|
|
<!-- Mobile: Overflow menu button -->
|
|
<div class="relative md:hidden">
|
|
<button
|
|
onclick={() => showOverflowMenu = !showOverflowMenu}
|
|
class="text-gray-400 hover:text-white transition-colors"
|
|
title="More options"
|
|
>
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Overflow menu dropdown -->
|
|
{#if showOverflowMenu}
|
|
<!-- Backdrop to close menu when clicking outside -->
|
|
<div
|
|
class="fixed inset-0 z-40"
|
|
onclick={() => showOverflowMenu = false}
|
|
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') showOverflowMenu = false; }}
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Close menu"
|
|
></div>
|
|
|
|
<!-- Menu -->
|
|
<div class="absolute right-0 top-full mt-2 w-48 bg-[var(--color-surface)] rounded-lg shadow-lg border border-gray-700 py-1 z-50">
|
|
<a
|
|
href="/downloads"
|
|
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
|
|
onclick={() => showOverflowMenu = 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"
|
|
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
|
|
onclick={() => showOverflowMenu = 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>
|
|
<div class="border-t border-gray-700 my-1"></div>
|
|
<button
|
|
onclick={() => { showOverflowMenu = false; handleLogout(); }}
|
|
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>
|
|
|
|
<!-- Desktop: Logout button -->
|
|
<button
|
|
onclick={handleLogout}
|
|
class="hidden md:block text-gray-400 hover:text-white transition-colors"
|
|
title="Sign out"
|
|
>
|
|
<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>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Main content. The fixed bottom UI (mini player + nav) is owned entirely
|
|
by the root layout on every platform, and its live-measured height is
|
|
published to `bottomUiHeight`. We reserve exactly that here (plus a
|
|
little breathing room) so the last row never hides behind the nav. -->
|
|
<main
|
|
class="flex-1 overflow-y-auto p-4 min-h-0"
|
|
style="padding-bottom: {reservedBottomPadding($bottomUiHeight, 1)}; overscroll-behavior: contain"
|
|
onscroll={scrollGuard.onScroll}
|
|
>
|
|
{@render children()}
|
|
</main>
|
|
|
|
<!-- Sleep Timer Modal -->
|
|
<SleepTimerModal
|
|
isOpen={showSleepTimerModal}
|
|
onClose={() => showSleepTimerModal = false}
|
|
/>
|
|
</div>
|
|
{/if}
|