feat: add page-context

This commit is contained in:
Oleg Proskurin 2025-10-26 17:37:23 +07:00
parent 2f8d239da0
commit d6a9cd6990
20 changed files with 722 additions and 197 deletions

View File

@ -3,7 +3,6 @@
import { useState, useEffect, useCallback } from 'react';
import { useApiKey } from '@/components/shared/ApiKeyWidget/apikey-context';
import { Section } from '@/components/shared/Section';
import { ImageZoomModal } from '@/components/demo/ImageZoomModal';
import { ImageGrid } from '@/components/demo/gallery/ImageGrid';
import { EmptyGalleryState } from '@/components/demo/gallery/EmptyGalleryState';
@ -46,7 +45,6 @@ export default function GalleryPage() {
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState('');
const [zoomedImageUrl, setZoomedImageUrl] = useState<string | null>(null);
const [downloadTimes, setDownloadTimes] = useState<DownloadTimeMap>({});
useEffect(() => {
@ -164,7 +162,6 @@ export default function GalleryPage() {
<>
<ImageGrid
images={images}
onImageZoom={setZoomedImageUrl}
onDownloadMeasured={handleDownloadMeasured}
/>
@ -192,8 +189,6 @@ export default function GalleryPage() {
)}
</section>
)}
<ImageZoomModal imageUrl={zoomedImageUrl} onClose={() => setZoomedImageUrl(null)} />
</Section>
);
}

View File

@ -2,9 +2,9 @@
import { ReactNode } from 'react';
import { usePathname } from 'next/navigation';
import { SubsectionNav } from '@/components/shared/SubsectionNav';
import { ApiKeyWidget } from '@/components/shared/ApiKeyWidget/apikey-widget';
import { ApiKeyProvider } from '@/components/shared/ApiKeyWidget/apikey-context';
import { PageProvider } from '@/contexts/page-context';
interface DemoLayoutProps {
children: ReactNode;
@ -21,19 +21,13 @@ export default function DemoLayout({ children }: DemoLayoutProps) {
return (
<ApiKeyProvider>
<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>
{/* Subsection Navigation */}
<SubsectionNav items={navItems} currentPath={pathname} rightSlot={<ApiKeyWidget />} />
{/* Page Content */}
<div className="relative z-10">{children}</div>
</div>
<PageProvider
navItems={navItems}
currentPath={pathname}
rightSlot={<ApiKeyWidget />}
>
{children}
</PageProvider>
</ApiKeyProvider>
);
}

View File

@ -6,7 +6,6 @@ import { Section } from '@/components/shared/Section';
import { GenerationTimer } from '@/components/demo/GenerationTimer';
import { ResultCard } from '@/components/demo/ResultCard';
import { AdvancedOptionsModal, AdvancedOptionsData } from '@/components/demo/AdvancedOptionsModal';
import { ImageZoomModal } from '@/components/demo/ImageZoomModal';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
@ -70,9 +69,6 @@ export default function DemoTTIPage() {
// Results State
const [results, setResults] = useState<GenerationResult[]>([]);
// Modal State
const [zoomedImage, setZoomedImage] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Generate Images
@ -425,7 +421,6 @@ export default function DemoTTIPage() {
key={result.id}
result={result}
apiKey={apiKey}
onZoom={setZoomedImage}
onCopy={copyToClipboard}
onDownload={downloadImage}
onReusePrompt={reusePrompt}
@ -433,9 +428,6 @@ export default function DemoTTIPage() {
))}
</section>
)}
{/* Zoom Modal */}
<ImageZoomModal imageUrl={zoomedImage} onClose={() => setZoomedImage(null)} />
</Section>
);
}

View File

@ -4,7 +4,6 @@ import { useState, useEffect, useRef, DragEvent, ChangeEvent } from 'react';
import { useApiKey } from '@/components/shared/ApiKeyWidget/apikey-context';
import { Section } from '@/components/shared/Section';
import { CodeExamplesWidget } from '@/components/demo/CodeExamplesWidget';
import { ImageZoomModal } from '@/components/demo/ImageZoomModal';
import { SelectedFileCodePreview } from '@/components/demo/SelectedFileCodePreview';
import { ImageMetadataBar } from '@/components/shared/ImageMetadataBar';
import { ImageCard } from '@/components/shared/ImageCard';
@ -66,9 +65,6 @@ export default function DemoUploadPage() {
// History State
const [uploadHistory, setUploadHistory] = useState<UploadHistoryItem[]>([]);
// Zoom Modal State
const [zoomedImageUrl, setZoomedImageUrl] = useState<string | null>(null);
// Copy Feedback State
const [codeCopied, setCodeCopied] = useState(false);
@ -470,7 +466,6 @@ Body (form-data):
fileSize={item.size}
fileType={item.contentType}
timestamp={item.timestamp}
onZoom={setZoomedImageUrl}
measureDownloadTime={true}
onDownloadMeasured={(downloadMs) => handleDownloadMeasured(item.id, downloadMs)}
/>
@ -488,9 +483,6 @@ Body (form-data):
</div>
</section>
)}
{/* Image Zoom Modal */}
<ImageZoomModal imageUrl={zoomedImageUrl} onClose={() => setZoomedImageUrl(null)} />
</Section>
);
}

View File

@ -2,11 +2,11 @@
import { ReactNode } from 'react';
import { usePathname } from 'next/navigation';
import { SubsectionNav } from '@/components/shared/SubsectionNav';
import { DocsSidebar } from '@/components/docs/layout/DocsSidebar';
import { ThreeColumnLayout } from '@/components/layout/ThreeColumnLayout';
import { ApiKeyWidget } from '@/components/shared/ApiKeyWidget/apikey-widget';
import { ApiKeyProvider } from '@/components/shared/ApiKeyWidget/apikey-context';
import { PageProvider } from '@/contexts/page-context';
/**
* Root Documentation Layout
@ -47,34 +47,20 @@ export default function DocsRootLayout({ children }: DocsRootLayoutProps) {
return (
<ApiKeyProvider>
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950">
{/* Animated gradient background (matching landing page) */}
<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>
{/* Subsection Navigation */}
<SubsectionNav
items={navItems}
currentPath={pathname}
ctaText="Join Beta"
ctaHref="/signup"
rightSlot={<ApiKeyWidget />}
<PageProvider
navItems={navItems}
currentPath={pathname}
rightSlot={<ApiKeyWidget />}
>
<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">
<DocsSidebar currentPath={pathname} />
</div>
}
center={children}
/>
{/* Three-column Documentation Layout */}
<div className="relative z-10">
<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">
<DocsSidebar currentPath={pathname} />
</div>
}
center={children}
/>
</div>
</div>
</PageProvider>
</ApiKeyProvider>
);
}

View File

@ -1,6 +1,7 @@
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({
@ -101,39 +102,7 @@ export default function RootLayout({
{/* Page content */}
{children}
{/* Footer */}
<footer className="relative z-10 border-t border-white/10 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-6 pt-12 pb-4">
<div className="flex flex-col md:flex-row justify-between items-center gap-6">
<div className="h-16 flex items-center">
<Image
src="/banatie-logo-horisontal.png"
alt="Banatie Logo"
width={200}
height={60}
className="h-full w-auto object-contain"
/>
</div>
<div className="flex gap-8 text-sm text-gray-400">
<a href="#" className="hover:text-white transition-colors">
Documentation
</a>
<a href="#" className="hover:text-white transition-colors">
API Reference
</a>
<a href="#" className="hover:text-white transition-colors">
Pricing
</a>
<a href="#" className="hover:text-white transition-colors">
Contact
</a>
</div>
</div>
<div className="mt-8 text-center text-sm text-gray-500">
&copy; 2025 Banatie. Built for builders who create.
</div>
</div>
</footer>
<Footer />
</div>
</body>
</html>

View File

@ -1,82 +0,0 @@
'use client';
import { useEffect, useRef } from 'react';
interface ImageZoomModalProps {
imageUrl: string | null;
onClose: () => void;
}
export const ImageZoomModal = ({ imageUrl, onClose }: ImageZoomModalProps) => {
const closeButtonRef = useRef<HTMLButtonElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (imageUrl) {
document.addEventListener('keydown', handleEscape);
// Focus trap
const previousActiveElement = document.activeElement as HTMLElement;
closeButtonRef.current?.focus();
// Disable body scroll
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
previousActiveElement?.focus();
};
}
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [imageUrl, onClose]);
if (!imageUrl) return null;
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === modalRef.current) {
onClose();
}
};
return (
<div
ref={modalRef}
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4 sm:p-6 md:p-8"
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div className="absolute top-4 right-4 sm:top-6 sm:right-6 flex items-center gap-3">
<span id="modal-title" className="sr-only">Full size image viewer</span>
<span className="hidden sm:block text-white/70 text-sm font-medium">Press ESC to close</span>
<button
ref={closeButtonRef}
onClick={onClose}
className="w-11 h-11 sm:w-12 sm:h-12 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-black/90"
aria-label="Close zoomed image"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<img
src={imageUrl}
alt="Full size view"
className="max-w-full max-h-full object-contain rounded-lg"
onClick={(e) => e.stopPropagation()}
/>
</div>
);
};

View File

@ -5,6 +5,8 @@ import { InspectMode } from './InspectMode';
import { PromptReuseButton } from './PromptReuseButton';
import { CompletedTimerBadge } from './GenerationTimer';
import { CodeExamplesWidget } from './CodeExamplesWidget';
import { usePageContext } from '@/contexts/page-context';
import { ExpandedImageView } from '@/components/shared/ExpandedImageView';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
@ -51,7 +53,6 @@ interface GenerationResult {
interface ResultCardProps {
result: GenerationResult;
apiKey: string;
onZoom: (url: string) => void;
onCopy: (text: string) => void;
onDownload: (url: string, filename: string) => void;
onReusePrompt: (prompt: string) => void;
@ -62,11 +63,11 @@ type ViewMode = 'preview' | 'inspect';
export function ResultCard({
result,
apiKey,
onZoom,
onCopy,
onDownload,
onReusePrompt,
}: ResultCardProps) {
const { openModal } = usePageContext();
const [viewMode, setViewMode] = useState<ViewMode>('preview');
// Build enhancement options JSON for code examples
@ -213,7 +214,7 @@ X-API-Key: ${apiKey}
image={result.leftImage}
label="Original Prompt"
prompt={result.originalPrompt}
onZoom={onZoom}
openModal={openModal}
onDownload={onDownload}
onReusePrompt={onReusePrompt}
filename="original.png"
@ -224,7 +225,7 @@ X-API-Key: ${apiKey}
image={result.rightImage}
label="Enhanced Prompt"
prompt={result.enhancedPrompt || result.originalPrompt}
onZoom={onZoom}
openModal={openModal}
onDownload={onDownload}
onReusePrompt={onReusePrompt}
filename="enhanced.png"
@ -264,7 +265,7 @@ function ImagePreview({
image,
label,
prompt,
onZoom,
openModal,
onDownload,
onReusePrompt,
filename,
@ -273,7 +274,7 @@ function ImagePreview({
image: GenerationResult['leftImage'];
label: string;
prompt: string;
onZoom: (url: string) => void;
openModal: (content: React.ReactNode) => void;
onDownload: (url: string, filename: string) => void;
onReusePrompt: (prompt: string) => void;
filename: string;
@ -282,6 +283,12 @@ function ImagePreview({
const [promptExpanded, setPromptExpanded] = useState(false);
const [urlCopied, setUrlCopied] = useState(false);
const handleImageClick = () => {
if (image?.url) {
openModal(<ExpandedImageView imageUrl={image.url} alt={label} />);
}
};
const copyImageUrl = () => {
if (image?.url) {
navigator.clipboard.writeText(image.url);
@ -318,7 +325,7 @@ function ImagePreview({
src={image.url}
alt={label}
className="h-96 w-auto object-contain rounded-lg border border-slate-700"
onClick={() => onZoom(image.url)}
onClick={handleImageClick}
/>
<button
onClick={(e) => {

View File

@ -5,6 +5,8 @@ import { useIntersectionObserver } from '@/hooks/useIntersectionObserver';
import { useImageDownloadTime } from '@/components/shared/ImageCard/useImageDownloadTime';
import { ImageMetadataBar } from '@/components/shared/ImageMetadataBar';
import { calculateAspectRatio } from '@/utils/imageUtils';
import { usePageContext } from '@/contexts/page-context';
import { ExpandedImageView } from '@/components/shared/ExpandedImageView';
type GalleryImageCardProps = {
imageUrl: string;
@ -12,7 +14,6 @@ type GalleryImageCardProps = {
size: number;
contentType: string;
lastModified: string;
onZoom: (url: string) => void;
onDownloadMeasured: (imageId: string, downloadMs: number) => void;
};
@ -22,9 +23,9 @@ export const GalleryImageCard = ({
size,
contentType,
lastModified,
onZoom,
onDownloadMeasured,
}: GalleryImageCardProps) => {
const { openModal } = usePageContext();
const [isVisible, setIsVisible] = useState(false);
const [imageDimensions, setImageDimensions] = useState<{
width: number;
@ -57,10 +58,14 @@ export const GalleryImageCard = ({
img.src = imageUrl;
}, [isVisible, imageUrl]);
const handleImageClick = () => {
openModal(<ExpandedImageView imageUrl={imageUrl} alt={filename} />);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onZoom(imageUrl);
handleImageClick();
}
};
@ -81,7 +86,7 @@ export const GalleryImageCard = ({
>
<div
className="aspect-video bg-slate-800 rounded-lg mb-3 overflow-hidden cursor-pointer group relative focus:outline-none focus:ring-2 focus:ring-amber-500"
onClick={() => onZoom(imageUrl)}
onClick={handleImageClick}
role="button"
tabIndex={0}
aria-label={`View full size image: ${filename}`}

View File

@ -12,11 +12,10 @@ type ImageItem = {
type ImageGridProps = {
images: ImageItem[];
onImageZoom: (url: string) => void;
onDownloadMeasured: (imageId: string, downloadMs: number) => void;
};
export const ImageGrid = ({ images, onImageZoom, onDownloadMeasured }: ImageGridProps) => {
export const ImageGrid = ({ images, onDownloadMeasured }: ImageGridProps) => {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
{images.map((image) => (
@ -27,7 +26,6 @@ export const ImageGrid = ({ images, onImageZoom, onDownloadMeasured }: ImageGrid
size={image.size}
contentType={image.contentType}
lastModified={image.lastModified}
onZoom={onImageZoom}
onDownloadMeasured={onDownloadMeasured}
/>
))}

View File

@ -0,0 +1,10 @@
'use client';
export const AnimatedBackground = () => {
return (
<div className="fixed inset-0 overflow-hidden pointer-events-none -z-10">
<div className="absolute top-1/4 -left-1/4 w-96 h-96 bg-purple-600/10 rounded-full blur-3xl animate-pulse" />
<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>
);
};

View File

@ -0,0 +1,71 @@
'use client';
import Image from 'next/image';
import { footerLinks, footerCopyright } from '@/config/footer';
export const CompactFooter = () => {
return (
<footer
className="border-t border-white/10 bg-slate-950/80 backdrop-blur-sm"
role="contentinfo"
>
<div className="hidden md:flex items-center justify-between h-16 px-6 max-w-7xl mx-auto">
<div className="flex-shrink-0">
<Image
src="/logo-square.png"
alt="Banatie"
width={32}
height={32}
className="opacity-80"
/>
</div>
<nav aria-label="Footer navigation" className="flex items-center gap-6">
{footerLinks.map((link) => (
<a
key={link.label}
href={link.href}
className="text-sm text-gray-400 hover:text-white transition-colors min-h-[44px] flex items-center"
>
{link.label}
</a>
))}
</nav>
<p className="text-xs text-gray-500 flex-shrink-0">
{footerCopyright}
</p>
</div>
<div className="md:hidden px-6 py-4 space-y-4">
<div className="flex items-center justify-between">
<Image
src="/logo-square.png"
alt="Banatie"
width={28}
height={28}
className="opacity-80"
/>
<p className="text-xs text-gray-500">
© 2025 Banatie
</p>
</div>
<nav
aria-label="Footer navigation"
className="grid grid-cols-2 gap-x-4 gap-y-3"
>
{footerLinks.map((link) => (
<a
key={link.label}
href={link.href}
className="text-sm text-gray-400 hover:text-white transition-colors min-h-[44px] flex items-center"
>
{link.label}
</a>
))}
</nav>
</div>
</footer>
);
};

View File

@ -0,0 +1,157 @@
# ExpandedImageView Component
Displays full-size images in modal overlays with loading, error, and success states.
## Features
- **Loading State:** Purple spinner with descriptive text
- **Error State:** Clear error message with retry button
- **Success State:** Smooth fade-in transition
- **Optional Metadata:** Display filename, dimensions, and file size
- **Responsive Padding:** Adapts to mobile, tablet, and desktop
- **Accessibility:** ARIA live regions, semantic roles, keyboard support
## Props
```typescript
interface ExpandedImageViewProps {
imageUrl: string; // Required: Image URL to display
alt?: string; // Optional: Alt text (default: 'Full size view')
metadata?: { // Optional: Image metadata
filename?: string; // e.g., 'sunset_1024x768.png'
size?: string; // e.g., '2.4 MB'
dimensions?: string; // e.g., '1024 × 768'
};
showMetadata?: boolean; // Optional: Show metadata bar (default: false)
}
```
## Usage Examples
### Basic Usage
```tsx
import { ExpandedImageView } from '@/components/shared/ExpandedImageView';
export default function ImageModal({ imageUrl }: { imageUrl: string }) {
return (
<div className="fixed inset-0 bg-black/90 z-50">
<ExpandedImageView
imageUrl={imageUrl}
alt="Generated landscape image"
/>
</div>
);
}
```
### With Metadata
```tsx
import { ExpandedImageView } from '@/components/shared/ExpandedImageView';
export default function ImageGalleryModal({ image }: { image: Image }) {
return (
<div className="fixed inset-0 bg-black/90 z-50">
<ExpandedImageView
imageUrl={image.url}
alt={image.prompt}
showMetadata
metadata={{
filename: image.filename,
dimensions: `${image.width} × ${image.height}`,
size: formatFileSize(image.sizeBytes)
}}
/>
</div>
);
}
```
### In Page Provider System
```tsx
'use client';
import { useState } from 'react';
import { ExpandedImageView } from '@/components/shared/ExpandedImageView';
import { CompactFooter } from '@/components/shared/CompactFooter';
export default function ImageViewerPage() {
const [selectedImage, setSelectedImage] = useState<string | null>(null);
return (
<>
{/* Gallery */}
<div className="grid grid-cols-3 gap-4 p-6">
{images.map((img) => (
<img
key={img.id}
src={img.thumbnail}
onClick={() => setSelectedImage(img.fullUrl)}
className="cursor-pointer hover:opacity-80"
/>
))}
</div>
{/* Overlay */}
{selectedImage && (
<div
className="fixed inset-0 bg-black/90 z-50 flex flex-col"
onClick={() => setSelectedImage(null)}
>
<nav className="h-16 border-b border-white/10">
<button onClick={() => setSelectedImage(null)}>Close</button>
</nav>
<main className="flex-1 overflow-auto">
<ExpandedImageView
imageUrl={selectedImage}
alt="Expanded gallery image"
showMetadata
/>
</main>
<CompactFooter />
</div>
)}
</>
);
}
```
## Accessibility Features
### ARIA Roles
- **Loading State:** `role="status"` with `aria-live="polite"`
- **Error State:** `role="alert"` with `aria-live="assertive"`
- **Icon Elements:** `aria-hidden="true"` (decorative only)
### Keyboard Support
- Retry button is keyboard accessible (Enter/Space)
- Image click event stops propagation (prevents modal close)
- Integrates with modal close handlers (Escape key)
### Visual Design
- **Loading:** Purple spinner (`border-purple-600`) matches Banatie design
- **Error:** Red color scheme (`red-900/20`, `red-700/50`, `red-400`)
- **Image:** Shadow (`shadow-2xl`) and rounded corners (`rounded-lg`)
- **Metadata:** Subtle bar with Banatie card styling
## Layout Constraints
- **Max Image Height:** `calc(100vh - 12rem)` reserves space for nav/footer
- **Object Fit:** `object-contain` maintains aspect ratio
- **Responsive Padding:** `p-4 sm:p-6 md:p-8`
- **Metadata Wrapping:** Flexbox with `flex-wrap` for mobile
## Performance
- **Lazy State Management:** Only renders visible state (loading/error/success)
- **Event Handlers:** Optimized with direct callbacks
- **Image Loading:** Native browser lazy loading via `onLoad`/`onError`
- **Transition:** CSS-only fade animation (no JavaScript)
## Error Handling
- **Network Failures:** Catches `onerror` events
- **User Retry:** Resets state and triggers re-render
- **Clear Messaging:** Friendly error text with actionable button
- **Visual Feedback:** Icon and color coding for quick recognition

View File

@ -0,0 +1,124 @@
'use client';
import { useState } from 'react';
interface ExpandedImageViewProps {
imageUrl: string;
alt?: string;
metadata?: {
filename?: string;
size?: string;
dimensions?: string;
};
showMetadata?: boolean;
}
export const ExpandedImageView = ({
imageUrl,
alt = 'Full size view',
metadata,
showMetadata = false
}: ExpandedImageViewProps) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const handleLoad = () => setLoading(false);
const handleError = () => {
setLoading(false);
setError(true);
};
const handleRetry = () => {
setError(false);
setLoading(true);
};
return (
<div className="flex flex-col items-center justify-center h-full p-4 sm:p-6 md:p-8">
{loading && !error && (
<div
className="absolute inset-0 flex items-center justify-center"
role="status"
aria-live="polite"
>
<div className="flex flex-col items-center gap-4">
<div
className="w-16 h-16 border-4 border-purple-600 border-t-transparent rounded-full animate-spin"
aria-hidden="true"
></div>
<p className="text-gray-400 text-sm">Loading image...</p>
</div>
</div>
)}
{error && (
<div
className="flex flex-col items-center gap-4 max-w-md text-center"
role="alert"
aria-live="assertive"
>
<div className="w-16 h-16 rounded-full bg-red-900/20 border border-red-700/50 flex items-center justify-center">
<svg
className="w-8 h-8 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-2">Failed to Load Image</h3>
<p className="text-gray-400 text-sm mb-4">
The image could not be loaded. Please check your connection and try again.
</p>
<button
onClick={handleRetry}
className="px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors"
>
Retry
</button>
</div>
</div>
)}
{!error && (
<div className="relative max-w-full max-h-full flex items-center justify-center">
<img
src={imageUrl}
alt={alt}
onLoad={handleLoad}
onError={handleError}
className={`max-w-full max-h-[calc(100vh-12rem)] object-contain rounded-lg shadow-2xl transition-opacity duration-300 ${
loading ? 'opacity-0' : 'opacity-100'
}`}
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
{showMetadata && metadata && !loading && !error && (
<div className="mt-4 px-4 py-3 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-lg">
<div className="flex flex-wrap items-center gap-4 text-xs text-gray-400">
{metadata.filename && (
<span className="font-medium text-gray-300">{metadata.filename}</span>
)}
{metadata.dimensions && (
<span>{metadata.dimensions}</span>
)}
{metadata.size && (
<span>{metadata.size}</span>
)}
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,68 @@
# Footer Components
This directory contains two footer implementations for different contexts:
## Footer.tsx (Standard)
**Use Case:** Full page layouts (homepage, documentation, etc.)
- Larger horizontal logo (200px)
- Expanded vertical spacing (pt-12 pb-4)
- Center-aligned copyright at bottom
- Full visual prominence
## CompactFooter.tsx (Compact)
**Use Case:** Modal overlays, page provider system
- Small square logo (32px desktop, 28px mobile)
- Minimal height (h-16 on desktop)
- Single-row horizontal layout (desktop)
- 2-column grid layout (mobile)
- Touch-optimized (min-h-[44px] on all links)
## Usage Examples
### Standard Footer
```tsx
import { Footer } from '@/components/shared/Footer';
export default function HomePage() {
return (
<div>
<main>{/* Page content */}</main>
<Footer />
</div>
);
}
```
### Compact Footer
```tsx
import { CompactFooter } from '@/components/shared/CompactFooter';
export default function ModalLayout() {
return (
<div className="fixed inset-0 bg-black/90">
<main className="h-[calc(100vh-4rem)]">{/* Modal content */}</main>
<CompactFooter />
</div>
);
}
```
## Accessibility Features
Both components include:
- Semantic `<footer>` with `role="contentinfo"`
- `<nav>` with `aria-label="Footer navigation"`
- WCAG 2.1 AA compliant touch targets (44px minimum)
- Proper color contrast ratios (4.5:1+)
- Keyboard navigation support
- Descriptive alt text on logos
## Responsive Breakpoints
### CompactFooter
- **Base (< 768px):** Vertical stack with 2-column link grid
- **md (>= 768px):** Horizontal single-row layout
### Footer
- **Base (< 768px):** Vertical stack with centered copyright
- **md (>= 768px):** Horizontal layout with spaced sections

View File

@ -0,0 +1,36 @@
'use client';
import Image from 'next/image';
import { footerLinks, footerCopyright } from '@/config/footer';
export const Footer = () => {
return (
<footer className="relative z-10 border-t border-white/10 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-6 pt-12 pb-4">
<div className="flex flex-col md:flex-row justify-between items-center gap-6">
<div className="h-16 flex items-center">
<Image
src="/banatie-logo-horisontal.png"
alt="Banatie Logo"
width={200}
height={60}
className="h-full w-auto object-contain"
/>
</div>
<div className="flex gap-8 text-sm text-gray-400">
{footerLinks.map((link) => (
<a
key={link.label}
href={link.href}
className="hover:text-white transition-colors"
>
{link.label}
</a>
))}
</div>
</div>
<div className="mt-8 text-center text-sm text-gray-500">{footerCopyright}</div>
</div>
</footer>
);
};

View File

@ -3,6 +3,8 @@
import { useEffect } from 'react';
import { ImageMetadataBar } from '../ImageMetadataBar';
import { useImageDownloadTime } from './useImageDownloadTime';
import { usePageContext } from '@/contexts/page-context';
import { ExpandedImageView } from '../ExpandedImageView';
export interface ImageCardProps {
imageUrl: string;
@ -11,7 +13,6 @@ export interface ImageCardProps {
height?: number;
fileSize: number;
fileType: string;
onZoom: (url: string) => void;
timestamp?: Date;
className?: string;
measureDownloadTime?: boolean;
@ -25,12 +26,12 @@ export const ImageCard = ({
height,
fileSize,
fileType,
onZoom,
timestamp,
className = '',
measureDownloadTime = false,
onDownloadMeasured,
}: ImageCardProps) => {
const { openModal } = usePageContext();
const { downloadTime, isLoading } = useImageDownloadTime(
measureDownloadTime ? imageUrl : null
);
@ -41,10 +42,14 @@ export const ImageCard = ({
}
}, [downloadTime, onDownloadMeasured]);
const handleImageClick = () => {
openModal(<ExpandedImageView imageUrl={imageUrl} alt={filename} />);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onZoom(imageUrl);
handleImageClick();
}
};
@ -63,7 +68,7 @@ export const ImageCard = ({
>
<div
className="aspect-video bg-slate-800 rounded-lg mb-3 overflow-hidden cursor-pointer group relative"
onClick={() => onZoom(imageUrl)}
onClick={handleImageClick}
role="button"
tabIndex={0}
aria-label="View full size image"

View File

@ -108,7 +108,7 @@ export const SubsectionNav = ({ items, currentPath, leftSlot, rightSlot }: Subse
{/* Right Slot - Absolutely Positioned */}
{rightSlot && (
<div className="absolute top-0 right-0 h-12 flex items-center pr-0 hidden xl:flex">
<div className="absolute top-0 right-0 h-12 flex items-center pr-0 hidden xl:flex overflow-visible">
{rightSlot}
</div>
)}

View File

@ -0,0 +1,8 @@
export const footerLinks = [
{ label: 'Documentation', href: '#' },
{ label: 'API Reference', href: '#' },
{ label: 'Pricing', href: '#' },
{ label: 'Contact', href: '#' },
];
export const footerCopyright = '© 2025 Banatie. Built for builders who create.';

View File

@ -0,0 +1,190 @@
'use client';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { usePathname } from 'next/navigation';
import { SubsectionNav } from '@/components/shared/SubsectionNav';
import { CompactFooter } from '@/components/shared/CompactFooter';
import { AnimatedBackground } from '@/components/shared/AnimatedBackground';
type NavItem = {
label: string;
href: string;
};
type PageContextValue = {
isOpen: boolean;
openModal: (content: ReactNode) => void;
closeModal: () => void;
};
type PageProviderProps = {
navItems: NavItem[];
currentPath: string;
rightSlot?: ReactNode;
children: ReactNode;
};
const PageContext = createContext<PageContextValue | null>(null);
export const usePageContext = () => {
const context = useContext(PageContext);
if (!context) {
throw new Error('usePageContext must be used within PageProvider');
}
return context;
};
export const PageProvider = ({
navItems,
currentPath,
rightSlot,
children,
}: PageProviderProps) => {
const [isOpen, setIsOpen] = useState(false);
const [modalContent, setModalContent] = useState<ReactNode | null>(null);
const [scrollPosition, setScrollPosition] = useState(0);
const pathname = usePathname();
const openModal = (content: ReactNode) => {
setScrollPosition(window.scrollY);
setIsOpen(true);
setModalContent(content);
};
const closeModal = () => {
setIsOpen(false);
setModalContent(null);
setTimeout(() => window.scrollTo(0, scrollPosition), 0);
};
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}
}, [isOpen]);
useEffect(() => {
if (isOpen) {
closeModal();
}
}, [pathname]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
closeModal();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const modalElement = document.querySelector('[data-modal-overlay]');
if (!modalElement) return;
const focusableElements = modalElement.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
};
document.addEventListener('keydown', handleTab);
return () => document.removeEventListener('keydown', handleTab);
}, [isOpen]);
useEffect(() => {
if (isOpen) {
const closeButton = document.querySelector('[data-modal-overlay] button[aria-label="Close fullscreen view"]') as HTMLElement;
if (closeButton) {
closeButton.focus();
}
}
}, [isOpen]);
const contextValue = { isOpen, openModal, closeModal };
return (
<PageContext.Provider value={contextValue}>
{isOpen ? (
<div
className="fixed inset-0 z-50 flex flex-col bg-black/90 backdrop-blur-sm transition-opacity duration-300"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
data-modal-overlay
>
<h2 id="modal-title" className="sr-only">
Fullscreen Viewer
</h2>
<div className="relative overflow-hidden">
<SubsectionNav
items={navItems}
currentPath={currentPath}
rightSlot={
<div className="flex items-center gap-3 pr-6">
<span className="hidden sm:block text-white/70 text-sm font-medium">
Press ESC to close
</span>
<button
onClick={closeModal}
className="w-11 h-11 sm:w-12 sm:h-12 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-black/90"
aria-label="Close fullscreen view"
>
<svg
className="w-6 h-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
}
/>
</div>
<main className="flex-1 min-h-0 overflow-auto" role="main" aria-label="Modal content">
{modalContent}
</main>
<CompactFooter />
</div>
) : (
<>
<AnimatedBackground />
<SubsectionNav items={navItems} currentPath={currentPath} rightSlot={rightSlot} />
<div className="relative z-10">{children}</div>
</>
)}
</PageContext.Provider>
);
};