feat: init lab section

This commit is contained in:
Oleg Proskurin 2025-11-29 22:57:07 +07:00
parent c148c53013
commit f247191ead
11 changed files with 641 additions and 0 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,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,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}
/>
);
};

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>
);
};