@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,103 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Comments Component (Organism)
|
|
3
|
-
* @description Giscus-based GitHub Discussions comment widget
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { useEffect, useRef } from 'react';
|
|
7
|
-
import { cn } from '../../infrastructure/utils';
|
|
8
|
-
import type { BaseProps } from '../../domain/types';
|
|
9
|
-
|
|
10
|
-
export interface GiscusConfig {
|
|
11
|
-
repo: string;
|
|
12
|
-
repoId: string;
|
|
13
|
-
category?: string;
|
|
14
|
-
mapping?: 'pathname' | 'url' | 'title' | 'og:title' | 'custom';
|
|
15
|
-
term?: string;
|
|
16
|
-
strict?: '0' | '1';
|
|
17
|
-
reactionsEnabled?: '0' | '1';
|
|
18
|
-
emitMetadata?: '0' | '1';
|
|
19
|
-
inputPosition?: 'top' | 'bottom';
|
|
20
|
-
theme?: 'light' | 'dark' | 'dark_dimmed' | 'transparent_dark' | 'preferred_color_scheme';
|
|
21
|
-
lang?: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface CommentsProps extends BaseProps {
|
|
25
|
-
slug: string;
|
|
26
|
-
config?: Partial<GiscusConfig>;
|
|
27
|
-
title?: string;
|
|
28
|
-
description?: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
declare global {
|
|
32
|
-
interface Window {
|
|
33
|
-
giscus?: {
|
|
34
|
-
render: (element: HTMLElement, config: Record<string, unknown>) => void;
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export const Comments = ({
|
|
40
|
-
slug,
|
|
41
|
-
config,
|
|
42
|
-
title = 'Comments',
|
|
43
|
-
description = 'Join the discussion! Share your thoughts and questions below.',
|
|
44
|
-
className,
|
|
45
|
-
}: CommentsProps) => {
|
|
46
|
-
const rootRef = useRef<HTMLDivElement>(null);
|
|
47
|
-
|
|
48
|
-
useEffect(() => {
|
|
49
|
-
// Load Giscus script dynamically
|
|
50
|
-
const script = document.createElement('script');
|
|
51
|
-
script.src = 'https://giscus.app/client.js';
|
|
52
|
-
script.async = true;
|
|
53
|
-
script.crossOrigin = 'anonymous';
|
|
54
|
-
|
|
55
|
-
script.addEventListener('load', () => {
|
|
56
|
-
if (window.giscus && rootRef.current) {
|
|
57
|
-
const defaultConfig: GiscusConfig = {
|
|
58
|
-
repo: 'umituz/umituz-apps',
|
|
59
|
-
repoId: 'R_kgDONJ7RJw',
|
|
60
|
-
category: 'Announcements',
|
|
61
|
-
mapping: 'pathname',
|
|
62
|
-
term: slug,
|
|
63
|
-
strict: '0',
|
|
64
|
-
reactionsEnabled: '1',
|
|
65
|
-
emitMetadata: '0',
|
|
66
|
-
inputPosition: 'top',
|
|
67
|
-
theme: 'dark',
|
|
68
|
-
lang: 'en',
|
|
69
|
-
...config,
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
window.giscus.render(rootRef.current, defaultConfig as unknown as Record<string, unknown>);
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
document.head.appendChild(script);
|
|
77
|
-
|
|
78
|
-
return () => {
|
|
79
|
-
// Only remove if script exists and is in DOM
|
|
80
|
-
if (script.parentNode === document.head) {
|
|
81
|
-
document.head.removeChild(script);
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
}, [slug, config]);
|
|
85
|
-
|
|
86
|
-
return (
|
|
87
|
-
<div className={cn('mt-8', className)}>
|
|
88
|
-
<div className="bg-bg-card rounded-xl p-6 border border-border transition-theme">
|
|
89
|
-
<h3 className="text-xl font-bold text-text-primary mb-4">{title}</h3>
|
|
90
|
-
<p className="text-text-secondary text-sm mb-4">
|
|
91
|
-
{description}
|
|
92
|
-
</p>
|
|
93
|
-
<div
|
|
94
|
-
ref={rootRef}
|
|
95
|
-
id="giscus-comments"
|
|
96
|
-
className="min-h-[200px]"
|
|
97
|
-
/>
|
|
98
|
-
</div>
|
|
99
|
-
</div>
|
|
100
|
-
);
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
Comments.displayName = 'Comments';
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ConfirmDialog Component (Organism)
|
|
3
|
-
* @description Confirmation dialog for destructive actions
|
|
4
|
-
* Reduces boilerplate in delete/remove confirmation modals
|
|
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 { AlertTriangle } from 'lucide-react';
|
|
19
|
-
import type { BaseProps } from '../../domain/types';
|
|
20
|
-
|
|
21
|
-
export interface ConfirmDialogProps extends BaseProps {
|
|
22
|
-
open: boolean;
|
|
23
|
-
onOpenChange: (open: boolean) => void;
|
|
24
|
-
title: string;
|
|
25
|
-
message?: string;
|
|
26
|
-
description?: string;
|
|
27
|
-
onConfirm: () => void | Promise<void>;
|
|
28
|
-
loading?: boolean;
|
|
29
|
-
confirmText?: string;
|
|
30
|
-
cancelText?: string;
|
|
31
|
-
variant?: 'default' | 'destructive';
|
|
32
|
-
icon?: ReactNode;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export const ConfirmDialog = forwardRef<HTMLDivElement, ConfirmDialogProps>(
|
|
36
|
-
(
|
|
37
|
-
{
|
|
38
|
-
className,
|
|
39
|
-
open,
|
|
40
|
-
onOpenChange,
|
|
41
|
-
title,
|
|
42
|
-
message,
|
|
43
|
-
description,
|
|
44
|
-
onConfirm,
|
|
45
|
-
loading = false,
|
|
46
|
-
confirmText = 'Confirm',
|
|
47
|
-
cancelText = 'Cancel',
|
|
48
|
-
variant = 'destructive',
|
|
49
|
-
icon,
|
|
50
|
-
...props
|
|
51
|
-
},
|
|
52
|
-
ref
|
|
53
|
-
) => {
|
|
54
|
-
const handleConfirm = async () => {
|
|
55
|
-
if (!loading) {
|
|
56
|
-
await onConfirm();
|
|
57
|
-
onOpenChange(false);
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
return (
|
|
62
|
-
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
63
|
-
<DialogContent className={cn('sm:max-w-[425px]', className)} ref={ref} {...props}>
|
|
64
|
-
<DialogHeader>
|
|
65
|
-
<div className="flex items-center gap-3">
|
|
66
|
-
{icon || (
|
|
67
|
-
variant === 'destructive' && (
|
|
68
|
-
<div className="w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
|
|
69
|
-
<AlertTriangle className="h-5 w-5 text-destructive" />
|
|
70
|
-
</div>
|
|
71
|
-
)
|
|
72
|
-
)}
|
|
73
|
-
<DialogTitle>{title}</DialogTitle>
|
|
74
|
-
</div>
|
|
75
|
-
{description && <DialogDescription>{description}</DialogDescription>}
|
|
76
|
-
</DialogHeader>
|
|
77
|
-
{message && (
|
|
78
|
-
<div className="py-4">
|
|
79
|
-
<p className="text-sm text-muted-foreground">{message}</p>
|
|
80
|
-
</div>
|
|
81
|
-
)}
|
|
82
|
-
<DialogFooter>
|
|
83
|
-
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
|
84
|
-
{cancelText}
|
|
85
|
-
</Button>
|
|
86
|
-
<Button variant={variant} onClick={handleConfirm} disabled={loading}>
|
|
87
|
-
{loading && <span className="mr-2 h-4 w-4 animate-spin">⟳</span>}
|
|
88
|
-
{confirmText}
|
|
89
|
-
</Button>
|
|
90
|
-
</DialogFooter>
|
|
91
|
-
</DialogContent>
|
|
92
|
-
</Dialog>
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
ConfirmDialog.displayName = 'ConfirmDialog';
|
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* FilterBar Component (Organism)
|
|
3
|
-
* @description Mobile filter bar with search, categories, tags, and sort with optimized performance
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { useState, useCallback, memo } from 'react';
|
|
7
|
-
import React from 'react';
|
|
8
|
-
import type { BaseProps } from '../../domain/types';
|
|
9
|
-
|
|
10
|
-
export interface Category {
|
|
11
|
-
id: string;
|
|
12
|
-
name: string;
|
|
13
|
-
count: number;
|
|
14
|
-
icon: React.ComponentType<{ className?: string; size?: number }>;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface SortOption {
|
|
18
|
-
id: string;
|
|
19
|
-
name: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface FilterBarProps extends BaseProps {
|
|
23
|
-
searchQuery: string;
|
|
24
|
-
setSearchQuery: (query: string) => void;
|
|
25
|
-
selectedCategory: string | null;
|
|
26
|
-
onCategoryChange: (category: string) => void;
|
|
27
|
-
categories: Category[];
|
|
28
|
-
popularTags: string[];
|
|
29
|
-
selectedTags: string[];
|
|
30
|
-
onTagToggle: (tag: string) => void;
|
|
31
|
-
sortBy: string;
|
|
32
|
-
onSortChange: (sort: string) => void;
|
|
33
|
-
sortOptions: readonly SortOption[] | SortOption[];
|
|
34
|
-
hasActiveFilters: boolean;
|
|
35
|
-
onClearFilters: () => void;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Memoize category button component
|
|
39
|
-
const CategoryButton = memo<{
|
|
40
|
-
category: Category;
|
|
41
|
-
selectedCategory: string | null;
|
|
42
|
-
onCategoryChange: (category: string) => void;
|
|
43
|
-
}>(({ category, selectedCategory, onCategoryChange }) => {
|
|
44
|
-
const Icon = category.icon;
|
|
45
|
-
const isActive = selectedCategory === category.id;
|
|
46
|
-
|
|
47
|
-
const handleClick = useCallback(() => {
|
|
48
|
-
onCategoryChange(category.id);
|
|
49
|
-
}, [category.id, onCategoryChange]);
|
|
50
|
-
|
|
51
|
-
return (
|
|
52
|
-
<button
|
|
53
|
-
onClick={handleClick}
|
|
54
|
-
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-xs transition-all transition-theme ${
|
|
55
|
-
isActive
|
|
56
|
-
? 'bg-primary-light text-text-primary font-medium'
|
|
57
|
-
: 'bg-bg-secondary text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
|
58
|
-
}`}
|
|
59
|
-
type="button"
|
|
60
|
-
aria-pressed={isActive}
|
|
61
|
-
>
|
|
62
|
-
<Icon size={14} />
|
|
63
|
-
<span>{category.name}</span>
|
|
64
|
-
</button>
|
|
65
|
-
);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
CategoryButton.displayName = 'CategoryButton';
|
|
69
|
-
|
|
70
|
-
// Memoize tag button component
|
|
71
|
-
const TagButton = memo<{
|
|
72
|
-
tag: string;
|
|
73
|
-
isSelected: boolean;
|
|
74
|
-
onTagToggle: (tag: string) => void;
|
|
75
|
-
}>(({ tag, isSelected, onTagToggle }) => {
|
|
76
|
-
const handleClick = useCallback(() => {
|
|
77
|
-
onTagToggle(tag);
|
|
78
|
-
}, [tag, onTagToggle]);
|
|
79
|
-
|
|
80
|
-
return (
|
|
81
|
-
<button
|
|
82
|
-
onClick={handleClick}
|
|
83
|
-
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all transition-theme ${
|
|
84
|
-
isSelected
|
|
85
|
-
? 'bg-primary-light text-text-primary'
|
|
86
|
-
: 'bg-bg-secondary text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
|
87
|
-
}`}
|
|
88
|
-
type="button"
|
|
89
|
-
aria-pressed={isSelected}
|
|
90
|
-
>
|
|
91
|
-
{tag}
|
|
92
|
-
</button>
|
|
93
|
-
);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
TagButton.displayName = 'TagButton';
|
|
97
|
-
|
|
98
|
-
export const FilterBar = memo<FilterBarProps>(({
|
|
99
|
-
searchQuery,
|
|
100
|
-
setSearchQuery,
|
|
101
|
-
selectedCategory,
|
|
102
|
-
onCategoryChange,
|
|
103
|
-
categories,
|
|
104
|
-
popularTags,
|
|
105
|
-
selectedTags,
|
|
106
|
-
onTagToggle,
|
|
107
|
-
sortBy,
|
|
108
|
-
onSortChange,
|
|
109
|
-
sortOptions,
|
|
110
|
-
hasActiveFilters,
|
|
111
|
-
onClearFilters,
|
|
112
|
-
className,
|
|
113
|
-
}) => {
|
|
114
|
-
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
|
115
|
-
|
|
116
|
-
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
117
|
-
setSearchQuery(e.target.value);
|
|
118
|
-
}, [setSearchQuery]);
|
|
119
|
-
|
|
120
|
-
const handleToggleFilters = useCallback(() => {
|
|
121
|
-
setIsFilterOpen(prev => !prev);
|
|
122
|
-
}, []);
|
|
123
|
-
|
|
124
|
-
const handleSortChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
125
|
-
onSortChange(e.target.value);
|
|
126
|
-
}, [onSortChange]);
|
|
127
|
-
|
|
128
|
-
const handleClearFilters = useCallback(() => {
|
|
129
|
-
onClearFilters();
|
|
130
|
-
}, [onClearFilters]);
|
|
131
|
-
|
|
132
|
-
return (
|
|
133
|
-
<div className={`lg:hidden mb-4 space-y-3 ${className || ''}`}>
|
|
134
|
-
{/* Search */}
|
|
135
|
-
<div className="relative">
|
|
136
|
-
<svg
|
|
137
|
-
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary"
|
|
138
|
-
width="18"
|
|
139
|
-
height="18"
|
|
140
|
-
viewBox="0 0 24 24"
|
|
141
|
-
fill="none"
|
|
142
|
-
stroke="currentColor"
|
|
143
|
-
strokeWidth="2"
|
|
144
|
-
strokeLinecap="round"
|
|
145
|
-
strokeLinejoin="round"
|
|
146
|
-
>
|
|
147
|
-
<circle cx="11" cy="11" r="8" />
|
|
148
|
-
<path d="m21 21-4.35-4.35" />
|
|
149
|
-
</svg>
|
|
150
|
-
<input
|
|
151
|
-
type="text"
|
|
152
|
-
placeholder="Search..."
|
|
153
|
-
value={searchQuery}
|
|
154
|
-
onChange={handleSearchChange}
|
|
155
|
-
className="w-full pl-9 pr-4 py-2.5 bg-bg-secondary text-text-primary rounded-lg border border-border focus:border-primary-light focus:outline-none placeholder-text-secondary/50 transition-theme text-sm"
|
|
156
|
-
aria-label="Search"
|
|
157
|
-
/>
|
|
158
|
-
</div>
|
|
159
|
-
|
|
160
|
-
{/* Filter Toggle + Sort + Clear Filters */}
|
|
161
|
-
<div className="flex items-center gap-2 flex-wrap">
|
|
162
|
-
<button
|
|
163
|
-
onClick={handleToggleFilters}
|
|
164
|
-
className="flex items-center gap-2 px-4 py-2.5 bg-bg-secondary text-text-primary rounded-lg border border-border hover:border-primary-light transition-all text-sm font-medium transition-theme"
|
|
165
|
-
type="button"
|
|
166
|
-
aria-expanded={isFilterOpen}
|
|
167
|
-
aria-label="Toggle filters"
|
|
168
|
-
>
|
|
169
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
170
|
-
<path d="M12 20v-6M6 20V10M18 20V4" />
|
|
171
|
-
</svg>
|
|
172
|
-
Filters
|
|
173
|
-
{(selectedCategory || selectedTags.length > 0) && (
|
|
174
|
-
<span className="ml-1 px-2 py-0.5 bg-primary-light text-text-primary text-xs rounded-full">
|
|
175
|
-
{[selectedCategory, ...selectedTags].filter(Boolean).length}
|
|
176
|
-
</span>
|
|
177
|
-
)}
|
|
178
|
-
</button>
|
|
179
|
-
|
|
180
|
-
{/* Sort Dropdown */}
|
|
181
|
-
<select
|
|
182
|
-
value={sortBy}
|
|
183
|
-
onChange={handleSortChange}
|
|
184
|
-
className="px-3 py-2.5 bg-bg-secondary text-text-primary rounded-lg border border-border focus:border-primary-light focus:outline-none transition-theme text-sm"
|
|
185
|
-
aria-label="Sort by"
|
|
186
|
-
>
|
|
187
|
-
{sortOptions.map((option) => (
|
|
188
|
-
<option key={option.id} value={option.id}>
|
|
189
|
-
{option.name}
|
|
190
|
-
</option>
|
|
191
|
-
))}
|
|
192
|
-
</select>
|
|
193
|
-
|
|
194
|
-
{/* Clear Filters */}
|
|
195
|
-
{hasActiveFilters && (
|
|
196
|
-
<button
|
|
197
|
-
onClick={handleClearFilters}
|
|
198
|
-
className="px-3 py-2.5 text-text-secondary hover:text-text-primary text-sm transition-colors"
|
|
199
|
-
type="button"
|
|
200
|
-
>
|
|
201
|
-
Clear All
|
|
202
|
-
</button>
|
|
203
|
-
)}
|
|
204
|
-
</div>
|
|
205
|
-
|
|
206
|
-
{/* Mobile Filter Drawer */}
|
|
207
|
-
{isFilterOpen && (
|
|
208
|
-
<div className="bg-bg-card rounded-xl p-4 border border-border space-y-4 transition-theme">
|
|
209
|
-
{/* Categories */}
|
|
210
|
-
<div>
|
|
211
|
-
<h4 className="text-sm font-semibold text-text-primary mb-3">Categories</h4>
|
|
212
|
-
<div className="grid grid-cols-2 gap-2">
|
|
213
|
-
{categories.map((category) => (
|
|
214
|
-
<CategoryButton
|
|
215
|
-
key={category.id}
|
|
216
|
-
category={category}
|
|
217
|
-
selectedCategory={selectedCategory}
|
|
218
|
-
onCategoryChange={onCategoryChange}
|
|
219
|
-
/>
|
|
220
|
-
))}
|
|
221
|
-
</div>
|
|
222
|
-
</div>
|
|
223
|
-
|
|
224
|
-
{/* Popular Tags */}
|
|
225
|
-
<div>
|
|
226
|
-
<h4 className="text-sm font-semibold text-text-primary mb-3">Popular Tags</h4>
|
|
227
|
-
<div className="flex flex-wrap gap-2">
|
|
228
|
-
{popularTags.map((tag) => (
|
|
229
|
-
<TagButton
|
|
230
|
-
key={tag}
|
|
231
|
-
tag={tag}
|
|
232
|
-
isSelected={selectedTags.includes(tag)}
|
|
233
|
-
onTagToggle={onTagToggle}
|
|
234
|
-
/>
|
|
235
|
-
))}
|
|
236
|
-
</div>
|
|
237
|
-
</div>
|
|
238
|
-
</div>
|
|
239
|
-
)}
|
|
240
|
-
</div>
|
|
241
|
-
);
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
FilterBar.displayName = 'FilterBar';
|
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* FilterSidebar Component (Organism)
|
|
3
|
-
* @description Desktop sidebar with search, categories, tags, and sort options
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import React from 'react';
|
|
7
|
-
import type { BaseProps } from '../../domain/types';
|
|
8
|
-
|
|
9
|
-
export interface Category {
|
|
10
|
-
id: string;
|
|
11
|
-
name: string;
|
|
12
|
-
count: number;
|
|
13
|
-
icon: React.ComponentType<{ className?: string; size?: number }>;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface SortOption {
|
|
17
|
-
id: string;
|
|
18
|
-
name: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface FilterSidebarProps extends BaseProps {
|
|
22
|
-
searchQuery: string;
|
|
23
|
-
setSearchQuery: (query: string) => void;
|
|
24
|
-
selectedCategory: string | null;
|
|
25
|
-
onCategoryChange: (category: string) => void;
|
|
26
|
-
categories: Category[];
|
|
27
|
-
popularTags: string[];
|
|
28
|
-
selectedTags: string[];
|
|
29
|
-
onTagToggle: (tag: string) => void;
|
|
30
|
-
sortBy: string;
|
|
31
|
-
onSortChange: (sort: string) => void;
|
|
32
|
-
sortOptions: readonly SortOption[] | SortOption[];
|
|
33
|
-
hasActiveFilters: boolean;
|
|
34
|
-
onClearFilters: () => void;
|
|
35
|
-
children?: React.ReactNode;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export const FilterSidebar = ({
|
|
39
|
-
searchQuery,
|
|
40
|
-
setSearchQuery,
|
|
41
|
-
selectedCategory,
|
|
42
|
-
onCategoryChange,
|
|
43
|
-
categories,
|
|
44
|
-
popularTags,
|
|
45
|
-
selectedTags,
|
|
46
|
-
onTagToggle,
|
|
47
|
-
sortBy,
|
|
48
|
-
onSortChange,
|
|
49
|
-
sortOptions,
|
|
50
|
-
hasActiveFilters,
|
|
51
|
-
onClearFilters,
|
|
52
|
-
children,
|
|
53
|
-
className,
|
|
54
|
-
}: FilterSidebarProps) => {
|
|
55
|
-
return (
|
|
56
|
-
<aside className={`hidden lg:block w-64 flex-shrink-0 ${className || ''}`}>
|
|
57
|
-
<div className="sticky top-24 space-y-6">
|
|
58
|
-
{/* Search */}
|
|
59
|
-
<div className="relative">
|
|
60
|
-
<svg
|
|
61
|
-
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary"
|
|
62
|
-
width="18"
|
|
63
|
-
height="18"
|
|
64
|
-
viewBox="0 0 24 24"
|
|
65
|
-
fill="none"
|
|
66
|
-
stroke="currentColor"
|
|
67
|
-
strokeWidth="2"
|
|
68
|
-
strokeLinecap="round"
|
|
69
|
-
strokeLinejoin="round"
|
|
70
|
-
>
|
|
71
|
-
<circle cx="11" cy="11" r="8" />
|
|
72
|
-
<path d="m21 21-4.35-4.35" />
|
|
73
|
-
</svg>
|
|
74
|
-
<input
|
|
75
|
-
type="text"
|
|
76
|
-
placeholder="Search..."
|
|
77
|
-
value={searchQuery}
|
|
78
|
-
onChange={(e) => setSearchQuery(e.target.value)}
|
|
79
|
-
className="w-full pl-9 pr-4 py-2.5 bg-bg-secondary text-text-primary rounded-lg border border-border focus:border-primary-light focus:outline-none placeholder-text-secondary/50 transition-theme text-sm"
|
|
80
|
-
aria-label="Search"
|
|
81
|
-
/>
|
|
82
|
-
</div>
|
|
83
|
-
|
|
84
|
-
{/* Categories */}
|
|
85
|
-
<div>
|
|
86
|
-
<h3 className="text-sm font-semibold text-text-primary mb-3">Categories</h3>
|
|
87
|
-
<div className="space-y-1">
|
|
88
|
-
{categories.map((category) => {
|
|
89
|
-
const Icon = category.icon;
|
|
90
|
-
const isActive = selectedCategory === category.id;
|
|
91
|
-
return (
|
|
92
|
-
<button
|
|
93
|
-
key={category.id}
|
|
94
|
-
onClick={() => onCategoryChange(category.id)}
|
|
95
|
-
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all transition-theme ${
|
|
96
|
-
isActive
|
|
97
|
-
? 'bg-primary-light text-text-primary font-medium'
|
|
98
|
-
: 'text-text-secondary hover:bg-bg-secondary hover:text-text-primary'
|
|
99
|
-
}`}
|
|
100
|
-
type="button"
|
|
101
|
-
aria-pressed={isActive}
|
|
102
|
-
>
|
|
103
|
-
<Icon size={16} />
|
|
104
|
-
<span className="flex-1 text-left">{category.name}</span>
|
|
105
|
-
<span className={`text-xs ${isActive ? 'text-text-primary' : 'text-text-secondary'}`}>
|
|
106
|
-
{category.count}
|
|
107
|
-
</span>
|
|
108
|
-
</button>
|
|
109
|
-
);
|
|
110
|
-
})}
|
|
111
|
-
</div>
|
|
112
|
-
</div>
|
|
113
|
-
|
|
114
|
-
{/* Popular Tags */}
|
|
115
|
-
<div>
|
|
116
|
-
<h3 className="text-sm font-semibold text-text-primary mb-3">Popular Tags</h3>
|
|
117
|
-
<div className="flex flex-wrap gap-2">
|
|
118
|
-
{popularTags.map((tag) => (
|
|
119
|
-
<button
|
|
120
|
-
key={tag}
|
|
121
|
-
onClick={() => onTagToggle(tag)}
|
|
122
|
-
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all transition-theme ${
|
|
123
|
-
selectedTags.includes(tag)
|
|
124
|
-
? 'bg-primary-light text-text-primary'
|
|
125
|
-
: 'bg-bg-secondary text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
|
126
|
-
}`}
|
|
127
|
-
type="button"
|
|
128
|
-
aria-pressed={selectedTags.includes(tag)}
|
|
129
|
-
>
|
|
130
|
-
{tag}
|
|
131
|
-
</button>
|
|
132
|
-
))}
|
|
133
|
-
</div>
|
|
134
|
-
</div>
|
|
135
|
-
|
|
136
|
-
{/* Sort */}
|
|
137
|
-
<div>
|
|
138
|
-
<h3 className="text-sm font-semibold text-text-primary mb-3">Sort By</h3>
|
|
139
|
-
<select
|
|
140
|
-
value={sortBy}
|
|
141
|
-
onChange={(e) => onSortChange(e.target.value)}
|
|
142
|
-
className="w-full px-3 py-2.5 bg-bg-secondary text-text-primary rounded-lg border border-border focus:border-primary-light focus:outline-none transition-theme text-sm"
|
|
143
|
-
aria-label="Sort by"
|
|
144
|
-
>
|
|
145
|
-
{sortOptions.map((option) => (
|
|
146
|
-
<option key={option.id} value={option.id}>
|
|
147
|
-
{option.name}
|
|
148
|
-
</option>
|
|
149
|
-
))}
|
|
150
|
-
</select>
|
|
151
|
-
</div>
|
|
152
|
-
|
|
153
|
-
{/* Additional Content */}
|
|
154
|
-
{children}
|
|
155
|
-
|
|
156
|
-
{/* Clear Filters */}
|
|
157
|
-
{hasActiveFilters && (
|
|
158
|
-
<button
|
|
159
|
-
onClick={onClearFilters}
|
|
160
|
-
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-bg-secondary text-text-secondary rounded-lg border border-border hover:border-primary-light hover:text-text-primary transition-all text-sm font-medium transition-theme"
|
|
161
|
-
type="button"
|
|
162
|
-
>
|
|
163
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
164
|
-
<path d="M18 6L6 18M6 6l12 12" />
|
|
165
|
-
</svg>
|
|
166
|
-
Clear All Filters
|
|
167
|
-
</button>
|
|
168
|
-
)}
|
|
169
|
-
</div>
|
|
170
|
-
</aside>
|
|
171
|
-
);
|
|
172
|
-
};
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Footer Organism Component
|
|
3
|
-
* @description Minimal footer with brand and social icons - optimized for performance
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { forwardRef, type HTMLAttributes, type ReactNode, memo } from 'react';
|
|
7
|
-
import React from 'react';
|
|
8
|
-
import { cn } from '../../infrastructure/utils';
|
|
9
|
-
import type { BaseProps } from '../../domain/types';
|
|
10
|
-
|
|
11
|
-
export interface FooterProps extends HTMLAttributes<HTMLElement>, BaseProps {
|
|
12
|
-
brand?: {
|
|
13
|
-
name: string;
|
|
14
|
-
};
|
|
15
|
-
social?: Array<{
|
|
16
|
-
name: string;
|
|
17
|
-
href: string;
|
|
18
|
-
icon: ReactNode;
|
|
19
|
-
}>;
|
|
20
|
-
copyright?: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
type SocialItem = {
|
|
24
|
-
name: string;
|
|
25
|
-
href: string;
|
|
26
|
-
icon: ReactNode;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
// Memoize social icon component to prevent unnecessary re-renders
|
|
30
|
-
const SocialIcon = memo<{
|
|
31
|
-
item: SocialItem;
|
|
32
|
-
}>(({ item }) => (
|
|
33
|
-
<a
|
|
34
|
-
href={item.href}
|
|
35
|
-
target="_blank"
|
|
36
|
-
rel="noopener noreferrer"
|
|
37
|
-
className="flex items-center justify-center w-10 h-10 rounded-lg bg-bg-secondary/50 text-text-secondary hover:bg-primary-gradient hover:text-white transition-all duration-200"
|
|
38
|
-
aria-label={item.name}
|
|
39
|
-
title={item.name}
|
|
40
|
-
>
|
|
41
|
-
<span className="relative">{item.icon}</span>
|
|
42
|
-
</a>
|
|
43
|
-
));
|
|
44
|
-
|
|
45
|
-
SocialIcon.displayName = 'SocialIcon';
|
|
46
|
-
|
|
47
|
-
export const Footer = memo(forwardRef<HTMLElement, FooterProps>(
|
|
48
|
-
({ className, brand, social, copyright = '© 2026 UmitUZ. All rights reserved.', ...props }, ref) => {
|
|
49
|
-
return (
|
|
50
|
-
<footer
|
|
51
|
-
ref={ref}
|
|
52
|
-
className={cn('relative mt-8 transition-theme', className)}
|
|
53
|
-
{...props}
|
|
54
|
-
>
|
|
55
|
-
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
56
|
-
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
|
|
57
|
-
{/* Brand */}
|
|
58
|
-
{brand && (
|
|
59
|
-
<div className="text-xl font-bold text-text-primary">
|
|
60
|
-
{brand.name}
|
|
61
|
-
</div>
|
|
62
|
-
)}
|
|
63
|
-
|
|
64
|
-
{/* Social Icons */}
|
|
65
|
-
{social && social.length > 0 && (
|
|
66
|
-
<div className="flex items-center gap-3">
|
|
67
|
-
{social.map((item, index) => (
|
|
68
|
-
<SocialIcon key={`${item.name}-${index}`} item={item} />
|
|
69
|
-
))}
|
|
70
|
-
</div>
|
|
71
|
-
)}
|
|
72
|
-
</div>
|
|
73
|
-
|
|
74
|
-
{/* Copyright */}
|
|
75
|
-
{copyright && (
|
|
76
|
-
<div className="text-center mt-6 pt-6 border-t border-border/20">
|
|
77
|
-
<p className="text-sm text-text-secondary/60">{copyright}</p>
|
|
78
|
-
</div>
|
|
79
|
-
)}
|
|
80
|
-
</div>
|
|
81
|
-
</footer>
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
));
|
|
85
|
-
|
|
86
|
-
Footer.displayName = 'Footer';
|