Compare commits

...

3 Commits

Author SHA1 Message Date
Oleg Proskurin 5590787f7f feat: scrollable layout 2025-11-30 01:54:06 +07:00
Oleg Proskurin 3579c8e4cf feat: lab layout 2025-11-30 01:53:45 +07:00
Oleg Proskurin f247191ead feat: init lab section 2025-11-29 23:04:30 +07:00
28 changed files with 870 additions and 28 deletions

View File

@ -42,6 +42,10 @@
"PERPLEXITY_TIMEOUT_MS": "600000"
}
},
"chrome-devtools": {
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest"]
},
"browsermcp": {
"type": "stdio",
"command": "npx",

View File

@ -0,0 +1,14 @@
'use client';
import { Section } from '@/components/shared/Section';
import { GenerateFormPlaceholder } from '@/components/lab/GenerateFormPlaceholder';
const GeneratePage = () => {
return (
<Section className="py-12 md:py-16 min-h-screen">
<GenerateFormPlaceholder />
</Section>
);
};
export default GeneratePage;

View File

@ -0,0 +1,28 @@
'use client';
import { Section } from '@/components/shared/Section';
const ImagesPage = () => {
return (
<Section className="py-12 md:py-16 min-h-screen">
<header className="mb-8 md:mb-12">
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3">
Image Library
</h1>
<p className="text-gray-400 text-base md:text-lg">
Browse and manage your generated images
</p>
</header>
<div className="p-8 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl">
<div className="text-center py-12">
<div className="text-6xl mb-4">🖼</div>
<h2 className="text-2xl font-semibold text-white mb-2">Image Browser</h2>
<p className="text-gray-400">Component will be implemented here</p>
</div>
</div>
</Section>
);
};
export default ImagesPage;

View File

@ -0,0 +1,55 @@
'use client';
/**
* Lab Section Layout
*
* Code Style:
* - Use `const` arrow function components (not function declarations)
* - Use `type` instead of `interface` for type definitions
* - Early returns for conditionals
* - No inline comments (JSDoc headers only)
* - Tailwind classes only
*
* Structure:
* - Layout components: src/components/layout/lab/
* - Feature components: src/components/lab/
* - Pages: src/app/lab/{section}/page.tsx
*
* Sub-navigation items:
* - /lab/generate - Image generation
* - /lab/images - Image library browser
* - /lab/live - Live generation testing
* - /lab/upload - File upload interface
*/
import { ReactNode } from 'react';
import { usePathname } from 'next/navigation';
import { ApiKeyWidget } from '@/components/shared/ApiKeyWidget/apikey-widget';
import { ApiKeyProvider } from '@/components/shared/ApiKeyWidget/apikey-context';
import { PageProvider } from '@/contexts/page-context';
import { LabLayout } from '@/components/layout/lab/LabLayout';
type LabLayoutWrapperProps = {
children: ReactNode;
};
const navItems = [
{ label: 'Generate', href: '/lab/generate' },
{ label: 'Images', href: '/lab/images' },
{ label: 'Live', href: '/lab/live' },
{ label: 'Upload', href: '/lab/upload' },
];
const LabLayoutWrapper = ({ children }: LabLayoutWrapperProps) => {
const pathname = usePathname();
return (
<ApiKeyProvider>
<PageProvider navItems={navItems} currentPath={pathname} rightSlot={<ApiKeyWidget />}>
<LabLayout>{children}</LabLayout>
</PageProvider>
</ApiKeyProvider>
);
};
export default LabLayoutWrapper;

View File

@ -0,0 +1,28 @@
'use client';
import { Section } from '@/components/shared/Section';
const LivePage = () => {
return (
<Section className="py-12 md:py-16 min-h-screen">
<header className="mb-8 md:mb-12">
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3">
Live Generation
</h1>
<p className="text-gray-400 text-base md:text-lg">
Real-time testing and experimentation workspace
</p>
</header>
<div className="p-8 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl">
<div className="text-center py-12">
<div className="text-6xl mb-4"></div>
<h2 className="text-2xl font-semibold text-white mb-2">Live Testing Interface</h2>
<p className="text-gray-400">Component will be implemented here</p>
</div>
</div>
</Section>
);
};
export default LivePage;

View File

@ -0,0 +1,16 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
const LabPage = () => {
const router = useRouter();
useEffect(() => {
router.replace('/lab/generate');
}, [router]);
return null;
};
export default LabPage;

View File

@ -0,0 +1,28 @@
'use client';
import { Section } from '@/components/shared/Section';
const UploadPage = () => {
return (
<Section className="py-12 md:py-16 min-h-screen">
<header className="mb-8 md:mb-12">
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3">
File Upload
</h1>
<p className="text-gray-400 text-base md:text-lg">
Upload and manage reference images for generation
</p>
</header>
<div className="p-8 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl">
<div className="text-center py-12">
<div className="text-6xl mb-4">📤</div>
<h2 className="text-2xl font-semibold text-white mb-2">Upload Interface</h2>
<p className="text-gray-400">Component will be implemented here</p>
</div>
</div>
</Section>
);
};
export default UploadPage;

View File

@ -0,0 +1,54 @@
'use client';
import Image from 'next/image';
import { LabScrollProvider, useLabScroll } from '@/contexts/lab-scroll-context';
const LabHeader = () => {
const { isScrolled } = useLabScroll();
return (
<header
className={`
relative z-10 border-b border-white/10 backdrop-blur-sm shrink-0
transition-all duration-300 ease-in-out
${isScrolled ? 'h-0 opacity-0 overflow-hidden border-b-0' : 'h-16 opacity-100'}
`}
>
<nav className="max-w-7xl mx-auto px-6 py-3 flex justify-between items-center h-16">
<div className="h-full flex items-center">
<Image
src="/banatie-logo-horisontal.png"
alt="Banatie Logo"
width={150}
height={40}
priority
className="h-full w-auto object-contain"
/>
</div>
<a
href="#waitlist"
className="text-sm text-gray-300 hover:text-white transition-colors"
>
Join Beta
</a>
</nav>
</header>
);
};
export default function LabLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<LabScrollProvider>
<div className="h-screen overflow-hidden flex flex-col">
<LabHeader />
<div className="flex-1 min-h-0">
{children}
</div>
</div>
</LabScrollProvider>
);
}

View File

@ -0,0 +1,37 @@
import Image from 'next/image';
import { Footer } from '@/components/shared/Footer';
export default function MainLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<>
<header className="relative z-10 border-b border-white/10 backdrop-blur-sm">
<nav className="max-w-7xl mx-auto px-6 py-3 flex justify-between items-center h-16">
<div className="h-full flex items-center">
<Image
src="/banatie-logo-horisontal.png"
alt="Banatie Logo"
width={150}
height={40}
priority
className="h-full w-auto object-contain"
/>
</div>
<a
href="#waitlist"
className="text-sm text-gray-300 hover:text-white transition-colors"
>
Join Beta
</a>
</nav>
</header>
{children}
<Footer />
</>
);
}

View File

@ -1,7 +1,5 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import Image from 'next/image';
import { Footer } from '@/components/shared/Footer';
import './globals.css';
const inter = Inter({
@ -71,38 +69,12 @@ export default function RootLayout({
</head>
<body className={`${inter.variable} antialiased`}>
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950">
{/* Animated gradient background */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 -left-1/4 w-96 h-96 bg-purple-600/10 rounded-full blur-3xl animate-pulse"></div>
<div className="absolute bottom-1/4 -right-1/4 w-96 h-96 bg-cyan-600/10 rounded-full blur-3xl animate-pulse delay-700"></div>
</div>
{/* Header */}
<header className="relative z-10 border-b border-white/10 backdrop-blur-sm">
<nav className="max-w-7xl mx-auto px-6 py-3 flex justify-between items-center h-16">
<div className="h-full flex items-center">
<Image
src="/banatie-logo-horisontal.png"
alt="Banatie Logo"
width={150}
height={40}
priority
className="h-full w-auto object-contain"
/>
</div>
<a
href="#waitlist"
className="text-sm text-gray-300 hover:text-white transition-colors"
>
Join Beta
</a>
</nav>
</header>
{/* Page content */}
{children}
<Footer />
</div>
</body>
</html>

View File

@ -0,0 +1,83 @@
'use client';
/**
* Filter Placeholder Component
*
* Checkbox/radio button group for sidebar filters.
* Supports both single-select (radio) and multi-select (checkbox) modes.
*
* Features:
* - Radio buttons for single selection
* - Checkboxes for multiple selection
* - Option counts (e.g., "All (127)")
* - Accessible keyboard navigation
* - Focus indicators
*/
import { useState } from 'react';
type FilterOption = {
id: string;
label: string;
count?: number;
};
type FilterPlaceholderProps = {
options: FilterOption[];
multiSelect?: boolean;
groupId: string;
};
export const FilterPlaceholder = ({
options,
multiSelect = false,
groupId,
}: FilterPlaceholderProps) => {
const [selected, setSelected] = useState<string[]>(multiSelect ? [] : [options[0]?.id || '']);
const handleSelect = (optionId: string) => {
if (multiSelect) {
setSelected((prev) =>
prev.includes(optionId) ? prev.filter((id) => id !== optionId) : [...prev, optionId]
);
} else {
setSelected([optionId]);
}
};
const isSelected = (optionId: string) => selected.includes(optionId);
return (
<div className="mt-2 space-y-1" role="group" aria-label={`${groupId} filters`}>
{options.map((option) => {
const checked = isSelected(option.id);
const inputId = `${groupId}-${option.id}`;
return (
<label
key={option.id}
htmlFor={inputId}
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm cursor-pointer transition-colors hover:bg-white/5 group"
>
<input
type={multiSelect ? 'checkbox' : 'radio'}
id={inputId}
name={groupId}
checked={checked}
onChange={() => handleSelect(option.id)}
className="w-4 h-4 bg-slate-800 border-slate-600 text-purple-600 focus:ring-2 focus:ring-purple-500 focus:ring-offset-0 rounded cursor-pointer"
/>
<span className={`flex-1 ${checked ? 'text-white font-medium' : 'text-gray-400 group-hover:text-gray-300'}`}>
{option.label}
</span>
{option.count !== undefined && (
<span className="text-xs text-gray-600">
{option.count}
</span>
)}
</label>
);
})}
</div>
);
};

View File

@ -0,0 +1,211 @@
'use client';
/**
* Generate Form Placeholder Component
*
* Main content placeholder for lab generation interface.
* Matches the /demo/tti page visual style with form card and results area.
*
* Features:
* - Header with title and description
* - Prompt textarea (similar to TTI page)
* - Options row with selects and buttons
* - Submit button with gradient styling
* - Results area placeholder
* - Keyboard shortcuts (Ctrl+Enter)
*/
import { useState, useRef, KeyboardEvent } from 'react';
export const GenerateFormPlaceholder = () => {
const [prompt, setPrompt] = useState('');
const [aspectRatio, setAspectRatio] = useState('1:1');
const [template, setTemplate] = useState('photorealistic');
const [generating, setGenerating] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleGenerate = () => {
if (!prompt.trim()) return;
setGenerating(true);
setTimeout(() => setGenerating(false), 2000);
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
handleGenerate();
}
};
return (
<div className="min-h-screen">
{/* Page Header */}
<header className="mb-8 md:mb-12">
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3">
Generate Workbench
</h1>
<p className="text-gray-400 text-base md:text-lg">
Create and experiment with AI-generated images
</p>
</header>
{/* Info Banner (Placeholder) */}
<div className="mb-6 p-5 bg-purple-900/10 border border-purple-700/50 rounded-2xl">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h3 className="text-lg font-semibold text-white mb-1">Lab Environment</h3>
<p className="text-sm text-gray-400">
This is an experimental interface for testing generation features
</p>
</div>
<div className="flex items-center gap-2 text-sm text-purple-400">
<span className="w-2 h-2 bg-purple-500 rounded-full animate-pulse"></span>
<span>Active</span>
</div>
</div>
</div>
{/* Generation Form Card */}
<section
className="mb-8 p-6 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl"
aria-label="Image Generation Form"
>
{/* Prompt Textarea */}
<div className="mb-4">
<label htmlFor="lab-prompt-input" className="block text-lg font-semibold text-white mb-3">
Your Prompt
</label>
<textarea
id="lab-prompt-input"
ref={textareaRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Describe the image you want to generate..."
disabled={generating}
rows={5}
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed resize-none"
aria-label="Image generation prompt"
/>
<p className="mt-2 text-xs text-gray-500">
Tip: Be specific and descriptive for better results
</p>
</div>
{/* Options Row */}
<div className="mb-4 grid grid-cols-1 md:grid-cols-3 gap-3">
{/* Aspect Ratio */}
<div>
<label
htmlFor="lab-aspect-ratio"
className="block text-xs font-medium text-gray-400 mb-1.5"
>
Aspect Ratio
</label>
<select
id="lab-aspect-ratio"
value={aspectRatio}
onChange={(e) => setAspectRatio(e.target.value)}
disabled={generating}
className="w-full px-3 py-2 text-sm bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="1:1">Square (1:1)</option>
<option value="3:4">Portrait (3:4)</option>
<option value="4:3">Landscape (4:3)</option>
<option value="9:16">Vertical (9:16)</option>
<option value="16:9">Widescreen (16:9)</option>
<option value="21:9">Ultrawide (21:9)</option>
</select>
</div>
{/* Template */}
<div>
<label htmlFor="lab-template" className="block text-xs font-medium text-gray-400 mb-1.5">
Style Template
</label>
<select
id="lab-template"
value={template}
onChange={(e) => setTemplate(e.target.value)}
disabled={generating}
className="w-full px-3 py-2 text-sm bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="photorealistic">Photorealistic</option>
<option value="illustration">Illustration</option>
<option value="minimalist">Minimalist</option>
<option value="sticker">Sticker</option>
<option value="product">Product</option>
<option value="comic">Comic</option>
<option value="general">General</option>
</select>
</div>
{/* Advanced Options Button */}
<div>
<label className="block text-xs font-medium text-gray-400 mb-1.5">
Advanced Options
</label>
<button
disabled={generating}
className="w-full px-3 py-2 text-sm bg-slate-800 border border-slate-700 rounded-lg text-gray-400 hover:text-white hover:bg-slate-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
aria-label="Advanced options"
>
<span></span>
<span>Configure</span>
</button>
</div>
</div>
{/* Submit Button Row */}
<div className="flex items-center justify-between gap-4 flex-wrap pt-2 border-t border-slate-700/50">
<div className="text-sm text-gray-500">
{generating ? (
<span className="flex items-center gap-2">
<span className="w-2 h-2 bg-purple-500 rounded-full animate-pulse"></span>
Generating...
</span>
) : (
'Press Ctrl+Enter to submit'
)}
</div>
<button
onClick={handleGenerate}
disabled={generating || !prompt.trim()}
className="px-6 py-2.5 rounded-lg bg-gradient-to-r from-purple-600 to-cyan-600 text-white font-semibold hover:from-purple-500 hover:to-cyan-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-purple-900/30 focus:ring-2 focus:ring-purple-500"
>
{generating ? 'Generating...' : 'Generate Images'}
</button>
</div>
</section>
{/* Results Area Placeholder */}
<section className="space-y-6" aria-label="Generated Results">
<div className="flex items-center justify-between">
<h2 className="text-xl md:text-2xl font-bold text-white">Results</h2>
<button className="text-sm text-gray-500 hover:text-gray-300 transition-colors">
Clear All
</button>
</div>
{/* Empty State */}
<div className="p-12 bg-slate-900/50 backdrop-blur-sm border border-slate-700 rounded-2xl text-center">
<div className="mb-4 text-5xl">🎨</div>
<h3 className="text-lg font-semibold text-white mb-2">No results yet</h3>
<p className="text-gray-400 text-sm">
Generate your first image to see results here
</p>
</div>
{/* Result Cards Placeholder (would appear after generation) */}
{generating && (
<div className="p-6 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl animate-pulse">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="aspect-square bg-slate-800 rounded-lg"></div>
<div className="aspect-square bg-slate-800 rounded-lg"></div>
</div>
</div>
)}
</section>
</div>
);
};

View File

@ -0,0 +1,62 @@
'use client';
/**
* Lab Footer Component
*
* Simple 1-line footer for lab section with contextual navigation links.
* Displays copyright on left and contextual docs/API links on right.
*/
import { usePathname } from 'next/navigation';
import Link from 'next/link';
type LinkMapEntry = {
docs: string;
api: string;
};
type LinkMap = {
[key: string]: LinkMapEntry;
};
const linkMap: LinkMap = {
'/lab/generate': { docs: '/docs', api: '/docs/api/text-to-image' },
'/lab/images': { docs: '/docs', api: '/docs/api/images' },
'/lab/live': { docs: '/docs', api: '/docs/api/text-to-image' },
'/lab/upload': { docs: '/docs', api: '/docs/api/upload' },
};
export const LabFooter = () => {
const pathname = usePathname();
const links = linkMap[pathname] || { docs: '/docs', api: '/docs/api' };
return (
<footer
className="border-t border-white/10 bg-slate-950/50 backdrop-blur-sm"
role="contentinfo"
>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 md:gap-0 px-6 py-4 md:h-14">
{/* Left: Copyright */}
<p className="text-sm text-gray-500 order-2 md:order-1">
© 2025 Banatie. Built for builders who create.
</p>
{/* Right: Contextual Links */}
<nav aria-label="Footer navigation" className="flex items-center gap-6 order-1 md:order-2">
<Link
href={links.docs}
className="text-sm text-gray-500 hover:text-white transition-colors min-h-[44px] flex items-center"
>
Documentation
</Link>
<Link
href={links.api}
className="text-sm text-gray-500 hover:text-white transition-colors min-h-[44px] flex items-center"
>
API Reference
</Link>
</nav>
</div>
</footer>
);
};

View File

@ -0,0 +1,76 @@
'use client';
/**
* Lab Layout Component
*
* Main layout wrapper for the Lab section combining sidebar and content.
* Uses ThreeColumnLayout for consistent column structure across the app.
*
* Structure:
* - Left: LabSidebar (w-64, hidden lg:block, scrollable)
* - Center: Scrollable content area + fixed LabFooter at bottom
* - Right: Reserved for future use (TOC, preview panels, etc.)
*
* The center column uses a fixed height container with:
* - Scrollable content area (flex-1 overflow-y-auto)
* - Sticky footer always visible at bottom
*
* This layout is rendered inside PageProvider context which provides:
* - SubsectionNav at the top
* - ApiKeyWidget in the right slot
* - AnimatedBackground
*
* Scroll Detection:
* - Detects scroll in the main content area
* - After 50px threshold, collapses the main header via LabScrollContext
* - Height adjusts dynamically: 100vh-7rem (header visible) 100vh-3rem (header hidden)
*/
import { ReactNode, useRef, useEffect, useCallback } from 'react';
import { ThreeColumnLayout } from '@/components/layout/ThreeColumnLayout';
import { LabSidebar } from './LabSidebar';
import { LabFooter } from './LabFooter';
import { useLabScroll } from '@/contexts/lab-scroll-context';
type LabLayoutProps = {
children: ReactNode;
};
const SCROLL_THRESHOLD = 50;
export const LabLayout = ({ children }: LabLayoutProps) => {
const { isScrolled, setIsScrolled } = useLabScroll();
const contentRef = useRef<HTMLDivElement>(null);
const handleScroll = useCallback(() => {
if (!contentRef.current) return;
const scrollTop = contentRef.current.scrollTop;
setIsScrolled(scrollTop > SCROLL_THRESHOLD);
}, [setIsScrolled]);
useEffect(() => {
const contentElement = contentRef.current;
if (!contentElement) return;
contentElement.addEventListener('scroll', handleScroll);
return () => contentElement.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
const containerHeight = isScrolled ? 'h-[calc(100vh-3rem)]' : 'h-[calc(100vh-7rem)]';
return (
<ThreeColumnLayout
left={
<div className={`border-r border-white/10 bg-slate-950/50 backdrop-blur-sm ${containerHeight} overflow-y-auto transition-all duration-300`}>
<LabSidebar />
</div>
}
center={
<div className={`flex flex-col ${containerHeight} transition-all duration-300`}>
<div ref={contentRef} className="flex-1 overflow-y-auto min-h-0">{children}</div>
<LabFooter />
</div>
}
/>
);
};

View File

@ -0,0 +1,135 @@
'use client';
/**
* Lab Sidebar - Filter Panel
*
* Narrow left sidebar for filtering lab content.
* Matches DocsSidebar visual style with collapsible filter sections.
*
* Features:
* - Multiple filter groups (Status, Date Range, Source Type)
* - Collapsible sections
* - Checkbox/radio button controls
* - Clean, minimal design
*/
import { useState } from 'react';
import { FilterPlaceholder } from '@/components/lab/FilterPlaceholder';
type FilterGroupProps = {
id: string;
label: string;
icon: string;
options: Array<{ id: string; label: string; count?: number }>;
multiSelect?: boolean;
};
const filterGroups: FilterGroupProps[] = [
{
id: 'status',
label: 'Status',
icon: '📊',
options: [
{ id: 'all', label: 'All', count: 127 },
{ id: 'completed', label: 'Completed', count: 98 },
{ id: 'pending', label: 'Pending', count: 23 },
{ id: 'failed', label: 'Failed', count: 6 },
],
multiSelect: false,
},
{
id: 'date',
label: 'Date Range',
icon: '📅',
options: [
{ id: 'today', label: 'Today', count: 12 },
{ id: 'week', label: 'Last 7 days', count: 45 },
{ id: 'month', label: 'Last 30 days', count: 89 },
{ id: 'all-time', label: 'All time', count: 127 },
],
multiSelect: false,
},
{
id: 'source',
label: 'Source Type',
icon: '🎨',
options: [
{ id: 'text', label: 'Text-to-Image', count: 78 },
{ id: 'upload', label: 'Uploads', count: 34 },
{ id: 'reference', label: 'Reference Images', count: 15 },
],
multiSelect: true,
},
];
export const LabSidebar = () => {
const [expandedSections, setExpandedSections] = useState<string[]>(['status', 'date', 'source']);
const toggleSection = (id: string) => {
setExpandedSections((prev) =>
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]
);
};
const isExpanded = (id: string) => expandedSections.includes(id);
return (
<aside className="h-full bg-slate-950 border-r border-white/10" aria-label="Lab filters">
{/* Header */}
<div className="p-6 border-b border-slate-800">
<h2 className="text-lg font-semibold text-white">Filters</h2>
<p className="text-xs text-gray-500 mt-1">Refine your results</p>
</div>
{/* Filter Groups */}
<div className="p-4 space-y-4">
{filterGroups.map((group) => {
const expanded = isExpanded(group.id);
return (
<div key={group.id} className="border-b border-slate-800 pb-4 last:border-b-0">
{/* Section Header */}
<button
onClick={() => toggleSection(group.id)}
className="w-full flex items-center justify-between px-2 py-2 rounded-lg text-sm transition-colors text-gray-400 hover:text-white hover:bg-white/5"
aria-expanded={expanded}
>
<span className="flex items-center gap-2">
<span className="text-base">{group.icon}</span>
<span className="font-medium">{group.label}</span>
</span>
<svg
className={`w-4 h-4 transition-transform ${expanded ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Filter Options */}
{expanded && (
<FilterPlaceholder
options={group.options}
multiSelect={group.multiSelect}
groupId={group.id}
/>
)}
</div>
);
})}
</div>
{/* Bottom Actions */}
<div className="mt-auto p-4 border-t border-slate-800">
<button
className="w-full px-3 py-2 text-sm text-gray-500 hover:text-gray-300 transition-colors rounded-lg hover:bg-white/5"
aria-label="Reset all filters"
>
Reset Filters
</button>
</div>
</aside>
);
};

View File

@ -0,0 +1,39 @@
'use client';
/**
* Lab Scroll Context
*
* Shares scroll state between the main content area and the header.
* Used to collapse the main header when user scrolls in the lab section.
*/
import { createContext, useContext, useState, ReactNode } from 'react';
type LabScrollContextValue = {
isScrolled: boolean;
setIsScrolled: (value: boolean) => void;
};
const LabScrollContext = createContext<LabScrollContextValue | null>(null);
export const useLabScroll = () => {
const context = useContext(LabScrollContext);
if (!context) {
throw new Error('useLabScroll must be used within LabScrollProvider');
}
return context;
};
type LabScrollProviderProps = {
children: ReactNode;
};
export const LabScrollProvider = ({ children }: LabScrollProviderProps) => {
const [isScrolled, setIsScrolled] = useState(false);
return (
<LabScrollContext.Provider value={{ isScrolled, setIsScrolled }}>
{children}
</LabScrollContext.Provider>
);
};