mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
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:
parent
af8bb9bcb0
commit
4eed41499a
8 changed files with 661 additions and 30 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -253,6 +253,12 @@
|
|||
emailInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Dev credentials auto-fill (only works in development)
|
||||
function fillDevCredentials() {
|
||||
email = 'till.schneider@memoro.ai';
|
||||
password = 'Aa-12345678';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -283,23 +289,24 @@
|
|||
<main>
|
||||
<!-- Top Section - Logo -->
|
||||
<div class="flex flex-col items-center justify-center pt-16 pb-8">
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full transition-all mb-4"
|
||||
<button
|
||||
type="button"
|
||||
onclick={fillDevCredentials}
|
||||
class="flex items-center justify-center rounded-full transition-all mb-4 cursor-pointer hover:scale-105 active:scale-95"
|
||||
class:success-pulse={showSuccess}
|
||||
style="width: 120px; height: 120px; border: 3px solid {showSuccess
|
||||
? '#22c55e'
|
||||
: primaryColor}; background-color: {isDark ? '#000' : '#fff'}; box-shadow: {isDark
|
||||
? '0 6px 12px rgba(0, 0, 0, 0.4)'
|
||||
: '0 6px 12px rgba(0, 0, 0, 0.15)'};"
|
||||
role="img"
|
||||
aria-label="{appName} logo"
|
||||
aria-label="{appName} logo - Click to fill dev credentials"
|
||||
>
|
||||
{#if showSuccess}
|
||||
<Icon name="check" size={55} color="#22c55e" />
|
||||
{:else}
|
||||
<Logo size={55} color={primaryColor} />
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<h1 class="text-2xl font-semibold" style="color: {isDark ? '#ffffff' : '#000000'};">
|
||||
{appName}
|
||||
</h1>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { PillNavItem, PillDropdownItem } from './types';
|
||||
import type { PillNavItem, PillDropdownItem, PillNavElement, PillTabGroupConfig } from './types';
|
||||
import PillDropdown from './PillDropdown.svelte';
|
||||
import PillTabGroup from './PillTabGroup.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Navigation items */
|
||||
|
|
@ -38,6 +39,10 @@
|
|||
showThemeToggle?: boolean;
|
||||
/** Primary color for active state (CSS custom property or hex) */
|
||||
primaryColor?: string;
|
||||
/** Additional elements (tab groups, dividers) to show after nav items */
|
||||
elements?: PillNavElement[];
|
||||
/** Show logout button */
|
||||
showLogout?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -58,8 +63,23 @@
|
|||
showLanguageSwitcher = false,
|
||||
showThemeToggle = true,
|
||||
primaryColor,
|
||||
elements = [],
|
||||
showLogout = true,
|
||||
}: Props = $props();
|
||||
|
||||
// Type guards for elements
|
||||
function isTabGroup(element: PillNavElement): element is PillTabGroupConfig {
|
||||
return 'type' in element && element.type === 'tabs';
|
||||
}
|
||||
|
||||
function isDivider(element: PillNavElement): element is { type: 'divider' } {
|
||||
return 'type' in element && element.type === 'divider';
|
||||
}
|
||||
|
||||
function isNavItem(element: PillNavElement): element is PillNavItem {
|
||||
return 'href' in element;
|
||||
}
|
||||
|
||||
// Local state for uncontrolled mode
|
||||
let internalSidebarMode = $state(false);
|
||||
let internalCollapsed = $state(false);
|
||||
|
|
@ -137,6 +157,10 @@
|
|||
chevronUp: 'M5 15l7-7 7 7',
|
||||
chevronLeft: 'M15 19l-7-7 7-7',
|
||||
menu: 'M4 6h16M4 12h16M4 18h16',
|
||||
fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z',
|
||||
grid: 'M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z',
|
||||
gridSmall:
|
||||
'M4 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM10 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1V5zM16 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1V5zM4 11a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1v-2zM10 11a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1v-2zM16 11a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1v-2z',
|
||||
};
|
||||
|
||||
function getIconPath(name: string): string {
|
||||
|
|
@ -233,6 +257,36 @@
|
|||
</a>
|
||||
{/each}
|
||||
|
||||
<!-- Additional Elements (Tab Groups, Dividers) -->
|
||||
{#each elements as element}
|
||||
{#if isTabGroup(element)}
|
||||
<PillTabGroup
|
||||
options={element.options}
|
||||
value={element.value}
|
||||
onChange={element.onChange}
|
||||
sectionLabel={element.sectionLabel}
|
||||
{isSidebarMode}
|
||||
{primaryColor}
|
||||
/>
|
||||
{:else if isDivider(element)}
|
||||
<div class="pill-divider" class:sidebar-divider={isSidebarMode}></div>
|
||||
{:else if isNavItem(element)}
|
||||
<a href={element.href} class="pill glass-pill" class:active={isActive(element.href)}>
|
||||
{#if element.icon}
|
||||
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={getIconPath(element.icon)}
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="pill-label">{element.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Language Switcher -->
|
||||
{#if showLanguageSwitcher && languageItems.length > 0}
|
||||
<PillDropdown
|
||||
|
|
@ -274,7 +328,7 @@
|
|||
{/if}
|
||||
|
||||
<!-- Logout -->
|
||||
{#if onLogout}
|
||||
{#if onLogout && showLogout}
|
||||
<button onclick={onLogout} class="pill glass-pill logout-pill" title="Logout">
|
||||
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
|
|
@ -429,6 +483,25 @@
|
|||
color: var(--pill-primary-color, var(--color-primary-500, #f8d62b));
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.pill-divider {
|
||||
width: 1px;
|
||||
height: 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
flex-shrink: 0;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
:global(.dark) .pill-divider {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.sidebar-divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Logout pill */
|
||||
.logout-pill {
|
||||
color: #dc2626;
|
||||
|
|
|
|||
251
packages/shared-ui/src/navigation/PillTabGroup.svelte
Normal file
251
packages/shared-ui/src/navigation/PillTabGroup.svelte
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
<script lang="ts">
|
||||
import type { PillTabOption } from './types';
|
||||
|
||||
interface Props {
|
||||
/** Tab options to display */
|
||||
options: PillTabOption[];
|
||||
/** Currently selected tab id */
|
||||
value: string;
|
||||
/** Called when selection changes */
|
||||
onChange: (id: string) => void;
|
||||
/** Optional section label */
|
||||
sectionLabel?: string;
|
||||
/** Whether in sidebar mode (affects layout) */
|
||||
isSidebarMode?: boolean;
|
||||
/** Primary color for active state */
|
||||
primaryColor?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
sectionLabel,
|
||||
isSidebarMode = false,
|
||||
primaryColor,
|
||||
}: Props = $props();
|
||||
|
||||
// Icon SVG paths (same as PillNavigation)
|
||||
const icons: Record<string, string> = {
|
||||
list: 'M4 6h16M4 10h16M4 14h16M4 18h16',
|
||||
grid: 'M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z',
|
||||
gridSmall:
|
||||
'M4 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM10 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1V5zM16 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1V5zM4 11a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1v-2zM10 11a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1v-2zM16 11a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1v-2z',
|
||||
heart:
|
||||
'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',
|
||||
star: 'M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z',
|
||||
clock: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z',
|
||||
trending: 'M13 7h8m0 0v8m0-8l-8 8-4-4-6 6',
|
||||
single: 'M4 6h16M4 12h16M4 18h16',
|
||||
};
|
||||
|
||||
function getIconPath(name: string): string {
|
||||
return icons[name] || '';
|
||||
}
|
||||
|
||||
function handleClick(optionId: string, disabled?: boolean) {
|
||||
if (!disabled) {
|
||||
onChange(optionId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="pill-tab-group" class:sidebar-mode={isSidebarMode}>
|
||||
{#if sectionLabel && isSidebarMode}
|
||||
<p class="section-label">{sectionLabel}</p>
|
||||
{/if}
|
||||
<div
|
||||
class="tab-container glass-pill"
|
||||
class:sidebar-tabs={isSidebarMode}
|
||||
style={primaryColor ? `--pill-primary-color: ${primaryColor}` : ''}
|
||||
>
|
||||
{#each options as option, index}
|
||||
{#if index > 0}
|
||||
<div class="tab-divider"></div>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => handleClick(option.id, option.disabled)}
|
||||
class="tab-btn"
|
||||
class:active={value === option.id}
|
||||
class:disabled={option.disabled}
|
||||
title={option.title || option.label}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{#if option.icon}
|
||||
{#if option.iconSvg}
|
||||
{@html option.iconSvg}
|
||||
{:else}
|
||||
<svg class="tab-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={getIconPath(option.icon)}
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if option.label && isSidebarMode}
|
||||
<span class="tab-label">{option.label}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pill-tab-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
:global(.dark) .section-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.tab-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* Glass effect */
|
||||
.glass-pill {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
:global(.dark) .glass-pill {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Sidebar mode - transparent */
|
||||
.sidebar-tabs {
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:global(.dark) .sidebar-tabs {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:global(.dark) .tab-btn {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.tab-btn:first-child {
|
||||
border-radius: 9999px 0 0 9999px;
|
||||
}
|
||||
|
||||
.tab-btn:last-child {
|
||||
border-radius: 0 9999px 9999px 0;
|
||||
}
|
||||
|
||||
.tab-btn:only-child {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.tab-btn:hover:not(.disabled) {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .tab-btn:hover:not(.disabled) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--pill-primary-color, var(--color-primary-500, #3b82f6)) 20%,
|
||||
white 80%
|
||||
);
|
||||
color: var(--pill-primary-color, var(--color-primary-500, #3b82f6));
|
||||
}
|
||||
|
||||
:global(.dark) .tab-btn.active {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--pill-primary-color, var(--color-primary-500, #3b82f6)) 30%,
|
||||
transparent 70%
|
||||
);
|
||||
color: var(--pill-primary-color, var(--color-primary-500, #3b82f6));
|
||||
}
|
||||
|
||||
.tab-btn.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tab-divider {
|
||||
width: 1px;
|
||||
height: 1rem;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:global(.dark) .tab-divider {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Sidebar mode adjustments */
|
||||
.sidebar-mode .tab-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-mode .tab-btn {
|
||||
justify-content: flex-start;
|
||||
padding: 0.5rem 0.625rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,6 +4,7 @@ export { default as Sidebar } from './Sidebar.svelte';
|
|||
export { default as SidebarSection } from './SidebarSection.svelte';
|
||||
export { default as PillNavigation } from './PillNavigation.svelte';
|
||||
export { default as PillDropdown } from './PillDropdown.svelte';
|
||||
export { default as PillTabGroup } from './PillTabGroup.svelte';
|
||||
export type {
|
||||
NavItem,
|
||||
NavbarProps,
|
||||
|
|
@ -13,4 +14,8 @@ export type {
|
|||
PillNavItem,
|
||||
PillDropdownItem,
|
||||
PillNavigationProps,
|
||||
PillTabOption,
|
||||
PillTabGroupConfig,
|
||||
PillDivider,
|
||||
PillNavElement,
|
||||
} from './types';
|
||||
|
|
|
|||
|
|
@ -39,6 +39,44 @@ export interface PillDropdownItem {
|
|||
active?: boolean;
|
||||
}
|
||||
|
||||
// ============ Pill Tab Group Types ============
|
||||
|
||||
export interface PillTabOption {
|
||||
/** Unique identifier for the tab */
|
||||
id: string;
|
||||
/** Icon name (predefined) */
|
||||
icon?: string;
|
||||
/** Custom SVG icon HTML */
|
||||
iconSvg?: string;
|
||||
/** Optional label (shown in sidebar mode) */
|
||||
label?: string;
|
||||
/** Tooltip text */
|
||||
title?: string;
|
||||
/** Whether this option is disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PillTabGroupConfig {
|
||||
/** Discriminator for type checking */
|
||||
type: 'tabs';
|
||||
/** Tab options */
|
||||
options: PillTabOption[];
|
||||
/** Currently selected tab id */
|
||||
value: string;
|
||||
/** Called when selection changes */
|
||||
onChange: (id: string) => void;
|
||||
/** Optional section label (shown above in sidebar mode) */
|
||||
sectionLabel?: string;
|
||||
}
|
||||
|
||||
export interface PillDivider {
|
||||
/** Discriminator for type checking */
|
||||
type: 'divider';
|
||||
}
|
||||
|
||||
/** Union type for all elements that can appear in PillNavigation */
|
||||
export type PillNavElement = PillNavItem | PillTabGroupConfig | PillDivider;
|
||||
|
||||
export interface PillNavigationProps {
|
||||
/** Navigation items */
|
||||
items: PillNavItem[];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue