@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,185 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Blog Footer Component
5
+ *
6
+ * Simple, clean footer with copyright, social links, and attribution.
7
+ */
8
+
9
+ import Link from 'next/link'
10
+ import { Github, Twitter, Linkedin, Mail, Heart } from 'lucide-react'
11
+
12
+ interface SocialLink {
13
+ name: string
14
+ href: string
15
+ icon: 'github' | 'twitter' | 'linkedin' | 'email'
16
+ }
17
+
18
+ interface BlogFooterProps {
19
+ blogTitle?: string
20
+ authorName?: string
21
+ socialLinks?: SocialLink[]
22
+ showNewsletter?: boolean
23
+ }
24
+
25
+ const iconMap = {
26
+ github: Github,
27
+ twitter: Twitter,
28
+ linkedin: Linkedin,
29
+ email: Mail
30
+ }
31
+
32
+ export function BlogFooter({
33
+ blogTitle = 'My Blog',
34
+ authorName = 'Author',
35
+ socialLinks = [],
36
+ showNewsletter = false
37
+ }: BlogFooterProps) {
38
+ const currentYear = new Date().getFullYear()
39
+
40
+ return (
41
+ <footer data-cy="blog-footer" className="border-t border-border bg-muted/30">
42
+ <div className="container mx-auto px-4 py-12">
43
+ <div data-cy="blog-footer-sections" className="grid gap-8 md:grid-cols-3">
44
+ {/* About Section */}
45
+ <div data-cy="blog-footer-about">
46
+ <h3 data-cy="blog-footer-title" className="font-serif text-lg font-bold mb-3">{blogTitle}</h3>
47
+ <p data-cy="blog-footer-description" className="text-sm text-muted-foreground leading-relaxed">
48
+ Thoughts, stories, and ideas about technology, life, and everything in between.
49
+ </p>
50
+ </div>
51
+
52
+ {/* Quick Links */}
53
+ <div data-cy="blog-footer-quick-links">
54
+ <h4 className="font-semibold text-sm uppercase tracking-wider mb-3 text-muted-foreground">
55
+ Quick Links
56
+ </h4>
57
+ <nav data-cy="blog-footer-nav" className="flex flex-col gap-2">
58
+ <Link
59
+ href="/"
60
+ data-cy="blog-footer-link-home"
61
+ className="text-sm text-muted-foreground hover:text-foreground transition-colors"
62
+ >
63
+ Home
64
+ </Link>
65
+ <Link
66
+ href="/posts"
67
+ data-cy="blog-footer-link-posts"
68
+ className="text-sm text-muted-foreground hover:text-foreground transition-colors"
69
+ >
70
+ All Posts
71
+ </Link>
72
+ <Link
73
+ href="/dashboard"
74
+ data-cy="blog-footer-link-dashboard"
75
+ className="text-sm text-muted-foreground hover:text-foreground transition-colors"
76
+ >
77
+ Dashboard
78
+ </Link>
79
+ </nav>
80
+ </div>
81
+
82
+ {/* Newsletter or Social */}
83
+ <div data-cy="blog-footer-connect">
84
+ {showNewsletter ? (
85
+ <>
86
+ <h4 className="font-semibold text-sm uppercase tracking-wider mb-3 text-muted-foreground">
87
+ Stay Updated
88
+ </h4>
89
+ <p className="text-sm text-muted-foreground mb-3">
90
+ Subscribe to get notified about new posts.
91
+ </p>
92
+ <form data-cy="blog-footer-newsletter-form" className="flex gap-2" onSubmit={(e) => e.preventDefault()}>
93
+ <input
94
+ type="email"
95
+ data-cy="blog-footer-newsletter-input"
96
+ placeholder="your@email.com"
97
+ className="flex-1 px-3 py-2 text-sm bg-background border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
98
+ />
99
+ <button
100
+ type="submit"
101
+ data-cy="blog-footer-newsletter-submit"
102
+ className="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
103
+ >
104
+ Subscribe
105
+ </button>
106
+ </form>
107
+ </>
108
+ ) : (
109
+ <>
110
+ <h4 className="font-semibold text-sm uppercase tracking-wider mb-3 text-muted-foreground">
111
+ Connect
112
+ </h4>
113
+ {socialLinks.length > 0 ? (
114
+ <div data-cy="blog-footer-social-links" className="flex gap-3">
115
+ {socialLinks.map((link) => {
116
+ const Icon = iconMap[link.icon]
117
+ return (
118
+ <a
119
+ key={link.name}
120
+ href={link.href}
121
+ data-cy={`blog-footer-social-${link.icon}`}
122
+ target="_blank"
123
+ rel="noopener noreferrer"
124
+ className="p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors"
125
+ aria-label={link.name}
126
+ >
127
+ <Icon className="h-5 w-5" />
128
+ </a>
129
+ )
130
+ })}
131
+ </div>
132
+ ) : (
133
+ <div data-cy="blog-footer-social-links" className="flex gap-3">
134
+ <a
135
+ href="https://twitter.com"
136
+ data-cy="blog-footer-social-twitter"
137
+ target="_blank"
138
+ rel="noopener noreferrer"
139
+ className="p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors"
140
+ aria-label="Twitter"
141
+ >
142
+ <Twitter className="h-5 w-5" />
143
+ </a>
144
+ <a
145
+ href="https://github.com"
146
+ data-cy="blog-footer-social-github"
147
+ target="_blank"
148
+ rel="noopener noreferrer"
149
+ className="p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors"
150
+ aria-label="GitHub"
151
+ >
152
+ <Github className="h-5 w-5" />
153
+ </a>
154
+ <a
155
+ href="https://linkedin.com"
156
+ data-cy="blog-footer-social-linkedin"
157
+ target="_blank"
158
+ rel="noopener noreferrer"
159
+ className="p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors"
160
+ aria-label="LinkedIn"
161
+ >
162
+ <Linkedin className="h-5 w-5" />
163
+ </a>
164
+ </div>
165
+ )}
166
+ </>
167
+ )}
168
+ </div>
169
+ </div>
170
+
171
+ {/* Bottom Bar */}
172
+ <div data-cy="blog-footer-bottom" className="mt-12 pt-6 border-t border-border flex flex-col md:flex-row items-center justify-between gap-4">
173
+ <p data-cy="blog-footer-copyright" className="text-sm text-muted-foreground">
174
+ &copy; {currentYear} {blogTitle}. All rights reserved.
175
+ </p>
176
+ <p data-cy="blog-footer-attribution" className="text-sm text-muted-foreground flex items-center gap-1">
177
+ Made with <Heart className="h-3 w-3 text-destructive fill-destructive" /> by {authorName}
178
+ </p>
179
+ </div>
180
+ </div>
181
+ </footer>
182
+ )
183
+ }
184
+
185
+ export default BlogFooter
@@ -0,0 +1,201 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Blog Navbar Component
5
+ *
6
+ * Editorial navigation with blog title, category links, and actions.
7
+ * Responsive design with mobile hamburger menu.
8
+ */
9
+
10
+ import { useState, useEffect } from 'react'
11
+ import Link from 'next/link'
12
+ import { Menu, X, Moon, Sun, Search } from 'lucide-react'
13
+ import { useTheme } from 'next-themes'
14
+ import { cn } from '@nextsparkjs/core/lib/utils'
15
+ import { Button } from '@nextsparkjs/core/components/ui/button'
16
+
17
+ interface BlogNavbarProps {
18
+ blogTitle?: string
19
+ navLinks?: Array<{ name: string; href: string }>
20
+ }
21
+
22
+ export function BlogNavbar({
23
+ blogTitle = 'My Blog',
24
+ navLinks = []
25
+ }: BlogNavbarProps) {
26
+ const [isMenuOpen, setIsMenuOpen] = useState(false)
27
+ const [isScrolled, setIsScrolled] = useState(false)
28
+ const { theme, setTheme, resolvedTheme } = useTheme()
29
+ const [mounted, setMounted] = useState(false)
30
+
31
+ useEffect(() => {
32
+ setMounted(true)
33
+ }, [])
34
+
35
+ useEffect(() => {
36
+ const handleScroll = () => {
37
+ setIsScrolled(window.scrollY > 10)
38
+ }
39
+
40
+ window.addEventListener('scroll', handleScroll, { passive: true })
41
+ return () => window.removeEventListener('scroll', handleScroll)
42
+ }, [])
43
+
44
+ const toggleTheme = () => {
45
+ setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
46
+ }
47
+
48
+ return (
49
+ <header
50
+ data-cy="blog-navbar"
51
+ className={cn(
52
+ 'sticky top-0 z-50 w-full transition-all duration-200',
53
+ isScrolled
54
+ ? 'bg-background/95 backdrop-blur-md border-b border-border shadow-sm'
55
+ : 'bg-background border-b border-border'
56
+ )}
57
+ >
58
+ <div className="container mx-auto px-4">
59
+ <nav data-cy="blog-navbar-nav" className="flex items-center justify-between h-16">
60
+ {/* Logo / Blog Title */}
61
+ <Link
62
+ href="/"
63
+ data-cy="blog-navbar-logo"
64
+ className="font-serif text-xl font-bold text-foreground hover:text-primary transition-colors"
65
+ >
66
+ {blogTitle}
67
+ </Link>
68
+
69
+ {/* Desktop Navigation */}
70
+ <div data-cy="blog-navbar-links" className="hidden md:flex items-center gap-1">
71
+ <Link
72
+ href="/"
73
+ data-cy="blog-navbar-link-home"
74
+ className="px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors rounded-md hover:bg-muted"
75
+ >
76
+ Home
77
+ </Link>
78
+ <Link
79
+ href="/posts"
80
+ data-cy="blog-navbar-link-posts"
81
+ className="px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors rounded-md hover:bg-muted"
82
+ >
83
+ Posts
84
+ </Link>
85
+ {navLinks.map((link) => (
86
+ <Link
87
+ key={link.href}
88
+ href={link.href}
89
+ data-cy={`blog-navbar-link-${link.name.toLowerCase().replace(/\s+/g, '-')}`}
90
+ className="px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors rounded-md hover:bg-muted"
91
+ >
92
+ {link.name}
93
+ </Link>
94
+ ))}
95
+ </div>
96
+
97
+ {/* Actions */}
98
+ <div data-cy="blog-navbar-actions" className="flex items-center gap-2">
99
+ {/* Search Toggle */}
100
+ <Button
101
+ variant="ghost"
102
+ size="icon"
103
+ data-cy="blog-navbar-search"
104
+ className="hidden md:flex"
105
+ aria-label="Search"
106
+ >
107
+ <Search className="h-4 w-4" />
108
+ </Button>
109
+
110
+ {/* Theme Toggle */}
111
+ {mounted && (
112
+ <Button
113
+ variant="ghost"
114
+ size="icon"
115
+ data-cy="blog-navbar-theme-toggle"
116
+ onClick={toggleTheme}
117
+ aria-label="Toggle theme"
118
+ >
119
+ {resolvedTheme === 'dark' ? (
120
+ <Sun className="h-4 w-4" />
121
+ ) : (
122
+ <Moon className="h-4 w-4" />
123
+ )}
124
+ </Button>
125
+ )}
126
+
127
+ {/* Dashboard Link */}
128
+ <Link href="/dashboard" data-cy="blog-navbar-dashboard-link">
129
+ <Button variant="outline" size="sm" className="hidden md:inline-flex">
130
+ Dashboard
131
+ </Button>
132
+ </Link>
133
+
134
+ {/* Mobile Menu Toggle */}
135
+ <Button
136
+ variant="ghost"
137
+ size="icon"
138
+ data-cy="blog-navbar-mobile-toggle"
139
+ className="md:hidden"
140
+ onClick={() => setIsMenuOpen(!isMenuOpen)}
141
+ aria-label="Toggle menu"
142
+ aria-expanded={isMenuOpen}
143
+ >
144
+ {isMenuOpen ? (
145
+ <X className="h-5 w-5" />
146
+ ) : (
147
+ <Menu className="h-5 w-5" />
148
+ )}
149
+ </Button>
150
+ </div>
151
+ </nav>
152
+
153
+ {/* Mobile Menu */}
154
+ {isMenuOpen && (
155
+ <div data-cy="blog-navbar-mobile-menu" className="md:hidden border-t border-border py-4">
156
+ <div className="flex flex-col gap-1">
157
+ <Link
158
+ href="/"
159
+ data-cy="blog-navbar-mobile-link-home"
160
+ className="px-3 py-2 text-sm font-medium text-foreground hover:bg-muted rounded-md"
161
+ onClick={() => setIsMenuOpen(false)}
162
+ >
163
+ Home
164
+ </Link>
165
+ <Link
166
+ href="/posts"
167
+ data-cy="blog-navbar-mobile-link-posts"
168
+ className="px-3 py-2 text-sm font-medium text-foreground hover:bg-muted rounded-md"
169
+ onClick={() => setIsMenuOpen(false)}
170
+ >
171
+ Posts
172
+ </Link>
173
+ {navLinks.map((link) => (
174
+ <Link
175
+ key={link.href}
176
+ href={link.href}
177
+ data-cy={`blog-navbar-mobile-link-${link.name.toLowerCase().replace(/\s+/g, '-')}`}
178
+ className="px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted rounded-md"
179
+ onClick={() => setIsMenuOpen(false)}
180
+ >
181
+ {link.name}
182
+ </Link>
183
+ ))}
184
+ <hr className="my-2 border-border" />
185
+ <Link
186
+ href="/dashboard"
187
+ data-cy="blog-navbar-mobile-link-dashboard"
188
+ className="px-3 py-2 text-sm font-medium text-primary hover:bg-muted rounded-md"
189
+ onClick={() => setIsMenuOpen(false)}
190
+ >
191
+ Dashboard
192
+ </Link>
193
+ </div>
194
+ </div>
195
+ )}
196
+ </div>
197
+ </header>
198
+ )
199
+ }
200
+
201
+ export default BlogNavbar
@@ -0,0 +1,306 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Post Card Component
5
+ *
6
+ * Blog-style post card with featured image, category badge,
7
+ * title, excerpt, author info, and reading time.
8
+ */
9
+
10
+ import Link from 'next/link'
11
+ import Image from 'next/image'
12
+ import { Calendar, Clock, User } from 'lucide-react'
13
+ import { cn } from '@nextsparkjs/core/lib/utils'
14
+
15
+ interface PostCardProps {
16
+ id: string
17
+ title: string
18
+ slug: string
19
+ excerpt?: string | null
20
+ featuredImage?: string | null
21
+ category?: string | null
22
+ categorySlug?: string | null
23
+ authorName?: string
24
+ authorUsername?: string
25
+ authorAvatar?: string | null
26
+ publishedAt?: string | null
27
+ readingTime?: number
28
+ variant?: 'default' | 'featured' | 'compact'
29
+ className?: string
30
+ }
31
+
32
+ function formatDate(dateString: string | null | undefined): string {
33
+ if (!dateString) return 'Draft'
34
+ const date = new Date(dateString)
35
+ return date.toLocaleDateString('en-US', {
36
+ year: 'numeric',
37
+ month: 'short',
38
+ day: 'numeric',
39
+ timeZone: 'UTC'
40
+ })
41
+ }
42
+
43
+ export function PostCard({
44
+ id,
45
+ title,
46
+ slug,
47
+ excerpt,
48
+ featuredImage,
49
+ category,
50
+ categorySlug,
51
+ authorName = 'Anonymous',
52
+ authorUsername,
53
+ authorAvatar,
54
+ publishedAt,
55
+ readingTime = 5,
56
+ variant = 'default',
57
+ className
58
+ }: PostCardProps) {
59
+ const postUrl = `/posts/${slug || id}`
60
+ const authorUrl = authorUsername ? `/author/${authorUsername}` : undefined
61
+
62
+ if (variant === 'featured') {
63
+ return (
64
+ <article
65
+ data-cy={`post-card-${id}`}
66
+ data-cy-variant="featured"
67
+ className={cn(
68
+ 'group relative overflow-hidden rounded-lg border border-border bg-card transition-all duration-300 hover:shadow-lg',
69
+ className
70
+ )}
71
+ >
72
+ {/* Featured Image */}
73
+ <div data-cy={`post-card-image-${id}`} className="aspect-[21/9] relative overflow-hidden">
74
+ {featuredImage ? (
75
+ <Image
76
+ src={featuredImage}
77
+ alt={title}
78
+ fill
79
+ className="object-cover transition-transform duration-500 group-hover:scale-105"
80
+ sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
81
+ />
82
+ ) : (
83
+ <div className="w-full h-full bg-gradient-to-br from-primary/20 to-primary/5" />
84
+ )}
85
+ {/* Gradient Overlay */}
86
+ <div className="absolute inset-0 bg-gradient-to-t from-background/90 via-background/30 to-transparent" />
87
+ </div>
88
+
89
+ {/* Content */}
90
+ <div data-cy={`post-card-content-${id}`} className="absolute bottom-0 left-0 right-0 p-6">
91
+ {category && (
92
+ <Link
93
+ href={categorySlug ? `/category/${categorySlug}` : '#'}
94
+ data-cy={`post-card-category-${id}`}
95
+ className="inline-block px-3 py-1 text-xs font-semibold rounded-full bg-primary text-primary-foreground mb-3 hover:bg-primary/90 transition-colors"
96
+ >
97
+ {category}
98
+ </Link>
99
+ )}
100
+
101
+ <Link href={postUrl} data-cy={`post-card-title-link-${id}`}>
102
+ <h2 data-cy={`post-card-title-${id}`} className="font-serif text-2xl md:text-3xl font-bold text-foreground mb-2 line-clamp-2 group-hover:text-primary transition-colors">
103
+ {title}
104
+ </h2>
105
+ </Link>
106
+
107
+ {excerpt && (
108
+ <p data-cy={`post-card-excerpt-${id}`} className="text-muted-foreground line-clamp-2 mb-4">
109
+ {excerpt}
110
+ </p>
111
+ )}
112
+
113
+ <div data-cy={`post-card-meta-${id}`} className="flex items-center gap-4 text-sm text-muted-foreground">
114
+ {authorUrl ? (
115
+ <Link href={authorUrl} data-cy={`post-card-author-${id}`} className="flex items-center gap-2 hover:text-foreground transition-colors">
116
+ {authorAvatar ? (
117
+ <Image
118
+ src={authorAvatar}
119
+ alt={authorName}
120
+ width={24}
121
+ height={24}
122
+ className="rounded-full"
123
+ />
124
+ ) : (
125
+ <div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center">
126
+ <User className="w-3 h-3" />
127
+ </div>
128
+ )}
129
+ <span>{authorName}</span>
130
+ </Link>
131
+ ) : (
132
+ <div data-cy={`post-card-author-${id}`} className="flex items-center gap-2">
133
+ {authorAvatar ? (
134
+ <Image
135
+ src={authorAvatar}
136
+ alt={authorName}
137
+ width={24}
138
+ height={24}
139
+ className="rounded-full"
140
+ />
141
+ ) : (
142
+ <div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center">
143
+ <User className="w-3 h-3" />
144
+ </div>
145
+ )}
146
+ <span>{authorName}</span>
147
+ </div>
148
+ )}
149
+ <span data-cy={`post-card-date-${id}`} className="flex items-center gap-1">
150
+ <Calendar className="w-3 h-3" />
151
+ {formatDate(publishedAt)}
152
+ </span>
153
+ <span data-cy={`post-card-reading-time-${id}`} className="flex items-center gap-1">
154
+ <Clock className="w-3 h-3" />
155
+ {readingTime} min read
156
+ </span>
157
+ </div>
158
+ </div>
159
+ </article>
160
+ )
161
+ }
162
+
163
+ if (variant === 'compact') {
164
+ return (
165
+ <article
166
+ data-cy={`post-card-${id}`}
167
+ data-cy-variant="compact"
168
+ className={cn(
169
+ 'group flex gap-4 p-4 rounded-lg border border-border bg-card hover:shadow-md transition-all duration-200',
170
+ className
171
+ )}
172
+ >
173
+ {/* Thumbnail */}
174
+ <div data-cy={`post-card-image-${id}`} className="flex-shrink-0 w-24 h-24 relative overflow-hidden rounded-md">
175
+ {featuredImage ? (
176
+ <Image
177
+ src={featuredImage}
178
+ alt={title}
179
+ fill
180
+ className="object-cover"
181
+ sizes="96px"
182
+ />
183
+ ) : (
184
+ <div className="w-full h-full bg-gradient-to-br from-primary/20 to-primary/5" />
185
+ )}
186
+ </div>
187
+
188
+ {/* Content */}
189
+ <div data-cy={`post-card-content-${id}`} className="flex-1 min-w-0">
190
+ <Link href={postUrl} data-cy={`post-card-title-link-${id}`}>
191
+ <h3 data-cy={`post-card-title-${id}`} className="font-semibold text-sm line-clamp-2 group-hover:text-primary transition-colors">
192
+ {title}
193
+ </h3>
194
+ </Link>
195
+ <div data-cy={`post-card-meta-${id}`} className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
196
+ <span data-cy={`post-card-date-${id}`}>{formatDate(publishedAt)}</span>
197
+ <span>·</span>
198
+ <span data-cy={`post-card-reading-time-${id}`}>{readingTime} min</span>
199
+ </div>
200
+ </div>
201
+ </article>
202
+ )
203
+ }
204
+
205
+ // Default variant
206
+ return (
207
+ <article
208
+ data-cy={`post-card-${id}`}
209
+ data-cy-variant="default"
210
+ className={cn(
211
+ 'group overflow-hidden rounded-lg border border-border bg-card transition-all duration-300 hover:shadow-lg hover:-translate-y-1',
212
+ className
213
+ )}
214
+ >
215
+ {/* Featured Image */}
216
+ <div data-cy={`post-card-image-${id}`} className="aspect-[16/10] relative overflow-hidden">
217
+ {featuredImage ? (
218
+ <Image
219
+ src={featuredImage}
220
+ alt={title}
221
+ fill
222
+ className="object-cover transition-transform duration-500 group-hover:scale-105"
223
+ sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
224
+ />
225
+ ) : (
226
+ <div className="w-full h-full bg-gradient-to-br from-primary/20 via-primary/10 to-primary/5" />
227
+ )}
228
+ </div>
229
+
230
+ {/* Content */}
231
+ <div data-cy={`post-card-content-${id}`} className="p-5">
232
+ {category && (
233
+ <Link
234
+ href={categorySlug ? `/category/${categorySlug}` : '#'}
235
+ data-cy={`post-card-category-${id}`}
236
+ className="inline-block px-2 py-0.5 text-xs font-medium rounded bg-primary/10 text-primary mb-3 hover:bg-primary/20 transition-colors"
237
+ >
238
+ {category}
239
+ </Link>
240
+ )}
241
+
242
+ <Link href={postUrl} data-cy={`post-card-title-link-${id}`}>
243
+ <h3 data-cy={`post-card-title-${id}`} className="font-serif text-lg font-bold text-foreground mb-2 line-clamp-2 group-hover:text-primary transition-colors">
244
+ {title}
245
+ </h3>
246
+ </Link>
247
+
248
+ {excerpt && (
249
+ <p data-cy={`post-card-excerpt-${id}`} className="text-sm text-muted-foreground line-clamp-2 mb-4">
250
+ {excerpt}
251
+ </p>
252
+ )}
253
+
254
+ <div data-cy={`post-card-meta-${id}`} className="flex items-center justify-between text-xs text-muted-foreground">
255
+ {authorUrl ? (
256
+ <Link href={authorUrl} data-cy={`post-card-author-${id}`} className="flex items-center gap-2 hover:text-foreground transition-colors">
257
+ {authorAvatar ? (
258
+ <Image
259
+ src={authorAvatar}
260
+ alt={authorName}
261
+ width={20}
262
+ height={20}
263
+ className="rounded-full"
264
+ />
265
+ ) : (
266
+ <div className="w-5 h-5 rounded-full bg-muted flex items-center justify-center">
267
+ <User className="w-2.5 h-2.5" />
268
+ </div>
269
+ )}
270
+ <span>{authorName}</span>
271
+ </Link>
272
+ ) : (
273
+ <div data-cy={`post-card-author-${id}`} className="flex items-center gap-2">
274
+ {authorAvatar ? (
275
+ <Image
276
+ src={authorAvatar}
277
+ alt={authorName}
278
+ width={20}
279
+ height={20}
280
+ className="rounded-full"
281
+ />
282
+ ) : (
283
+ <div className="w-5 h-5 rounded-full bg-muted flex items-center justify-center">
284
+ <User className="w-2.5 h-2.5" />
285
+ </div>
286
+ )}
287
+ <span>{authorName}</span>
288
+ </div>
289
+ )}
290
+ <div className="flex items-center gap-3">
291
+ <span data-cy={`post-card-date-${id}`} className="flex items-center gap-1">
292
+ <Calendar className="w-3 h-3" />
293
+ {formatDate(publishedAt)}
294
+ </span>
295
+ <span data-cy={`post-card-reading-time-${id}`} className="flex items-center gap-1">
296
+ <Clock className="w-3 h-3" />
297
+ {readingTime} min
298
+ </span>
299
+ </div>
300
+ </div>
301
+ </div>
302
+ </article>
303
+ )
304
+ }
305
+
306
+ export default PostCard