@umituz/web-design-system 3.1.22 → 4.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -469
- package/dist/styles.css +2 -0
- package/package.json +31 -31
- package/src/__tests__/cn.test.ts +24 -0
- package/src/__tests__/components.smoke.test.tsx +78 -0
- package/src/__tests__/styles.contract.test.ts +60 -0
- package/src/domain/tokens/index.ts +8 -49
- package/src/index.ts +8 -10
- package/src/infrastructure/constants/component.constants.ts +3 -17
- package/src/infrastructure/constants/index.ts +4 -2
- package/src/infrastructure/constants/timing.constants.ts +46 -0
- package/src/infrastructure/utils/calculation.utils.ts +74 -0
- package/src/infrastructure/utils/cn.util.ts +1 -1
- package/src/infrastructure/utils/index.ts +12 -0
- package/src/presentation/atoms/IconButton.tsx +55 -0
- package/src/presentation/atoms/Progress.tsx +2 -1
- package/src/presentation/atoms/Spinner.tsx +1 -1
- package/src/presentation/atoms/Switch.tsx +2 -2
- package/src/presentation/atoms/Text.tsx +15 -13
- package/src/presentation/atoms/Tooltip.tsx +2 -1
- package/src/presentation/atoms/index.ts +3 -0
- package/src/presentation/hooks/index.ts +0 -3
- package/src/presentation/hooks/useClickOutside.ts +16 -10
- package/src/presentation/hooks/useClipboard.ts +30 -12
- package/src/presentation/hooks/useDebounce.ts +9 -11
- package/src/presentation/hooks/useLocalStorage.ts +32 -20
- package/src/presentation/hooks/useMediaQuery.ts +20 -15
- package/src/presentation/hooks/useScrollLock.ts +40 -12
- package/src/presentation/hooks/useToggle.ts +8 -6
- package/src/presentation/molecules/ActiveFilterTags.tsx +19 -18
- package/src/presentation/molecules/Avatar.tsx +1 -1
- package/src/presentation/molecules/CodeBlock.tsx +50 -48
- package/src/presentation/molecules/InputGroup.tsx +1 -1
- package/src/presentation/molecules/ListItem.tsx +40 -29
- package/src/presentation/molecules/Select.tsx +1 -1
- package/src/presentation/molecules/Toggle.tsx +4 -4
- package/src/presentation/molecules/index.ts +0 -3
- package/src/presentation/organisms/Alert.tsx +1 -1
- package/src/presentation/organisms/DataTable.tsx +26 -15
- package/src/presentation/organisms/Dialog.tsx +1 -1
- package/src/presentation/organisms/DropdownMenu.tsx +2 -2
- package/src/presentation/organisms/HoverCard.tsx +1 -1
- package/src/presentation/organisms/LoadingState.tsx +1 -5
- package/src/presentation/organisms/Popover.tsx +1 -1
- package/src/presentation/organisms/StatCard.tsx +2 -2
- package/src/presentation/organisms/Table.tsx +2 -2
- package/src/presentation/organisms/index.ts +0 -60
- package/src/styles/compile.css +18 -0
- package/src/styles/theme.css +131 -0
- package/src/domain/tokens/animation.tokens.ts +0 -87
- package/src/domain/tokens/color.tokens.ts +0 -95
- package/src/domain/tokens/radius.tokens.ts +0 -27
- package/src/domain/tokens/shadow.tokens.ts +0 -25
- package/src/domain/tokens/spacing.tokens.ts +0 -65
- package/src/domain/tokens/typography.tokens.ts +0 -74
- package/src/infrastructure/error/ErrorBoundary.tsx +0 -258
- package/src/infrastructure/error/ErrorDisplay.tsx +0 -346
- package/src/infrastructure/error/SuspenseWrapper.tsx +0 -133
- package/src/infrastructure/error/index.ts +0 -5
- package/src/infrastructure/performance/index.ts +0 -92
- package/src/infrastructure/performance/useLazyLoading.ts +0 -342
- package/src/infrastructure/performance/useMemoryOptimization.ts +0 -293
- package/src/infrastructure/performance/usePerformanceMonitor.ts +0 -158
- package/src/infrastructure/security/index.ts +0 -10
- package/src/infrastructure/security/security-config.ts +0 -171
- package/src/infrastructure/security/useFormValidation.ts +0 -216
- package/src/infrastructure/security/validation.ts +0 -246
- package/src/presentation/hooks/useLanguage.ts +0 -109
- package/src/presentation/molecules/Breadcrumb.tsx +0 -54
- package/src/presentation/organisms/AlertDialog.tsx +0 -181
- package/src/presentation/organisms/Calendar.tsx +0 -79
- package/src/presentation/organisms/Comments.tsx +0 -103
- package/src/presentation/organisms/ConfirmDialog.tsx +0 -97
- package/src/presentation/organisms/FilterBar.tsx +0 -244
- package/src/presentation/organisms/FilterSidebar.tsx +0 -172
- package/src/presentation/organisms/Footer.tsx +0 -86
- package/src/presentation/organisms/FormModal.tsx +0 -126
- package/src/presentation/organisms/Grid.tsx +0 -128
- package/src/presentation/organisms/ImageLightbox.tsx +0 -252
- package/src/presentation/organisms/MainNavbar.tsx +0 -320
- package/src/presentation/organisms/Modal.tsx +0 -173
- package/src/presentation/organisms/Navbar.tsx +0 -75
- package/src/presentation/organisms/NewsletterSignup.tsx +0 -165
- package/src/presentation/organisms/QuickActionCard.tsx +0 -93
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* FormModal Component (Template)
|
|
3
|
-
* @description Pre-configured modal with form wrapper and loading state
|
|
4
|
-
* Reduces boilerplate in modal components like CampaignModal, ProjectListModal
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { forwardRef, type ReactNode } from 'react';
|
|
8
|
-
import { cn } from '../../infrastructure/utils';
|
|
9
|
-
import {
|
|
10
|
-
Dialog,
|
|
11
|
-
DialogContent,
|
|
12
|
-
DialogDescription,
|
|
13
|
-
DialogFooter,
|
|
14
|
-
DialogHeader,
|
|
15
|
-
DialogTitle,
|
|
16
|
-
} from './Dialog';
|
|
17
|
-
import { Button } from '../atoms';
|
|
18
|
-
import { Loader2 } from 'lucide-react';
|
|
19
|
-
import type { BaseProps } from '../../domain/types';
|
|
20
|
-
|
|
21
|
-
export interface FormModalProps extends BaseProps {
|
|
22
|
-
open: boolean;
|
|
23
|
-
onOpenChange: (open: boolean) => void;
|
|
24
|
-
title: string;
|
|
25
|
-
description?: string;
|
|
26
|
-
children: ReactNode;
|
|
27
|
-
onSubmit?: (e: React.FormEvent) => void | Promise<void>;
|
|
28
|
-
loading?: boolean;
|
|
29
|
-
submitText?: string;
|
|
30
|
-
cancelText?: string;
|
|
31
|
-
showFooter?: boolean;
|
|
32
|
-
footer?: ReactNode;
|
|
33
|
-
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
|
34
|
-
preventCloseOnSubmit?: boolean;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const sizeStyles = {
|
|
38
|
-
sm: 'sm:max-w-[400px]',
|
|
39
|
-
md: 'sm:max-w-[425px]',
|
|
40
|
-
lg: 'sm:max-w-[600px]',
|
|
41
|
-
xl: 'sm:max-w-[800px]',
|
|
42
|
-
full: 'sm:max-w-[95vw]',
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export const FormModal = forwardRef<HTMLDivElement, FormModalProps>(
|
|
46
|
-
(
|
|
47
|
-
{
|
|
48
|
-
className,
|
|
49
|
-
open,
|
|
50
|
-
onOpenChange,
|
|
51
|
-
title,
|
|
52
|
-
description,
|
|
53
|
-
children,
|
|
54
|
-
onSubmit,
|
|
55
|
-
loading = false,
|
|
56
|
-
submitText = 'Submit',
|
|
57
|
-
cancelText = 'Cancel',
|
|
58
|
-
showFooter = true,
|
|
59
|
-
footer,
|
|
60
|
-
size = 'md',
|
|
61
|
-
preventCloseOnSubmit = false,
|
|
62
|
-
...props
|
|
63
|
-
},
|
|
64
|
-
ref
|
|
65
|
-
) => {
|
|
66
|
-
const handleSubmit = async (e: React.FormEvent) => {
|
|
67
|
-
e.preventDefault();
|
|
68
|
-
if (onSubmit && !loading) {
|
|
69
|
-
await onSubmit(e);
|
|
70
|
-
if (!preventCloseOnSubmit) {
|
|
71
|
-
onOpenChange(false);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
return (
|
|
77
|
-
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
78
|
-
<DialogContent className={cn(sizeStyles[size], className)} ref={ref} {...props}>
|
|
79
|
-
{onSubmit ? (
|
|
80
|
-
<form onSubmit={handleSubmit}>
|
|
81
|
-
<DialogHeader>
|
|
82
|
-
<DialogTitle>{title}</DialogTitle>
|
|
83
|
-
{description && <DialogDescription>{description}</DialogDescription>}
|
|
84
|
-
</DialogHeader>
|
|
85
|
-
<div className="grid gap-4 py-4">{children}</div>
|
|
86
|
-
{showFooter && (
|
|
87
|
-
<DialogFooter>
|
|
88
|
-
{footer || (
|
|
89
|
-
<>
|
|
90
|
-
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
|
91
|
-
{cancelText}
|
|
92
|
-
</Button>
|
|
93
|
-
<Button type="submit" disabled={loading}>
|
|
94
|
-
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
95
|
-
{submitText}
|
|
96
|
-
</Button>
|
|
97
|
-
</>
|
|
98
|
-
)}
|
|
99
|
-
</DialogFooter>
|
|
100
|
-
)}
|
|
101
|
-
</form>
|
|
102
|
-
) : (
|
|
103
|
-
<>
|
|
104
|
-
<DialogHeader>
|
|
105
|
-
<DialogTitle>{title}</DialogTitle>
|
|
106
|
-
{description && <DialogDescription>{description}</DialogDescription>}
|
|
107
|
-
</DialogHeader>
|
|
108
|
-
<div className="grid gap-4 py-4">{children}</div>
|
|
109
|
-
{showFooter && (
|
|
110
|
-
<DialogFooter>
|
|
111
|
-
{footer || (
|
|
112
|
-
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
|
113
|
-
{cancelText}
|
|
114
|
-
</Button>
|
|
115
|
-
)}
|
|
116
|
-
</DialogFooter>
|
|
117
|
-
)}
|
|
118
|
-
</>
|
|
119
|
-
)}
|
|
120
|
-
</DialogContent>
|
|
121
|
-
</Dialog>
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
FormModal.displayName = 'FormModal';
|
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Grid Component (Organism)
|
|
3
|
-
* @description Responsive grid system with configurable columns
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { cn } from '../../infrastructure/utils';
|
|
7
|
-
import type { BaseProps } from '../../domain/types';
|
|
8
|
-
|
|
9
|
-
export interface GridProps extends BaseProps {
|
|
10
|
-
children: React.ReactNode;
|
|
11
|
-
cols?: {
|
|
12
|
-
xs?: number;
|
|
13
|
-
sm?: number;
|
|
14
|
-
md?: number;
|
|
15
|
-
lg?: number;
|
|
16
|
-
xl?: number;
|
|
17
|
-
'2xl'?: number;
|
|
18
|
-
};
|
|
19
|
-
gap?: number | string;
|
|
20
|
-
className?: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Responsive grid system that adjusts columns based on breakpoint
|
|
25
|
-
* @param cols - Number of columns per breakpoint
|
|
26
|
-
* @param gap - Gap between items (spacing unit or custom value)
|
|
27
|
-
* @param children - Grid items
|
|
28
|
-
*
|
|
29
|
-
* @example
|
|
30
|
-
* ```tsx
|
|
31
|
-
* // Auto responsive: 1 col mobile, 2 tablet, 3 desktop
|
|
32
|
-
* <Grid cols={{ xs: 1, sm: 2, lg: 3 }}>
|
|
33
|
-
* <div>Item 1</div>
|
|
34
|
-
* <div>Item 2</div>
|
|
35
|
-
* </Grid>
|
|
36
|
-
*
|
|
37
|
-
* // Fixed 4 columns
|
|
38
|
-
* <Grid cols={{ lg: 4 }}>
|
|
39
|
-
* {items.map(item => <Card key={item.id}>{item}</Card>)}
|
|
40
|
-
* </Grid>
|
|
41
|
-
* ```
|
|
42
|
-
*/
|
|
43
|
-
export const Grid = ({ children, cols = {}, gap = 4, className }: GridProps) => {
|
|
44
|
-
const {
|
|
45
|
-
xs = 1,
|
|
46
|
-
sm = xs,
|
|
47
|
-
md = sm,
|
|
48
|
-
lg = md,
|
|
49
|
-
xl = lg,
|
|
50
|
-
'2xl': xl2 = xl
|
|
51
|
-
} = cols;
|
|
52
|
-
|
|
53
|
-
const gridCols = cn(
|
|
54
|
-
'grid',
|
|
55
|
-
`grid-cols-${xs}`, // xs always applies (mobile-first)
|
|
56
|
-
sm !== xs && `sm:grid-cols-${sm}`,
|
|
57
|
-
md !== sm && `md:grid-cols-${md}`,
|
|
58
|
-
lg !== md && `lg:grid-cols-${lg}`,
|
|
59
|
-
xl !== lg && `xl:grid-cols-${xl}`,
|
|
60
|
-
xl2 !== xl && `2xl:grid-cols-${xl2}`
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
const gapClass = typeof gap === 'number' ? `gap-${gap}` : gap;
|
|
64
|
-
|
|
65
|
-
return (
|
|
66
|
-
<div className={cn(gridCols, gapClass, className)}>
|
|
67
|
-
{children}
|
|
68
|
-
</div>
|
|
69
|
-
);
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
Grid.displayName = 'Grid';
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Grid Item component for custom column spans
|
|
76
|
-
*/
|
|
77
|
-
export interface GridItemProps extends BaseProps {
|
|
78
|
-
children: React.ReactNode;
|
|
79
|
-
span?: {
|
|
80
|
-
xs?: number;
|
|
81
|
-
sm?: number;
|
|
82
|
-
md?: number;
|
|
83
|
-
lg?: number;
|
|
84
|
-
xl?: number;
|
|
85
|
-
'2xl'?: number;
|
|
86
|
-
};
|
|
87
|
-
className?: string;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Grid item with custom column span per breakpoint
|
|
92
|
-
* @param span - Number of columns to span (1-12)
|
|
93
|
-
*
|
|
94
|
-
* @example
|
|
95
|
-
* ```tsx
|
|
96
|
-
* <Grid cols={{ lg: 4 }}>
|
|
97
|
-
* <GridItem span={{ lg: 2 }}>Spans 2 columns</GridItem>
|
|
98
|
-
* <GridItem>Regular 1 column</GridItem>
|
|
99
|
-
* </Grid>
|
|
100
|
-
* ```
|
|
101
|
-
*/
|
|
102
|
-
export const GridItem = ({ children, span = {}, className }: GridItemProps) => {
|
|
103
|
-
const {
|
|
104
|
-
xs = 1,
|
|
105
|
-
sm = xs,
|
|
106
|
-
md = sm,
|
|
107
|
-
lg = md,
|
|
108
|
-
xl = lg,
|
|
109
|
-
'2xl': xl2 = xl
|
|
110
|
-
} = span;
|
|
111
|
-
|
|
112
|
-
const colSpan = cn(
|
|
113
|
-
'col-span-1',
|
|
114
|
-
`sm:col-span-${sm}`,
|
|
115
|
-
`md:col-span-${md}`,
|
|
116
|
-
`lg:col-span-${lg}`,
|
|
117
|
-
`xl:col-span-${xl}`,
|
|
118
|
-
`2xl:col-span-${xl2}`
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
return (
|
|
122
|
-
<div className={cn(colSpan, className)}>
|
|
123
|
-
{children}
|
|
124
|
-
</div>
|
|
125
|
-
);
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
GridItem.displayName = 'GridItem';
|
|
@@ -1,252 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ImageLightbox Component (Organism)
|
|
3
|
-
* @description Full-screen image gallery with zoom, navigation, and thumbnails
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { useState, useCallback, useEffect } from 'react';
|
|
7
|
-
import { cn } from '../../infrastructure/utils';
|
|
8
|
-
import type { BaseProps } from '../../domain/types';
|
|
9
|
-
import { Icon } from '../atoms/Icon';
|
|
10
|
-
|
|
11
|
-
export interface ImageLightboxImage {
|
|
12
|
-
src: string;
|
|
13
|
-
alt: string;
|
|
14
|
-
title?: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface ImageLightboxProps extends BaseProps {
|
|
18
|
-
images: ImageLightboxImage[];
|
|
19
|
-
initialIndex?: number;
|
|
20
|
-
isOpen: boolean;
|
|
21
|
-
onClose: () => void;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export const ImageLightbox = ({
|
|
25
|
-
images,
|
|
26
|
-
initialIndex = 0,
|
|
27
|
-
isOpen,
|
|
28
|
-
onClose,
|
|
29
|
-
}: ImageLightboxProps) => {
|
|
30
|
-
// Early return if closed
|
|
31
|
-
if (!isOpen) return null;
|
|
32
|
-
|
|
33
|
-
// Early return if no images
|
|
34
|
-
if (!images || images.length === 0) {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
|
39
|
-
const [isZoomed, setIsZoomed] = useState(false);
|
|
40
|
-
|
|
41
|
-
// Ensure initialIndex is within bounds
|
|
42
|
-
const safeIndex = Math.min(Math.max(initialIndex, 0), images.length - 1);
|
|
43
|
-
const currentImage = images[safeIndex];
|
|
44
|
-
|
|
45
|
-
// Use useCallback to memoize navigation functions
|
|
46
|
-
const goToNext = useCallback(() => {
|
|
47
|
-
setCurrentIndex((prev) => (prev + 1) % images.length);
|
|
48
|
-
}, [images.length]);
|
|
49
|
-
|
|
50
|
-
const goToPrevious = useCallback(() => {
|
|
51
|
-
setCurrentIndex((prev) => (prev - 1 + images.length) % images.length);
|
|
52
|
-
}, [images.length]);
|
|
53
|
-
|
|
54
|
-
const handleImageClick = useCallback(() => {
|
|
55
|
-
setIsZoomed(!isZoomed);
|
|
56
|
-
}, [isZoomed]);
|
|
57
|
-
|
|
58
|
-
// Handle keyboard navigation
|
|
59
|
-
useEffect(() => {
|
|
60
|
-
const handleKeyDown = (e: KeyboardEvent) => {
|
|
61
|
-
switch (e.key) {
|
|
62
|
-
case 'Escape':
|
|
63
|
-
onClose();
|
|
64
|
-
break;
|
|
65
|
-
case 'ArrowLeft':
|
|
66
|
-
goToPrevious();
|
|
67
|
-
break;
|
|
68
|
-
case 'ArrowRight':
|
|
69
|
-
goToNext();
|
|
70
|
-
break;
|
|
71
|
-
case '+':
|
|
72
|
-
case '=':
|
|
73
|
-
setIsZoomed(!isZoomed);
|
|
74
|
-
break;
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
window.addEventListener('keydown', handleKeyDown);
|
|
79
|
-
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
80
|
-
}, [onClose, goToPrevious, goToNext, isZoomed]);
|
|
81
|
-
|
|
82
|
-
// Prevent body scroll when lightbox is open
|
|
83
|
-
useEffect(() => {
|
|
84
|
-
const originalOverflow = document.body.style.overflow;
|
|
85
|
-
document.body.style.overflow = 'hidden';
|
|
86
|
-
|
|
87
|
-
return () => {
|
|
88
|
-
document.body.style.overflow = originalOverflow;
|
|
89
|
-
};
|
|
90
|
-
}, []);
|
|
91
|
-
|
|
92
|
-
if (!currentImage) return null;
|
|
93
|
-
|
|
94
|
-
return (
|
|
95
|
-
<div
|
|
96
|
-
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
|
|
97
|
-
onClick={onClose}
|
|
98
|
-
>
|
|
99
|
-
{/* Close button */}
|
|
100
|
-
<button
|
|
101
|
-
onClick={onClose}
|
|
102
|
-
className="absolute top-4 right-4 p-2 bg-black/50 hover:bg-black/70 rounded-full text-white transition-all"
|
|
103
|
-
aria-label="Close lightbox"
|
|
104
|
-
>
|
|
105
|
-
<Icon className="text-white">
|
|
106
|
-
<path
|
|
107
|
-
strokeLinecap="round"
|
|
108
|
-
strokeLinejoin="round"
|
|
109
|
-
strokeWidth={2}
|
|
110
|
-
d="M6 18L18 6M6 6l12 12"
|
|
111
|
-
/>
|
|
112
|
-
</Icon>
|
|
113
|
-
</button>
|
|
114
|
-
|
|
115
|
-
{/* Navigation - Previous */}
|
|
116
|
-
{images.length > 1 && (
|
|
117
|
-
<button
|
|
118
|
-
onClick={(e) => {
|
|
119
|
-
e.stopPropagation();
|
|
120
|
-
goToPrevious();
|
|
121
|
-
}}
|
|
122
|
-
className="absolute left-4 p-2 bg-black/50 hover:bg-black/70 rounded-full text-white transition-all"
|
|
123
|
-
aria-label="Previous image"
|
|
124
|
-
>
|
|
125
|
-
<Icon className="text-white" size="lg">
|
|
126
|
-
<path
|
|
127
|
-
strokeLinecap="round"
|
|
128
|
-
strokeLinejoin="round"
|
|
129
|
-
strokeWidth={2}
|
|
130
|
-
d="M15 19l-7-7 7-7"
|
|
131
|
-
/>
|
|
132
|
-
</Icon>
|
|
133
|
-
</button>
|
|
134
|
-
)}
|
|
135
|
-
|
|
136
|
-
{/* Navigation - Next */}
|
|
137
|
-
{images.length > 1 && (
|
|
138
|
-
<button
|
|
139
|
-
onClick={(e) => {
|
|
140
|
-
e.stopPropagation();
|
|
141
|
-
goToNext();
|
|
142
|
-
}}
|
|
143
|
-
className="absolute right-4 p-2 bg-black/50 hover:bg-black/70 rounded-full text-white transition-all"
|
|
144
|
-
aria-label="Next image"
|
|
145
|
-
>
|
|
146
|
-
<Icon className="text-white" size="lg">
|
|
147
|
-
<path
|
|
148
|
-
strokeLinecap="round"
|
|
149
|
-
strokeLinejoin="round"
|
|
150
|
-
strokeWidth={2}
|
|
151
|
-
d="M9 5l7 7-7 7"
|
|
152
|
-
/>
|
|
153
|
-
</Icon>
|
|
154
|
-
</button>
|
|
155
|
-
)}
|
|
156
|
-
|
|
157
|
-
{/* Main Image */}
|
|
158
|
-
<div
|
|
159
|
-
className="relative max-w-5xl max-h-[90vh] w-full"
|
|
160
|
-
onClick={handleImageClick}
|
|
161
|
-
>
|
|
162
|
-
<img
|
|
163
|
-
src={currentImage.src}
|
|
164
|
-
alt={currentImage.alt}
|
|
165
|
-
className={cn(
|
|
166
|
-
'w-full h-full object-contain',
|
|
167
|
-
isZoomed ? 'cursor-zoom-out' : 'cursor-zoom-in'
|
|
168
|
-
)}
|
|
169
|
-
style={{
|
|
170
|
-
maxHeight: '90vh',
|
|
171
|
-
objectFit: 'contain',
|
|
172
|
-
}}
|
|
173
|
-
/>
|
|
174
|
-
|
|
175
|
-
{/* Zoom indicator */}
|
|
176
|
-
<div className="absolute bottom-4 right-4 flex items-center gap-2 bg-black/50 px-3 py-2 rounded-full">
|
|
177
|
-
<button
|
|
178
|
-
onClick={(e) => {
|
|
179
|
-
e.stopPropagation();
|
|
180
|
-
setIsZoomed(!isZoomed);
|
|
181
|
-
}}
|
|
182
|
-
className="p-1 hover:bg-black/70 rounded-full transition-all text-white"
|
|
183
|
-
aria-label={isZoomed ? 'Zoom out' : 'Zoom in'}
|
|
184
|
-
>
|
|
185
|
-
{isZoomed ? (
|
|
186
|
-
<Icon className="text-white" size="sm">
|
|
187
|
-
<path
|
|
188
|
-
strokeLinecap="round"
|
|
189
|
-
strokeLinejoin="round"
|
|
190
|
-
strokeWidth={2}
|
|
191
|
-
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM13 10H7"
|
|
192
|
-
/>
|
|
193
|
-
</Icon>
|
|
194
|
-
) : (
|
|
195
|
-
<Icon className="text-white" size="sm">
|
|
196
|
-
<path
|
|
197
|
-
strokeLinecap="round"
|
|
198
|
-
strokeLinejoin="round"
|
|
199
|
-
strokeWidth={2}
|
|
200
|
-
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v6m3-3H7"
|
|
201
|
-
/>
|
|
202
|
-
</Icon>
|
|
203
|
-
)}
|
|
204
|
-
</button>
|
|
205
|
-
</div>
|
|
206
|
-
</div>
|
|
207
|
-
|
|
208
|
-
{/* Image Counter */}
|
|
209
|
-
{images.length > 1 && (
|
|
210
|
-
<div className="absolute bottom-4 left-4 bg-black/50 px-4 py-2 rounded-full text-white text-sm">
|
|
211
|
-
{currentIndex + 1} / {images.length}
|
|
212
|
-
</div>
|
|
213
|
-
)}
|
|
214
|
-
|
|
215
|
-
{/* Image Title */}
|
|
216
|
-
{currentImage.title && (
|
|
217
|
-
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-black/50 px-4 py-2 rounded-full text-white text-sm max-w-md text-center">
|
|
218
|
-
{currentImage.title}
|
|
219
|
-
</div>
|
|
220
|
-
)}
|
|
221
|
-
|
|
222
|
-
{/* Thumbnails */}
|
|
223
|
-
{images.length > 1 && (
|
|
224
|
-
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 flex gap-2 max-w-xl overflow-x-auto">
|
|
225
|
-
{images.map((image, index) => (
|
|
226
|
-
<button
|
|
227
|
-
key={index}
|
|
228
|
-
onClick={(e) => {
|
|
229
|
-
e.stopPropagation();
|
|
230
|
-
setCurrentIndex(index);
|
|
231
|
-
}}
|
|
232
|
-
className={cn(
|
|
233
|
-
'flex-shrink-0 w-16 h-16 rounded-lg overflow-hidden border-2 transition-all',
|
|
234
|
-
index === currentIndex
|
|
235
|
-
? 'border-white scale-110'
|
|
236
|
-
: 'border-transparent opacity-50 hover:opacity-100'
|
|
237
|
-
)}
|
|
238
|
-
>
|
|
239
|
-
<img
|
|
240
|
-
src={image.src}
|
|
241
|
-
alt={image.alt}
|
|
242
|
-
className="w-full h-full object-cover"
|
|
243
|
-
/>
|
|
244
|
-
</button>
|
|
245
|
-
))}
|
|
246
|
-
</div>
|
|
247
|
-
)}
|
|
248
|
-
</div>
|
|
249
|
-
);
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
ImageLightbox.displayName = 'ImageLightbox';
|