Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
22 changes: 12 additions & 10 deletions src/components/discussion/CommentItem.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useState } from 'react'
import React, { useState } from 'react'
import type { Comment } from './types'
import { t } from '../../i18n/index.ts'

export interface CommentItemProps {
comment: Comment
onReply?: (commentId: string) => void
isReply?: boolean
loggedIn?: boolean
labels: any
}

const REACTION_EMOJIS: Record<string, string> = {
Expand All @@ -19,7 +21,7 @@ const REACTION_EMOJIS: Record<string, string> = {
EYES: '👀',
}

export default function CommentItem({ comment: initialComment, onReply, isReply = false, loggedIn = false }: CommentItemProps) {
export default function CommentItem({ comment: initialComment, onReply, isReply = false, loggedIn = false, labels }: CommentItemProps) {
const [comment, setComment] = useState(initialComment)
const [replyBoxOpen, setReplyBoxOpen] = useState(false)
const [replyText, setReplyText] = useState('')
Expand Down Expand Up @@ -109,19 +111,19 @@ export default function CommentItem({ comment: initialComment, onReply, isReply
<div className="border border-border dark:border-border-dark rounded-md bg-card dark:bg-card-dark overflow-hidden">
<div className="bg-muted/50 dark:bg-muted-dark/50 px-4 py-2 border-b border-border dark:border-border-dark flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<a href={comment.author.url} className="font-semibold text-foreground dark:text-foreground-dark hover:text-primary dark:hover:text-primary-dark transition-colors">
<a href={comment.author.url} target="_blank" rel="noreferrer" className="font-semibold text-foreground dark:text-foreground-dark hover:text-primary dark:hover:text-primary-dark transition-colors">
{comment.author.login}
</a>
<span className="text-muted-foreground dark:text-muted-dark-foreground text-xs">
on {date}
{t(labels, 'discussion.on')} {date}
</span>
</div>
</div>
<div className="p-4 text-foreground dark:text-foreground-dark prose prose-sm dark:prose-invert max-w-none" dangerouslySetInnerHTML={{ __html: comment.bodyHTML }} />
</div>

<div className="mt-2 flex items-center gap-2 flex-wrap">
{['THUMBS_UP', 'HEART', 'ROCKET'].map(content => {
{Object.keys(REACTION_EMOJIS).map(content => {
const reaction = comment.reactionGroups?.find(r => r.content === content)
const count = reaction?.users.totalCount || 0
const userReacted = reaction?.viewerHasReacted || false
Expand Down Expand Up @@ -150,7 +152,7 @@ export default function CommentItem({ comment: initialComment, onReply, isReply
className="flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium bg-transparent text-muted-foreground dark:text-muted-dark-foreground hover:text-foreground dark:hover:text-foreground-dark transition-colors ml-auto"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 17 4 12 9 7"></polyline><path d="M20 18v-2a4 4 0 0 0-4-4H4"></path></svg>
Reply
{t(labels, 'discussion.reply')}
</button>
)}
</div>
Expand All @@ -162,21 +164,21 @@ export default function CommentItem({ comment: initialComment, onReply, isReply
value={replyText}
onChange={e => setReplyText(e.target.value)}
className="w-full bg-background dark:bg-background-dark border border-border dark:border-border-dark rounded-md p-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary dark:focus:ring-primary-dark min-h-[80px] mb-2 text-foreground dark:text-foreground-dark"
placeholder={`Reply to ${comment.author.login}...`}
placeholder={t(labels, 'discussion.replyTo').replace('{user}', comment.author.login)}
/>
<div className="flex justify-end gap-2">
<button
onClick={() => setReplyBoxOpen(false)}
className="px-3 py-1.5 rounded-md font-medium text-xs text-muted-foreground hover:bg-muted dark:text-muted-dark-foreground dark:hover:bg-muted-dark transition-colors"
>
Cancel
{t(labels, 'discussion.cancel')}
</button>
<button
onClick={submitReply}
disabled={replying || !replyText.trim()}
className="bg-primary dark:bg-primary-dark text-primary-foreground dark:text-primary-dark-foreground px-3 py-1.5 rounded-md font-medium text-xs transition-colors hover:brightness-110 disabled:opacity-50"
>
{replying ? 'Replying...' : 'Reply'}
{replying ? t(labels, 'discussion.replying') : t(labels, 'discussion.reply')}
</button>
</div>
</div>
Expand All @@ -186,7 +188,7 @@ export default function CommentItem({ comment: initialComment, onReply, isReply
{(comment.replies?.nodes.length || 0) > 0 && (
<div className="mt-4 pl-4 border-l-2 border-border/50 dark:border-border-dark/50">
{comment.replies!.nodes.map((reply) => (
<CommentItem key={reply.id} comment={reply} isReply={true} loggedIn={loggedIn} />
<CommentItem key={reply.id} comment={reply} isReply={true} loggedIn={loggedIn} labels={labels} />
))}
</div>
)}
Expand Down
132 changes: 118 additions & 14 deletions src/components/discussion/Discussion.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import { useState, useEffect } from 'react'
import type { Comment } from './types'
import React, { useState, useEffect } from 'react'
import type { Comment, DiscussionData } from './types'
import { t } from '../../i18n/index.ts'
import CommentItem from './CommentItem'

export interface DiscussionProps {
slug: string
labels: any
}

export default function Discussion({ slug }: DiscussionProps) {
const REACTION_EMOJIS: Record<string, string> = {
THUMBS_UP: '👍',
THUMBS_DOWN: '👎',
LAUGH: '😄',
HOORAY: '🎉',
CONFUSED: '😕',
HEART: '❤️',
ROCKET: '🚀',
EYES: '👀',
}

export default function Discussion({ slug, labels }: DiscussionProps) {
const [comments, setComments] = useState<Comment[]>([])
const [appData, setAppData] = useState<any>(null)
const [appData, setAppData] = useState<DiscussionData | null>(null)
const [newComment, setNewComment] = useState('')
const [loading, setLoading] = useState(true)
const [posting, setPosting] = useState(false)
Expand Down Expand Up @@ -55,32 +68,123 @@ export default function Discussion({ slug }: DiscussionProps) {
}
}

const toggleUpvote = async () => {
if (!loggedIn || !appData) return
const action = appData.viewerHasUpvoted ? 'remove' : 'add'

setAppData({
...appData,
viewerHasUpvoted: !appData.viewerHasUpvoted,
upvoteCount: appData.upvoteCount + (action === 'add' ? 1 : -1)
})

try {
await fetch('/api/discussions/upvote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subjectId: appData.id, action })
})
} catch (error) { }
}

const toggleReaction = async (content: string, viewerHasReacted: boolean) => {
if (!loggedIn || !appData) return
const action = viewerHasReacted ? 'remove' : 'add'

const newReactions = appData.reactionGroups?.map(r => {
if (r.content === content) {
return {
...r,
viewerHasReacted: !viewerHasReacted,
users: { totalCount: r.users.totalCount + (viewerHasReacted ? -1 : 1) }
}
}
return r
}) || []

const didExist = newReactions.find(r => r.content === content)
if (!didExist && action === 'add') {
newReactions.push({ content, viewerHasReacted: true, users: { totalCount: 1 } })
}

setAppData({ ...appData, reactionGroups: newReactions })

try {
await fetch('/api/discussions/react', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subjectId: appData.id, content, action })
})
} catch (error) { }
}

const totalComments = comments.length + comments.reduce((acc, c) => acc + (c.replies?.nodes.length || 0), 0)

if (loading) {
return <div className="mt-16 pt-8 text-center text-muted-foreground dark:text-muted-dark-foreground">Loading discussion...</div>
return <div className="mt-16 pt-8 text-center text-muted-foreground dark:text-muted-dark-foreground">{t(labels, 'discussion.loading')}</div>
}

return (
<div className="mt-16 pt-8 border-t border-border dark:border-border-dark" id="discussion">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-foreground dark:text-foreground-dark flex items-center gap-2">
Discussion
{t(labels, 'discussion.title')}
<span className="bg-muted dark:bg-muted-dark text-muted-foreground dark:text-muted-dark-foreground text-sm py-0.5 px-2.5 rounded-full font-medium">
{totalComments}
</span>
</h2>
{!loggedIn && (
<a href="/api/auth/login" className="text-sm text-primary dark:text-primary-dark hover:underline font-medium">
Sign in with GitHub
{t(labels, 'discussion.signIn')}
</a>
)}
</div>

{appData && (
<div className="flex flex-wrap gap-2 items-center mb-6">
<button
onClick={toggleUpvote}
disabled={!loggedIn}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border transition-colors ${appData.viewerHasUpvoted
? 'bg-primary/10 border-primary/30 text-primary dark:bg-primary-dark/10 dark:border-primary-dark/30 dark:text-primary-dark'
: 'bg-transparent border-border dark:border-border-dark text-muted-foreground dark:text-muted-dark-foreground hover:bg-muted dark:hover:bg-muted-dark'
}`}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m18 15-6-6-6 6" /></svg>
{appData.upvoteCount || 0} {t(labels, 'discussion.upvotes')}
</button>

<div className="w-px h-6 bg-border dark:bg-border-dark mx-2"></div>

{Object.keys(REACTION_EMOJIS).map(content => {
const reaction = appData.reactionGroups?.find(r => r.content === content)
const count = reaction?.users.totalCount || 0
const userReacted = reaction?.viewerHasReacted || false

if (count === 0 && !loggedIn) return null

return (
<button
key={content}
onClick={() => toggleReaction(content, userReacted)}
disabled={!loggedIn}
className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-sm font-medium border transition-colors ${userReacted
? 'bg-primary/10 border-primary/30 text-primary dark:bg-primary-dark/10 dark:border-primary-dark/30 dark:text-primary-dark'
: 'bg-transparent border-border dark:border-border-dark text-muted-foreground dark:text-muted-dark-foreground hover:bg-muted dark:hover:bg-muted-dark'
} ${count === 0 && 'opacity-50'}`}
>
<span>{REACTION_EMOJIS[content]}</span>
{count > 0 && <span>{count}</span>}
</button>
)
})}
</div>
)}

<div className="bg-card dark:bg-card-dark rounded-lg mb-8 border border-border dark:border-border-dark overflow-hidden flex flex-col">
<div className="bg-muted/30 dark:bg-muted-dark/30 p-2 border-b border-border dark:border-border-dark flex items-center justify-between">
<div className="flex space-x-2">
<button className="px-3 py-1.5 text-sm font-medium border-b-2 border-primary dark:border-primary-dark text-foreground dark:text-foreground-dark">Write</button>
<button className="px-3 py-1.5 text-sm font-medium border-b-2 border-primary dark:border-primary-dark text-foreground dark:text-foreground-dark">{t(labels, 'discussion.write')}</button>
</div>
</div>
<div className="p-3 bg-muted/10 dark:bg-muted-dark/10">
Expand All @@ -89,18 +193,18 @@ export default function Discussion({ slug }: DiscussionProps) {
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
className="w-full bg-background dark:bg-background-dark border border-border dark:border-border-dark rounded-md p-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary dark:focus:ring-primary-dark min-h-[100px] mb-3 text-foreground dark:text-foreground-dark disabled:opacity-50"
placeholder={loggedIn ? "Leave a comment..." : "Sign in to leave a comment"}
placeholder={loggedIn ? t(labels, 'discussion.leaveComment') : t(labels, 'discussion.signInToComment')}
/>
<div className="flex justify-between items-center">
<div className="text-xs text-muted-foreground dark:text-muted-dark-foreground">
Styling with Markdown is supported
{t(labels, 'discussion.markdownSupported')}
</div>
<button
onClick={submitComment}
disabled={!loggedIn || !newComment.trim() || posting}
className="bg-primary hover:bg-primary/90 dark:bg-primary-dark dark:hover:bg-primary-dark/90 text-primary-foreground dark:text-primary-dark-foreground px-4 py-2 rounded-md font-medium text-sm transition-colors cursor-pointer disabled:opacity-50"
>
{posting ? 'Posting...' : 'Comment'}
{posting ? t(labels, 'discussion.posting') : t(labels, 'discussion.comment')}
</button>
</div>
</div>
Expand All @@ -109,16 +213,16 @@ export default function Discussion({ slug }: DiscussionProps) {
<div className="space-y-4">
{!appData ? (
<div className="text-center py-12 border border-dashed border-border dark:border-border-dark rounded-lg text-muted-foreground dark:text-muted-dark-foreground">
Discussion not found.
{t(labels, 'discussion.notFound')}
</div>
) : comments.length === 0 ? (
<div className="text-center py-12 border border-dashed border-border dark:border-border-dark rounded-lg text-muted-foreground dark:text-muted-dark-foreground">
No comments yet. Be the first to start the discussion!
{t(labels, 'discussion.noComments')}
</div>
) : (
<div className="flex flex-col">
{comments.map(comment => (
<CommentItem key={comment.id} comment={comment} loggedIn={loggedIn} />
<CommentItem key={comment.id} comment={comment} loggedIn={loggedIn} labels={labels} />
))}
</div>
)}
Expand Down
14 changes: 11 additions & 3 deletions src/components/discussion/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,18 @@ export interface Comment {
author: User
createdAt: string
bodyHTML: string
reactions: {
nodes: ReactionGroup[]
}
reactionGroups: ReactionGroup[]
replies?: {
nodes: Comment[]
}
}

export interface DiscussionData {
id: string
title: string
url: string
upvoteCount: number
viewerHasUpvoted: boolean
reactionGroups: ReactionGroup[]
comments: { nodes: Comment[] }
}
19 changes: 19 additions & 0 deletions src/data/en/labels.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,24 @@
"lang": {
"tr": "TR",
"en": "EN"
},
"discussion": {
"title": "Discussion",
"signIn": "Sign in with GitHub",
"upvotes": "Upvotes",
"write": "Write",
"leaveComment": "Leave a comment...",
"signInToComment": "Sign in to leave a comment",
"markdownSupported": "Styling with Markdown is supported",
"comment": "Comment",
"posting": "Posting...",
"notFound": "Discussion not found.",
"noComments": "No comments yet. Be the first to start the discussion!",
"loading": "Loading discussion...",
"reply": "Reply",
"cancel": "Cancel",
"replying": "Replying...",
"replyTo": "Reply to {user}...",
"on": "on"
}
}
19 changes: 19 additions & 0 deletions src/data/tr/labels.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,24 @@
"lang": {
"tr": "TR",
"en": "EN"
},
"discussion": {
"title": "Tartışma",
"signIn": "GitHub ile Giriş Yap",
"upvotes": "Beğeni",
"write": "Yaz",
"leaveComment": "Bir yorum bırakın...",
"signInToComment": "Yorum yapmak için giriş yapın",
"markdownSupported": "Markdown biçimlendirmesi desteklenmektedir",
"comment": "Yorum Yap",
"posting": "Gönderiliyor...",
"notFound": "Tartışma bulunamadı.",
"noComments": "Henüz yorum yok. Tartışmayı başlatan ilk siz olun!",
"loading": "Tartışma yükleniyor...",
"reply": "Yanıtla",
"cancel": "İptal",
"replying": "Yanıtlanıyor...",
"replyTo": "{user} adlı kullanıcıyı yanıtla...",
"on": "tarihinde"
}
}
9 changes: 9 additions & 0 deletions src/pages/api/discussions/[slug].ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ export const GET: APIRoute = async ({ params }) => {
id
title
url
upvoteCount
viewerHasUpvoted
reactionGroups {
content
viewerHasReacted
users {
totalCount
}
}
comments(first: 100) {
totalCount
nodes {
Expand Down
Loading
Loading