@umituz/web-design-system 2.2.0 → 2.4.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/web-design-system",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "private": false,
5
5
  "description": "Web Design System - Atomic Design components (Atoms, Molecules, Organisms, Templates) for React applications",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,83 @@
1
+ /**
2
+ * ActiveFilterTags Component (Molecule)
3
+ * @description Display active filters with remove buttons
4
+ */
5
+
6
+ import type { BaseProps } from '../../domain/types';
7
+
8
+ export interface ActiveFilterTagsProps extends BaseProps {
9
+ selectedCategory: string | null;
10
+ categories: Array<{ id: string; name: string }>;
11
+ selectedTags: string[];
12
+ onCategoryRemove: () => void;
13
+ onTagRemove: (tag: string) => void;
14
+ onClearAll: () => void;
15
+ }
16
+
17
+ export const ActiveFilterTags = ({
18
+ selectedCategory,
19
+ categories,
20
+ selectedTags,
21
+ onCategoryRemove,
22
+ onTagRemove,
23
+ onClearAll,
24
+ className,
25
+ }: ActiveFilterTagsProps) => {
26
+ const categoryName = selectedCategory
27
+ ? categories.find(c => c.id === selectedCategory)?.name
28
+ : null;
29
+
30
+ if (!selectedCategory && selectedTags.length === 0) {
31
+ return null;
32
+ }
33
+
34
+ return (
35
+ <div className={`hidden lg:flex flex-wrap items-center gap-2 mb-4 md:mb-6 ${className || ''}`}>
36
+ <span className="text-sm text-text-secondary">Active filters:</span>
37
+
38
+ {/* Category Tag */}
39
+ {categoryName && (
40
+ <span className="inline-flex items-center gap-1 px-3 py-1.5 bg-primary-light text-text-primary rounded-full text-xs font-medium">
41
+ {categoryName}
42
+ <button
43
+ onClick={onCategoryRemove}
44
+ className="ml-1 hover:opacity-70 transition-opacity"
45
+ aria-label={`Remove ${categoryName} filter`}
46
+ >
47
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
48
+ <path d="M18 6L6 18M6 6l12 12" />
49
+ </svg>
50
+ </button>
51
+ </span>
52
+ )}
53
+
54
+ {/* Tag Buttons */}
55
+ {selectedTags.map((tag) => (
56
+ <span
57
+ key={tag}
58
+ className="inline-flex items-center gap-1 px-3 py-1.5 bg-bg-secondary text-text-primary rounded-full text-xs font-medium border border-border"
59
+ >
60
+ {tag}
61
+ <button
62
+ onClick={() => onTagRemove(tag)}
63
+ className="ml-1 hover:opacity-70 transition-opacity"
64
+ aria-label={`Remove ${tag} filter`}
65
+ >
66
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
67
+ <path d="M18 6L6 18M6 6l12 12" />
68
+ </svg>
69
+ </button>
70
+ </span>
71
+ ))}
72
+
73
+ {/* Clear All Button */}
74
+ <button
75
+ onClick={onClearAll}
76
+ className="px-3 py-1.5 text-primary-light hover:underline text-xs font-medium"
77
+ type="button"
78
+ >
79
+ Clear all
80
+ </button>
81
+ </div>
82
+ );
83
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Breadcrumb Component (Molecule)
3
+ * @description Navigation breadcrumb with home icon
4
+ */
5
+
6
+ import { Link } from 'react-router-dom';
7
+ import type { BaseProps } from '../../domain/types';
8
+
9
+ export interface BreadcrumbItem {
10
+ label: string;
11
+ href?: string;
12
+ }
13
+
14
+ export interface BreadcrumbProps extends BaseProps {
15
+ items: BreadcrumbItem[];
16
+ }
17
+
18
+ export const Breadcrumb = ({ items, className }: BreadcrumbProps) => {
19
+ return (
20
+ <nav className={`flex items-center gap-2 text-sm mb-4 md:mb-6 ${className || ''}`} aria-label="Breadcrumb">
21
+ {/* Home */}
22
+ <Link
23
+ to="/"
24
+ className="flex items-center gap-1 text-text-secondary hover:text-primary-light transition-colors"
25
+ aria-label="Home"
26
+ >
27
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
28
+ <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
29
+ <polyline points="9 22 9 12 15 12 15 22" />
30
+ </svg>
31
+ </Link>
32
+
33
+ {/* Breadcrumb items */}
34
+ {items.map((item, index) => (
35
+ <div key={index} className="flex items-center gap-2">
36
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-text-secondary">
37
+ <polyline points="9 18 15 12 9 6" />
38
+ </svg>
39
+ {item.href ? (
40
+ <Link
41
+ to={item.href}
42
+ className="text-text-secondary hover:text-primary-light transition-colors"
43
+ >
44
+ {item.label}
45
+ </Link>
46
+ ) : (
47
+ <span className="text-text-primary font-medium">{item.label}</span>
48
+ )}
49
+ </div>
50
+ ))}
51
+ </nav>
52
+ );
53
+ };
@@ -0,0 +1,144 @@
1
+ /**
2
+ * CodeBlock Component (Molecule)
3
+ * @description Syntax-highlighted code block with copy button and expand/collapse
4
+ */
5
+
6
+ import { useState, useEffect } from 'react';
7
+ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
8
+ import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
9
+ import type { BaseProps } from '../../domain/types';
10
+
11
+ export interface CodeBlockProps extends BaseProps {
12
+ children: string;
13
+ language?: string;
14
+ filename?: string;
15
+ showLineNumbers?: boolean;
16
+ }
17
+
18
+ export const CodeBlock = ({
19
+ children,
20
+ language = 'javascript',
21
+ filename,
22
+ showLineNumbers = false,
23
+ className,
24
+ }: CodeBlockProps) => {
25
+ const [copied, setCopied] = useState(false);
26
+ const [isExpanded, setIsExpanded] = useState(false);
27
+
28
+ // Cleanup copy timeout
29
+ useEffect(() => {
30
+ if (copied) {
31
+ const timeout = setTimeout(() => setCopied(false), 2000);
32
+ return () => clearTimeout(timeout);
33
+ }
34
+ }, [copied]);
35
+
36
+ const handleCopy = async () => {
37
+ try {
38
+ await navigator.clipboard.writeText(children);
39
+ setCopied(true);
40
+ } catch (error) {
41
+ console.error('Failed to copy code:', error);
42
+ }
43
+ };
44
+
45
+ // Auto-detect language if not provided
46
+ const detectedLanguage = language || 'javascript';
47
+
48
+ // Check if code is long enough for collapse
49
+ const lineCount = children.split('\n').length;
50
+ const isLongCode = lineCount > 10;
51
+
52
+ return (
53
+ <div className={`my-6 rounded-lg border border-border overflow-hidden bg-bg-card transition-theme ${className || ''}`}>
54
+ {/* Header */}
55
+ <div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary border-b border-border">
56
+ <div className="flex items-center gap-2">
57
+ {filename && (
58
+ <span className="text-xs font-medium text-text-secondary">{filename}</span>
59
+ )}
60
+ <span className="text-xs px-2 py-1 bg-bg-primary text-text-primary rounded-md font-medium">
61
+ {detectedLanguage}
62
+ </span>
63
+ </div>
64
+
65
+ <div className="flex items-center gap-2">
66
+ {isLongCode && (
67
+ <button
68
+ onClick={() => setIsExpanded(!isExpanded)}
69
+ className="p-1 text-text-secondary hover:text-text-primary rounded transition-colors"
70
+ title={isExpanded ? 'Collapse' : 'Expand'}
71
+ type="button"
72
+ aria-label={isExpanded ? 'Collapse code' : 'Expand code'}
73
+ >
74
+ {isExpanded ? (
75
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
76
+ <polyline points="18 15 12 9 6 15" />
77
+ </svg>
78
+ ) : (
79
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
80
+ <polyline points="6 9 12 15 18 9" />
81
+ </svg>
82
+ )}
83
+ </button>
84
+ )}
85
+
86
+ <button
87
+ onClick={handleCopy}
88
+ className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
89
+ copied
90
+ ? 'bg-green-500 text-white'
91
+ : 'bg-bg-secondary text-text-primary hover:bg-bg-tertiary border border-border'
92
+ }`}
93
+ title={copied ? 'Copied!' : 'Copy code'}
94
+ type="button"
95
+ aria-label={copied ? 'Code copied' : 'Copy code'}
96
+ >
97
+ {copied ? (
98
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
99
+ <polyline points="20 6 9 17 4 12" />
100
+ </svg>
101
+ ) : (
102
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
103
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
104
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
105
+ </svg>
106
+ )}
107
+ <span className="hidden sm:inline">{copied ? 'Copied!' : 'Copy'}</span>
108
+ </button>
109
+ </div>
110
+ </div>
111
+
112
+ {/* Code */}
113
+ <div className={isLongCode && !isExpanded ? 'max-h-80 overflow-hidden' : ''}>
114
+ <SyntaxHighlighter
115
+ language={detectedLanguage}
116
+ style={vscDarkPlus}
117
+ showLineNumbers={showLineNumbers}
118
+ customStyle={{
119
+ margin: 0,
120
+ borderRadius: 0,
121
+ fontSize: '14px',
122
+ background: 'transparent',
123
+ }}
124
+ >
125
+ {children}
126
+ </SyntaxHighlighter>
127
+ </div>
128
+
129
+ {/* Expand hint for collapsed code */}
130
+ {isLongCode && !isExpanded && (
131
+ <div className="px-4 py-2 bg-bg-secondary text-center border-t border-border">
132
+ <button
133
+ onClick={() => setIsExpanded(true)}
134
+ className="text-sm text-primary-light hover:underline font-medium"
135
+ type="button"
136
+ aria-label={`Show all ${lineCount} lines`}
137
+ >
138
+ Show all {lineCount} lines
139
+ </button>
140
+ </div>
141
+ )}
142
+ </div>
143
+ );
144
+ };
@@ -37,3 +37,13 @@ export { ScrollArea, ScrollBar } from './ScrollArea';
37
37
 
38
38
  export { ListItem } from './ListItem';
39
39
  export type { ListItemProps } from './ListItem';
40
+
41
+ // NEW: Filter & Navigation Components
42
+ export { ActiveFilterTags } from './ActiveFilterTags';
43
+ export type { ActiveFilterTagsProps } from './ActiveFilterTags';
44
+
45
+ export { Breadcrumb } from './Breadcrumb';
46
+ export type { BreadcrumbProps, BreadcrumbItem } from './Breadcrumb';
47
+
48
+ export { CodeBlock } from './CodeBlock';
49
+ export type { CodeBlockProps } from './CodeBlock';
@@ -0,0 +1,103 @@
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);
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';
@@ -0,0 +1,186 @@
1
+ /**
2
+ * FilterBar Component (Organism)
3
+ * @description Mobile filter bar with search, categories, tags, and sort
4
+ */
5
+
6
+ import { useState } 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 FilterBarProps 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
+ }
36
+
37
+ export const FilterBar = ({
38
+ searchQuery,
39
+ setSearchQuery,
40
+ selectedCategory,
41
+ onCategoryChange,
42
+ categories,
43
+ popularTags,
44
+ selectedTags,
45
+ onTagToggle,
46
+ sortBy,
47
+ onSortChange,
48
+ sortOptions,
49
+ hasActiveFilters,
50
+ onClearFilters,
51
+ className,
52
+ }: FilterBarProps) => {
53
+ const [isFilterOpen, setIsFilterOpen] = useState(false);
54
+
55
+ return (
56
+ <div className={`lg:hidden mb-4 space-y-3 ${className || ''}`}>
57
+ {/* Search */}
58
+ <div className="relative">
59
+ <svg
60
+ className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary"
61
+ width="18"
62
+ height="18"
63
+ viewBox="0 0 24 24"
64
+ fill="none"
65
+ stroke="currentColor"
66
+ strokeWidth="2"
67
+ strokeLinecap="round"
68
+ strokeLinejoin="round"
69
+ >
70
+ <circle cx="11" cy="11" r="8" />
71
+ <path d="m21 21-4.35-4.35" />
72
+ </svg>
73
+ <input
74
+ type="text"
75
+ placeholder="Search..."
76
+ value={searchQuery}
77
+ onChange={(e) => setSearchQuery(e.target.value)}
78
+ className="w-full pl-10 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"
79
+ aria-label="Search"
80
+ />
81
+ </div>
82
+
83
+ {/* Filter Toggle + Sort + Clear Filters */}
84
+ <div className="flex items-center gap-2 flex-wrap">
85
+ <button
86
+ onClick={() => setIsFilterOpen(!isFilterOpen)}
87
+ 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"
88
+ type="button"
89
+ aria-expanded={isFilterOpen}
90
+ aria-label="Toggle filters"
91
+ >
92
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
93
+ <path d="M12 20v-6M6 20V10M18 20V4" />
94
+ </svg>
95
+ Filters
96
+ {(selectedCategory || selectedTags.length > 0) && (
97
+ <span className="ml-1 px-2 py-0.5 bg-primary-light text-text-primary text-xs rounded-full">
98
+ {[selectedCategory, ...selectedTags].filter(Boolean).length}
99
+ </span>
100
+ )}
101
+ </button>
102
+
103
+ {/* Sort Dropdown */}
104
+ <select
105
+ value={sortBy}
106
+ onChange={(e) => onSortChange(e.target.value)}
107
+ 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"
108
+ aria-label="Sort by"
109
+ >
110
+ {sortOptions.map((option) => (
111
+ <option key={option.id} value={option.id}>
112
+ {option.name}
113
+ </option>
114
+ ))}
115
+ </select>
116
+
117
+ {/* Clear Filters */}
118
+ {hasActiveFilters && (
119
+ <button
120
+ onClick={onClearFilters}
121
+ className="px-3 py-2.5 text-text-secondary hover:text-text-primary text-sm transition-colors"
122
+ type="button"
123
+ >
124
+ Clear All
125
+ </button>
126
+ )}
127
+ </div>
128
+
129
+ {/* Mobile Filter Drawer */}
130
+ {isFilterOpen && (
131
+ <div className="bg-bg-card rounded-xl p-4 border border-border space-y-4 transition-theme">
132
+ {/* Categories */}
133
+ <div>
134
+ <h4 className="text-sm font-semibold text-text-primary mb-3">Categories</h4>
135
+ <div className="grid grid-cols-2 gap-2">
136
+ {categories.map((category) => {
137
+ const Icon = category.icon;
138
+ const isActive = selectedCategory === category.id;
139
+ return (
140
+ <button
141
+ key={category.id}
142
+ onClick={() => {
143
+ onCategoryChange(category.id);
144
+ }}
145
+ className={`flex items-center gap-2 px-3 py-2 rounded-lg text-xs transition-all transition-theme ${
146
+ isActive
147
+ ? 'bg-primary-light text-text-primary font-medium'
148
+ : 'bg-bg-secondary text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
149
+ }`}
150
+ type="button"
151
+ aria-pressed={isActive}
152
+ >
153
+ <Icon size={14} />
154
+ <span>{category.name}</span>
155
+ </button>
156
+ );
157
+ })}
158
+ </div>
159
+ </div>
160
+
161
+ {/* Popular Tags */}
162
+ <div>
163
+ <h4 className="text-sm font-semibold text-text-primary mb-3">Popular Tags</h4>
164
+ <div className="flex flex-wrap gap-2">
165
+ {popularTags.map((tag) => (
166
+ <button
167
+ key={tag}
168
+ onClick={() => onTagToggle(tag)}
169
+ className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all transition-theme ${
170
+ selectedTags.includes(tag)
171
+ ? 'bg-primary-light text-text-primary'
172
+ : 'bg-bg-secondary text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
173
+ }`}
174
+ type="button"
175
+ aria-pressed={selectedTags.includes(tag)}
176
+ >
177
+ {tag}
178
+ </button>
179
+ ))}
180
+ </div>
181
+ </div>
182
+ </div>
183
+ )}
184
+ </div>
185
+ );
186
+ };
@@ -0,0 +1,170 @@
1
+ /**
2
+ * FilterSidebar Component (Organism)
3
+ * @description Desktop sidebar with search, categories, tags, and sort options
4
+ */
5
+
6
+ import type { BaseProps, ChildrenProps } from '../../domain/types';
7
+
8
+ export interface Category {
9
+ id: string;
10
+ name: string;
11
+ count: number;
12
+ icon: React.ComponentType<{ className?: string; size?: number }>;
13
+ }
14
+
15
+ export interface SortOption {
16
+ id: string;
17
+ name: string;
18
+ }
19
+
20
+ export interface FilterSidebarProps extends BaseProps, ChildrenProps {
21
+ searchQuery: string;
22
+ setSearchQuery: (query: string) => void;
23
+ selectedCategory: string | null;
24
+ onCategoryChange: (category: string) => void;
25
+ categories: Category[];
26
+ popularTags: string[];
27
+ selectedTags: string[];
28
+ onTagToggle: (tag: string) => void;
29
+ sortBy: string;
30
+ onSortChange: (sort: string) => void;
31
+ sortOptions: readonly SortOption[] | SortOption[];
32
+ hasActiveFilters: boolean;
33
+ onClearFilters: () => void;
34
+ }
35
+
36
+ export const FilterSidebar = ({
37
+ searchQuery,
38
+ setSearchQuery,
39
+ selectedCategory,
40
+ onCategoryChange,
41
+ categories,
42
+ popularTags,
43
+ selectedTags,
44
+ onTagToggle,
45
+ sortBy,
46
+ onSortChange,
47
+ sortOptions,
48
+ hasActiveFilters,
49
+ onClearFilters,
50
+ children,
51
+ className,
52
+ }: FilterSidebarProps) => {
53
+ return (
54
+ <aside className={`hidden lg:block w-64 flex-shrink-0 ${className || ''}`}>
55
+ <div className="sticky top-24 space-y-6">
56
+ {/* Search */}
57
+ <div className="relative">
58
+ <svg
59
+ className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary"
60
+ width="18"
61
+ height="18"
62
+ viewBox="0 0 24 24"
63
+ fill="none"
64
+ stroke="currentColor"
65
+ strokeWidth="2"
66
+ strokeLinecap="round"
67
+ strokeLinejoin="round"
68
+ >
69
+ <circle cx="11" cy="11" r="8" />
70
+ <path d="m21 21-4.35-4.35" />
71
+ </svg>
72
+ <input
73
+ type="text"
74
+ placeholder="Search..."
75
+ value={searchQuery}
76
+ onChange={(e) => setSearchQuery(e.target.value)}
77
+ className="w-full pl-10 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"
78
+ aria-label="Search"
79
+ />
80
+ </div>
81
+
82
+ {/* Categories */}
83
+ <div>
84
+ <h3 className="text-sm font-semibold text-text-primary mb-3">Categories</h3>
85
+ <div className="space-y-1">
86
+ {categories.map((category) => {
87
+ const Icon = category.icon;
88
+ const isActive = selectedCategory === category.id;
89
+ return (
90
+ <button
91
+ key={category.id}
92
+ onClick={() => onCategoryChange(category.id)}
93
+ className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all transition-theme ${
94
+ isActive
95
+ ? 'bg-primary-light text-text-primary font-medium'
96
+ : 'text-text-secondary hover:bg-bg-secondary hover:text-text-primary'
97
+ }`}
98
+ type="button"
99
+ aria-pressed={isActive}
100
+ >
101
+ <Icon size={16} />
102
+ <span className="flex-1 text-left">{category.name}</span>
103
+ <span className={`text-xs ${isActive ? 'text-text-primary' : 'text-text-secondary'}`}>
104
+ {category.count}
105
+ </span>
106
+ </button>
107
+ );
108
+ })}
109
+ </div>
110
+ </div>
111
+
112
+ {/* Popular Tags */}
113
+ <div>
114
+ <h3 className="text-sm font-semibold text-text-primary mb-3">Popular Tags</h3>
115
+ <div className="flex flex-wrap gap-2">
116
+ {popularTags.map((tag) => (
117
+ <button
118
+ key={tag}
119
+ onClick={() => onTagToggle(tag)}
120
+ className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all transition-theme ${
121
+ selectedTags.includes(tag)
122
+ ? 'bg-primary-light text-text-primary'
123
+ : 'bg-bg-secondary text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
124
+ }`}
125
+ type="button"
126
+ aria-pressed={selectedTags.includes(tag)}
127
+ >
128
+ {tag}
129
+ </button>
130
+ ))}
131
+ </div>
132
+ </div>
133
+
134
+ {/* Sort */}
135
+ <div>
136
+ <h3 className="text-sm font-semibold text-text-primary mb-3">Sort By</h3>
137
+ <select
138
+ value={sortBy}
139
+ onChange={(e) => onSortChange(e.target.value)}
140
+ 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"
141
+ aria-label="Sort by"
142
+ >
143
+ {sortOptions.map((option) => (
144
+ <option key={option.id} value={option.id}>
145
+ {option.name}
146
+ </option>
147
+ ))}
148
+ </select>
149
+ </div>
150
+
151
+ {/* Additional Content */}
152
+ {children}
153
+
154
+ {/* Clear Filters */}
155
+ {hasActiveFilters && (
156
+ <button
157
+ onClick={onClearFilters}
158
+ 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"
159
+ type="button"
160
+ >
161
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
162
+ <path d="M18 6L6 18M6 6l12 12" />
163
+ </svg>
164
+ Clear All Filters
165
+ </button>
166
+ )}
167
+ </div>
168
+ </aside>
169
+ );
170
+ };
@@ -0,0 +1,165 @@
1
+ /**
2
+ * NewsletterSignup Component (Organism)
3
+ * @description Newsletter subscription form with email validation
4
+ */
5
+
6
+ import { useState, useEffect, useMemo } from 'react';
7
+ import { cn } from '../../infrastructure/utils';
8
+ import type { BaseProps } from '../../domain/types';
9
+ import { Icon } from '../atoms/Icon';
10
+
11
+ const SUBSCRIBER_COUNT_MIN = 1000;
12
+ const SUBSCRIBER_COUNT_MAX = 6000;
13
+
14
+ export interface NewsletterSignupProps extends BaseProps {
15
+ onSubscribe?: (email: string) => Promise<void>;
16
+ subscriberCountMin?: number;
17
+ subscriberCountMax?: number;
18
+ }
19
+
20
+ export const NewsletterSignup = ({
21
+ onSubscribe,
22
+ subscriberCountMin = SUBSCRIBER_COUNT_MIN,
23
+ subscriberCountMax = SUBSCRIBER_COUNT_MAX,
24
+ className,
25
+ }: NewsletterSignupProps) => {
26
+ const [email, setEmail] = useState('');
27
+ const [isSubscribed, setIsSubscribed] = useState(false);
28
+ const [isLoading, setIsLoading] = useState(false);
29
+ const [error, setError] = useState('');
30
+
31
+ // FIXED: Generate random count per component instance, not at module load
32
+ const subscriberCount = useMemo(
33
+ () => Math.floor(Math.random() * (subscriberCountMax - subscriberCountMin + 1)) + subscriberCountMin,
34
+ [subscriberCountMin, subscriberCountMax]
35
+ );
36
+
37
+ const validateEmail = (email: string) => {
38
+ const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
39
+ return re.test(email);
40
+ };
41
+
42
+ // Reset success message after 5 seconds with cleanup
43
+ useEffect(() => {
44
+ if (isSubscribed) {
45
+ const timeout = setTimeout(() => {
46
+ setIsSubscribed(false);
47
+ }, 5000);
48
+ return () => clearTimeout(timeout);
49
+ }
50
+ }, [isSubscribed]);
51
+
52
+ const handleSubmit = async (e: React.FormEvent) => {
53
+ e.preventDefault();
54
+ setError('');
55
+
56
+ // Validate email
57
+ if (!email) {
58
+ setError('Please enter your email address');
59
+ return;
60
+ }
61
+
62
+ if (!validateEmail(email)) {
63
+ setError('Please enter a valid email address');
64
+ return;
65
+ }
66
+
67
+ setIsLoading(true);
68
+
69
+ try {
70
+ // Call the subscribe function if provided
71
+ if (onSubscribe) {
72
+ await onSubscribe(email);
73
+ } else {
74
+ // Simulate API call for demo purposes
75
+ await new Promise(resolve => setTimeout(resolve, 1000));
76
+ }
77
+
78
+ setIsLoading(false);
79
+ setIsSubscribed(true);
80
+ setEmail('');
81
+ } catch (err) {
82
+ setIsLoading(false);
83
+ setError('Failed to subscribe. Please try again.');
84
+ }
85
+ };
86
+
87
+ if (isSubscribed) {
88
+ return (
89
+ <div className={cn('bg-bg-card rounded-xl p-4 border border-border', className)}>
90
+ <div className="flex items-center gap-3 text-green-600">
91
+ <Icon className="text-green-600" size="lg">
92
+ <path
93
+ strokeLinecap="round"
94
+ strokeLinejoin="round"
95
+ strokeWidth={2}
96
+ d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
97
+ />
98
+ </Icon>
99
+ <div>
100
+ <div className="font-semibold text-sm">Thanks for subscribing!</div>
101
+ <div className="text-xs text-text-secondary">Check your inbox to confirm your subscription</div>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ return (
109
+ <div className={cn('bg-bg-card rounded-xl p-4 border border-border', className)}>
110
+ <div className="flex items-center gap-2 mb-3">
111
+ <Icon className="text-primary-light" size="lg">
112
+ <path
113
+ strokeLinecap="round"
114
+ strokeLinejoin="round"
115
+ strokeWidth={2}
116
+ d="M3 8l7.89 5.26a2 2 0 002.22 0l7.89-5.26a2 2 0 002.22 0L21 8V5a2 2 0 00-2-2H5a2 2 0 00-2 2v3a2 2 0 002.22 0z"
117
+ />
118
+ </Icon>
119
+ <h3 className="text-sm font-semibold text-text-primary">Newsletter</h3>
120
+ </div>
121
+ <p className="text-xs text-text-secondary mb-3">
122
+ Get the latest articles delivered straight to your inbox. No spam, ever.
123
+ </p>
124
+ <form onSubmit={handleSubmit} className="space-y-2">
125
+ <input
126
+ type="email"
127
+ placeholder="your@email.com"
128
+ value={email}
129
+ onChange={(e) => setEmail(e.target.value)}
130
+ className="w-full px-3 py-2 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"
131
+ disabled={isLoading}
132
+ />
133
+ {error && (
134
+ <p className="text-xs text-red-500">{error}</p>
135
+ )}
136
+ <button
137
+ type="submit"
138
+ disabled={isLoading}
139
+ className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-primary-gradient text-text-primary rounded-lg font-medium hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed text-sm"
140
+ >
141
+ {isLoading ? (
142
+ <>Subscribing...</>
143
+ ) : (
144
+ <>
145
+ <Icon size="sm">
146
+ <path
147
+ strokeLinecap="round"
148
+ strokeLinejoin="round"
149
+ strokeWidth={2}
150
+ d="M22 2L11 13M22 2l-7 20M2 2l15 15L2 2l15-15"
151
+ />
152
+ </Icon>
153
+ Subscribe
154
+ </>
155
+ )}
156
+ </button>
157
+ </form>
158
+ <p className="text-xs text-text-secondary mt-2">
159
+ Join {subscriberCount.toLocaleString()}+ subscribers
160
+ </p>
161
+ </div>
162
+ );
163
+ };
164
+
165
+ NewsletterSignup.displayName = 'NewsletterSignup';
@@ -135,3 +135,17 @@ export { ToggleGroup, ToggleGroupItem } from './ToggleGroup';
135
135
  // NEW: Media & Content Components
136
136
  export { ImageLightbox } from './ImageLightbox';
137
137
  export type { ImageLightboxProps, ImageLightboxImage } from './ImageLightbox';
138
+
139
+ // NEW: Content & Engagement Components
140
+ export { NewsletterSignup } from './NewsletterSignup';
141
+ export type { NewsletterSignupProps } from './NewsletterSignup';
142
+
143
+ export { Comments } from './Comments';
144
+ export type { CommentsProps, GiscusConfig } from './Comments';
145
+
146
+ // NEW: Filter Components
147
+ export { FilterBar } from './FilterBar';
148
+ export type { FilterBarProps, Category, SortOption } from './FilterBar';
149
+
150
+ export { FilterSidebar } from './FilterSidebar';
151
+ export type { FilterSidebarProps } from './FilterSidebar';