My personal site. theclashfruit.me
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: comments design

+189 -1
+43 -1
app/(main)/post/[slug]/page.tsx
··· 1 1 import { db } from '@/lib/db/drizzle'; 2 - import { postsTable, usersTable } from '@/lib/db/schema'; 2 + import { commentsTable, postsTable, usersTable } from '@/lib/db/schema'; 3 3 4 4 import rehypeStarryNight from 'rehype-starry-night'; 5 5 import remarkGfm from 'remark-gfm'; ··· 10 10 11 11 import { components } from '@/mdx-components'; 12 12 13 + import Comment, { CommentWithReplies } from '@/components/Comment'; 14 + 13 15 const options: MDXRemoteOptions = { 14 16 mdxOptions: { 15 17 rehypePlugins: [rehypeStarryNight], ··· 24 26 }) { 25 27 const { slug } = await params; 26 28 const { posts: post } = await fetchPostData(slug); 29 + const comments = await fetchComments(post.id); 27 30 28 31 return ( 29 32 <> ··· 36 39 options={options} 37 40 /> 38 41 </article> 42 + 43 + <div> 44 + <h2>Comments</h2> 45 + 46 + {comments && 47 + comments.map((c) => ( 48 + <Comment 49 + key={c.id} 50 + comment={c} 51 + author={post.author!} 52 + isRoot={true} 53 + /> 54 + ))} 55 + </div> 39 56 </> 40 57 ); 41 58 } ··· 50 67 51 68 return post[0]; 52 69 }; 70 + 71 + const fetchComments = async (id: bigint): Promise<CommentWithReplies[]> => { 72 + const rows = await db 73 + .select() 74 + .from(commentsTable) 75 + .leftJoin(usersTable, eq(commentsTable.author, usersTable.id)) 76 + .where(eq(commentsTable.post, id)); 77 + 78 + const map = new Map<string, CommentWithReplies>(); 79 + const roots: CommentWithReplies[] = []; 80 + 81 + for (const { comments: c, users: u } of rows) { 82 + map.set(c.id.toString(), { ...c, user: u, replies: [] }); 83 + } 84 + 85 + for (const c of map.values()) { 86 + if (c.parent === null) { 87 + roots.push(c); 88 + } else { 89 + map.get(c.parent.toString())?.replies.push(c); 90 + } 91 + } 92 + 93 + return roots; 94 + };
+56
components/Comment.tsx
··· 1 + import { commentsTable, usersTable } from '@/lib/db/schema'; 2 + 3 + import { Reply } from 'lucide-react'; 4 + 5 + import styles from '@/styles/components/Comment.module.scss'; 6 + 7 + export type CommentWithReplies = typeof commentsTable.$inferSelect & { 8 + user: typeof usersTable.$inferSelect | null; 9 + replies: CommentWithReplies[]; 10 + }; 11 + 12 + export default function Comment({ 13 + comment, 14 + author, 15 + isRoot = false 16 + }: { 17 + comment: CommentWithReplies; 18 + author: bigint; 19 + isRoot?: boolean; 20 + }) { 21 + const displayName = 22 + comment.displayName ?? 23 + comment.user?.displayName ?? 24 + comment.user?.username ?? 25 + 'Unknown User'; 26 + 27 + return ( 28 + <div className={styles.comment} data-root={isRoot}> 29 + <div className={styles.content}> 30 + <div className={styles.userName}> 31 + <h6>{displayName}</h6> 32 + 33 + {comment.author !== null && comment.author === author && ( 34 + <span>Author</span> 35 + )} 36 + </div> 37 + 38 + <p>{comment.content}</p> 39 + 40 + <div className={styles.actions}> 41 + <button> 42 + <Reply /> Reply 43 + </button> 44 + </div> 45 + </div> 46 + 47 + {comment.replies.length > 0 && ( 48 + <div className={styles.reply}> 49 + {comment.replies.map((c) => ( 50 + <Comment key={c.id} comment={c} author={author} /> 51 + ))} 52 + </div> 53 + )} 54 + </div> 55 + ); 56 + }
+1
lib/db/schema.ts
··· 8 8 9 9 import { snowflake } from '@/lib/db/types/snowflake'; 10 10 import { snowflakeGenerator } from '@/lib/snowflake'; 11 + import { relations } from 'drizzle-orm'; 11 12 12 13 export const usersTable = pgTable('users', { 13 14 id: snowflake()
+89
styles/components/Comment.module.scss
··· 1 + .comment { 2 + > .content { 3 + display: flex; 4 + 5 + flex-direction: column; 6 + 7 + gap: 10px; 8 + 9 + margin-bottom: 10px; 10 + 11 + > .userName { 12 + display: flex; 13 + 14 + flex-direction: column; 15 + 16 + gap: 2px; 17 + 18 + > h6 { 19 + margin: 0; 20 + 21 + font-weight: 600; 22 + } 23 + 24 + > span { 25 + font-size: 85%; 26 + 27 + color: var(--primary); 28 + } 29 + } 30 + 31 + > p { 32 + margin: 0; 33 + } 34 + 35 + > .actions { 36 + display: flex; 37 + 38 + gap: 8px; 39 + 40 + > button { 41 + display: flex; 42 + 43 + align-items: center; 44 + justify-content: center; 45 + 46 + gap: 6px; 47 + 48 + padding: 4px 12px; 49 + 50 + background: none; 51 + color: var(--onSurface); 52 + 53 + border: 1px solid var(--outlineVariant); 54 + border-radius: 16px; 55 + 56 + cursor: pointer; 57 + 58 + font-weight: 450; 59 + 60 + transition: 150ms; 61 + 62 + &:hover { 63 + color: var(--primary); 64 + border-color: var(--primary); 65 + } 66 + } 67 + } 68 + 69 + &:last-child { 70 + margin: 0; 71 + } 72 + } 73 + 74 + > .reply { 75 + padding-left: 16px; 76 + border-left: 1px solid var(--outlineVariant); 77 + } 78 + 79 + &[data-root='true'] { 80 + padding: 16px; 81 + 82 + background: var(--surfaceContainer); 83 + 84 + border: 1px solid var(--outlineVariant); 85 + border-radius: 16px; 86 + 87 + margin-bottom: 8px; 88 + } 89 + }