@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 +1 -1
- package/src/presentation/molecules/ActiveFilterTags.tsx +83 -0
- package/src/presentation/molecules/Breadcrumb.tsx +53 -0
- package/src/presentation/molecules/CodeBlock.tsx +144 -0
- package/src/presentation/molecules/index.ts +10 -0
- package/src/presentation/organisms/Comments.tsx +103 -0
- package/src/presentation/organisms/FilterBar.tsx +186 -0
- package/src/presentation/organisms/FilterSidebar.tsx +170 -0
- package/src/presentation/organisms/NewsletterSignup.tsx +165 -0
- package/src/presentation/organisms/index.ts +14 -0
package/package.json
CHANGED
|
@@ -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';
|