diff --git a/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/d1866dd99f5441005a55b0a86f46e43724c857ce74a35b86238b75e6e807ff7f.sqlite-shm b/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/d1866dd99f5441005a55b0a86f46e43724c857ce74a35b86238b75e6e807ff7f.sqlite-shm new file mode 100644 index 0000000..fe9ac28 Binary files /dev/null and b/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/d1866dd99f5441005a55b0a86f46e43724c857ce74a35b86238b75e6e807ff7f.sqlite-shm differ diff --git a/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/d1866dd99f5441005a55b0a86f46e43724c857ce74a35b86238b75e6e807ff7f.sqlite-wal b/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/d1866dd99f5441005a55b0a86f46e43724c857ce74a35b86238b75e6e807ff7f.sqlite-wal new file mode 100644 index 0000000..e69de29 diff --git a/src/components/discussion/CommentItem.tsx b/src/components/discussion/CommentItem.tsx index ad07c7d..ab87d50 100644 --- a/src/components/discussion/CommentItem.tsx +++ b/src/components/discussion/CommentItem.tsx @@ -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 = { @@ -19,7 +21,7 @@ const REACTION_EMOJIS: Record = { 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('') @@ -109,11 +111,11 @@ export default function CommentItem({ comment: initialComment, onReply, isReply
- + {comment.author.login} - on {date} + {t(labels, 'discussion.on')} {date}
@@ -121,7 +123,7 @@ export default function CommentItem({ comment: initialComment, onReply, isReply
- {['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 @@ -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" > - Reply + {t(labels, 'discussion.reply')} )}
@@ -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)} />
@@ -186,7 +188,7 @@ export default function CommentItem({ comment: initialComment, onReply, isReply {(comment.replies?.nodes.length || 0) > 0 && (
{comment.replies!.nodes.map((reply) => ( - + ))}
)} diff --git a/src/components/discussion/Discussion.tsx b/src/components/discussion/Discussion.tsx index a9940d9..77eae45 100644 --- a/src/components/discussion/Discussion.tsx +++ b/src/components/discussion/Discussion.tsx @@ -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 = { + THUMBS_UP: '👍', + THUMBS_DOWN: '👎', + LAUGH: '😄', + HOORAY: '🎉', + CONFUSED: '😕', + HEART: '❤️', + ROCKET: '🚀', + EYES: '👀', +} + +export default function Discussion({ slug, labels }: DiscussionProps) { const [comments, setComments] = useState([]) - const [appData, setAppData] = useState(null) + const [appData, setAppData] = useState(null) const [newComment, setNewComment] = useState('') const [loading, setLoading] = useState(true) const [posting, setPosting] = useState(false) @@ -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
Loading discussion...
+ return
{t(labels, 'discussion.loading')}
} return (

- Discussion + {t(labels, 'discussion.title')} {totalComments}

{!loggedIn && ( - Sign in with GitHub + {t(labels, 'discussion.signIn')} )}
+ {appData && ( +
+ + +
+ + {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 ( + + ) + })} +
+ )} +
- +
@@ -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')} />
- Styling with Markdown is supported + {t(labels, 'discussion.markdownSupported')}
@@ -109,16 +213,16 @@ export default function Discussion({ slug }: DiscussionProps) {
{!appData ? (
- Discussion not found. + {t(labels, 'discussion.notFound')}
) : comments.length === 0 ? (
- No comments yet. Be the first to start the discussion! + {t(labels, 'discussion.noComments')}
) : (
{comments.map(comment => ( - + ))}
)} diff --git a/src/components/discussion/types.ts b/src/components/discussion/types.ts index 7fc4345..b2e4125 100644 --- a/src/components/discussion/types.ts +++ b/src/components/discussion/types.ts @@ -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[] } +} diff --git a/src/data/en/labels.json b/src/data/en/labels.json index 23fa628..a7a5fb5 100644 --- a/src/data/en/labels.json +++ b/src/data/en/labels.json @@ -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" } } diff --git a/src/data/tr/labels.json b/src/data/tr/labels.json index 93e62ff..d199715 100644 --- a/src/data/tr/labels.json +++ b/src/data/tr/labels.json @@ -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" } } diff --git a/src/pages/api/discussions/[slug].ts b/src/pages/api/discussions/[slug].ts index 6cbf9a1..09db753 100644 --- a/src/pages/api/discussions/[slug].ts +++ b/src/pages/api/discussions/[slug].ts @@ -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 { diff --git a/src/pages/api/discussions/upvote.ts b/src/pages/api/discussions/upvote.ts new file mode 100644 index 0000000..877e995 --- /dev/null +++ b/src/pages/api/discussions/upvote.ts @@ -0,0 +1,76 @@ +export const prerender = false + +import type { APIRoute } from 'astro' +import { env } from 'cloudflare:workers' +import { verifyTokensCookie, getTokensCookieName } from '../../../lib/auth' + +const GITHUB_GRAPHQL_API = 'https://api.github.com/graphql' + +export const POST: APIRoute = async ({ request, cookies }) => { + const tokensCookie = cookies.get(getTokensCookieName()) + if (!tokensCookie) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }) + } + + const tokens = await verifyTokensCookie(tokensCookie.value, env as any) + if (!tokens || !tokens.access_token) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }) + } + + const body = await request.json().catch(() => ({})) + const { subjectId, action } = body + + if (!subjectId || !action) { + return new Response(JSON.stringify({ error: 'Missing required fields' }), { status: 400 }) + } + + const getQuery = () => { + if (action === 'add') { + return ` + mutation($subjectId: ID!) { + addUpvote(input: {subjectId: $subjectId}) { + subject { id } + } + } + ` + } else { + return ` + mutation($subjectId: ID!) { + removeUpvote(input: {subjectId: $subjectId}) { + subject { id } + } + } + ` + } + } + + try { + const response = await fetch(GITHUB_GRAPHQL_API, { + method: 'POST', + headers: { + Authorization: `Bearer ${tokens.access_token}`, + 'Content-Type': 'application/json', + 'User-Agent': '4byte-dev', + }, + body: JSON.stringify({ + query: getQuery(), + variables: { subjectId }, + }), + }) + + const data = await response.json() + + if (data.errors) { + console.error('GraphQL mutation errors:', data.errors) + return new Response(JSON.stringify({ error: 'Failed to toggle upvote' }), { status: 400 }) + } + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } catch (error) { + console.error('API Error:', error) + return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500 }) + } +} diff --git a/src/pages/articles/[slug].astro b/src/pages/articles/[slug].astro index 5695c47..5f1f2e9 100644 --- a/src/pages/articles/[slug].astro +++ b/src/pages/articles/[slug].astro @@ -163,7 +163,7 @@ const onThisPageLabel = t(labels, 'article.onThisPage') - +