feat: init lab section
This commit is contained in:
parent
c148c53013
commit
f247191ead
|
|
@ -42,6 +42,10 @@
|
||||||
"PERPLEXITY_TIMEOUT_MS": "600000"
|
"PERPLEXITY_TIMEOUT_MS": "600000"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"chrome-devtools": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "chrome-devtools-mcp@latest"]
|
||||||
|
},
|
||||||
"browsermcp": {
|
"browsermcp": {
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "npx",
|
"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