feat(shared-ui): extend PillNavigation with tab groups and migrate Picture app

- Add PillTabGroup component for segmented controls within navigation
- Extend PillNavigation with elements prop supporting tabs and dividers
- Add new types: PillTabOption, PillTabGroupConfig, PillDivider, PillNavElement
- Migrate Picture app from custom Sidebar to shared PillNavigation
- Add transparent filter bars to Gallery and Explore pages
- Add dev credentials auto-fill on logo click in shared LoginPage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-29 06:55:14 +01:00
parent af8bb9bcb0
commit 4eed41499a
8 changed files with 661 additions and 30 deletions

View file

@ -1,17 +1,50 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Sidebar from '$lib/components/layout/Sidebar.svelte';
import KeyboardShortcutsModal from '$lib/components/ui/KeyboardShortcutsModal.svelte';
import { currentTheme } from '$lib/stores/theme';
import { isSidebarCollapsed, toggleSidebar } from '$lib/stores/sidebar';
import { isUIVisible, toggleUI, showKeyboardShortcuts } from '$lib/stores/ui';
import { viewMode } from '$lib/stores/view';
import { page } from '$app/stores';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillNavElement } from '@manacore/shared-ui';
import KeyboardShortcutsModal from '$lib/components/ui/KeyboardShortcutsModal.svelte';
import { currentTheme, actualMode, toggleThemeMode } from '$lib/stores/theme';
import { isUIVisible, toggleUI, showKeyboardShortcuts } from '$lib/stores/ui';
import { viewMode, setViewMode, type ViewMode } from '$lib/stores/view';
import { browser } from '$app/environment';
let { children } = $props();
// PillNav state
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
// Load persisted nav state
$effect(() => {
if (browser) {
const savedSidebarMode = localStorage.getItem('picture-nav-sidebar');
const savedCollapsed = localStorage.getItem('picture-nav-collapsed');
if (savedSidebarMode !== null) isSidebarMode = savedSidebarMode === 'true';
if (savedCollapsed !== null) isCollapsed = savedCollapsed === 'true';
}
});
function handleModeChange(isSidebar: boolean) {
isSidebarMode = isSidebar;
if (browser) localStorage.setItem('picture-nav-sidebar', String(isSidebar));
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
if (browser) localStorage.setItem('picture-nav-collapsed', String(collapsed));
}
async function handleLogout() {
await authStore.signOut();
goto('/auth/login');
}
function handleToggleTheme() {
toggleThemeMode();
}
// Client-side auth check
$effect(() => {
if (authStore.initialized && !authStore.loading && !authStore.user) {
@ -19,9 +52,42 @@
}
});
// Navigation items
const navItems: PillNavItem[] = [
{ href: '/app/gallery', label: 'Galerie', icon: 'home' },
{ href: '/app/board', label: 'Moodboards', icon: 'grid' },
{ href: '/app/explore', label: 'Entdecken', icon: 'search' },
{ href: '/app/generate', label: 'Generieren', icon: 'fire' },
{ href: '/app/upload', label: 'Upload', icon: 'upload' },
{ href: '/app/tags', label: 'Tags', icon: 'tag' },
{ href: '/app/archive', label: 'Archiv', icon: 'archive' },
{ href: '/app/subscription', label: 'Abo', icon: 'creditCard' },
];
// View mode options for tab group
const viewModeOptions = [
{ id: 'single', icon: 'list', title: 'Liste (1)' },
{ id: 'grid3', icon: 'grid', title: 'Mittel (2)' },
{ id: 'gridSmall', icon: 'gridSmall', title: 'Klein (3)' },
];
// Elements (divider + view mode tabs)
let elements: PillNavElement[] = $derived([
{ type: 'divider' as const },
{
type: 'tabs' as const,
sectionLabel: 'Ansicht',
options: viewModeOptions,
value: $viewMode === 'grid5' ? 'gridSmall' : $viewMode,
onChange: (id: string) => {
const mode = id === 'gridSmall' ? 'grid5' : id as ViewMode;
setViewMode(mode);
},
},
]);
// Global keyboard shortcuts
function handleKeyDown(e: KeyboardEvent) {
// Ignore if user is typing in an input/textarea
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
@ -65,19 +131,15 @@
break;
case '1':
e.preventDefault();
viewMode.set('single');
setViewMode('single');
break;
case '2':
e.preventDefault();
viewMode.set('grid3');
setViewMode('grid3');
break;
case '3':
e.preventDefault();
viewMode.set('grid5');
break;
case 's':
e.preventDefault();
toggleSidebar();
setViewMode('grid5');
break;
}
}
@ -96,20 +158,34 @@
</div>
{:else if authStore.user}
<div class="min-h-screen" style="background-color: {$currentTheme.background};">
<!-- Sidebar (conditionally visible) -->
<!-- PillNavigation (conditionally visible) -->
{#if $isUIVisible}
<Sidebar />
<PillNavigation
items={navItems}
{elements}
currentPath={$page.url.pathname}
appName="Picture"
homeRoute="/app/gallery"
onLogout={handleLogout}
onToggleTheme={handleToggleTheme}
isDark={$actualMode === 'dark'}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showLanguageSwitcher={false}
primaryColor="#3b82f6"
/>
{/if}
<!-- Main Content Area -->
<main
class="transition-all duration-300 {$isSidebarCollapsed || !$isUIVisible
? 'lg:pl-0'
: 'lg:pl-[17rem]'}"
class="main-content transition-all duration-300"
class:sidebar-mode={isSidebarMode && !isCollapsed && $isUIVisible}
class:floating-mode={!isSidebarMode && !isCollapsed && $isUIVisible}
>
<!-- Desktop: Left padding when sidebar is open -->
<!-- Mobile: Top padding for header + Bottom padding for nav -->
<div class="min-h-screen pb-20 pt-16 lg:pb-0 lg:pt-0">
<div class="min-h-screen">
{@render children?.()}
</div>
</main>
@ -118,3 +194,26 @@
<KeyboardShortcutsModal />
</div>
{/if}
<style>
/* Floating nav mode - add top padding for fixed nav */
.main-content.floating-mode {
padding-top: 80px;
}
/* Sidebar mode - add left padding for sidebar nav */
.main-content.sidebar-mode {
padding-left: 180px;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.main-content.floating-mode {
padding-top: 70px;
}
.main-content.sidebar-mode {
padding-left: 0;
padding-top: 70px;
}
}
</style>

View file

@ -167,6 +167,99 @@
<title>Entdecken - Picture</title>
</svelte:head>
<!-- Filter Bar -->
<div class="sticky top-0 z-20 px-4 py-3">
<div class="flex flex-wrap items-center gap-3">
<!-- Search -->
<div class="relative min-w-[200px] flex-1 max-w-md">
<input
type="text"
value={searchInput}
oninput={handleSearchInput}
placeholder="Bilder suchen..."
class="w-full rounded-full border border-gray-300/50 bg-white/80 px-4 py-2 pl-10 text-sm text-gray-900 placeholder-gray-500 backdrop-blur-xl transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600/50 dark:bg-gray-800/80 dark:text-gray-100 dark:placeholder-gray-400"
/>
<svg
class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{#if searchInput}
<button
onclick={() => {
searchInput = '';
exploreSearchQuery.set('');
loadInitialImages();
}}
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
<!-- Sort -->
<select
value={$exploreSortBy}
onchange={handleSortChange}
class="rounded-full border border-gray-300/50 bg-white/80 px-4 py-2 text-sm text-gray-900 backdrop-blur-xl focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600/50 dark:bg-gray-800/80 dark:text-gray-100"
>
<option value="recent">Neueste</option>
<option value="popular">Beliebt</option>
<option value="trending">Im Trend</option>
</select>
<!-- Favorites Toggle -->
<button
onclick={() => showExploreFavoritesOnly.update((v) => !v)}
class="inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all {$showExploreFavoritesOnly
? 'bg-blue-600 text-white'
: 'bg-gray-100/80 text-gray-700 backdrop-blur-xl hover:bg-gray-200/80 dark:bg-gray-800/80 dark:text-gray-300 dark:hover:bg-gray-700/80'}"
>
<svg
class="h-4 w-4"
fill={$showExploreFavoritesOnly ? 'currentColor' : 'none'}
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
<span>Favoriten</span>
</button>
<!-- Reset Filter -->
{#if $exploreSearchQuery || $showExploreFavoritesOnly || $exploreSortBy !== 'recent'}
<button
onclick={() => {
searchInput = '';
exploreSearchQuery.set('');
exploreSortBy.set('recent');
showExploreFavoritesOnly.set(false);
loadInitialImages();
}}
class="text-sm font-medium text-blue-600 hover:underline dark:text-blue-400"
>
Zurücksetzen
</button>
{/if}
</div>
</div>
{#if $isLoadingExplore && $exploreImages.length === 0}
<div class="px-4 py-8">
<ImageSkeleton count={20} />

View file

@ -18,6 +18,7 @@
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
import ImageSkeleton from '$lib/components/ui/ImageSkeleton.svelte';
import ViewModeSwitcher from '$lib/components/ui/ViewModeSwitcher.svelte';
import TagPills from '$lib/components/tags/TagPills.svelte';
import { onMount } from 'svelte';
let loadingMore = $state(false);
@ -119,6 +120,70 @@
<title>Gallery - Picture</title>
</svelte:head>
<!-- Filter Bar -->
<div class="sticky top-0 z-20 px-4 py-3">
<div class="flex flex-wrap items-center gap-3">
<!-- Favorites Toggle -->
<button
onclick={() => showFavoritesOnly.update((v) => !v)}
class="inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all {$showFavoritesOnly
? 'bg-blue-600 text-white'
: 'bg-gray-100/80 text-gray-700 backdrop-blur-xl hover:bg-gray-200/80 dark:bg-gray-800/80 dark:text-gray-300 dark:hover:bg-gray-700/80'}"
>
<svg
class="h-4 w-4"
fill={$showFavoritesOnly ? 'currentColor' : 'none'}
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
<span>Favoriten</span>
</button>
<!-- Tags -->
{#if $tags.length > 0}
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Tags:</span>
<TagPills />
</div>
{/if}
<!-- Reset Filter -->
{#if $selectedTags.length > 0 || $showFavoritesOnly}
<button
onclick={() => {
selectedTags.set([]);
showFavoritesOnly.set(false);
}}
class="ml-auto text-sm font-medium text-blue-600 hover:underline dark:text-blue-400"
>
Filter zurücksetzen
</button>
{/if}
</div>
<!-- Active Filter Summary -->
{#if $selectedTags.length > 0 || $showFavoritesOnly}
<div class="mt-2 rounded-lg bg-blue-50/80 px-3 py-1.5 backdrop-blur-xl dark:bg-blue-900/30">
<p class="text-xs font-medium text-blue-900 dark:text-blue-100">
{#if $showFavoritesOnly && $selectedTags.length > 0}
Favoriten + {$selectedTags.length} {$selectedTags.length === 1 ? 'Tag' : 'Tags'}
{:else if $showFavoritesOnly}
Nur Favoriten
{:else}
{$selectedTags.length} {$selectedTags.length === 1 ? 'Tag' : 'Tags'} ausgewählt
{/if}
</p>
</div>
{/if}
</div>
{#if $isLoading}
<div class="px-4 py-8">
<ImageSkeleton count={20} />