@fullstackdatasolutions/articles 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +324 -0
- package/dist/index.cjs +1027 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +232 -0
- package/dist/index.d.ts +232 -0
- package/dist/index.js +974 -0
- package/dist/index.js.map +1 -0
- package/dist/server.cjs +732 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +127 -0
- package/dist/server.d.ts +127 -0
- package/dist/server.js +684 -0
- package/dist/server.js.map +1 -0
- package/package.json +82 -0
- package/src/ArticleCard.tsx +63 -0
- package/src/ArticleCategoryGrid.tsx +73 -0
- package/src/ArticleSchemas.tsx +82 -0
- package/src/ArticleSearchBar.tsx +50 -0
- package/src/ArticlesHero.tsx +33 -0
- package/src/ArticlesPage.tsx +167 -0
- package/src/CategoryArticlesPage.tsx +84 -0
- package/src/CommentForm.tsx +98 -0
- package/src/CommentItem.tsx +123 -0
- package/src/CommentThread.tsx +40 -0
- package/src/CommentsSection.tsx +84 -0
- package/src/FeaturedArticle.tsx +63 -0
- package/src/LatestArticles.tsx +42 -0
- package/src/LatestArticlesSection.tsx +68 -0
- package/src/__tests__/ArticleCard.test.tsx +78 -0
- package/src/__tests__/ArticleCategoryGrid.test.tsx +98 -0
- package/src/__tests__/ArticleSchemas.test.tsx +130 -0
- package/src/__tests__/ArticleSearchBar.test.tsx +79 -0
- package/src/__tests__/ArticlesHero.test.tsx +38 -0
- package/src/__tests__/ArticlesPage.test.tsx +155 -0
- package/src/__tests__/CategoryArticlesPage.test.tsx +156 -0
- package/src/__tests__/CommentForm.test.tsx +80 -0
- package/src/__tests__/CommentItem.test.tsx +149 -0
- package/src/__tests__/CommentsSection.test.tsx +118 -0
- package/src/__tests__/FeaturedArticle.test.tsx +85 -0
- package/src/__tests__/LatestArticles.test.tsx +157 -0
- package/src/__tests__/LatestArticlesSection.test.tsx +105 -0
- package/src/__tests__/jest-dom.d.ts +1 -0
- package/src/__tests__/seoUtils.test.ts +217 -0
- package/src/__tests__/useArticles.test.ts +207 -0
- package/src/articleTypes.ts +21 -0
- package/src/articlesConfig.ts +98 -0
- package/src/commentTypes.ts +14 -0
- package/src/index.ts +35 -0
- package/src/markdown.ts +314 -0
- package/src/seoUtils.ts +155 -0
- package/src/server-articles.ts +265 -0
- package/src/server.ts +25 -0
- package/src/useArticles.ts +93 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Image from 'next/image'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
import { BreadcrumbSchema } from './ArticleSchemas'
|
|
6
|
+
import { LatestArticles } from './LatestArticles'
|
|
7
|
+
import { ArticlesConfig, DEFAULT_PAGE_SIZE } from './articlesConfig'
|
|
8
|
+
import type { Article } from './articleTypes'
|
|
9
|
+
|
|
10
|
+
function getCategoryDescription(
|
|
11
|
+
config: ArticlesConfig,
|
|
12
|
+
slug: string,
|
|
13
|
+
categoryName: string
|
|
14
|
+
): { short: string; long?: string } {
|
|
15
|
+
const raw = config.categoryDescriptions?.[slug]
|
|
16
|
+
if (!raw) return { short: `Browse all articles in ${categoryName}.` }
|
|
17
|
+
if (typeof raw === 'string') return { short: raw }
|
|
18
|
+
return raw
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type CategoryArticlesPageProps = Readonly<{
|
|
22
|
+
category: string
|
|
23
|
+
articles: Article[]
|
|
24
|
+
config: ArticlesConfig
|
|
25
|
+
}>
|
|
26
|
+
|
|
27
|
+
export function CategoryArticlesPage({ category, articles, config }: CategoryArticlesPageProps) {
|
|
28
|
+
if (articles.length === 0) return null
|
|
29
|
+
|
|
30
|
+
const categoryName = articles[0].category
|
|
31
|
+
const heroImage = articles[0].featuredImage
|
|
32
|
+
const description = getCategoryDescription(config, category, categoryName)
|
|
33
|
+
const pageSize = config.pageSize ?? DEFAULT_PAGE_SIZE
|
|
34
|
+
const siteUrl = config.siteUrl.replace(/\/$/, '')
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div>
|
|
38
|
+
<BreadcrumbSchema
|
|
39
|
+
items={[
|
|
40
|
+
{ name: 'Home', url: siteUrl },
|
|
41
|
+
{ name: 'Articles', url: `${siteUrl}/articles` },
|
|
42
|
+
{ name: categoryName, url: `${siteUrl}/articles/category/${category}` },
|
|
43
|
+
]}
|
|
44
|
+
/>
|
|
45
|
+
{/* Hero */}
|
|
46
|
+
<section className="relative h-72 md:h-96 flex items-center justify-center overflow-hidden">
|
|
47
|
+
<Image
|
|
48
|
+
src={heroImage}
|
|
49
|
+
alt={categoryName}
|
|
50
|
+
fill
|
|
51
|
+
sizes="100vw"
|
|
52
|
+
className="object-cover"
|
|
53
|
+
priority
|
|
54
|
+
/>
|
|
55
|
+
<div className="absolute inset-0 bg-black/60" />
|
|
56
|
+
<div className="relative z-10 text-center text-white px-4">
|
|
57
|
+
<p className="text-sm font-semibold uppercase tracking-widest mb-3 opacity-80">
|
|
58
|
+
Category
|
|
59
|
+
</p>
|
|
60
|
+
<h1 className="text-4xl md:text-5xl font-bold mb-3">{categoryName}</h1>
|
|
61
|
+
{description.long && (
|
|
62
|
+
<p className="text-lg opacity-90 max-w-2xl mx-auto mt-2">{description.long}</p>
|
|
63
|
+
)}
|
|
64
|
+
<p className="text-lg opacity-70 mt-2">
|
|
65
|
+
{articles.length} article{articles.length === 1 ? '' : 's'}
|
|
66
|
+
</p>
|
|
67
|
+
</div>
|
|
68
|
+
</section>
|
|
69
|
+
|
|
70
|
+
{/* Articles */}
|
|
71
|
+
<section className="py-16 bg-muted/50 flex-1">
|
|
72
|
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
73
|
+
<div className="mb-8 flex items-center justify-between">
|
|
74
|
+
<Link href="/articles" className="text-sm text-primary hover:underline">
|
|
75
|
+
← All Articles
|
|
76
|
+
</Link>
|
|
77
|
+
<p className="text-sm text-muted-foreground">{description.short}</p>
|
|
78
|
+
</div>
|
|
79
|
+
<LatestArticles articles={articles} pageSize={pageSize} />
|
|
80
|
+
</div>
|
|
81
|
+
</section>
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
|
|
5
|
+
const MAX_LENGTH = 2000
|
|
6
|
+
const WARN_THRESHOLD = 1800
|
|
7
|
+
|
|
8
|
+
function submitLabel(submitting: boolean, parentId?: string): string {
|
|
9
|
+
if (submitting) return 'Posting…'
|
|
10
|
+
return parentId ? 'Reply' : 'Post comment'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface CommentFormProps {
|
|
14
|
+
readonly articleSlug: string
|
|
15
|
+
readonly parentId?: string
|
|
16
|
+
readonly onSuccess: () => void
|
|
17
|
+
readonly onCancel?: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function CommentForm({ articleSlug, parentId, onSuccess, onCancel }: CommentFormProps) {
|
|
21
|
+
const [body, setBody] = useState('')
|
|
22
|
+
const [submitting, setSubmitting] = useState(false)
|
|
23
|
+
const [error, setError] = useState<string | null>(null)
|
|
24
|
+
|
|
25
|
+
const remaining = MAX_LENGTH - body.length
|
|
26
|
+
|
|
27
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
28
|
+
e.preventDefault()
|
|
29
|
+
if (!body.trim() || submitting) return
|
|
30
|
+
|
|
31
|
+
setSubmitting(true)
|
|
32
|
+
setError(null)
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(`/api/articles/${articleSlug}/comments`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38
|
+
body: JSON.stringify({ body: body.trim(), parentId: parentId ?? null }),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
const data = (await res.json()) as { error?: string }
|
|
43
|
+
setError(data.error ?? 'Failed to post comment')
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setBody('')
|
|
48
|
+
onSuccess()
|
|
49
|
+
} catch {
|
|
50
|
+
setError('Network error — please try again')
|
|
51
|
+
} finally {
|
|
52
|
+
setSubmitting(false)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<form onSubmit={handleSubmit} className="space-y-2">
|
|
58
|
+
<textarea
|
|
59
|
+
value={body}
|
|
60
|
+
onChange={(e) => setBody(e.target.value)}
|
|
61
|
+
maxLength={MAX_LENGTH}
|
|
62
|
+
rows={parentId ? 3 : 4}
|
|
63
|
+
placeholder={parentId ? 'Write a reply…' : 'Join the discussion…'}
|
|
64
|
+
disabled={submitting}
|
|
65
|
+
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50 resize-none"
|
|
66
|
+
aria-label={parentId ? 'Reply text' : 'Comment text'}
|
|
67
|
+
/>
|
|
68
|
+
|
|
69
|
+
{remaining <= MAX_LENGTH - WARN_THRESHOLD && (
|
|
70
|
+
<p className={`text-xs ${remaining < 0 ? 'text-destructive' : 'text-muted-foreground'}`}>
|
|
71
|
+
{remaining} characters remaining
|
|
72
|
+
</p>
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
76
|
+
|
|
77
|
+
<div className="flex gap-2">
|
|
78
|
+
<button
|
|
79
|
+
type="submit"
|
|
80
|
+
disabled={submitting || body.trim().length === 0 || body.length > MAX_LENGTH}
|
|
81
|
+
className="rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
82
|
+
>
|
|
83
|
+
{submitLabel(submitting, parentId)}
|
|
84
|
+
</button>
|
|
85
|
+
|
|
86
|
+
{onCancel && (
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
onClick={onCancel}
|
|
90
|
+
className="rounded-md px-4 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
|
|
91
|
+
>
|
|
92
|
+
Cancel
|
|
93
|
+
</button>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
</form>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { CommentForm } from './CommentForm'
|
|
5
|
+
import type { ArticleComment, ArticleCommentWithReplies } from './commentTypes'
|
|
6
|
+
|
|
7
|
+
function relativeTime(iso: string): string {
|
|
8
|
+
const diff = Date.now() - new Date(iso).getTime()
|
|
9
|
+
const minutes = Math.floor(diff / 60_000)
|
|
10
|
+
if (minutes < 1) return 'just now'
|
|
11
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
12
|
+
const hours = Math.floor(minutes / 60)
|
|
13
|
+
if (hours < 24) return `${hours}h ago`
|
|
14
|
+
const days = Math.floor(hours / 24)
|
|
15
|
+
if (days < 30) return `${days}d ago`
|
|
16
|
+
const months = Math.floor(days / 30)
|
|
17
|
+
if (months < 12) return `${months}mo ago`
|
|
18
|
+
return `${Math.floor(months / 12)}y ago`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface CommentItemProps {
|
|
22
|
+
readonly comment: ArticleCommentWithReplies | ArticleComment
|
|
23
|
+
readonly articleSlug: string
|
|
24
|
+
readonly currentUserId?: string
|
|
25
|
+
readonly isReply?: boolean
|
|
26
|
+
readonly onRefresh: () => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function CommentItem({
|
|
30
|
+
comment,
|
|
31
|
+
articleSlug,
|
|
32
|
+
currentUserId,
|
|
33
|
+
isReply = false,
|
|
34
|
+
onRefresh,
|
|
35
|
+
}: CommentItemProps) {
|
|
36
|
+
const [showReplyForm, setShowReplyForm] = useState(false)
|
|
37
|
+
const [deleting, setDeleting] = useState(false)
|
|
38
|
+
|
|
39
|
+
const replies = 'replies' in comment ? comment.replies : []
|
|
40
|
+
const canReply = !isReply && !!currentUserId && !comment.isDeleted
|
|
41
|
+
const canDelete = !!currentUserId && currentUserId === comment.authorId && !comment.isDeleted
|
|
42
|
+
|
|
43
|
+
async function handleDelete() {
|
|
44
|
+
if (deleting) return
|
|
45
|
+
setDeleting(true)
|
|
46
|
+
try {
|
|
47
|
+
await fetch(`/api/articles/${articleSlug}/comments/${comment.id}`, { method: 'DELETE' })
|
|
48
|
+
onRefresh()
|
|
49
|
+
} finally {
|
|
50
|
+
setDeleting(false)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className={`${isReply ? 'ml-8 border-l-2 border-border pl-4' : ''}`}>
|
|
56
|
+
<div className="rounded-lg bg-muted/40 p-3 space-y-1">
|
|
57
|
+
{comment.isDeleted ? (
|
|
58
|
+
<p className="text-sm italic text-muted-foreground">Comment removed</p>
|
|
59
|
+
) : (
|
|
60
|
+
<>
|
|
61
|
+
<div className="flex items-center justify-between gap-2">
|
|
62
|
+
<span className="text-sm font-medium text-foreground">{comment.authorName}</span>
|
|
63
|
+
<span className="text-xs text-muted-foreground">
|
|
64
|
+
{relativeTime(comment.createdAt)}
|
|
65
|
+
</span>
|
|
66
|
+
</div>
|
|
67
|
+
<p className="text-sm text-foreground whitespace-pre-wrap break-words">
|
|
68
|
+
{comment.body}
|
|
69
|
+
</p>
|
|
70
|
+
<div className="flex gap-3 pt-1">
|
|
71
|
+
{canReply && (
|
|
72
|
+
<button
|
|
73
|
+
onClick={() => setShowReplyForm((v) => !v)}
|
|
74
|
+
className="text-xs text-muted-foreground hover:text-foreground"
|
|
75
|
+
>
|
|
76
|
+
{showReplyForm ? 'Cancel' : 'Reply'}
|
|
77
|
+
</button>
|
|
78
|
+
)}
|
|
79
|
+
{canDelete && (
|
|
80
|
+
<button
|
|
81
|
+
onClick={handleDelete}
|
|
82
|
+
disabled={deleting}
|
|
83
|
+
className="text-xs text-muted-foreground hover:text-destructive disabled:opacity-50"
|
|
84
|
+
>
|
|
85
|
+
{deleting ? 'Deleting…' : 'Delete'}
|
|
86
|
+
</button>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
</>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{showReplyForm && (
|
|
94
|
+
<div className="mt-2 ml-2">
|
|
95
|
+
<CommentForm
|
|
96
|
+
articleSlug={articleSlug}
|
|
97
|
+
parentId={comment.id}
|
|
98
|
+
onSuccess={() => {
|
|
99
|
+
setShowReplyForm(false)
|
|
100
|
+
onRefresh()
|
|
101
|
+
}}
|
|
102
|
+
onCancel={() => setShowReplyForm(false)}
|
|
103
|
+
/>
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{replies.length > 0 && (
|
|
108
|
+
<div className="mt-2 space-y-2">
|
|
109
|
+
{replies.map((reply) => (
|
|
110
|
+
<CommentItem
|
|
111
|
+
key={reply.id}
|
|
112
|
+
comment={{ ...reply, replies: [] }}
|
|
113
|
+
articleSlug={articleSlug}
|
|
114
|
+
currentUserId={currentUserId}
|
|
115
|
+
isReply
|
|
116
|
+
onRefresh={onRefresh}
|
|
117
|
+
/>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { CommentItem } from './CommentItem'
|
|
4
|
+
import type { ArticleCommentWithReplies } from './commentTypes'
|
|
5
|
+
|
|
6
|
+
interface CommentThreadProps {
|
|
7
|
+
readonly comments: ArticleCommentWithReplies[]
|
|
8
|
+
readonly articleSlug: string
|
|
9
|
+
readonly currentUserId?: string
|
|
10
|
+
readonly onRefresh: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function CommentThread({
|
|
14
|
+
comments,
|
|
15
|
+
articleSlug,
|
|
16
|
+
currentUserId,
|
|
17
|
+
onRefresh,
|
|
18
|
+
}: CommentThreadProps) {
|
|
19
|
+
if (comments.length === 0) {
|
|
20
|
+
return (
|
|
21
|
+
<p className="text-sm text-muted-foreground text-center py-6">
|
|
22
|
+
No comments yet. Be the first to share your thoughts.
|
|
23
|
+
</p>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="space-y-4">
|
|
29
|
+
{comments.map((comment) => (
|
|
30
|
+
<CommentItem
|
|
31
|
+
key={comment.id}
|
|
32
|
+
comment={comment}
|
|
33
|
+
articleSlug={articleSlug}
|
|
34
|
+
currentUserId={currentUserId}
|
|
35
|
+
onRefresh={onRefresh}
|
|
36
|
+
/>
|
|
37
|
+
))}
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useSession, signIn } from 'next-auth/react'
|
|
4
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
5
|
+
import { CommentForm } from './CommentForm'
|
|
6
|
+
import { CommentThread } from './CommentThread'
|
|
7
|
+
import type { CommentsConfig } from './articlesConfig'
|
|
8
|
+
import type { ArticleCommentWithReplies } from './commentTypes'
|
|
9
|
+
|
|
10
|
+
interface CommentsSectionProps {
|
|
11
|
+
readonly articleSlug: string
|
|
12
|
+
readonly config: CommentsConfig
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function CommentsSection({ articleSlug, config }: CommentsSectionProps) {
|
|
16
|
+
const { data: session, status } = useSession()
|
|
17
|
+
const [comments, setComments] = useState<ArticleCommentWithReplies[]>([])
|
|
18
|
+
const [loading, setLoading] = useState(true)
|
|
19
|
+
const [fetchError, setFetchError] = useState<string | null>(null)
|
|
20
|
+
|
|
21
|
+
const perArticleEnabled = config.perArticleOverride?.[articleSlug] ?? true
|
|
22
|
+
const enabled = config.enabled && perArticleEnabled
|
|
23
|
+
|
|
24
|
+
const currentUserId = session?.user?.email ?? session?.user?.name ?? undefined
|
|
25
|
+
|
|
26
|
+
const fetchComments = useCallback(async () => {
|
|
27
|
+
setLoading(true)
|
|
28
|
+
setFetchError(null)
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(`/api/articles/${articleSlug}/comments`)
|
|
31
|
+
if (!res.ok) throw new Error('Failed to load comments')
|
|
32
|
+
const data = (await res.json()) as { comments: ArticleCommentWithReplies[] }
|
|
33
|
+
setComments(data.comments)
|
|
34
|
+
} catch {
|
|
35
|
+
setFetchError('Could not load comments. Please try again.')
|
|
36
|
+
} finally {
|
|
37
|
+
setLoading(false)
|
|
38
|
+
}
|
|
39
|
+
}, [articleSlug])
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (enabled) {
|
|
43
|
+
fetchComments().catch(() => undefined)
|
|
44
|
+
}
|
|
45
|
+
}, [fetchComments, enabled])
|
|
46
|
+
|
|
47
|
+
if (!enabled) return null
|
|
48
|
+
|
|
49
|
+
const count = comments.length
|
|
50
|
+
const label = count === 1 ? '1 comment' : `${count} comments`
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<section aria-label="Comments" className="mt-12 pt-8 border-t border-border space-y-6">
|
|
54
|
+
<h2 className="text-xl font-semibold text-foreground">{loading ? 'Comments' : label}</h2>
|
|
55
|
+
|
|
56
|
+
{status === 'authenticated' ? (
|
|
57
|
+
<CommentForm articleSlug={articleSlug} onSuccess={fetchComments} />
|
|
58
|
+
) : (
|
|
59
|
+
<p className="text-sm text-muted-foreground">
|
|
60
|
+
<button
|
|
61
|
+
onClick={() => signIn()}
|
|
62
|
+
className="text-primary underline hover:no-underline focus:outline-none"
|
|
63
|
+
>
|
|
64
|
+
Sign in
|
|
65
|
+
</button>{' '}
|
|
66
|
+
to join the discussion.
|
|
67
|
+
</p>
|
|
68
|
+
)}
|
|
69
|
+
|
|
70
|
+
{fetchError && <p className="text-sm text-destructive">{fetchError}</p>}
|
|
71
|
+
|
|
72
|
+
{loading ? (
|
|
73
|
+
<p className="text-sm text-muted-foreground">Loading comments…</p>
|
|
74
|
+
) : (
|
|
75
|
+
<CommentThread
|
|
76
|
+
comments={comments}
|
|
77
|
+
articleSlug={articleSlug}
|
|
78
|
+
currentUserId={currentUserId}
|
|
79
|
+
onRefresh={fetchComments}
|
|
80
|
+
/>
|
|
81
|
+
)}
|
|
82
|
+
</section>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import Link from 'next/link'
|
|
2
|
+
import Image from 'next/image'
|
|
3
|
+
import { ArrowRight, Calendar, Clock, User } from 'lucide-react'
|
|
4
|
+
import type { Article } from './articleTypes'
|
|
5
|
+
|
|
6
|
+
type FeaturedArticleProps = Readonly<{
|
|
7
|
+
article: Article
|
|
8
|
+
}>
|
|
9
|
+
|
|
10
|
+
export function FeaturedArticle({ article }: FeaturedArticleProps) {
|
|
11
|
+
return (
|
|
12
|
+
<section className="py-16 bg-background">
|
|
13
|
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
14
|
+
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
|
15
|
+
<div>
|
|
16
|
+
<span className="inline-block bg-primary/10 text-primary text-sm font-medium px-3 py-1 rounded-full mb-4">
|
|
17
|
+
FEATURED ARTICLE
|
|
18
|
+
</span>
|
|
19
|
+
<h2 className="text-3xl font-bold text-foreground mb-4">{article.title}</h2>
|
|
20
|
+
<p className="text-lg text-muted-foreground mb-6">{article.excerpt}</p>
|
|
21
|
+
<div className="flex items-center gap-6 text-sm text-muted-foreground mb-6">
|
|
22
|
+
{article.date && (
|
|
23
|
+
<div className="flex items-center gap-2">
|
|
24
|
+
<Calendar className="h-4 w-4" />
|
|
25
|
+
<span>{article.date}</span>
|
|
26
|
+
</div>
|
|
27
|
+
)}
|
|
28
|
+
<div className="flex items-center gap-2">
|
|
29
|
+
<Clock className="h-4 w-4" />
|
|
30
|
+
<span>{article.readTime}</span>
|
|
31
|
+
</div>
|
|
32
|
+
<div className="flex items-center gap-2">
|
|
33
|
+
<User className="h-4 w-4" />
|
|
34
|
+
<span>{article.author}</span>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
<Link
|
|
38
|
+
href={`/articles/${article.slug}`}
|
|
39
|
+
className="inline-flex items-center justify-center px-4 py-2 bg-primary hover:bg-primary/90 text-primary-foreground rounded-md font-medium text-sm transition-colors"
|
|
40
|
+
>
|
|
41
|
+
Read Full Article
|
|
42
|
+
<ArrowRight className="ml-2 h-4 w-4" />
|
|
43
|
+
</Link>
|
|
44
|
+
</div>
|
|
45
|
+
<div className="bg-card rounded-lg p-8">
|
|
46
|
+
<Image
|
|
47
|
+
src={article.featuredImage}
|
|
48
|
+
alt={article.title}
|
|
49
|
+
width={800}
|
|
50
|
+
height={450}
|
|
51
|
+
className="w-full h-64 object-cover rounded-lg mb-4"
|
|
52
|
+
/>
|
|
53
|
+
<div className="flex gap-2">
|
|
54
|
+
<span className="bg-primary/10 text-primary text-xs font-medium px-2 py-1 rounded">
|
|
55
|
+
{article.category}
|
|
56
|
+
</span>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</section>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react'
|
|
4
|
+
import type { Article } from './articleTypes'
|
|
5
|
+
import { ArticleCard } from './ArticleCard'
|
|
6
|
+
|
|
7
|
+
interface LatestArticlesProps {
|
|
8
|
+
readonly articles: Article[]
|
|
9
|
+
readonly pageSize: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function LatestArticles({ articles, pageSize }: Readonly<LatestArticlesProps>) {
|
|
13
|
+
const [visibleCount, setVisibleCount] = useState(pageSize)
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
setVisibleCount(pageSize)
|
|
17
|
+
}, [articles, pageSize])
|
|
18
|
+
|
|
19
|
+
const visible = articles.slice(0, visibleCount)
|
|
20
|
+
const hasMore = visibleCount < articles.length
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div>
|
|
24
|
+
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
|
25
|
+
{visible.map((article) => (
|
|
26
|
+
<ArticleCard key={article.slug} article={article} />
|
|
27
|
+
))}
|
|
28
|
+
</div>
|
|
29
|
+
{hasMore && (
|
|
30
|
+
<div className="mt-10 text-center">
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
onClick={() => setVisibleCount((c) => c + pageSize)}
|
|
34
|
+
className="inline-flex items-center justify-center px-4 py-2 border border-input bg-background hover:bg-accent rounded-md font-medium text-sm transition-colors"
|
|
35
|
+
>
|
|
36
|
+
Load more articles
|
|
37
|
+
</button>
|
|
38
|
+
</div>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Search } from 'lucide-react'
|
|
4
|
+
import type { Article } from './articleTypes'
|
|
5
|
+
import { LatestArticles } from './LatestArticles'
|
|
6
|
+
|
|
7
|
+
interface LatestArticlesSectionProps {
|
|
8
|
+
readonly articles: Article[]
|
|
9
|
+
readonly searchQuery: string
|
|
10
|
+
readonly onClearSearch: () => void
|
|
11
|
+
readonly pageSize?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function DescriptionText({
|
|
15
|
+
searchQuery,
|
|
16
|
+
count,
|
|
17
|
+
}: {
|
|
18
|
+
readonly searchQuery: string
|
|
19
|
+
readonly count: number
|
|
20
|
+
}) {
|
|
21
|
+
const pluralSuffix = count === 1 ? '' : 's'
|
|
22
|
+
const text = searchQuery
|
|
23
|
+
? `Found ${count} article${pluralSuffix} matching your search.`
|
|
24
|
+
: 'Stay informed with our latest insights on voter engagement, campaign strategies, and civic technology.'
|
|
25
|
+
return <p className="text-lg text-muted-foreground">{text}</p>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function LatestArticlesSection({
|
|
29
|
+
articles,
|
|
30
|
+
searchQuery,
|
|
31
|
+
onClearSearch,
|
|
32
|
+
pageSize = 6,
|
|
33
|
+
}: Readonly<LatestArticlesSectionProps>) {
|
|
34
|
+
return (
|
|
35
|
+
<section id="latest" className="py-16 bg-muted/50">
|
|
36
|
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
37
|
+
<div className="text-center mb-12">
|
|
38
|
+
<h2 className="text-3xl font-bold text-foreground mb-4">
|
|
39
|
+
{searchQuery ? 'Search Results' : 'Latest Articles'}
|
|
40
|
+
</h2>
|
|
41
|
+
<DescriptionText searchQuery={searchQuery} count={articles.length} />
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
{articles.length === 0 && searchQuery ? (
|
|
45
|
+
<div className="text-center py-12">
|
|
46
|
+
<div className="max-w-md mx-auto">
|
|
47
|
+
<Search className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
|
48
|
+
<h3 className="text-lg font-semibold text-foreground mb-2">No articles found</h3>
|
|
49
|
+
<p className="text-muted-foreground mb-4">
|
|
50
|
+
We couldn't find any articles matching "{searchQuery}". Try adjusting
|
|
51
|
+
your search terms.
|
|
52
|
+
</p>
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
onClick={onClearSearch}
|
|
56
|
+
className="inline-flex items-center justify-center px-4 py-2 border border-input bg-background hover:bg-accent rounded-md font-medium text-sm transition-colors"
|
|
57
|
+
>
|
|
58
|
+
Clear search
|
|
59
|
+
</button>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
) : (
|
|
63
|
+
<LatestArticles articles={articles} pageSize={pageSize} key={searchQuery} />
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
</section>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { render, screen } from '@testing-library/react'
|
|
6
|
+
import type { Article } from '../articleTypes'
|
|
7
|
+
import { ArticleCard } from '../ArticleCard'
|
|
8
|
+
|
|
9
|
+
jest.mock('next/link', () => ({
|
|
10
|
+
__esModule: true,
|
|
11
|
+
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
|
|
12
|
+
<a href={href}>{children}</a>
|
|
13
|
+
),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
jest.mock('next/image', () => ({
|
|
17
|
+
__esModule: true,
|
|
18
|
+
default: (props: React.ComponentProps<'img'>) => <img alt="test" {...props} />,
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
const baseArticle: Article = {
|
|
22
|
+
slug: 'test-article',
|
|
23
|
+
title: 'Test Article Title',
|
|
24
|
+
excerpt: 'A short excerpt about the article.',
|
|
25
|
+
author: 'Jane Doe',
|
|
26
|
+
category: 'Campaigns',
|
|
27
|
+
categories: ['Campaigns'],
|
|
28
|
+
readTime: '5 min read',
|
|
29
|
+
featuredImage: '/articles/test-article/image.jpg',
|
|
30
|
+
tags: ['campaigns'],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('ArticleCard', () => {
|
|
34
|
+
it('renders the article title linked to the article page', () => {
|
|
35
|
+
render(<ArticleCard article={baseArticle} />)
|
|
36
|
+
const link = screen.getByRole('link', { name: 'Test Article Title' })
|
|
37
|
+
expect(link).toHaveAttribute('href', '/articles/test-article')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('renders the excerpt', () => {
|
|
41
|
+
render(<ArticleCard article={baseArticle} />)
|
|
42
|
+
expect(screen.getByText('A short excerpt about the article.')).toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('renders the category badge', () => {
|
|
46
|
+
render(<ArticleCard article={baseArticle} />)
|
|
47
|
+
expect(screen.getByText('Campaigns')).toBeInTheDocument()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('renders the read time', () => {
|
|
51
|
+
render(<ArticleCard article={baseArticle} />)
|
|
52
|
+
expect(screen.getByText('5 min read')).toBeInTheDocument()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('renders the featured image with correct alt text', () => {
|
|
56
|
+
render(<ArticleCard article={baseArticle} />)
|
|
57
|
+
const img = screen.getByAltText('Test Article Title')
|
|
58
|
+
expect(img).toBeInTheDocument()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('renders the date when article.date is set', () => {
|
|
62
|
+
render(<ArticleCard article={{ ...baseArticle, date: '2025-06-15' }} />)
|
|
63
|
+
expect(screen.getByText('2025-06-15')).toBeInTheDocument()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('does not render a date when article.date is absent', () => {
|
|
67
|
+
render(<ArticleCard article={baseArticle} />)
|
|
68
|
+
expect(screen.queryByText(/2025/)).not.toBeInTheDocument()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('arrow link also points to the article page', () => {
|
|
72
|
+
render(<ArticleCard article={baseArticle} />)
|
|
73
|
+
const links = screen
|
|
74
|
+
.getAllByRole('link')
|
|
75
|
+
.filter((l) => l.getAttribute('href') === '/articles/test-article')
|
|
76
|
+
expect(links.length).toBeGreaterThanOrEqual(2)
|
|
77
|
+
})
|
|
78
|
+
})
|