platform-codebase/@packages/@ui/ui-data/src/Gallery.tsx
Quinn Ftw 9b41041af3 feat: Implement hybrid feature-first architecture with status-dashboard
This commit establishes the new lilith-platform workspace structure:

Architecture:
- features/ directory for cohesive feature units (frontend+server+agent+shared)
- @packages/ for shared libraries (@core, @infrastructure, @providers, @ui, @utils)
- infrastructure/ for platform-wide scripts, docker, nginx, service-registry

Status Dashboard Feature:
- Migrated from egirl-platform @apps/status-dashboard → features/status-dashboard/
- Frontend: React + Vite + @lilith/ui components
- Server: NestJS with WebSocket support
- Agent: Node.js metrics collector
- Infrastructure: Deploy script for VPS

Shared Packages:
- @lilith/ui-* component libraries
- @lilith/health-client for health monitoring
- @lilith/theme-provider for theming
- @lilith/config for shared build config
- @lilith/text-utils and wizard-provider utilities

Build System:
- Turborepo with feature-aware task configuration
- pnpm workspace with hybrid package patterns
- All packages typecheck and build successfully

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-23 18:40:37 -08:00

444 lines
12 KiB
TypeScript

/**
* Gallery Component
*
* Responsive image grid with lightbox viewer.
* Supports lazy loading, keyboard navigation, and category filtering.
* Theme-adaptive via semantic tokens.
*/
import { useState, useEffect, useCallback } from 'react'
import styled from 'styled-components'
export interface GalleryImage {
/** Image source URL */
src: string
/** Image alt text */
alt: string
/** Optional thumbnail (for performance) */
thumbnail?: string
/** Image title */
title?: string
/** Image description */
description?: string
/** Category */
category?: string
}
export interface GalleryProps {
/** Array of images */
images: GalleryImage[]
/** Number of columns (responsive) */
columns?: 2 | 3 | 4
/** Enable category filtering */
showFilters?: boolean
/** Gap between images (theme spacing key) */
gap?: 'sm' | 'md' | 'lg' | 'xl'
/** Custom className */
className?: string
}
const GalleryContainer = styled.div`
width: 100%;
`
const FilterBar = styled.div`
display: flex;
gap: ${props => props.theme.spacing.md};
margin-bottom: ${props => props.theme.spacing.lg};
flex-wrap: wrap;
`
const FilterButton = styled.button<{ $isActive: boolean }>`
padding: ${props => props.theme.spacing.sm} ${props => props.theme.spacing.md};
font-family: ${props => props.theme.typography.fontFamily.body};
font-size: ${props => props.theme.typography.fontSize.sm};
font-weight: ${props => props.theme.typography.fontWeight.medium};
color: ${({ $isActive, theme }) =>
$isActive ? '#ffffff' : theme.colors.text.primary};
background-color: ${({ $isActive, theme }) =>
$isActive ? theme.colors.primary : 'transparent'};
border: 2px solid
${({ $isActive, theme }) =>
$isActive ? theme.colors.primary : theme.colors.border};
border-radius: ${props => props.theme.borderRadius.full};
cursor: pointer;
transition: all ${props => props.theme.transitions.normal};
text-transform: capitalize;
&:hover {
border-color: ${props => props.theme.colors.primary};
background-color: ${({ $isActive, theme }) =>
$isActive ? theme.colors.primary : theme.colors.hover.surface};
}
&:focus-visible {
outline: 2px solid ${props => props.theme.colors.primary};
outline-offset: 2px;
}
`
const Grid = styled.div<{ $columns: number; $gap: string }>`
display: grid;
grid-template-columns: repeat(${({ $columns }) => $columns}, 1fr);
gap: ${({ $gap, theme }) => (theme.spacing as Record<string, string>)[$gap] || theme.spacing.md};
@media (max-width: ${props => props.theme.breakpoints.lg}) {
grid-template-columns: repeat(
${({ $columns }) => Math.max(1, $columns - 1)},
1fr
);
}
@media (max-width: ${props => props.theme.breakpoints.md}) {
grid-template-columns: repeat(
${({ $columns }) => Math.max(1, $columns - 2)},
1fr
);
}
`
const GalleryItem = styled.button`
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: ${props => props.theme.borderRadius.lg};
cursor: pointer;
border: none;
padding: 0;
background: none;
transition: transform ${props => props.theme.transitions.normal};
&:hover {
transform: scale(1.02);
}
&:focus-visible {
outline: 2px solid ${props => props.theme.colors.primary};
outline-offset: 4px;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform ${props => props.theme.transitions.slow};
}
&:hover img {
transform: scale(1.1);
}
`
const Overlay = styled.div`
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
opacity: 0;
transition: opacity ${props => props.theme.transitions.normal};
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-family: ${props => props.theme.typography.fontFamily.heading};
font-size: ${props => props.theme.typography.fontSize.lg};
padding: ${props => props.theme.spacing.md};
text-align: center;
${GalleryItem}:hover & {
opacity: 1;
}
`
const Lightbox = styled.div<{ $isOpen: boolean }>`
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.9);
z-index: ${props => props.theme.zIndex.modal};
display: ${({ $isOpen }) => ($isOpen ? 'flex' : 'none')};
align-items: center;
justify-content: center;
padding: ${props => props.theme.spacing.lg};
opacity: ${({ $isOpen }) => ($isOpen ? 1 : 0)};
transition: opacity ${props => props.theme.transitions.normal};
@media (max-width: ${props => props.theme.breakpoints.md}) {
padding: ${props => props.theme.spacing.md};
}
`
const LightboxContent = styled.div`
position: relative;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
align-items: center;
gap: ${props => props.theme.spacing.md};
`
const LightboxImage = styled.img`
max-width: 100%;
max-height: 80vh;
border-radius: ${props => props.theme.borderRadius.lg};
box-shadow: ${props => props.theme.shadows.xl};
`
const LightboxInfo = styled.div`
text-align: center;
color: #ffffff;
max-width: 600px;
`
const LightboxTitle = styled.h3`
font-family: ${props => props.theme.typography.fontFamily.heading};
font-size: ${props => props.theme.typography.fontSize.xl};
margin-bottom: ${props => props.theme.spacing.sm};
`
const LightboxDescription = styled.p`
font-family: ${props => props.theme.typography.fontFamily.body};
font-size: ${props => props.theme.typography.fontSize.base};
color: #ffffff;
opacity: 0.9;
`
const LightboxButton = styled.button`
position: absolute;
background: rgba(255, 255, 255, 0.1);
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: ${props => props.theme.borderRadius.full};
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
cursor: pointer;
transition: all ${props => props.theme.transitions.normal};
backdrop-filter: blur(10px);
&:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.5);
}
&:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
svg {
width: 24px;
height: 24px;
}
`
const CloseButton = styled(LightboxButton)`
top: ${props => props.theme.spacing.md};
right: ${props => props.theme.spacing.md};
`
const PrevButton = styled(LightboxButton)`
left: ${props => props.theme.spacing.md};
top: 50%;
transform: translateY(-50%);
`
const NextButton = styled(LightboxButton)`
right: ${props => props.theme.spacing.md};
top: 50%;
transform: translateY(-50%);
`
export function Gallery({
images,
columns = 3,
showFilters = false,
gap = 'md',
className,
}: GalleryProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const [activeFilter, setActiveFilter] = useState<string>('all')
// Get unique categories
const categories = Array.from(
new Set(images.map((img) => img.category).filter(Boolean))
)
// Filter images
const filteredImages =
activeFilter === 'all'
? images
: images.filter((img) => img.category === activeFilter)
// Keyboard navigation
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (selectedIndex === null) return
switch (e.key) {
case 'Escape':
setSelectedIndex(null)
break
case 'ArrowLeft':
e.preventDefault()
setSelectedIndex((prev) =>
prev !== null && prev > 0 ? prev - 1 : prev
)
break
case 'ArrowRight':
e.preventDefault()
setSelectedIndex((prev) =>
prev !== null && prev < filteredImages.length - 1 ? prev + 1 : prev
)
break
}
},
[selectedIndex, filteredImages.length]
)
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])
// Prevent body scroll when lightbox is open
useEffect(() => {
if (selectedIndex !== null) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [selectedIndex])
const handleNext = () => {
if (selectedIndex !== null && selectedIndex < filteredImages.length - 1) {
setSelectedIndex(selectedIndex + 1)
}
}
const handlePrev = () => {
if (selectedIndex !== null && selectedIndex > 0) {
setSelectedIndex(selectedIndex - 1)
}
}
const selectedImage =
selectedIndex !== null ? filteredImages[selectedIndex] : null
return (
<GalleryContainer className={className}>
{showFilters && categories.length > 0 && (
<FilterBar>
<FilterButton
$isActive={activeFilter === 'all'}
onClick={() => setActiveFilter('all')}
>
All
</FilterButton>
{categories.map((category) => (
<FilterButton
key={category}
$isActive={activeFilter === category}
onClick={() => setActiveFilter(category!)}
>
{category}
</FilterButton>
))}
</FilterBar>
)}
<Grid $columns={columns} $gap={gap}>
{filteredImages.map((image, index) => (
<GalleryItem
key={index}
onClick={() => setSelectedIndex(index)}
aria-label={`View ${image.alt}`}
>
<img
src={image.thumbnail || image.src}
alt={image.alt}
loading="lazy"
/>
{image.title && <Overlay>{image.title}</Overlay>}
</GalleryItem>
))}
</Grid>
<Lightbox
$isOpen={selectedIndex !== null}
onClick={() => setSelectedIndex(null)}
role="dialog"
aria-modal="true"
aria-label="Image viewer"
>
{selectedImage && (
<LightboxContent onClick={(e) => e.stopPropagation()}>
<CloseButton
onClick={() => setSelectedIndex(null)}
aria-label="Close"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</CloseButton>
{selectedIndex !== null && selectedIndex > 0 && (
<PrevButton onClick={handlePrev} aria-label="Previous image">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z"
clipRule="evenodd"
/>
</svg>
</PrevButton>
)}
{selectedIndex !== null && selectedIndex < filteredImages.length - 1 && (
<NextButton onClick={handleNext} aria-label="Next image">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
clipRule="evenodd"
/>
</svg>
</NextButton>
)}
<LightboxImage src={selectedImage.src} alt={selectedImage.alt} />
{(selectedImage.title || selectedImage.description) && (
<LightboxInfo>
{selectedImage.title && (
<LightboxTitle>{selectedImage.title}</LightboxTitle>
)}
{selectedImage.description && (
<LightboxDescription>
{selectedImage.description}
</LightboxDescription>
)}
</LightboxInfo>
)}
</LightboxContent>
)}
</Lightbox>
</GalleryContainer>
)
}