feat: init lab section
This commit is contained in:
parent
c148c53013
commit
f247191ead
|
|
@ -42,6 +42,10 @@
|
|||
"PERPLEXITY_TIMEOUT_MS": "600000"
|
||||
}
|
||||
},
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "chrome-devtools-mcp@latest"]
|
||||
},
|
||||
"browsermcp": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
'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)
|
||||
* - Center: Page content (flex-1)
|
||||
* - Right: Reserved for future use (TOC, preview panels, etc.)
|
||||
*
|
||||
* This layout is rendered inside PageProvider context which provides:
|
||||
* - SubsectionNav at the top
|
||||
* - ApiKeyWidget in the right slot
|
||||
* - AnimatedBackground
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { ThreeColumnLayout } from '@/components/layout/ThreeColumnLayout';
|
||||
import { LabSidebar } from './LabSidebar';
|
||||
|
||||
type LabLayoutProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const LabLayout = ({ children }: LabLayoutProps) => {
|
||||
return (
|
||||
<ThreeColumnLayout
|
||||
left={
|
||||
<div className="border-r border-white/10 bg-slate-950/50 backdrop-blur-sm sticky top-12 h-[calc(100vh-3rem)] overflow-y-auto">
|
||||
<LabSidebar />
|
||||
</div>
|
||||
}
|
||||
center={children}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in New Issue