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,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;

View 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>

View file

@ -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';

View file

@ -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[];