Compare commits
No commits in common. "a397de80e9563a088ff3ecde16ac149abe6f2504" and "f46a8d66d38c3dcedd5f60c4bae69c96b36687e0" have entirely different histories.
a397de80e9
...
f46a8d66d3
|
|
@ -6,7 +6,6 @@ import { createProjectApiKey, listApiKeys } from '@/lib/actions/apiKeyActions';
|
||||||
import KeyDisplay from '@/components/admin/KeyDisplay';
|
import KeyDisplay from '@/components/admin/KeyDisplay';
|
||||||
import AdminFormInput from '@/components/admin/AdminFormInput';
|
import AdminFormInput from '@/components/admin/AdminFormInput';
|
||||||
import AdminButton from '@/components/admin/AdminButton';
|
import AdminButton from '@/components/admin/AdminButton';
|
||||||
import { Section } from '@/components/shared/Section';
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'banatie_master_key';
|
const STORAGE_KEY = 'banatie_master_key';
|
||||||
|
|
||||||
|
|
@ -69,7 +68,7 @@ export default function ApiKeysPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section>
|
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-4xl font-bold text-white mb-2">Project API Keys</h1>
|
<h1 className="text-4xl font-bold text-white mb-2">Project API Keys</h1>
|
||||||
|
|
@ -189,6 +188,6 @@ export default function ApiKeysPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { bootstrapMasterKey } from '@/lib/actions/apiKeyActions';
|
||||||
import KeyDisplay from '@/components/admin/KeyDisplay';
|
import KeyDisplay from '@/components/admin/KeyDisplay';
|
||||||
import AdminFormInput from '@/components/admin/AdminFormInput';
|
import AdminFormInput from '@/components/admin/AdminFormInput';
|
||||||
import AdminButton from '@/components/admin/AdminButton';
|
import AdminButton from '@/components/admin/AdminButton';
|
||||||
import { NarrowSection } from '@/components/shared/NarrowSection';
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'banatie_master_key';
|
const STORAGE_KEY = 'banatie_master_key';
|
||||||
|
|
||||||
|
|
@ -67,7 +66,7 @@ export default function MasterKeyPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NarrowSection>
|
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-4xl font-bold text-white mb-2">Master Key Management</h1>
|
<h1 className="text-4xl font-bold text-white mb-2">Master Key Management</h1>
|
||||||
|
|
@ -133,6 +132,6 @@ export default function MasterKeyPage() {
|
||||||
Save Manual Key
|
Save Manual Key
|
||||||
</AdminButton>
|
</AdminButton>
|
||||||
</div>
|
</div>
|
||||||
</NarrowSection>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useApiKey } from '@/components/shared/ApiKeyWidget/apikey-context';
|
import { MinimizedApiKey } from '@/components/demo/MinimizedApiKey';
|
||||||
import { Section } from '@/components/shared/Section';
|
import { ImageZoomModal } from '@/components/demo/ImageZoomModal';
|
||||||
import { ImageGrid } from '@/components/demo/gallery/ImageGrid';
|
import { ImageGrid } from '@/components/demo/gallery/ImageGrid';
|
||||||
import { EmptyGalleryState } from '@/components/demo/gallery/EmptyGalleryState';
|
import { EmptyGalleryState } from '@/components/demo/gallery/EmptyGalleryState';
|
||||||
|
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||||
|
const API_KEY_STORAGE_KEY = 'banatie_demo_api_key';
|
||||||
const IMAGES_PER_PAGE = 30;
|
const IMAGES_PER_PAGE = 30;
|
||||||
|
|
||||||
type ImageItem = {
|
type ImageItem = {
|
||||||
|
|
@ -30,13 +31,22 @@ type ImagesResponse = {
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ApiKeyInfo = {
|
||||||
|
organizationSlug?: string;
|
||||||
|
projectSlug?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type DownloadTimeMap = {
|
type DownloadTimeMap = {
|
||||||
[imageId: string]: number;
|
[imageId: string]: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function GalleryPage() {
|
export default function GalleryPage() {
|
||||||
// API Key from context
|
const [apiKey, setApiKey] = useState('');
|
||||||
const { apiKey, apiKeyValidated, focus } = useApiKey();
|
const [apiKeyVisible, setApiKeyVisible] = useState(false);
|
||||||
|
const [apiKeyValidated, setApiKeyValidated] = useState(false);
|
||||||
|
const [apiKeyInfo, setApiKeyInfo] = useState<ApiKeyInfo | null>(null);
|
||||||
|
const [apiKeyError, setApiKeyError] = useState('');
|
||||||
|
const [validatingKey, setValidatingKey] = useState(false);
|
||||||
|
|
||||||
const [images, setImages] = useState<ImageItem[]>([]);
|
const [images, setImages] = useState<ImageItem[]>([]);
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
|
|
@ -45,13 +55,114 @@ export default function GalleryPage() {
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const [zoomedImageUrl, setZoomedImageUrl] = useState<string | null>(null);
|
||||||
const [downloadTimes, setDownloadTimes] = useState<DownloadTimeMap>({});
|
const [downloadTimes, setDownloadTimes] = useState<DownloadTimeMap>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (apiKeyValidated) {
|
const storedApiKey = localStorage.getItem(API_KEY_STORAGE_KEY);
|
||||||
fetchImages(apiKey, 0);
|
if (storedApiKey) {
|
||||||
|
setApiKey(storedApiKey);
|
||||||
|
validateStoredApiKey(storedApiKey);
|
||||||
}
|
}
|
||||||
}, [apiKeyValidated]);
|
}, []);
|
||||||
|
|
||||||
|
const validateStoredApiKey = async (keyToValidate: string) => {
|
||||||
|
setValidatingKey(true);
|
||||||
|
setApiKeyError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/info`, {
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': keyToValidate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setApiKeyValidated(true);
|
||||||
|
if (data.keyInfo) {
|
||||||
|
setApiKeyInfo({
|
||||||
|
organizationSlug: data.keyInfo.organizationSlug || data.keyInfo.organizationId,
|
||||||
|
projectSlug: data.keyInfo.projectSlug || data.keyInfo.projectId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setApiKeyInfo({
|
||||||
|
organizationSlug: 'Unknown',
|
||||||
|
projectSlug: 'Unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await fetchImages(keyToValidate, 0);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(API_KEY_STORAGE_KEY);
|
||||||
|
setApiKeyError('Stored API key is invalid or expired');
|
||||||
|
setApiKeyValidated(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setApiKeyError('Failed to validate stored API key');
|
||||||
|
setApiKeyValidated(false);
|
||||||
|
} finally {
|
||||||
|
setValidatingKey(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateApiKey = async () => {
|
||||||
|
if (!apiKey.trim()) {
|
||||||
|
setApiKeyError('Please enter an API key');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidatingKey(true);
|
||||||
|
setApiKeyError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/info`, {
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setApiKeyValidated(true);
|
||||||
|
localStorage.setItem(API_KEY_STORAGE_KEY, apiKey);
|
||||||
|
|
||||||
|
if (data.keyInfo) {
|
||||||
|
setApiKeyInfo({
|
||||||
|
organizationSlug: data.keyInfo.organizationSlug || data.keyInfo.organizationId,
|
||||||
|
projectSlug: data.keyInfo.projectSlug || data.keyInfo.projectId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setApiKeyInfo({
|
||||||
|
organizationSlug: 'Unknown',
|
||||||
|
projectSlug: 'Unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchImages(apiKey, 0);
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
setApiKeyError(error.message || 'Invalid API key');
|
||||||
|
setApiKeyValidated(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setApiKeyError('Failed to validate API key. Please check your connection.');
|
||||||
|
setApiKeyValidated(false);
|
||||||
|
} finally {
|
||||||
|
setValidatingKey(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revokeApiKey = () => {
|
||||||
|
localStorage.removeItem(API_KEY_STORAGE_KEY);
|
||||||
|
setApiKey('');
|
||||||
|
setApiKeyValidated(false);
|
||||||
|
setApiKeyInfo(null);
|
||||||
|
setApiKeyError('');
|
||||||
|
setImages([]);
|
||||||
|
setOffset(0);
|
||||||
|
setHasMore(false);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
const fetchImages = async (keyToUse: string, fetchOffset: number) => {
|
const fetchImages = async (keyToUse: string, fetchOffset: number) => {
|
||||||
if (fetchOffset === 0) {
|
if (fetchOffset === 0) {
|
||||||
|
|
@ -112,7 +223,16 @@ export default function GalleryPage() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="py-12 md:py-16 min-h-screen">
|
<div className="relative z-10 max-w-7xl mx-auto px-6 py-12 md:py-16 min-h-screen">
|
||||||
|
{apiKeyValidated && apiKeyInfo && (
|
||||||
|
<MinimizedApiKey
|
||||||
|
organizationSlug={apiKeyInfo.organizationSlug || 'Unknown'}
|
||||||
|
projectSlug={apiKeyInfo.projectSlug || 'Unknown'}
|
||||||
|
apiKey={apiKey}
|
||||||
|
onRevoke={revokeApiKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<header className="mb-8 md:mb-12">
|
<header className="mb-8 md:mb-12">
|
||||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3">
|
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3">
|
||||||
Image Gallery
|
Image Gallery
|
||||||
|
|
@ -122,22 +242,82 @@ export default function GalleryPage() {
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* API Key Required Notice - Only show when not validated */}
|
|
||||||
{!apiKeyValidated && (
|
{!apiKeyValidated && (
|
||||||
<div className="mb-6 p-5 bg-amber-900/10 border border-amber-700/50 rounded-2xl">
|
<section
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
className="mb-6 p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl"
|
||||||
<div>
|
aria-label="API Key Validation"
|
||||||
<h3 className="text-lg font-semibold text-white mb-1">API Key Required</h3>
|
>
|
||||||
<p className="text-sm text-gray-400">Enter your API key to browse your images</p>
|
<h2 className="text-lg font-semibold text-white mb-3">API Key</h2>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<input
|
||||||
|
type={apiKeyVisible ? 'text' : 'password'}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
validateApiKey();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Enter your API key"
|
||||||
|
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-amber-500 focus:border-transparent pr-12"
|
||||||
|
aria-label="API key input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setApiKeyVisible(!apiKeyVisible)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-2 text-gray-400 hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-amber-500 rounded"
|
||||||
|
aria-label={apiKeyVisible ? 'Hide API key' : 'Show API key'}
|
||||||
|
>
|
||||||
|
{apiKeyVisible ? (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={focus}
|
onClick={validateApiKey}
|
||||||
className="px-5 py-2.5 bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
|
disabled={validatingKey}
|
||||||
|
className="px-6 py-3 rounded-lg bg-amber-600 text-white font-semibold hover:bg-amber-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-slate-950 min-h-[44px]"
|
||||||
|
aria-busy={validatingKey}
|
||||||
>
|
>
|
||||||
Enter API Key
|
{validatingKey ? 'Validating...' : 'Validate'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{apiKeyError && (
|
||||||
|
<div className="mt-3 p-3 bg-red-900/20 border border-red-700/50 rounded-lg" role="alert" aria-live="assertive">
|
||||||
|
<p className="text-sm text-red-400 font-medium mb-1">{apiKeyError}</p>
|
||||||
|
<p className="text-xs text-red-300/80">
|
||||||
|
{apiKeyError.includes('Invalid')
|
||||||
|
? 'Please check your API key and try again. You can create a new key in the admin dashboard.'
|
||||||
|
: 'Please check your internet connection and try again.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{apiKeyValidated && (
|
{apiKeyValidated && (
|
||||||
|
|
@ -162,6 +342,7 @@ export default function GalleryPage() {
|
||||||
<>
|
<>
|
||||||
<ImageGrid
|
<ImageGrid
|
||||||
images={images}
|
images={images}
|
||||||
|
onImageZoom={setZoomedImageUrl}
|
||||||
onDownloadMeasured={handleDownloadMeasured}
|
onDownloadMeasured={handleDownloadMeasured}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -189,6 +370,8 @@ export default function GalleryPage() {
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</Section>
|
|
||||||
|
<ImageZoomModal imageUrl={zoomedImageUrl} onClose={() => setZoomedImageUrl(null)} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
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';
|
|
||||||
|
|
||||||
interface DemoLayoutProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ label: 'Text to Image', href: '/demo/tti' },
|
|
||||||
{ label: 'Upload', href: '/demo/upload' },
|
|
||||||
{ label: 'Gallery', href: '/demo/gallery' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function DemoLayout({ children }: DemoLayoutProps) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ApiKeyProvider>
|
|
||||||
<PageProvider
|
|
||||||
navItems={navItems}
|
|
||||||
currentPath={pathname}
|
|
||||||
rightSlot={<ApiKeyWidget />}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</PageProvider>
|
|
||||||
</ApiKeyProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, KeyboardEvent } from 'react';
|
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
||||||
import { useApiKey } from '@/components/shared/ApiKeyWidget/apikey-context';
|
import { MinimizedApiKey } from '@/components/demo/MinimizedApiKey';
|
||||||
import { Section } from '@/components/shared/Section';
|
|
||||||
import { GenerationTimer } from '@/components/demo/GenerationTimer';
|
import { GenerationTimer } from '@/components/demo/GenerationTimer';
|
||||||
import { ResultCard } from '@/components/demo/ResultCard';
|
import { ResultCard } from '@/components/demo/ResultCard';
|
||||||
import { AdvancedOptionsModal, AdvancedOptionsData } from '@/components/demo/AdvancedOptionsModal';
|
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';
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||||
|
const API_KEY_STORAGE_KEY = 'banatie_demo_api_key';
|
||||||
|
|
||||||
// Generate random 6-character uppercase ID for pairing images
|
// Generate random 6-character uppercase ID for pairing images
|
||||||
function generatePairId(): string {
|
function generatePairId(): string {
|
||||||
|
|
@ -50,9 +51,19 @@ interface GenerationResult {
|
||||||
} & AdvancedOptionsData;
|
} & AdvancedOptionsData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ApiKeyInfo {
|
||||||
|
organizationSlug?: string;
|
||||||
|
projectSlug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function DemoTTIPage() {
|
export default function DemoTTIPage() {
|
||||||
// API Key from context
|
// API Key State
|
||||||
const { apiKey, apiKeyValidated, focus } = useApiKey();
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [apiKeyVisible, setApiKeyVisible] = useState(false);
|
||||||
|
const [apiKeyValidated, setApiKeyValidated] = useState(false);
|
||||||
|
const [apiKeyInfo, setApiKeyInfo] = useState<ApiKeyInfo | null>(null);
|
||||||
|
const [apiKeyError, setApiKeyError] = useState('');
|
||||||
|
const [validatingKey, setValidatingKey] = useState(false);
|
||||||
|
|
||||||
// Prompt State
|
// Prompt State
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
|
|
@ -69,8 +80,123 @@ export default function DemoTTIPage() {
|
||||||
// Results State
|
// Results State
|
||||||
const [results, setResults] = useState<GenerationResult[]>([]);
|
const [results, setResults] = useState<GenerationResult[]>([]);
|
||||||
|
|
||||||
|
// Modal State
|
||||||
|
const [zoomedImage, setZoomedImage] = useState<string | null>(null);
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Load API key from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const storedApiKey = localStorage.getItem(API_KEY_STORAGE_KEY);
|
||||||
|
if (storedApiKey) {
|
||||||
|
setApiKey(storedApiKey);
|
||||||
|
// Auto-validate the stored key
|
||||||
|
validateStoredApiKey(storedApiKey);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Validate stored API key (without user interaction)
|
||||||
|
const validateStoredApiKey = async (keyToValidate: string) => {
|
||||||
|
setValidatingKey(true);
|
||||||
|
setApiKeyError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/info`, {
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': keyToValidate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setApiKeyValidated(true);
|
||||||
|
if (data.keyInfo) {
|
||||||
|
setApiKeyInfo({
|
||||||
|
organizationSlug: data.keyInfo.organizationSlug || data.keyInfo.organizationId,
|
||||||
|
projectSlug: data.keyInfo.projectSlug || data.keyInfo.projectId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setApiKeyInfo({
|
||||||
|
organizationSlug: 'Unknown',
|
||||||
|
projectSlug: 'Unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Stored key is invalid, clear it
|
||||||
|
localStorage.removeItem(API_KEY_STORAGE_KEY);
|
||||||
|
setApiKeyError('Stored API key is invalid or expired');
|
||||||
|
setApiKeyValidated(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setApiKeyError('Failed to validate stored API key');
|
||||||
|
setApiKeyValidated(false);
|
||||||
|
} finally {
|
||||||
|
setValidatingKey(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate API Key
|
||||||
|
const validateApiKey = async () => {
|
||||||
|
if (!apiKey.trim()) {
|
||||||
|
setApiKeyError('Please enter an API key');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidatingKey(true);
|
||||||
|
setApiKeyError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test API key with a minimal request to /api/info or similar
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/info`, {
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setApiKeyValidated(true);
|
||||||
|
|
||||||
|
// Save to localStorage on successful validation
|
||||||
|
localStorage.setItem(API_KEY_STORAGE_KEY, apiKey);
|
||||||
|
|
||||||
|
// Extract org/project info from API response
|
||||||
|
if (data.keyInfo) {
|
||||||
|
setApiKeyInfo({
|
||||||
|
organizationSlug: data.keyInfo.organizationSlug || data.keyInfo.organizationId,
|
||||||
|
projectSlug: data.keyInfo.projectSlug || data.keyInfo.projectId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setApiKeyInfo({
|
||||||
|
organizationSlug: 'Unknown',
|
||||||
|
projectSlug: 'Unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
setApiKeyError(error.message || 'Invalid API key');
|
||||||
|
setApiKeyValidated(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setApiKeyError('Failed to validate API key. Please check your connection.');
|
||||||
|
setApiKeyValidated(false);
|
||||||
|
} finally {
|
||||||
|
setValidatingKey(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Revoke API Key
|
||||||
|
const revokeApiKey = () => {
|
||||||
|
// Clear localStorage
|
||||||
|
localStorage.removeItem(API_KEY_STORAGE_KEY);
|
||||||
|
|
||||||
|
// Clear state
|
||||||
|
setApiKey('');
|
||||||
|
setApiKeyValidated(false);
|
||||||
|
setApiKeyInfo(null);
|
||||||
|
setApiKeyError('');
|
||||||
|
};
|
||||||
|
|
||||||
// Generate Images
|
// Generate Images
|
||||||
const generateImages = async () => {
|
const generateImages = async () => {
|
||||||
if (!prompt.trim()) {
|
if (!prompt.trim()) {
|
||||||
|
|
@ -254,7 +380,17 @@ export default function DemoTTIPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="py-12 md:py-16 min-h-screen">
|
<div className="relative z-10 max-w-7xl mx-auto px-6 py-12 md:py-16 min-h-screen">
|
||||||
|
{/* Minimized API Key Badge */}
|
||||||
|
{apiKeyValidated && apiKeyInfo && (
|
||||||
|
<MinimizedApiKey
|
||||||
|
organizationSlug={apiKeyInfo.organizationSlug || 'Unknown'}
|
||||||
|
projectSlug={apiKeyInfo.projectSlug || 'Unknown'}
|
||||||
|
apiKey={apiKey}
|
||||||
|
onRevoke={revokeApiKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<header className="mb-8 md:mb-12">
|
<header className="mb-8 md:mb-12">
|
||||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3">
|
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3">
|
||||||
|
|
@ -265,22 +401,77 @@ export default function DemoTTIPage() {
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* API Key Required Notice - Only show when not validated */}
|
{/* API Key Section - Only show when not validated */}
|
||||||
{!apiKeyValidated && (
|
{!apiKeyValidated && (
|
||||||
<div className="mb-6 p-5 bg-amber-900/10 border border-amber-700/50 rounded-2xl">
|
<section
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
className="mb-6 p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl"
|
||||||
<div>
|
aria-label="API Key Validation"
|
||||||
<h3 className="text-lg font-semibold text-white mb-1">API Key Required</h3>
|
>
|
||||||
<p className="text-sm text-gray-400">Enter your API key to use this workbench</p>
|
<h2 className="text-lg font-semibold text-white mb-3">API Key</h2>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<input
|
||||||
|
type={apiKeyVisible ? 'text' : 'password'}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
validateApiKey();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Enter your API key"
|
||||||
|
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-amber-500 focus:border-transparent pr-12"
|
||||||
|
aria-label="API key input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setApiKeyVisible(!apiKeyVisible)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
|
||||||
|
aria-label={apiKeyVisible ? 'Hide API key' : 'Show API key'}
|
||||||
|
>
|
||||||
|
{apiKeyVisible ? (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={focus}
|
onClick={validateApiKey}
|
||||||
className="px-5 py-2.5 bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
|
disabled={validatingKey}
|
||||||
|
className="px-6 py-3 rounded-lg bg-amber-600 text-white font-semibold hover:bg-amber-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed focus:ring-2 focus:ring-amber-500"
|
||||||
>
|
>
|
||||||
Enter API Key
|
{validatingKey ? 'Validating...' : 'Validate'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{apiKeyError && (
|
||||||
|
<p className="mt-3 text-sm text-red-400" role="alert">
|
||||||
|
{apiKeyError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Unified Prompt & Generation Card */}
|
{/* Unified Prompt & Generation Card */}
|
||||||
|
|
@ -421,6 +612,7 @@ export default function DemoTTIPage() {
|
||||||
key={result.id}
|
key={result.id}
|
||||||
result={result}
|
result={result}
|
||||||
apiKey={apiKey}
|
apiKey={apiKey}
|
||||||
|
onZoom={setZoomedImage}
|
||||||
onCopy={copyToClipboard}
|
onCopy={copyToClipboard}
|
||||||
onDownload={downloadImage}
|
onDownload={downloadImage}
|
||||||
onReusePrompt={reusePrompt}
|
onReusePrompt={reusePrompt}
|
||||||
|
|
@ -428,6 +620,9 @@ export default function DemoTTIPage() {
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</Section>
|
|
||||||
|
{/* Zoom Modal */}
|
||||||
|
<ImageZoomModal imageUrl={zoomedImage} onClose={() => setZoomedImage(null)} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback, DragEvent, ChangeEvent } from 'react';
|
import { useState, useEffect, useRef, DragEvent, ChangeEvent } from 'react';
|
||||||
import { useApiKey } from '@/components/shared/ApiKeyWidget/apikey-context';
|
import { MinimizedApiKey } from '@/components/demo/MinimizedApiKey';
|
||||||
import { Section } from '@/components/shared/Section';
|
|
||||||
import { CodeExamplesWidget } from '@/components/demo/CodeExamplesWidget';
|
import { CodeExamplesWidget } from '@/components/demo/CodeExamplesWidget';
|
||||||
|
import { ImageZoomModal } from '@/components/demo/ImageZoomModal';
|
||||||
import { SelectedFileCodePreview } from '@/components/demo/SelectedFileCodePreview';
|
import { SelectedFileCodePreview } from '@/components/demo/SelectedFileCodePreview';
|
||||||
import { ImageMetadataBar } from '@/components/shared/ImageMetadataBar';
|
import { ImageMetadataBar } from '@/components/shared/ImageMetadataBar';
|
||||||
import { ImageCard } from '@/components/shared/ImageCard';
|
import { ImageCard } from '@/components/shared/ImageCard';
|
||||||
import { calculateAspectRatio } from '@/utils/imageUtils';
|
import { calculateAspectRatio } from '@/utils/imageUtils';
|
||||||
|
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||||
|
const API_KEY_STORAGE_KEY = 'banatie_demo_api_key';
|
||||||
const UPLOAD_HISTORY_KEY = 'banatie_upload_history';
|
const UPLOAD_HISTORY_KEY = 'banatie_upload_history';
|
||||||
|
|
||||||
const ALLOWED_FILE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
|
const ALLOWED_FILE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
|
||||||
|
|
@ -45,9 +46,19 @@ interface UploadHistoryItem {
|
||||||
downloadMs?: number;
|
downloadMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ApiKeyInfo {
|
||||||
|
organizationSlug?: string;
|
||||||
|
projectSlug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function DemoUploadPage() {
|
export default function DemoUploadPage() {
|
||||||
// API Key from context
|
// API Key State
|
||||||
const { apiKey, apiKeyValidated, focus } = useApiKey();
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [apiKeyVisible, setApiKeyVisible] = useState(false);
|
||||||
|
const [apiKeyValidated, setApiKeyValidated] = useState(false);
|
||||||
|
const [apiKeyInfo, setApiKeyInfo] = useState<ApiKeyInfo | null>(null);
|
||||||
|
const [apiKeyError, setApiKeyError] = useState('');
|
||||||
|
const [validatingKey, setValidatingKey] = useState(false);
|
||||||
|
|
||||||
// Upload State
|
// Upload State
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
|
@ -65,12 +76,24 @@ export default function DemoUploadPage() {
|
||||||
// History State
|
// History State
|
||||||
const [uploadHistory, setUploadHistory] = useState<UploadHistoryItem[]>([]);
|
const [uploadHistory, setUploadHistory] = useState<UploadHistoryItem[]>([]);
|
||||||
|
|
||||||
|
// Zoom Modal State
|
||||||
|
const [zoomedImageUrl, setZoomedImageUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
// Copy Feedback State
|
// Copy Feedback State
|
||||||
const [codeCopied, setCodeCopied] = useState(false);
|
const [codeCopied, setCodeCopied] = useState(false);
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Load API key from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const storedApiKey = localStorage.getItem(API_KEY_STORAGE_KEY);
|
||||||
|
if (storedApiKey) {
|
||||||
|
setApiKey(storedApiKey);
|
||||||
|
validateStoredApiKey(storedApiKey);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Load upload history from sessionStorage
|
// Load upload history from sessionStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedHistory = sessionStorage.getItem(UPLOAD_HISTORY_KEY);
|
const storedHistory = sessionStorage.getItem(UPLOAD_HISTORY_KEY);
|
||||||
|
|
@ -96,6 +119,97 @@ export default function DemoUploadPage() {
|
||||||
}
|
}
|
||||||
}, [uploadHistory]);
|
}, [uploadHistory]);
|
||||||
|
|
||||||
|
const validateStoredApiKey = async (keyToValidate: string) => {
|
||||||
|
setValidatingKey(true);
|
||||||
|
setApiKeyError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/info`, {
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': keyToValidate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setApiKeyValidated(true);
|
||||||
|
if (data.keyInfo) {
|
||||||
|
setApiKeyInfo({
|
||||||
|
organizationSlug: data.keyInfo.organizationSlug || data.keyInfo.organizationId,
|
||||||
|
projectSlug: data.keyInfo.projectSlug || data.keyInfo.projectId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setApiKeyInfo({
|
||||||
|
organizationSlug: 'Unknown',
|
||||||
|
projectSlug: 'Unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(API_KEY_STORAGE_KEY);
|
||||||
|
setApiKeyError('Stored API key is invalid or expired');
|
||||||
|
setApiKeyValidated(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setApiKeyError('Failed to validate stored API key');
|
||||||
|
setApiKeyValidated(false);
|
||||||
|
} finally {
|
||||||
|
setValidatingKey(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateApiKey = async () => {
|
||||||
|
if (!apiKey.trim()) {
|
||||||
|
setApiKeyError('Please enter an API key');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidatingKey(true);
|
||||||
|
setApiKeyError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/info`, {
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setApiKeyValidated(true);
|
||||||
|
localStorage.setItem(API_KEY_STORAGE_KEY, apiKey);
|
||||||
|
|
||||||
|
if (data.keyInfo) {
|
||||||
|
setApiKeyInfo({
|
||||||
|
organizationSlug: data.keyInfo.organizationSlug || data.keyInfo.organizationId,
|
||||||
|
projectSlug: data.keyInfo.projectSlug || data.keyInfo.projectId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setApiKeyInfo({
|
||||||
|
organizationSlug: 'Unknown',
|
||||||
|
projectSlug: 'Unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
setApiKeyError(error.message || 'Invalid API key');
|
||||||
|
setApiKeyValidated(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setApiKeyError('Failed to validate API key. Please check your connection.');
|
||||||
|
setApiKeyValidated(false);
|
||||||
|
} finally {
|
||||||
|
setValidatingKey(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revokeApiKey = () => {
|
||||||
|
localStorage.removeItem(API_KEY_STORAGE_KEY);
|
||||||
|
setApiKey('');
|
||||||
|
setApiKeyValidated(false);
|
||||||
|
setApiKeyInfo(null);
|
||||||
|
setApiKeyError('');
|
||||||
|
};
|
||||||
|
|
||||||
const validateFile = (file: File): string | null => {
|
const validateFile = (file: File): string | null => {
|
||||||
if (!ALLOWED_FILE_TYPES.includes(file.type)) {
|
if (!ALLOWED_FILE_TYPES.includes(file.type)) {
|
||||||
return `Invalid file type. Allowed: PNG, JPEG, JPG, WebP`;
|
return `Invalid file type. Allowed: PNG, JPEG, JPG, WebP`;
|
||||||
|
|
@ -235,17 +349,13 @@ export default function DemoUploadPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadMeasured = useCallback((itemId: string, downloadMs: number) => {
|
const handleDownloadMeasured = (itemId: string, downloadMs: number) => {
|
||||||
setUploadHistory((prev) =>
|
setUploadHistory((prev) =>
|
||||||
prev.map((item) => {
|
prev.map((item) =>
|
||||||
// Only update if this item doesn't have downloadMs yet (prevent re-measuring)
|
item.id === itemId ? { ...item, downloadMs } : item
|
||||||
if (item.id === itemId && item.downloadMs === undefined) {
|
)
|
||||||
return { ...item, downloadMs };
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const generateUploadCodeExamples = (item: UploadHistoryItem, key: string, baseUrl: string) => {
|
const generateUploadCodeExamples = (item: UploadHistoryItem, key: string, baseUrl: string) => {
|
||||||
const fileName = item.originalName;
|
const fileName = item.originalName;
|
||||||
|
|
@ -297,7 +407,16 @@ Body (form-data):
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="py-12 md:py-16 min-h-screen">
|
<div className="relative z-10 max-w-7xl mx-auto px-6 py-12 md:py-16 min-h-screen">
|
||||||
|
{apiKeyValidated && apiKeyInfo && (
|
||||||
|
<MinimizedApiKey
|
||||||
|
organizationSlug={apiKeyInfo.organizationSlug || 'Unknown'}
|
||||||
|
projectSlug={apiKeyInfo.projectSlug || 'Unknown'}
|
||||||
|
apiKey={apiKey}
|
||||||
|
onRevoke={revokeApiKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<header className="mb-8 md:mb-12">
|
<header className="mb-8 md:mb-12">
|
||||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3">
|
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3">
|
||||||
File Upload Workbench
|
File Upload Workbench
|
||||||
|
|
@ -307,22 +426,76 @@ Body (form-data):
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* API Key Required Notice - Only show when not validated */}
|
|
||||||
{!apiKeyValidated && (
|
{!apiKeyValidated && (
|
||||||
<div className="mb-6 p-5 bg-amber-900/10 border border-amber-700/50 rounded-2xl">
|
<section
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
className="mb-6 p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl"
|
||||||
<div>
|
aria-label="API Key Validation"
|
||||||
<h3 className="text-lg font-semibold text-white mb-1">API Key Required</h3>
|
>
|
||||||
<p className="text-sm text-gray-400">Enter your API key to use this workbench</p>
|
<h2 className="text-lg font-semibold text-white mb-3">API Key</h2>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<input
|
||||||
|
type={apiKeyVisible ? 'text' : 'password'}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
validateApiKey();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Enter your API key"
|
||||||
|
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-amber-500 focus:border-transparent pr-12"
|
||||||
|
aria-label="API key input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setApiKeyVisible(!apiKeyVisible)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
|
||||||
|
aria-label={apiKeyVisible ? 'Hide API key' : 'Show API key'}
|
||||||
|
>
|
||||||
|
{apiKeyVisible ? (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={focus}
|
onClick={validateApiKey}
|
||||||
className="px-5 py-2.5 bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
|
disabled={validatingKey}
|
||||||
|
className="px-6 py-3 rounded-lg bg-amber-600 text-white font-semibold hover:bg-amber-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed focus:ring-2 focus:ring-amber-500"
|
||||||
>
|
>
|
||||||
Enter API Key
|
{validatingKey ? 'Validating...' : 'Validate'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{apiKeyError && (
|
||||||
|
<p className="mt-3 text-sm text-red-400" role="alert">
|
||||||
|
{apiKeyError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section
|
<section
|
||||||
|
|
@ -470,6 +643,7 @@ Body (form-data):
|
||||||
fileSize={item.size}
|
fileSize={item.size}
|
||||||
fileType={item.contentType}
|
fileType={item.contentType}
|
||||||
timestamp={item.timestamp}
|
timestamp={item.timestamp}
|
||||||
|
onZoom={setZoomedImageUrl}
|
||||||
measureDownloadTime={true}
|
measureDownloadTime={true}
|
||||||
onDownloadMeasured={(downloadMs) => handleDownloadMeasured(item.id, downloadMs)}
|
onDownloadMeasured={(downloadMs) => handleDownloadMeasured(item.id, downloadMs)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -487,6 +661,9 @@ Body (form-data):
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</Section>
|
|
||||||
|
{/* Image Zoom Modal */}
|
||||||
|
<ImageZoomModal imageUrl={zoomedImageUrl} onClose={() => setZoomedImageUrl(null)} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
|
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { SubsectionNav } from '@/components/shared/SubsectionNav';
|
||||||
import { DocsSidebar } from '@/components/docs/layout/DocsSidebar';
|
import { DocsSidebar } from '@/components/docs/layout/DocsSidebar';
|
||||||
import { ThreeColumnLayout } from '@/components/layout/ThreeColumnLayout';
|
import { ThreeColumnLayout } from '@/components/layout/ThreeColumnLayout';
|
||||||
import { ApiKeyWidget } from '@/components/shared/ApiKeyWidget/apikey-widget';
|
import { ApiKeyWidget } from '@/components/shared/ApiKeyWidget/apikey-widget';
|
||||||
import { ApiKeyProvider } from '@/components/shared/ApiKeyWidget/apikey-context';
|
import { ApiKeyProvider } from '@/components/shared/ApiKeyWidget/apikey-context';
|
||||||
import { PageProvider } from '@/contexts/page-context';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root Documentation Layout
|
* Root Documentation Layout
|
||||||
|
|
@ -47,20 +47,34 @@ export default function DocsRootLayout({ children }: DocsRootLayoutProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ApiKeyProvider>
|
<ApiKeyProvider>
|
||||||
<PageProvider
|
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950">
|
||||||
navItems={navItems}
|
{/* Animated gradient background (matching landing page) */}
|
||||||
currentPath={pathname}
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
rightSlot={<ApiKeyWidget />}
|
<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>
|
||||||
<ThreeColumnLayout
|
</div>
|
||||||
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">
|
{/* Subsection Navigation */}
|
||||||
<DocsSidebar currentPath={pathname} />
|
<SubsectionNav
|
||||||
</div>
|
items={navItems}
|
||||||
}
|
currentPath={pathname}
|
||||||
center={children}
|
ctaText="Join Beta"
|
||||||
|
ctaHref="/signup"
|
||||||
|
rightSlot={<ApiKeyWidget />}
|
||||||
/>
|
/>
|
||||||
</PageProvider>
|
|
||||||
|
{/* 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>
|
||||||
</ApiKeyProvider>
|
</ApiKeyProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Footer } from '@/components/shared/Footer';
|
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
|
|
@ -102,7 +101,39 @@ export default function RootLayout({
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
<Footer />
|
{/* 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">
|
||||||
|
© 2025 Banatie. Built for builders who create.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
'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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -5,8 +5,6 @@ import { InspectMode } from './InspectMode';
|
||||||
import { PromptReuseButton } from './PromptReuseButton';
|
import { PromptReuseButton } from './PromptReuseButton';
|
||||||
import { CompletedTimerBadge } from './GenerationTimer';
|
import { CompletedTimerBadge } from './GenerationTimer';
|
||||||
import { CodeExamplesWidget } from './CodeExamplesWidget';
|
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';
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
|
@ -53,6 +51,7 @@ interface GenerationResult {
|
||||||
interface ResultCardProps {
|
interface ResultCardProps {
|
||||||
result: GenerationResult;
|
result: GenerationResult;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
onZoom: (url: string) => void;
|
||||||
onCopy: (text: string) => void;
|
onCopy: (text: string) => void;
|
||||||
onDownload: (url: string, filename: string) => void;
|
onDownload: (url: string, filename: string) => void;
|
||||||
onReusePrompt: (prompt: string) => void;
|
onReusePrompt: (prompt: string) => void;
|
||||||
|
|
@ -63,11 +62,11 @@ type ViewMode = 'preview' | 'inspect';
|
||||||
export function ResultCard({
|
export function ResultCard({
|
||||||
result,
|
result,
|
||||||
apiKey,
|
apiKey,
|
||||||
|
onZoom,
|
||||||
onCopy,
|
onCopy,
|
||||||
onDownload,
|
onDownload,
|
||||||
onReusePrompt,
|
onReusePrompt,
|
||||||
}: ResultCardProps) {
|
}: ResultCardProps) {
|
||||||
const { openModal } = usePageContext();
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('preview');
|
const [viewMode, setViewMode] = useState<ViewMode>('preview');
|
||||||
|
|
||||||
// Build enhancement options JSON for code examples
|
// Build enhancement options JSON for code examples
|
||||||
|
|
@ -214,7 +213,7 @@ X-API-Key: ${apiKey}
|
||||||
image={result.leftImage}
|
image={result.leftImage}
|
||||||
label="Original Prompt"
|
label="Original Prompt"
|
||||||
prompt={result.originalPrompt}
|
prompt={result.originalPrompt}
|
||||||
openModal={openModal}
|
onZoom={onZoom}
|
||||||
onDownload={onDownload}
|
onDownload={onDownload}
|
||||||
onReusePrompt={onReusePrompt}
|
onReusePrompt={onReusePrompt}
|
||||||
filename="original.png"
|
filename="original.png"
|
||||||
|
|
@ -225,7 +224,7 @@ X-API-Key: ${apiKey}
|
||||||
image={result.rightImage}
|
image={result.rightImage}
|
||||||
label="Enhanced Prompt"
|
label="Enhanced Prompt"
|
||||||
prompt={result.enhancedPrompt || result.originalPrompt}
|
prompt={result.enhancedPrompt || result.originalPrompt}
|
||||||
openModal={openModal}
|
onZoom={onZoom}
|
||||||
onDownload={onDownload}
|
onDownload={onDownload}
|
||||||
onReusePrompt={onReusePrompt}
|
onReusePrompt={onReusePrompt}
|
||||||
filename="enhanced.png"
|
filename="enhanced.png"
|
||||||
|
|
@ -265,7 +264,7 @@ function ImagePreview({
|
||||||
image,
|
image,
|
||||||
label,
|
label,
|
||||||
prompt,
|
prompt,
|
||||||
openModal,
|
onZoom,
|
||||||
onDownload,
|
onDownload,
|
||||||
onReusePrompt,
|
onReusePrompt,
|
||||||
filename,
|
filename,
|
||||||
|
|
@ -274,7 +273,7 @@ function ImagePreview({
|
||||||
image: GenerationResult['leftImage'];
|
image: GenerationResult['leftImage'];
|
||||||
label: string;
|
label: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
openModal: (content: React.ReactNode) => void;
|
onZoom: (url: string) => void;
|
||||||
onDownload: (url: string, filename: string) => void;
|
onDownload: (url: string, filename: string) => void;
|
||||||
onReusePrompt: (prompt: string) => void;
|
onReusePrompt: (prompt: string) => void;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
|
@ -283,12 +282,6 @@ function ImagePreview({
|
||||||
const [promptExpanded, setPromptExpanded] = useState(false);
|
const [promptExpanded, setPromptExpanded] = useState(false);
|
||||||
const [urlCopied, setUrlCopied] = useState(false);
|
const [urlCopied, setUrlCopied] = useState(false);
|
||||||
|
|
||||||
const handleImageClick = () => {
|
|
||||||
if (image?.url) {
|
|
||||||
openModal(<ExpandedImageView imageUrl={image.url} alt={label} />);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyImageUrl = () => {
|
const copyImageUrl = () => {
|
||||||
if (image?.url) {
|
if (image?.url) {
|
||||||
navigator.clipboard.writeText(image.url);
|
navigator.clipboard.writeText(image.url);
|
||||||
|
|
@ -325,7 +318,7 @@ function ImagePreview({
|
||||||
src={image.url}
|
src={image.url}
|
||||||
alt={label}
|
alt={label}
|
||||||
className="h-96 w-auto object-contain rounded-lg border border-slate-700"
|
className="h-96 w-auto object-contain rounded-lg border border-slate-700"
|
||||||
onClick={handleImageClick}
|
onClick={() => onZoom(image.url)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ import { useIntersectionObserver } from '@/hooks/useIntersectionObserver';
|
||||||
import { useImageDownloadTime } from '@/components/shared/ImageCard/useImageDownloadTime';
|
import { useImageDownloadTime } from '@/components/shared/ImageCard/useImageDownloadTime';
|
||||||
import { ImageMetadataBar } from '@/components/shared/ImageMetadataBar';
|
import { ImageMetadataBar } from '@/components/shared/ImageMetadataBar';
|
||||||
import { calculateAspectRatio } from '@/utils/imageUtils';
|
import { calculateAspectRatio } from '@/utils/imageUtils';
|
||||||
import { usePageContext } from '@/contexts/page-context';
|
|
||||||
import { ExpandedImageView } from '@/components/shared/ExpandedImageView';
|
|
||||||
|
|
||||||
type GalleryImageCardProps = {
|
type GalleryImageCardProps = {
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
|
|
@ -14,6 +12,7 @@ type GalleryImageCardProps = {
|
||||||
size: number;
|
size: number;
|
||||||
contentType: string;
|
contentType: string;
|
||||||
lastModified: string;
|
lastModified: string;
|
||||||
|
onZoom: (url: string) => void;
|
||||||
onDownloadMeasured: (imageId: string, downloadMs: number) => void;
|
onDownloadMeasured: (imageId: string, downloadMs: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -23,9 +22,9 @@ export const GalleryImageCard = ({
|
||||||
size,
|
size,
|
||||||
contentType,
|
contentType,
|
||||||
lastModified,
|
lastModified,
|
||||||
|
onZoom,
|
||||||
onDownloadMeasured,
|
onDownloadMeasured,
|
||||||
}: GalleryImageCardProps) => {
|
}: GalleryImageCardProps) => {
|
||||||
const { openModal } = usePageContext();
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const [imageDimensions, setImageDimensions] = useState<{
|
const [imageDimensions, setImageDimensions] = useState<{
|
||||||
width: number;
|
width: number;
|
||||||
|
|
@ -58,14 +57,10 @@ export const GalleryImageCard = ({
|
||||||
img.src = imageUrl;
|
img.src = imageUrl;
|
||||||
}, [isVisible, imageUrl]);
|
}, [isVisible, imageUrl]);
|
||||||
|
|
||||||
const handleImageClick = () => {
|
|
||||||
openModal(<ExpandedImageView imageUrl={imageUrl} alt={filename} />);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleImageClick();
|
onZoom(imageUrl);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -86,7 +81,7 @@ export const GalleryImageCard = ({
|
||||||
>
|
>
|
||||||
<div
|
<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"
|
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={handleImageClick}
|
onClick={() => onZoom(imageUrl)}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={`View full size image: ${filename}`}
|
aria-label={`View full size image: ${filename}`}
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,11 @@ type ImageItem = {
|
||||||
|
|
||||||
type ImageGridProps = {
|
type ImageGridProps = {
|
||||||
images: ImageItem[];
|
images: ImageItem[];
|
||||||
|
onImageZoom: (url: string) => void;
|
||||||
onDownloadMeasured: (imageId: string, downloadMs: number) => void;
|
onDownloadMeasured: (imageId: string, downloadMs: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ImageGrid = ({ images, onDownloadMeasured }: ImageGridProps) => {
|
export const ImageGrid = ({ images, onImageZoom, onDownloadMeasured }: ImageGridProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
||||||
{images.map((image) => (
|
{images.map((image) => (
|
||||||
|
|
@ -26,6 +27,7 @@ export const ImageGrid = ({ images, onDownloadMeasured }: ImageGridProps) => {
|
||||||
size={image.size}
|
size={image.size}
|
||||||
contentType={image.contentType}
|
contentType={image.contentType}
|
||||||
lastModified={image.lastModified}
|
lastModified={image.lastModified}
|
||||||
|
onZoom={onImageZoom}
|
||||||
onDownloadMeasured={onDownloadMeasured}
|
onDownloadMeasured={onDownloadMeasured}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
'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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
'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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
'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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
'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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { ImageMetadataBar } from '../ImageMetadataBar';
|
import { ImageMetadataBar } from '../ImageMetadataBar';
|
||||||
import { useImageDownloadTime } from './useImageDownloadTime';
|
import { useImageDownloadTime } from './useImageDownloadTime';
|
||||||
import { usePageContext } from '@/contexts/page-context';
|
|
||||||
import { ExpandedImageView } from '../ExpandedImageView';
|
|
||||||
|
|
||||||
export interface ImageCardProps {
|
export interface ImageCardProps {
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
|
|
@ -13,6 +11,7 @@ export interface ImageCardProps {
|
||||||
height?: number;
|
height?: number;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
fileType: string;
|
fileType: string;
|
||||||
|
onZoom: (url: string) => void;
|
||||||
timestamp?: Date;
|
timestamp?: Date;
|
||||||
className?: string;
|
className?: string;
|
||||||
measureDownloadTime?: boolean;
|
measureDownloadTime?: boolean;
|
||||||
|
|
@ -26,34 +25,26 @@ export const ImageCard = ({
|
||||||
height,
|
height,
|
||||||
fileSize,
|
fileSize,
|
||||||
fileType,
|
fileType,
|
||||||
|
onZoom,
|
||||||
timestamp,
|
timestamp,
|
||||||
className = '',
|
className = '',
|
||||||
measureDownloadTime = false,
|
measureDownloadTime = false,
|
||||||
onDownloadMeasured,
|
onDownloadMeasured,
|
||||||
}: ImageCardProps) => {
|
}: ImageCardProps) => {
|
||||||
const { openModal } = usePageContext();
|
|
||||||
const { downloadTime, isLoading } = useImageDownloadTime(
|
const { downloadTime, isLoading } = useImageDownloadTime(
|
||||||
measureDownloadTime ? imageUrl : null
|
measureDownloadTime ? imageUrl : null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track if we've already called onDownloadMeasured to prevent duplicate calls
|
|
||||||
const hasCalledCallback = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (downloadTime !== null && onDownloadMeasured && !hasCalledCallback.current) {
|
if (downloadTime !== null && onDownloadMeasured) {
|
||||||
onDownloadMeasured(downloadTime);
|
onDownloadMeasured(downloadTime);
|
||||||
hasCalledCallback.current = true;
|
|
||||||
}
|
}
|
||||||
}, [downloadTime, onDownloadMeasured]);
|
}, [downloadTime, onDownloadMeasured]);
|
||||||
|
|
||||||
const handleImageClick = () => {
|
|
||||||
openModal(<ExpandedImageView imageUrl={imageUrl} alt={filename} />);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleImageClick();
|
onZoom(imageUrl);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -72,7 +63,7 @@ export const ImageCard = ({
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="aspect-video bg-slate-800 rounded-lg mb-3 overflow-hidden cursor-pointer group relative"
|
className="aspect-video bg-slate-800 rounded-lg mb-3 overflow-hidden cursor-pointer group relative"
|
||||||
onClick={handleImageClick}
|
onClick={() => onZoom(imageUrl)}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label="View full size image"
|
aria-label="View full size image"
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface NarrowSectionProps {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
bgClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NarrowSection = ({ children, className = '', bgClassName = '' }: NarrowSectionProps) => {
|
|
||||||
return (
|
|
||||||
<div className={bgClassName}>
|
|
||||||
<div className={`max-w-4xl mx-auto px-6 py-8 ${className}`}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface SectionProps {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
bgClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Section = ({ children, className = '', bgClassName = '' }: SectionProps) => {
|
|
||||||
return (
|
|
||||||
<div className={bgClassName}>
|
|
||||||
<div className={`max-w-7xl mx-auto px-6 py-8 ${className}`}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -108,7 +108,7 @@ export const SubsectionNav = ({ items, currentPath, leftSlot, rightSlot }: Subse
|
||||||
|
|
||||||
{/* Right Slot - Absolutely Positioned */}
|
{/* Right Slot - Absolutely Positioned */}
|
||||||
{rightSlot && (
|
{rightSlot && (
|
||||||
<div className="absolute top-0 right-0 h-12 flex items-center pr-0 hidden xl:flex overflow-visible">
|
<div className="absolute top-0 right-0 h-12 flex items-center pr-0 hidden xl:flex">
|
||||||
{rightSlot}
|
{rightSlot}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
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.';
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
'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 pathname = usePathname();
|
|
||||||
|
|
||||||
const openModal = (content: ReactNode) => {
|
|
||||||
setIsOpen(true);
|
|
||||||
setModalContent(content);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setIsOpen(false);
|
|
||||||
setModalContent(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
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}>
|
|
||||||
<div aria-hidden={isOpen}>
|
|
||||||
<AnimatedBackground />
|
|
||||||
<SubsectionNav items={navItems} currentPath={currentPath} rightSlot={rightSlot} />
|
|
||||||
<div className="relative z-10">{children}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{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-9 h-9 sm:w-10 sm:h-10 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>
|
|
||||||
)}
|
|
||||||
</PageContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Loading…
Reference in New Issue