@nextsparkjs/theme-blog 0.1.0-beta.1

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.
Files changed (64) hide show
  1. package/README.md +65 -0
  2. package/about.md +93 -0
  3. package/api/authors/[username]/route.ts +150 -0
  4. package/api/authors/route.ts +63 -0
  5. package/api/posts/public/route.ts +151 -0
  6. package/components/ExportPostsButton.tsx +102 -0
  7. package/components/ImportPostsDialog.tsx +284 -0
  8. package/components/PostsToolbar.tsx +24 -0
  9. package/components/editor/FeaturedImageUpload.tsx +185 -0
  10. package/components/editor/WysiwygEditor.tsx +340 -0
  11. package/components/index.ts +4 -0
  12. package/components/public/AuthorBio.tsx +105 -0
  13. package/components/public/AuthorCard.tsx +130 -0
  14. package/components/public/BlogFooter.tsx +185 -0
  15. package/components/public/BlogNavbar.tsx +201 -0
  16. package/components/public/PostCard.tsx +306 -0
  17. package/components/public/ReadingProgress.tsx +70 -0
  18. package/components/public/RelatedPosts.tsx +78 -0
  19. package/config/app.config.ts +200 -0
  20. package/config/billing.config.ts +146 -0
  21. package/config/dashboard.config.ts +333 -0
  22. package/config/dev.config.ts +48 -0
  23. package/config/features.config.ts +196 -0
  24. package/config/flows.config.ts +333 -0
  25. package/config/permissions.config.ts +101 -0
  26. package/config/theme.config.ts +128 -0
  27. package/entities/categories/categories.config.ts +60 -0
  28. package/entities/categories/categories.fields.ts +115 -0
  29. package/entities/categories/categories.service.ts +333 -0
  30. package/entities/categories/categories.types.ts +58 -0
  31. package/entities/categories/messages/en.json +33 -0
  32. package/entities/categories/messages/es.json +33 -0
  33. package/entities/posts/messages/en.json +100 -0
  34. package/entities/posts/messages/es.json +100 -0
  35. package/entities/posts/migrations/001_posts_table.sql +110 -0
  36. package/entities/posts/migrations/002_add_featured.sql +19 -0
  37. package/entities/posts/migrations/003_post_categories_pivot.sql +47 -0
  38. package/entities/posts/posts.config.ts +61 -0
  39. package/entities/posts/posts.fields.ts +234 -0
  40. package/entities/posts/posts.service.ts +464 -0
  41. package/entities/posts/posts.types.ts +80 -0
  42. package/lib/selectors.ts +179 -0
  43. package/messages/en.json +113 -0
  44. package/messages/es.json +113 -0
  45. package/migrations/002_author_profile_fields.sql +37 -0
  46. package/migrations/003_categories_table.sql +90 -0
  47. package/migrations/999_sample_data.sql +412 -0
  48. package/migrations/999_theme_sample_data.sql +1070 -0
  49. package/package.json +18 -0
  50. package/permissions-matrix.md +63 -0
  51. package/styles/article.css +333 -0
  52. package/styles/components.css +204 -0
  53. package/styles/globals.css +327 -0
  54. package/styles/theme.css +167 -0
  55. package/templates/(public)/author/[username]/page.tsx +247 -0
  56. package/templates/(public)/authors/page.tsx +161 -0
  57. package/templates/(public)/layout.tsx +44 -0
  58. package/templates/(public)/page.tsx +276 -0
  59. package/templates/(public)/posts/[slug]/page.tsx +342 -0
  60. package/templates/dashboard/(main)/page.tsx +385 -0
  61. package/templates/dashboard/(main)/posts/[id]/edit/page.tsx +529 -0
  62. package/templates/dashboard/(main)/posts/[id]/page.tsx +33 -0
  63. package/templates/dashboard/(main)/posts/create/page.tsx +353 -0
  64. package/templates/dashboard/(main)/posts/page.tsx +833 -0
@@ -0,0 +1,340 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * WYSIWYG Editor Component
5
+ *
6
+ * A rich text editor built with native contentEditable.
7
+ * Provides formatting, markdown shortcuts, and a clean interface.
8
+ *
9
+ * No external dependencies - uses document.execCommand for formatting.
10
+ */
11
+
12
+ import { useRef, useCallback, useEffect, useState } from 'react'
13
+ import { cn } from '@nextsparkjs/core/lib/utils'
14
+ import { Button } from '@nextsparkjs/core/components/ui/button'
15
+ import {
16
+ Bold,
17
+ Italic,
18
+ Underline,
19
+ Strikethrough,
20
+ Heading1,
21
+ Heading2,
22
+ Heading3,
23
+ List,
24
+ ListOrdered,
25
+ Quote,
26
+ Code,
27
+ Link as LinkIcon,
28
+ Image,
29
+ Minus,
30
+ Undo,
31
+ Redo,
32
+ Eye,
33
+ EyeOff
34
+ } from 'lucide-react'
35
+
36
+ interface WysiwygEditorProps {
37
+ value: string
38
+ onChange: (value: string) => void
39
+ placeholder?: string
40
+ className?: string
41
+ minHeight?: string
42
+ autoFocus?: boolean
43
+ }
44
+
45
+ interface ToolbarButton {
46
+ icon: React.ReactNode
47
+ command: string
48
+ value?: string
49
+ title: string
50
+ }
51
+
52
+ const TOOLBAR_GROUPS: ToolbarButton[][] = [
53
+ [
54
+ { icon: <Undo className="h-4 w-4" />, command: 'undo', title: 'Undo' },
55
+ { icon: <Redo className="h-4 w-4" />, command: 'redo', title: 'Redo' },
56
+ ],
57
+ [
58
+ { icon: <Bold className="h-4 w-4" />, command: 'bold', title: 'Bold (Ctrl+B)' },
59
+ { icon: <Italic className="h-4 w-4" />, command: 'italic', title: 'Italic (Ctrl+I)' },
60
+ { icon: <Underline className="h-4 w-4" />, command: 'underline', title: 'Underline (Ctrl+U)' },
61
+ { icon: <Strikethrough className="h-4 w-4" />, command: 'strikeThrough', title: 'Strikethrough' },
62
+ ],
63
+ [
64
+ { icon: <Heading1 className="h-4 w-4" />, command: 'formatBlock', value: 'h1', title: 'Heading 1' },
65
+ { icon: <Heading2 className="h-4 w-4" />, command: 'formatBlock', value: 'h2', title: 'Heading 2' },
66
+ { icon: <Heading3 className="h-4 w-4" />, command: 'formatBlock', value: 'h3', title: 'Heading 3' },
67
+ ],
68
+ [
69
+ { icon: <List className="h-4 w-4" />, command: 'insertUnorderedList', title: 'Bullet List' },
70
+ { icon: <ListOrdered className="h-4 w-4" />, command: 'insertOrderedList', title: 'Numbered List' },
71
+ { icon: <Quote className="h-4 w-4" />, command: 'formatBlock', value: 'blockquote', title: 'Quote' },
72
+ ],
73
+ [
74
+ { icon: <Code className="h-4 w-4" />, command: 'formatBlock', value: 'pre', title: 'Code Block' },
75
+ { icon: <LinkIcon className="h-4 w-4" />, command: 'createLink', title: 'Insert Link' },
76
+ { icon: <Image className="h-4 w-4" />, command: 'insertImage', title: 'Insert Image' },
77
+ { icon: <Minus className="h-4 w-4" />, command: 'insertHorizontalRule', title: 'Horizontal Line' },
78
+ ],
79
+ ]
80
+
81
+ export function WysiwygEditor({
82
+ value,
83
+ onChange,
84
+ placeholder = 'Start writing...',
85
+ className,
86
+ minHeight = '400px',
87
+ autoFocus = false
88
+ }: WysiwygEditorProps) {
89
+ const editorRef = useRef<HTMLDivElement>(null)
90
+ const [isPreview, setIsPreview] = useState(false)
91
+ const [isFocused, setIsFocused] = useState(false)
92
+ const isComposing = useRef(false)
93
+
94
+ // Initialize content
95
+ useEffect(() => {
96
+ if (editorRef.current && !isComposing.current) {
97
+ const currentContent = editorRef.current.innerHTML
98
+ if (currentContent !== value) {
99
+ editorRef.current.innerHTML = value || ''
100
+ }
101
+ }
102
+ }, [value])
103
+
104
+ // Auto focus
105
+ useEffect(() => {
106
+ if (autoFocus && editorRef.current) {
107
+ editorRef.current.focus()
108
+ }
109
+ }, [autoFocus])
110
+
111
+ const handleInput = useCallback(() => {
112
+ if (editorRef.current && !isComposing.current) {
113
+ const html = editorRef.current.innerHTML
114
+ onChange(html)
115
+ }
116
+ }, [onChange])
117
+
118
+ const execCommand = useCallback((command: string, value?: string) => {
119
+ if (command === 'createLink') {
120
+ const url = prompt('Enter URL:')
121
+ if (url) {
122
+ document.execCommand('createLink', false, url)
123
+ }
124
+ } else if (command === 'insertImage') {
125
+ const url = prompt('Enter image URL:')
126
+ if (url) {
127
+ document.execCommand('insertImage', false, url)
128
+ }
129
+ } else if (command === 'formatBlock' && value) {
130
+ document.execCommand('formatBlock', false, `<${value}>`)
131
+ } else {
132
+ document.execCommand(command, false, value)
133
+ }
134
+
135
+ // Update content after command
136
+ if (editorRef.current) {
137
+ onChange(editorRef.current.innerHTML)
138
+ }
139
+
140
+ // Keep focus on editor
141
+ editorRef.current?.focus()
142
+ }, [onChange])
143
+
144
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
145
+ // Keyboard shortcuts
146
+ if (e.ctrlKey || e.metaKey) {
147
+ switch (e.key.toLowerCase()) {
148
+ case 'b':
149
+ e.preventDefault()
150
+ execCommand('bold')
151
+ break
152
+ case 'i':
153
+ e.preventDefault()
154
+ execCommand('italic')
155
+ break
156
+ case 'u':
157
+ e.preventDefault()
158
+ execCommand('underline')
159
+ break
160
+ case 'z':
161
+ if (e.shiftKey) {
162
+ e.preventDefault()
163
+ execCommand('redo')
164
+ } else {
165
+ e.preventDefault()
166
+ execCommand('undo')
167
+ }
168
+ break
169
+ }
170
+ }
171
+
172
+ // Markdown shortcuts on space
173
+ if (e.key === ' ' && editorRef.current) {
174
+ const selection = window.getSelection()
175
+ if (selection && selection.anchorNode) {
176
+ const text = selection.anchorNode.textContent || ''
177
+ const offset = selection.anchorOffset
178
+
179
+ // Check for markdown patterns at the beginning of line
180
+ if (offset <= 3) {
181
+ const line = text.substring(0, offset)
182
+
183
+ if (line === '#') {
184
+ e.preventDefault()
185
+ selection.anchorNode.textContent = text.substring(1)
186
+ execCommand('formatBlock', 'h1')
187
+ } else if (line === '##') {
188
+ e.preventDefault()
189
+ selection.anchorNode.textContent = text.substring(2)
190
+ execCommand('formatBlock', 'h2')
191
+ } else if (line === '###') {
192
+ e.preventDefault()
193
+ selection.anchorNode.textContent = text.substring(3)
194
+ execCommand('formatBlock', 'h3')
195
+ } else if (line === '-' || line === '*') {
196
+ e.preventDefault()
197
+ selection.anchorNode.textContent = text.substring(1)
198
+ execCommand('insertUnorderedList')
199
+ } else if (line === '1.') {
200
+ e.preventDefault()
201
+ selection.anchorNode.textContent = text.substring(2)
202
+ execCommand('insertOrderedList')
203
+ } else if (line === '>') {
204
+ e.preventDefault()
205
+ selection.anchorNode.textContent = text.substring(1)
206
+ execCommand('formatBlock', 'blockquote')
207
+ }
208
+ }
209
+ }
210
+ }
211
+ }, [execCommand])
212
+
213
+ const handleCompositionStart = () => {
214
+ isComposing.current = true
215
+ }
216
+
217
+ const handleCompositionEnd = () => {
218
+ isComposing.current = false
219
+ handleInput()
220
+ }
221
+
222
+ return (
223
+ <div className={cn('rounded-lg border border-input bg-background', className)} data-cy="wysiwyg-container">
224
+ {/* Toolbar */}
225
+ <div className="shrink-0 flex flex-wrap items-center gap-1 p-2 border-b border-border bg-muted/30" data-cy="wysiwyg-toolbar">
226
+ {TOOLBAR_GROUPS.map((group, groupIndex) => (
227
+ <div key={groupIndex} className="flex items-center">
228
+ {group.map((button) => (
229
+ <Button
230
+ key={button.command + (button.value || '')}
231
+ type="button"
232
+ variant="ghost"
233
+ size="sm"
234
+ className="h-8 w-8 p-0"
235
+ onClick={() => execCommand(button.command, button.value)}
236
+ title={button.title}
237
+ data-cy={`wysiwyg-${button.command}${button.value ? `-${button.value}` : ''}`}
238
+ >
239
+ {button.icon}
240
+ </Button>
241
+ ))}
242
+ {groupIndex < TOOLBAR_GROUPS.length - 1 && (
243
+ <div className="w-px h-6 bg-border mx-1" />
244
+ )}
245
+ </div>
246
+ ))}
247
+
248
+ {/* Preview Toggle */}
249
+ <div className="ml-auto">
250
+ <Button
251
+ type="button"
252
+ variant={isPreview ? 'default' : 'ghost'}
253
+ size="sm"
254
+ className="h-8"
255
+ onClick={() => setIsPreview(!isPreview)}
256
+ data-cy="wysiwyg-preview-toggle"
257
+ >
258
+ {isPreview ? (
259
+ <>
260
+ <EyeOff className="h-4 w-4 mr-1" />
261
+ Edit
262
+ </>
263
+ ) : (
264
+ <>
265
+ <Eye className="h-4 w-4 mr-1" />
266
+ Preview
267
+ </>
268
+ )}
269
+ </Button>
270
+ </div>
271
+ </div>
272
+
273
+ {/* Editor / Preview */}
274
+ {isPreview ? (
275
+ <div
276
+ className="flex-1 min-h-0 prose prose-sm max-w-none p-4 overflow-auto"
277
+ style={{ minHeight }}
278
+ dangerouslySetInnerHTML={{ __html: value || '<p class="text-muted-foreground">Nothing to preview...</p>' }}
279
+ data-cy="wysiwyg-preview"
280
+ />
281
+ ) : (
282
+ <div className="flex-1 min-h-0 relative" data-cy="wysiwyg-editor-wrapper">
283
+ <div
284
+ ref={editorRef}
285
+ contentEditable
286
+ className={cn(
287
+ 'h-full prose prose-sm max-w-none p-4 outline-none overflow-auto',
288
+ 'focus:ring-0 focus:outline-none',
289
+ '[&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mb-4',
290
+ '[&_h2]:text-xl [&_h2]:font-bold [&_h2]:mb-3',
291
+ '[&_h3]:text-lg [&_h3]:font-bold [&_h3]:mb-2',
292
+ '[&_p]:mb-4 [&_p]:last:mb-0',
293
+ '[&_ul]:list-disc [&_ul]:pl-5 [&_ul]:mb-4',
294
+ '[&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:mb-4',
295
+ '[&_li]:mb-1',
296
+ '[&_blockquote]:border-l-4 [&_blockquote]:border-primary [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground [&_blockquote]:mb-4',
297
+ '[&_pre]:bg-muted [&_pre]:p-3 [&_pre]:rounded [&_pre]:mb-4 [&_pre]:overflow-x-auto',
298
+ '[&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm [&_code]:font-mono',
299
+ '[&_a]:text-primary [&_a]:underline',
300
+ '[&_img]:max-w-full [&_img]:h-auto [&_img]:rounded [&_img]:my-4',
301
+ '[&_hr]:my-6 [&_hr]:border-border'
302
+ )}
303
+ style={{ minHeight }}
304
+ onInput={handleInput}
305
+ onKeyDown={handleKeyDown}
306
+ onFocus={() => setIsFocused(true)}
307
+ onBlur={() => setIsFocused(false)}
308
+ onCompositionStart={handleCompositionStart}
309
+ onCompositionEnd={handleCompositionEnd}
310
+ data-placeholder={placeholder}
311
+ data-cy="wysiwyg-content"
312
+ />
313
+
314
+ {/* Placeholder */}
315
+ {!value && !isFocused && (
316
+ <div
317
+ className="absolute top-4 left-4 text-muted-foreground pointer-events-none"
318
+ aria-hidden="true"
319
+ data-cy="wysiwyg-placeholder"
320
+ >
321
+ {placeholder}
322
+ </div>
323
+ )}
324
+ </div>
325
+ )}
326
+
327
+ {/* Status Bar */}
328
+ <div className="shrink-0 flex items-center justify-between px-4 py-2 border-t border-border bg-muted/30 text-xs text-muted-foreground" data-cy="wysiwyg-statusbar">
329
+ <span data-cy="wysiwyg-shortcuts">
330
+ Shortcuts: # H1, ## H2, ### H3, - List, 1. Numbered, &gt; Quote
331
+ </span>
332
+ <span data-cy="wysiwyg-wordcount">
333
+ {value.replace(/<[^>]*>/g, '').split(/\s+/).filter(Boolean).length} words
334
+ </span>
335
+ </div>
336
+ </div>
337
+ )
338
+ }
339
+
340
+ export default WysiwygEditor
@@ -0,0 +1,4 @@
1
+ export { ExportPostsButton } from './ExportPostsButton'
2
+ export { ImportPostsDialog } from './ImportPostsDialog'
3
+ export { PostsToolbar } from './PostsToolbar'
4
+
@@ -0,0 +1,105 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Author Bio Component
5
+ *
6
+ * Author section with avatar, name, bio, and social links.
7
+ */
8
+
9
+ import Image from 'next/image'
10
+ import Link from 'next/link'
11
+ import { Twitter, Github, Linkedin, Globe, User } from 'lucide-react'
12
+
13
+ interface SocialLink {
14
+ type: 'twitter' | 'github' | 'linkedin' | 'website'
15
+ url: string
16
+ }
17
+
18
+ interface AuthorBioProps {
19
+ name: string
20
+ bio?: string
21
+ avatar?: string | null
22
+ socialLinks?: SocialLink[]
23
+ showMoreLink?: boolean
24
+ }
25
+
26
+ const socialIcons = {
27
+ twitter: Twitter,
28
+ github: Github,
29
+ linkedin: Linkedin,
30
+ website: Globe
31
+ }
32
+
33
+ export function AuthorBio({
34
+ name,
35
+ bio,
36
+ avatar,
37
+ socialLinks = [],
38
+ showMoreLink = true
39
+ }: AuthorBioProps) {
40
+ return (
41
+ <div data-cy="author-bio" className="flex items-start gap-4 p-6 bg-muted/30 rounded-xl border border-border">
42
+ {/* Avatar */}
43
+ <div data-cy="author-bio-avatar" className="flex-shrink-0">
44
+ {avatar ? (
45
+ <Image
46
+ src={avatar}
47
+ alt={name}
48
+ width={64}
49
+ height={64}
50
+ className="rounded-full"
51
+ />
52
+ ) : (
53
+ <div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center">
54
+ <User className="w-8 h-8 text-muted-foreground" />
55
+ </div>
56
+ )}
57
+ </div>
58
+
59
+ {/* Content */}
60
+ <div data-cy="author-bio-content" className="flex-1 min-w-0">
61
+ <div className="flex items-center justify-between mb-1">
62
+ <h3 data-cy="author-bio-name" className="font-semibold text-foreground">{name}</h3>
63
+ {showMoreLink && (
64
+ <Link
65
+ href="/about"
66
+ data-cy="author-bio-more-link"
67
+ className="text-sm text-primary hover:underline"
68
+ >
69
+ More articles
70
+ </Link>
71
+ )}
72
+ </div>
73
+
74
+ {bio && (
75
+ <p data-cy="author-bio-text" className="text-sm text-muted-foreground mb-3 line-clamp-2">
76
+ {bio}
77
+ </p>
78
+ )}
79
+
80
+ {socialLinks.length > 0 && (
81
+ <div data-cy="author-bio-social-links" className="flex gap-2">
82
+ {socialLinks.map((link) => {
83
+ const Icon = socialIcons[link.type]
84
+ return (
85
+ <a
86
+ key={link.type}
87
+ href={link.url}
88
+ data-cy={`author-bio-social-${link.type}`}
89
+ target="_blank"
90
+ rel="noopener noreferrer"
91
+ className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors"
92
+ aria-label={link.type}
93
+ >
94
+ <Icon className="w-4 h-4" />
95
+ </a>
96
+ )
97
+ })}
98
+ </div>
99
+ )}
100
+ </div>
101
+ </div>
102
+ )
103
+ }
104
+
105
+ export default AuthorBio
@@ -0,0 +1,130 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Author Card Component
5
+ *
6
+ * Displays author information in a card format with avatar, name, bio,
7
+ * and post count. Can be used in different variants (full or compact).
8
+ */
9
+
10
+ import Link from 'next/link'
11
+ import Image from 'next/image'
12
+ import { User } from 'lucide-react'
13
+ import { cn } from '@nextsparkjs/core/lib/utils'
14
+
15
+ interface AuthorCardProps {
16
+ username: string
17
+ name: string
18
+ bio?: string | null
19
+ avatar?: string | null
20
+ postCount: number
21
+ variant?: 'full' | 'compact'
22
+ className?: string
23
+ }
24
+
25
+ export function AuthorCard({
26
+ username,
27
+ name,
28
+ bio,
29
+ avatar,
30
+ postCount,
31
+ variant = 'full',
32
+ className
33
+ }: AuthorCardProps) {
34
+ const authorUrl = `/author/${username}`
35
+
36
+ if (variant === 'compact') {
37
+ return (
38
+ <Link
39
+ href={authorUrl}
40
+ data-cy={`author-card-${username}`}
41
+ data-cy-variant="compact"
42
+ className={cn(
43
+ 'group flex items-center gap-3 p-4 rounded-lg border border-border bg-card hover:shadow-md transition-all duration-200',
44
+ className
45
+ )}
46
+ >
47
+ {/* Avatar */}
48
+ <div data-cy={`author-card-avatar-${username}`} className="flex-shrink-0">
49
+ {avatar ? (
50
+ <Image
51
+ src={avatar}
52
+ alt={name}
53
+ width={48}
54
+ height={48}
55
+ className="rounded-full"
56
+ />
57
+ ) : (
58
+ <div className="w-12 h-12 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center">
59
+ <User className="w-6 h-6 text-muted-foreground" />
60
+ </div>
61
+ )}
62
+ </div>
63
+
64
+ {/* Info */}
65
+ <div className="flex-1 min-w-0">
66
+ <h3 data-cy={`author-card-name-${username}`} className="font-semibold text-sm group-hover:text-primary transition-colors truncate">
67
+ {name}
68
+ </h3>
69
+ <p data-cy={`author-card-post-count-${username}`} className="text-xs text-muted-foreground">
70
+ {postCount} {postCount === 1 ? 'post' : 'posts'}
71
+ </p>
72
+ </div>
73
+ </Link>
74
+ )
75
+ }
76
+
77
+ // Full variant
78
+ return (
79
+ <Link
80
+ href={authorUrl}
81
+ data-cy={`author-card-${username}`}
82
+ data-cy-variant="full"
83
+ className={cn(
84
+ 'group block p-6 rounded-lg border border-border bg-card hover:shadow-lg transition-all duration-300',
85
+ className
86
+ )}
87
+ >
88
+ {/* Avatar */}
89
+ <div data-cy={`author-card-avatar-${username}`} className="flex justify-center mb-4">
90
+ {avatar ? (
91
+ <Image
92
+ src={avatar}
93
+ alt={name}
94
+ width={96}
95
+ height={96}
96
+ className="rounded-full"
97
+ />
98
+ ) : (
99
+ <div className="w-24 h-24 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center">
100
+ <User className="w-12 h-12 text-muted-foreground" />
101
+ </div>
102
+ )}
103
+ </div>
104
+
105
+ {/* Name */}
106
+ <h3 data-cy={`author-card-name-${username}`} className="font-serif text-xl font-bold text-center mb-2 group-hover:text-primary transition-colors">
107
+ {name}
108
+ </h3>
109
+
110
+ {/* Bio */}
111
+ {bio && (
112
+ <p data-cy={`author-card-bio-${username}`} className="text-sm text-muted-foreground text-center mb-4 line-clamp-2">
113
+ {bio}
114
+ </p>
115
+ )}
116
+
117
+ {/* Post Count */}
118
+ <div data-cy={`author-card-post-count-${username}`} className="text-center">
119
+ <span className="inline-flex items-center gap-2 px-4 py-2 bg-muted rounded-full text-xs">
120
+ <span className="font-semibold">{postCount}</span>
121
+ <span className="text-muted-foreground">
122
+ {postCount === 1 ? 'published post' : 'published posts'}
123
+ </span>
124
+ </span>
125
+ </div>
126
+ </Link>
127
+ )
128
+ }
129
+
130
+ export default AuthorCard