@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,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
|
+
© {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
|