@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.
- package/README.md +65 -0
- package/about.md +93 -0
- package/api/authors/[username]/route.ts +150 -0
- package/api/authors/route.ts +63 -0
- package/api/posts/public/route.ts +151 -0
- package/components/ExportPostsButton.tsx +102 -0
- package/components/ImportPostsDialog.tsx +284 -0
- package/components/PostsToolbar.tsx +24 -0
- package/components/editor/FeaturedImageUpload.tsx +185 -0
- package/components/editor/WysiwygEditor.tsx +340 -0
- package/components/index.ts +4 -0
- package/components/public/AuthorBio.tsx +105 -0
- package/components/public/AuthorCard.tsx +130 -0
- package/components/public/BlogFooter.tsx +185 -0
- package/components/public/BlogNavbar.tsx +201 -0
- package/components/public/PostCard.tsx +306 -0
- package/components/public/ReadingProgress.tsx +70 -0
- package/components/public/RelatedPosts.tsx +78 -0
- package/config/app.config.ts +200 -0
- package/config/billing.config.ts +146 -0
- package/config/dashboard.config.ts +333 -0
- package/config/dev.config.ts +48 -0
- package/config/features.config.ts +196 -0
- package/config/flows.config.ts +333 -0
- package/config/permissions.config.ts +101 -0
- package/config/theme.config.ts +128 -0
- package/entities/categories/categories.config.ts +60 -0
- package/entities/categories/categories.fields.ts +115 -0
- package/entities/categories/categories.service.ts +333 -0
- package/entities/categories/categories.types.ts +58 -0
- package/entities/categories/messages/en.json +33 -0
- package/entities/categories/messages/es.json +33 -0
- package/entities/posts/messages/en.json +100 -0
- package/entities/posts/messages/es.json +100 -0
- package/entities/posts/migrations/001_posts_table.sql +110 -0
- package/entities/posts/migrations/002_add_featured.sql +19 -0
- package/entities/posts/migrations/003_post_categories_pivot.sql +47 -0
- package/entities/posts/posts.config.ts +61 -0
- package/entities/posts/posts.fields.ts +234 -0
- package/entities/posts/posts.service.ts +464 -0
- package/entities/posts/posts.types.ts +80 -0
- package/lib/selectors.ts +179 -0
- package/messages/en.json +113 -0
- package/messages/es.json +113 -0
- package/migrations/002_author_profile_fields.sql +37 -0
- package/migrations/003_categories_table.sql +90 -0
- package/migrations/999_sample_data.sql +412 -0
- package/migrations/999_theme_sample_data.sql +1070 -0
- package/package.json +18 -0
- package/permissions-matrix.md +63 -0
- package/styles/article.css +333 -0
- package/styles/components.css +204 -0
- package/styles/globals.css +327 -0
- package/styles/theme.css +167 -0
- package/templates/(public)/author/[username]/page.tsx +247 -0
- package/templates/(public)/authors/page.tsx +161 -0
- package/templates/(public)/layout.tsx +44 -0
- package/templates/(public)/page.tsx +276 -0
- package/templates/(public)/posts/[slug]/page.tsx +342 -0
- package/templates/dashboard/(main)/page.tsx +385 -0
- package/templates/dashboard/(main)/posts/[id]/edit/page.tsx +529 -0
- package/templates/dashboard/(main)/posts/[id]/page.tsx +33 -0
- package/templates/dashboard/(main)/posts/create/page.tsx +353 -0
- 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, > 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,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
|