diff --git a/src/components/gallery/FamilyCard.tsx b/src/components/gallery/FamilyCard.tsx index 45be124..209c9f2 100644 --- a/src/components/gallery/FamilyCard.tsx +++ b/src/components/gallery/FamilyCard.tsx @@ -1,240 +1,130 @@ -import { useState } from 'react'; -import { Badge } from '../ui'; -import { cn } from '../ui/cn'; -import type { MineralFamily, MineralExpression } from '../../lib/db'; - -// Crystal system color mapping for badges -const SYSTEM_COLORS: Record = { - cubic: 'cubic', - hexagonal: 'hexagonal', - trigonal: 'trigonal', - tetragonal: 'tetragonal', - orthorhombic: 'orthorhombic', - monoclinic: 'monoclinic', - triclinic: 'triclinic', -}; +import { useState, useMemo } from 'react'; +import { Badge } from '../ui/Badge'; +import { clsx } from 'clsx'; +import { sanitizeSvg } from '../../lib/sanitize-svg'; +import type { MineralFamily } from '../../lib/db'; interface FamilyCardProps { family: MineralFamily; - expressions?: MineralExpression[]; - onClick?: () => void; href?: string; + onClick?: () => void; + className?: string; } /** - * Gallery card showing mineral families with expression indicators. - * Displays the primary crystal form with a badge showing total expression count. + * Card component for displaying a mineral family in the gallery. + * Shows family name, crystal system, expression count, and primary SVG. */ -export function FamilyCard({ family, expressions = [], onClick, href }: FamilyCardProps) { +export function FamilyCard({ + family, + href, + onClick, + className, +}: FamilyCardProps) { const [imageError, setImageError] = useState(false); - const [isHovered, setIsHovered] = useState(false); - const systemColor = SYSTEM_COLORS[family.crystal_system.toLowerCase()] || 'default'; - const primaryExpression = expressions.find(e => e.is_primary) || expressions[0]; - const expressionCount = family.expressionCount || expressions.length; - - // Get SVG content - prefer inline, then path-based - const svgContent = primaryExpression?.model_svg || family.primarySvg; - const svgPath = !svgContent && primaryExpression ? `/crystals/${primaryExpression.id}.svg` : null; - - const CardWrapper = href ? 'a' : 'article'; - const cardProps = href ? { href } : {}; + // Sanitize SVG content to prevent XSS + const sanitizedSvg = useMemo( + () => (family.primarySvg ? sanitizeSvg(family.primarySvg) : ''), + [family.primarySvg] + ); - const formatHardness = () => { - if (!family.hardness_min) return null; - if (family.hardness_min === family.hardness_max) { - return family.hardness_min; - } - return `${family.hardness_min}-${family.hardness_max}`; + const systemColors: Record = { + cubic: 'crystal', + hexagonal: 'sapphire', + trigonal: 'emerald', + tetragonal: 'ruby', + orthorhombic: 'default', + monoclinic: 'default', + triclinic: 'default', }; - const formatSG = () => { - if (!family.sg_min) return null; - if (family.sg_min === family.sg_max) { - return family.sg_min.toFixed(2); + const handleClick = (e: React.MouseEvent) => { + if (onClick) { + e.preventDefault(); + onClick(); } - return `${family.sg_min.toFixed(2)}-${family.sg_max?.toFixed(2)}`; }; - const formatRI = () => { - if (!family.ri_min) return null; - if (family.ri_min === family.ri_max) { - return family.ri_min.toFixed(3); - } - return `${family.ri_min.toFixed(3)}-${family.ri_max?.toFixed(3)}`; - }; + const Wrapper = href ? 'a' : 'div'; + const wrapperProps = href + ? { href, onClick: handleClick } + : { onClick }; + + const expressionCount = family.expressionCount || 0; + + // Format hardness range + const hardnessDisplay = family.hardness_min && family.hardness_max + ? family.hardness_min === family.hardness_max + ? family.hardness_min.toString() + : `${family.hardness_min}-${family.hardness_max}` + : null; return ( - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} > - {/* Crystal Preview */} -
- {svgContent ? ( -
- ) : svgPath && !imageError ? ( - {family.name} setImageError(true)} - /> - ) : ( -
- -
- )} + {/* Preview */} +
+
- {/* Expression Count Badge */} + {/* Expression count badge */} {expressionCount > 1 && ( -
- - {expressionCount} forms +
+ + + + + {expressionCount} forms +
)} - {/* Hover: Expression Thumbnails */} - {expressionCount > 1 && isHovered && expressions.length > 0 && ( -
-
- {expressions.slice(0, 5).map((expr) => ( -
- {expr.model_svg ? ( -
- ) : ( -
- )} -
- ))} - {expressionCount > 5 && ( -
- +{expressionCount - 5} -
- )} +
+ {sanitizedSvg ? ( +
+ ) : !imageError ? ( + {family.name} setImageError(true)} + /> + ) : ( +
+ + +
-
- )} + )} +
- {/* Info Section */} + {/* Info */}
-
-

+
+

{family.name}

- + {family.crystal_system}
- - {family.chemistry && ( -

{family.chemistry}

+ {(family.chemistry || hardnessDisplay) && ( +
+ {family.chemistry && {family.chemistry}} + {hardnessDisplay && H: {hardnessDisplay}} +
)} - - {/* Compact Properties */} -
- {formatHardness() && ( - - H - {formatHardness()} - - )} - {formatSG() && ( - - SG - {formatSG()} - - )} - {formatRI() && ( - - RI - {formatRI()} - - )} -

- + ); } - -/** - * Placeholder icon when no crystal SVG is available. - */ -function CrystalPlaceholder({ system }: { system: string }) { - return ( - - - - - - ); -} - -/** - * Small crystal icon for the expression count badge. - */ -function CrystalIcon({ className }: { className?: string }) { - return ( - - - - - - ); -} - -export default FamilyCard; diff --git a/src/components/gallery/FamilyModal.tsx b/src/components/gallery/FamilyModal.tsx new file mode 100644 index 0000000..c82009b --- /dev/null +++ b/src/components/gallery/FamilyModal.tsx @@ -0,0 +1,335 @@ +import { useEffect, useCallback, useState, useMemo } from 'react'; +import { Badge } from '../ui/Badge'; +import { Crystal3DViewer } from '../crystal/Crystal3DViewer'; +import { ViewerToggle } from '../crystal/ViewerToggle'; +import { Button } from '../ui/Button'; +import { useFamilyExpressions } from '../../hooks/useFamilies'; +import type { MineralFamily, MineralExpression } from '../../lib/db'; +import { sanitizeSvg } from '../../lib/sanitize-svg'; +import { clsx } from 'clsx'; + +interface FamilyModalProps { + family: MineralFamily; + onClose: () => void; +} + +type ViewMode = '2d' | '3d'; + +/** + * Modal for displaying a mineral family with expression selector. + */ +export function FamilyModal({ family, onClose }: FamilyModalProps) { + const { expressions, loading: expressionsLoading } = useFamilyExpressions(family.id); + const [viewMode, setViewMode] = useState('2d'); + const [selectedExpression, setSelectedExpression] = useState(null); + const [gltfData, setGltfData] = useState(null); + const [isLoadingGltf, setIsLoadingGltf] = useState(false); + + // Select primary expression by default when expressions load + useEffect(() => { + if (expressions.length > 0 && !selectedExpression) { + const primary = expressions.find(e => e.is_primary) || expressions[0]; + setSelectedExpression(primary); + } + }, [expressions, selectedExpression]); + + // Load glTF when switching to 3D mode + useEffect(() => { + if (viewMode === '3d' && selectedExpression?.model_gltf && !gltfData) { + setIsLoadingGltf(true); + try { + const parsed = JSON.parse(selectedExpression.model_gltf as string); + setGltfData(parsed); + } catch (error) { + console.error('Failed to parse glTF:', error); + } finally { + setIsLoadingGltf(false); + } + } + }, [viewMode, selectedExpression, gltfData]); + + // Reset gltfData when expression changes + useEffect(() => { + setGltfData(null); + }, [selectedExpression?.id]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }, + [onClose] + ); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + document.body.style.overflow = 'hidden'; + + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = ''; + }; + }, [handleKeyDown]); + + // Sanitize SVG content + const sanitizedSvg = useMemo(() => { + if (selectedExpression?.model_svg) { + return sanitizeSvg(selectedExpression.model_svg); + } + if (family.primarySvg) { + return sanitizeSvg(family.primarySvg); + } + return ''; + }, [selectedExpression?.model_svg, family.primarySvg]); + + // Download handlers + const handleDownloadSVG = () => { + const svg = selectedExpression?.model_svg || family.primarySvg; + if (svg) { + const blob = new Blob([svg], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${selectedExpression?.id || family.id}.svg`; + a.click(); + URL.revokeObjectURL(url); + } + }; + + const handleDownloadGLTF = () => { + const gltf = selectedExpression?.model_gltf; + if (gltf) { + const blob = new Blob([typeof gltf === 'string' ? gltf : JSON.stringify(gltf, null, 2)], { + type: 'model/gltf+json', + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${selectedExpression?.id || family.id}.gltf`; + a.click(); + URL.revokeObjectURL(url); + } + }; + + // Format property display values + const formatRange = (min: number | undefined, max: number | undefined) => { + if (min === undefined && max === undefined) return undefined; + if (min === max || max === undefined) return min?.toString(); + if (min === undefined) return max?.toString(); + return `${min}-${max}`; + }; + + const hardnessDisplay = formatRange(family.hardness_min, family.hardness_max); + const sgDisplay = formatRange(family.sg_min, family.sg_max); + const riDisplay = formatRange(family.ri_min, family.ri_max); + + const properties = [ + { label: 'Chemistry', value: family.chemistry }, + { label: 'Hardness', value: hardnessDisplay }, + { label: 'Specific Gravity', value: sgDisplay }, + { label: 'Refractive Index', value: riDisplay }, + { label: 'Birefringence', value: family.birefringence }, + { label: 'Pleochroism', value: family.pleochroism }, + { label: 'Dispersion', value: family.dispersion }, + { label: 'Lustre', value: family.lustre }, + { label: 'Cleavage', value: family.cleavage }, + { label: 'Fracture', value: family.fracture }, + ].filter(p => p.value !== undefined && p.value !== null && p.value !== ''); + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Close button */} + + +
+ {/* Crystal Preview */} +
+ {/* Viewer Toggle */} +
+ +
+ + {/* Crystal Visualization */} +
+ {viewMode === '2d' ? ( +
+
+ {expressionsLoading ? ( +
+
+
+ ) : sanitizedSvg ? ( +
+ ) : ( + {family.name} { + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + }} + /> + )} +
+ ) : ( +
+ {isLoadingGltf ? ( +
+
+
+
Loading 3D model...
+
+
+ ) : ( + + )} +
+ )} +
+ + {/* Expression Selector */} + {expressions.length > 1 && ( +
+
Crystal Forms
+
+ {expressions.map((expr) => ( + + ))} +
+
+ )} +
+ + {/* Info Panel */} +
+
+

{family.name}

+ {family.crystal_system} +
+ + {/* Properties */} +
+ {properties.map(({ label, value }) => ( +
+ {label} + {value} +
+ ))} +
+ + {/* CDL */} + {selectedExpression?.cdl && ( +
+ CDL +
+                  {selectedExpression.cdl}
+                
+
+ )} + + {/* Action Buttons */} +
+
+ + +
+ + {/* Download Buttons */} +
+
Download Models
+
+ + +
+
+
+
+
+
+
+ ); +} diff --git a/src/components/gallery/FilterBar.tsx b/src/components/gallery/FilterBar.tsx index 5e15150..bc8f635 100644 --- a/src/components/gallery/FilterBar.tsx +++ b/src/components/gallery/FilterBar.tsx @@ -9,6 +9,7 @@ interface FilterBarProps { selectedSystem: string | null; onSystemChange: (system: string | null) => void; resultCount?: number; + resultLabel?: string; className?: string; } @@ -19,6 +20,7 @@ export function FilterBar({ selectedSystem, onSystemChange, resultCount, + resultLabel = 'mineral', className, }: FilterBarProps) { return ( @@ -34,7 +36,7 @@ export function FilterBar({
{resultCount !== undefined && (
- {resultCount} mineral{resultCount !== 1 ? 's' : ''} + {resultCount} {resultCount === 1 ? resultLabel.replace(/ies$/, 'y').replace(/s$/, '') : resultLabel}
)}
diff --git a/src/components/gallery/Gallery.tsx b/src/components/gallery/Gallery.tsx index ba716e8..5cb10b9 100644 --- a/src/components/gallery/Gallery.tsx +++ b/src/components/gallery/Gallery.tsx @@ -2,7 +2,8 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { GalleryGrid } from './GalleryGrid'; import { FilterBar } from './FilterBar'; import { Pagination } from '../ui/Pagination'; -import { useCrystalDB, useFilters } from '../../hooks/useCrystalDB'; +import { useFamilies } from '../../hooks/useFamilies'; +import { useFilters } from '../../hooks/useCrystalDB'; import { usePagination } from '../../hooks/usePagination'; interface GalleryProps { @@ -11,7 +12,7 @@ interface GalleryProps { } export function Gallery({ initialSystem = '', initialSearch = '' }: GalleryProps) { - const { minerals, loading, error, search, filterBySystem } = useCrystalDB(); + const { families, loading, error, search, filterBySystem } = useFamilies(); const { systems, loading: filtersLoading } = useFilters(); const [searchQuery, setSearchQuery] = useState(initialSearch); const [selectedSystem, setSelectedSystem] = useState(initialSystem || null); @@ -21,17 +22,17 @@ export function Gallery({ initialSystem = '', initialSearch = '' }: GalleryProps initialPageSize: 12, // 4 columns x 3 rows }); - // Paginate minerals - const totalPages = Math.ceil(minerals.length / paginationParams.pageSize); + // Paginate families + const totalPages = Math.ceil(families.length / paginationParams.pageSize); const startIndex = (page - 1) * paginationParams.pageSize; - const paginatedMinerals = useMemo(() => { - return minerals.slice(startIndex, startIndex + paginationParams.pageSize); - }, [minerals, startIndex, paginationParams.pageSize]); + const paginatedFamilies = useMemo(() => { + return families.slice(startIndex, startIndex + paginationParams.pageSize); + }, [families, startIndex, paginationParams.pageSize]); const pagination = { page, pageSize: paginationParams.pageSize, - total: minerals.length, + total: families.length, totalPages, hasNext: page < totalPages, hasPrev: page > 1, @@ -97,7 +98,7 @@ export function Gallery({ initialSystem = '', initialSearch = '' }: GalleryProps
-

Failed to load minerals

+

Failed to load families

{error.message}