diff --git a/src/app/api/posts/[slug]/engagement/route.ts b/src/app/api/posts/[slug]/engagement/route.ts new file mode 100644 index 0000000..b1eb938 --- /dev/null +++ b/src/app/api/posts/[slug]/engagement/route.ts @@ -0,0 +1,264 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { createServerComponentClient, createServiceRoleClient } from '@/lib/supabase/server-client'; + +const voteSchema = z.object({ + voteType: z.enum(['upvote', 'downvote']), +}); + +interface SessionProfile { + id: string; + displayName: string | null; +} + +const getSessionProfile = async (): Promise => { + const supabase = createServerComponentClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return null; + } + + const { data, error } = await supabase + .from('profiles') + .select('id, display_name') + .eq('user_id', user.id) + .maybeSingle(); + + if (error || !data) { + return null; + } + + return { + id: data.id as string, + displayName: (data.display_name as string | null) ?? null, + }; +}; + +interface PostRecord { + id: string; + views: number | null; +} + +const fetchPostBySlug = async (slug: string): Promise => { + const supabase = createServiceRoleClient(); + const { data, error } = await supabase + .from('posts') + .select('id, views') + .eq('slug', slug) + .eq('status', 'published') + .maybeSingle(); + + if (error || !data) { + return null; + } + + return data as PostRecord; +}; + +interface EngagementStats { + upvotes: number; + downvotes: number; + comments: number; + bookmarks: number; + views: number; +} + +const loadEngagementStats = async (postId: string): Promise => { + const supabase = createServiceRoleClient(); + + const [{ count: upvotes }, { count: downvotes }, { count: comments }, { count: bookmarks }] = await Promise.all([ + supabase + .from('post_votes') + .select('id', { head: true, count: 'exact' }) + .eq('post_id', postId) + .eq('vote_type', 'upvote'), + supabase + .from('post_votes') + .select('id', { head: true, count: 'exact' }) + .eq('post_id', postId) + .eq('vote_type', 'downvote'), + supabase + .from('comments') + .select('id', { head: true, count: 'exact' }) + .eq('post_id', postId) + .eq('status', 'approved'), + supabase + .from('bookmarks') + .select('id', { head: true, count: 'exact' }) + .eq('post_id', postId), + ]); + + return { + upvotes: upvotes ?? 0, + downvotes: downvotes ?? 0, + comments: comments ?? 0, + bookmarks: bookmarks ?? 0, + views: 0, + }; +}; + +const buildEngagementResponse = async ( + post: PostRecord, + profile: SessionProfile | null, +): Promise<{ stats: EngagementStats; viewer: { vote: 'upvote' | 'downvote' | null; bookmarkId: string | null } }> => { + const supabase = createServiceRoleClient(); + const stats = await loadEngagementStats(post.id); + stats.views = post.views ?? 0; + + let vote: 'upvote' | 'downvote' | null = null; + let bookmarkId: string | null = null; + + if (profile) { + const [{ data: voteRow }, { data: bookmarkRow }] = await Promise.all([ + supabase + .from('post_votes') + .select('id, vote_type') + .eq('post_id', post.id) + .eq('profile_id', profile.id) + .maybeSingle(), + supabase + .from('bookmarks') + .select('id') + .eq('post_id', post.id) + .eq('profile_id', profile.id) + .maybeSingle(), + ]); + + vote = (voteRow?.vote_type as 'upvote' | 'downvote' | null) ?? null; + bookmarkId = (bookmarkRow?.id as string | null) ?? null; + } + + return { + stats, + viewer: { + vote, + bookmarkId, + }, + }; +}; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const { slug } = await params; + const decodedSlug = decodeURIComponent(slug); + const post = await fetchPostBySlug(decodedSlug); + + if (!post) { + return NextResponse.json({ error: 'Post not found.' }, { status: 404 }); + } + + const profile = await getSessionProfile(); + const payload = await buildEngagementResponse(post, profile); + + return NextResponse.json({ + postId: post.id, + stats: payload.stats, + viewer: payload.viewer, + }); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const { slug } = await params; + const decodedSlug = decodeURIComponent(slug); + const profile = await getSessionProfile(); + + if (!profile) { + return NextResponse.json({ error: 'Authentication required.' }, { status: 401 }); + } + + const post = await fetchPostBySlug(decodedSlug); + + if (!post) { + return NextResponse.json({ error: 'Post not found.' }, { status: 404 }); + } + + const raw = await request.json().catch(() => null); + const parsed = voteSchema.safeParse(raw); + + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid payload.' }, { status: 400 }); + } + + const supabase = createServiceRoleClient(); + + const { data: existing } = await supabase + .from('post_votes') + .select('id, vote_type') + .eq('post_id', post.id) + .eq('profile_id', profile.id) + .maybeSingle(); + + if (existing && existing.vote_type === parsed.data.voteType) { + await supabase + .from('post_votes') + .delete() + .eq('id', existing.id); + + const payload = await buildEngagementResponse(post, profile); + return NextResponse.json({ + postId: post.id, + stats: payload.stats, + viewer: payload.viewer, + }); + } + + await supabase + .from('post_votes') + .upsert( + { + post_id: post.id, + profile_id: profile.id, + vote_type: parsed.data.voteType, + }, + { onConflict: 'post_id,profile_id' }, + ); + + const payload = await buildEngagementResponse(post, profile); + return NextResponse.json({ + postId: post.id, + stats: payload.stats, + viewer: payload.viewer, + }); +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const { slug } = await params; + const decodedSlug = decodeURIComponent(slug); + const profile = await getSessionProfile(); + + if (!profile) { + return NextResponse.json({ error: 'Authentication required.' }, { status: 401 }); + } + + const post = await fetchPostBySlug(decodedSlug); + + if (!post) { + return NextResponse.json({ error: 'Post not found.' }, { status: 404 }); + } + + const supabase = createServiceRoleClient(); + + await supabase + .from('post_votes') + .delete() + .eq('post_id', post.id) + .eq('profile_id', profile.id); + + const payload = await buildEngagementResponse(post, profile); + return NextResponse.json({ + postId: post.id, + stats: payload.stats, + viewer: payload.viewer, + }); +} diff --git a/src/app/topics/page.tsx b/src/app/topics/page.tsx index b81d8ee..6d5c017 100644 --- a/src/app/topics/page.tsx +++ b/src/app/topics/page.tsx @@ -22,7 +22,7 @@ export const metadata: Metadata = { type SearchParamsShape = Record; type TopicsPageProps = { - searchParams?: SearchParamsShape | Promise; + searchParams?: Promise; }; const footerLinks = [ @@ -229,10 +229,7 @@ const normalizeParam = (value: string | string[] | undefined) => (Array.isArray(value) ? value[0] : value) ?? null; export default async function TopicsPage({ searchParams }: TopicsPageProps) { - const resolvedSearchParams: SearchParamsShape = - searchParams && typeof (searchParams as Promise).then === 'function' - ? await (searchParams as Promise) - : (searchParams ?? {}); + const resolvedSearchParams: SearchParamsShape = searchParams ? await searchParams : {}; const rawTopic = normalizeParam(resolvedSearchParams.topic); const rawQuery = normalizeParam(resolvedSearchParams.q); diff --git a/src/components/admin/CommentsModeration.tsx b/src/components/admin/CommentsModeration.tsx index 5ed6520..b151db1 100644 --- a/src/components/admin/CommentsModeration.tsx +++ b/src/components/admin/CommentsModeration.tsx @@ -187,7 +187,8 @@ export const CommentsModeration = ({ Delete comment? - This will permanently delete this comment from {comment.authorName}. This action cannot be undone. + This will permanently delete this comment from {comment.authorDisplayName ?? 'this reader'}. This + action cannot be undone. diff --git a/src/components/ui/BlogEngagementToolbar.tsx b/src/components/ui/BlogEngagementToolbar.tsx new file mode 100644 index 0000000..d16530b --- /dev/null +++ b/src/components/ui/BlogEngagementToolbar.tsx @@ -0,0 +1,428 @@ +"use client"; + +import Link from 'next/link'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + ArrowBigDown, + ArrowBigUp, + Bookmark, + BookmarkCheck, + Loader2, + MessageCircle, + MoreHorizontal, + Share2, +} from 'lucide-react'; +import { formatCompactNumber } from '@/lib/utils'; + +type VoteType = 'upvote' | 'downvote'; + +interface EngagementStats { + upvotes: number; + downvotes: number; + comments: number; + bookmarks: number; + views: number; +} + +interface EngagementResponse { + postId: string; + stats: EngagementStats; + viewer: { + vote: VoteType | null; + bookmarkId: string | null; + }; +} + +interface BlogEngagementToolbarProps { + postId: string; + slug: string; + title: string; + excerpt: string; + initialViews: number; + authorId?: string | null; + authorName?: string | null; + isFollowingAuthor?: boolean; + isAuthorMuted?: boolean; + onHide?: () => void; + onToggleFollow?: (nextAction: 'follow' | 'unfollow') => void; + onToggleMute?: (nextAction: 'mute' | 'unmute') => void; +} + +const buildShareFallback = (slug: string) => { + if (typeof window === 'undefined') { + return `/blogs/${slug}`; + } + + return `${window.location.origin}/blogs/${slug}`; +}; + +export function BlogEngagementToolbar({ + postId, + slug, + title, + excerpt, + initialViews, + authorId, + authorName, + isFollowingAuthor = false, + isAuthorMuted = false, + onHide, + onToggleFollow, + onToggleMute, +}: BlogEngagementToolbarProps) { + const [stats, setStats] = useState({ + upvotes: 0, + downvotes: 0, + comments: 0, + bookmarks: 0, + views: initialViews, + }); + const [viewerVote, setViewerVote] = useState(null); + const [bookmarkId, setBookmarkId] = useState(null); + const [loadingVote, setLoadingVote] = useState(false); + const [bookmarkLoading, setBookmarkLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [menuOpen, setMenuOpen] = useState(false); + const [copyState, setCopyState] = useState<'idle' | 'copied'>('idle'); + const menuRef = useRef(null); + + const shareUrl = useMemo(() => buildShareFallback(slug), [slug]); + + useEffect(() => { + let isMounted = true; + + const loadEngagement = async () => { + try { + const response = await fetch(`/api/posts/${encodeURIComponent(slug)}/engagement`, { + credentials: 'include', + }); + + if (!response.ok) { + throw new Error(await response.text()); + } + + const payload = (await response.json()) as EngagementResponse; + + if (!isMounted) { + return; + } + + setStats({ ...payload.stats, views: payload.stats.views ?? initialViews }); + setViewerVote(payload.viewer.vote); + setBookmarkId(payload.viewer.bookmarkId); + } catch (error) { + if (!isMounted) { + return; + } + console.warn('Unable to load engagement for post', slug, error); + setErrorMessage('Unable to load engagement details right now.'); + } + }; + + void loadEngagement(); + + return () => { + isMounted = false; + }; + }, [initialViews, slug]); + + useEffect(() => { + if (!menuOpen) { + return undefined; + } + + const handleOutsideClick = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setMenuOpen(false); + } + }; + + document.addEventListener('mousedown', handleOutsideClick); + return () => document.removeEventListener('mousedown', handleOutsideClick); + }, [menuOpen]); + + useEffect(() => { + if (copyState !== 'copied') { + return undefined; + } + + const timeout = window.setTimeout(() => setCopyState('idle'), 2500); + return () => window.clearTimeout(timeout); + }, [copyState]); + + const updateEngagementState = (payload: EngagementResponse) => { + setStats({ ...payload.stats, views: payload.stats.views ?? initialViews }); + setViewerVote(payload.viewer.vote); + setBookmarkId(payload.viewer.bookmarkId); + }; + + const handleVote = async (voteType: VoteType) => { + setLoadingVote(true); + setErrorMessage(null); + + try { + const method = viewerVote === voteType ? 'DELETE' : 'POST'; + const response = await fetch(`/api/posts/${encodeURIComponent(slug)}/engagement`, { + method, + credentials: 'include', + headers: method === 'POST' ? { 'Content-Type': 'application/json' } : undefined, + body: method === 'POST' ? JSON.stringify({ voteType }) : undefined, + }); + + if (!response.ok) { + if (response.status === 401) { + setErrorMessage('Sign in to vote on stories.'); + return; + } + throw new Error(await response.text()); + } + + const payload = (await response.json()) as EngagementResponse; + updateEngagementState(payload); + } catch (error) { + console.error('Unable to update vote', error); + setErrorMessage('Unable to update your vote. Please try again.'); + } finally { + setLoadingVote(false); + } + }; + + const handleBookmark = async () => { + setBookmarkLoading(true); + setErrorMessage(null); + + try { + if (bookmarkId) { + const response = await fetch(`/api/library/bookmarks/${bookmarkId}`, { + method: 'DELETE', + credentials: 'include', + }); + + if (!response.ok) { + if (response.status === 401) { + setErrorMessage('Sign in to manage your reading list.'); + return; + } + throw new Error(await response.text()); + } + + setBookmarkId(null); + setStats((previous) => ({ + ...previous, + bookmarks: previous.bookmarks > 0 ? previous.bookmarks - 1 : 0, + })); + return; + } + + const response = await fetch('/api/library/bookmarks', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ postId }), + }); + + if (!response.ok) { + if (response.status === 401) { + setErrorMessage('Sign in to save stories for later.'); + return; + } + const payload = await response.json().catch(() => null); + throw new Error(payload?.error ?? 'Unable to save bookmark'); + } + + const payload = (await response.json()) as { bookmark: { id: string } }; + setBookmarkId(payload.bookmark.id); + setStats((previous) => ({ + ...previous, + bookmarks: previous.bookmarks + 1, + })); + } catch (error) { + console.error('Unable to toggle bookmark', error); + setErrorMessage('Unable to update your bookmark. Please try again.'); + } finally { + setBookmarkLoading(false); + } + }; + + const handleShare = async () => { + setErrorMessage(null); + + try { + if (navigator.share) { + await navigator.share({ + title, + text: excerpt, + url: shareUrl, + }); + return; + } + + if (navigator.clipboard) { + await navigator.clipboard.writeText(shareUrl); + setCopyState('copied'); + return; + } + + window.prompt('Copy this link', shareUrl); + setCopyState('copied'); + } catch (error) { + console.error('Unable to share story', error); + setErrorMessage('Unable to share this story right now.'); + } + }; + + const handleFollowToggle = () => { + if (!authorId || !onToggleFollow) { + return; + } + + onToggleFollow(isFollowingAuthor ? 'unfollow' : 'follow'); + setMenuOpen(false); + }; + + const handleMuteToggle = () => { + if (!authorId || !onToggleMute) { + return; + } + + onToggleMute(isAuthorMuted ? 'unmute' : 'mute'); + setMenuOpen(false); + }; + + const handleHide = () => { + onHide?.(); + setMenuOpen(false); + }; + + const handleReport = () => { + setMenuOpen(false); + const subject = encodeURIComponent(`Report story: ${title}`); + const greeting = authorName ? `Author: ${authorName}\n` : ''; + const body = encodeURIComponent(`${greeting}Slug: /blogs/${slug}\n\nPlease describe the issue:`); + if (typeof window !== 'undefined') { + window.location.href = `mailto:editors@syntax-blogs.test?subject=${subject}&body=${body}`; + } + }; + + const voteButtonClasses = (active: boolean) => + `inline-flex items-center gap-1 rounded-full border-2 border-black px-3 py-1 text-xs font-bold transition-transform ${ + active ? 'bg-[#6C63FF] text-white shadow-[4px_4px_0_rgba(0,0,0,0.25)]' : 'bg-white text-black hover:-translate-y-0.5' + }`; + + const iconButtonClasses = (active: boolean) => + `inline-flex items-center gap-1 rounded-full border-2 border-black px-3 py-1 text-xs font-bold transition-transform ${ + active ? 'bg-[#118AB2] text-white shadow-[4px_4px_0_rgba(0,0,0,0.25)]' : 'bg-white text-black hover:-translate-y-0.5' + }`; + + return ( +
+
+
+ + + +
+
+ +
+ + {menuOpen ? ( +
+ + + + +
+ ) : null} +
+
+
+ {errorMessage ?

{errorMessage}

: null} +
+ ); +} diff --git a/src/components/ui/NewBlogCard.tsx b/src/components/ui/NewBlogCard.tsx index 85b634d..fbdadb3 100644 --- a/src/components/ui/NewBlogCard.tsx +++ b/src/components/ui/NewBlogCard.tsx @@ -1,12 +1,14 @@ "use client"; -import React, { useEffect, useState, useRef } from 'react'; +import React from 'react'; import Link from 'next/link'; -import { MoreHorizontal, Calendar, Clock } from 'lucide-react'; +import { Calendar, Clock } from 'lucide-react'; import { NeobrutalCard } from '@/components/neobrutal/card'; +import { BlogEngagementToolbar } from './BlogEngagementToolbar'; interface BlogCardProps { + id: string; title: string; categoryLabel: string; accentColor?: string | null; @@ -14,6 +16,15 @@ interface BlogCardProps { views: number; excerpt: string; slug: string; + author?: { + id: string | null; + displayName: string | null; + }; + onHide?: () => void; + onToggleFollow?: (nextAction: 'follow' | 'unfollow') => void; + onToggleMute?: (nextAction: 'mute' | 'unmute') => void; + isAuthorFollowed?: boolean; + isAuthorMuted?: boolean; } const colorPalette = ['#6C63FF', '#FF5252', '#06D6A0', '#FFD166', '#118AB2']; @@ -34,6 +45,7 @@ const getFallbackColor = (label: string) => { }; export function NewBlogCard({ + id, title, categoryLabel, accentColor, @@ -41,44 +53,13 @@ export function NewBlogCard({ views, excerpt, slug, + author, + onHide, + onToggleFollow, + onToggleMute, + isAuthorFollowed = false, + isAuthorMuted = false, }: BlogCardProps) { - const [menuOpen, setMenuOpen] = useState(false); - const menuRef = useRef(null); - - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - setMenuOpen(false); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - - const handleShare = () => { - if (navigator.share) { - navigator.share({ - title: title, - text: excerpt, - url: window.location.origin + '/blogs/' + slug, - }).catch(console.error); - } else { - // Fallback for browsers that don't support the Web Share API - navigator.clipboard.writeText(window.location.origin + '/blogs/' + slug) - .then(() => alert('Link copied to clipboard!')) - .catch(console.error); - } - setMenuOpen(false); - }; - - const handleGenerateWithAI = () => { - // This would be implemented with your AI generation functionality - alert('Generate with AI functionality would go here'); - setMenuOpen(false); - }; - const badgeColor = accentColor ?? getFallbackColor(categoryLabel); return ( @@ -93,34 +74,6 @@ export function NewBlogCard({ > {categoryLabel} -
- - {menuOpen && ( -
- - -
- )} -

{title}

{excerpt}

@@ -142,6 +95,20 @@ export function NewBlogCard({ READ POST + ); diff --git a/src/components/ui/NewBlogGrid.tsx b/src/components/ui/NewBlogGrid.tsx index 1dfdcc8..2b7546a 100644 --- a/src/components/ui/NewBlogGrid.tsx +++ b/src/components/ui/NewBlogGrid.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { NewBlogCard } from './NewBlogCard'; export interface BlogGridItem { + id: string; slug: string; title: string; excerpt: string; @@ -9,19 +10,37 @@ export interface BlogGridItem { dateLabel: string; views: number; accentColor: string | null; + authorId: string | null; + authorName: string | null; } interface BlogGridProps { blogs: BlogGridItem[]; + onHidePost?: (blog: BlogGridItem) => void; + onToggleFollow?: (authorId: string, action: 'follow' | 'unfollow', authorName: string | null) => void; + onToggleMute?: (authorId: string, action: 'mute' | 'unmute', authorName: string | null) => void; + followingAuthorIds?: string[]; + mutedAuthorIds?: string[]; } -export function NewBlogGrid({ blogs }: BlogGridProps) { +export function NewBlogGrid({ + blogs, + onHidePost, + onToggleFollow, + onToggleMute, + followingAuthorIds = [], + mutedAuthorIds = [], +}: BlogGridProps) { + const followingSet = new Set(followingAuthorIds.filter(Boolean)); + const mutedSet = new Set(mutedAuthorIds.filter(Boolean)); + return (
{blogs.length > 0 ? ( blogs.map((blog) => ( onHidePost(blog) : undefined} + onToggleFollow={ + blog.authorId && onToggleFollow + ? (action) => onToggleFollow(blog.authorId as string, action, blog.authorName) + : undefined + } + onToggleMute={ + blog.authorId && onToggleMute + ? (action) => onToggleMute(blog.authorId as string, action, blog.authorName) + : undefined + } + isAuthorFollowed={Boolean(blog.authorId && followingSet.has(blog.authorId))} + isAuthorMuted={Boolean(blog.authorId && mutedSet.has(blog.authorId))} /> )) ) : ( diff --git a/src/components/ui/NewBlogsPage.tsx b/src/components/ui/NewBlogsPage.tsx index 9bdbdea..491d3df 100644 --- a/src/components/ui/NewBlogsPage.tsx +++ b/src/components/ui/NewBlogsPage.tsx @@ -19,9 +19,63 @@ export function NewBlogsPage({ posts }: NewBlogsPageProps) { const [query, setQuery] = useState(''); const [sortBy, setSortBy] = useState<'latest' | 'popular'>('latest'); const [page, setPage] = useState(1); + const [hiddenSlugs, setHiddenSlugs] = useState([]); + const [mutedAuthorIds, setMutedAuthorIds] = useState([]); + const [followedAuthorIds, setFollowedAuthorIds] = useState([]); + const [statusMessage, setStatusMessage] = useState(''); const PAGE_SIZE = 6; + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const readList = (key: string) => { + try { + const raw = window.localStorage.getItem(key); + if (!raw) { + return [] as string[]; + } + + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed)) { + return parsed.filter((value): value is string => typeof value === 'string'); + } + + return [] as string[]; + } catch (error) { + console.warn('Unable to parse personalization list', key, error); + return [] as string[]; + } + }; + + setHiddenSlugs(readList('syntaxBlogs.hiddenSlugs')); + setMutedAuthorIds(readList('syntaxBlogs.mutedAuthors')); + setFollowedAuthorIds(readList('syntaxBlogs.followedAuthors')); + }, []); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + window.localStorage.setItem('syntaxBlogs.hiddenSlugs', JSON.stringify(hiddenSlugs)); + }, [hiddenSlugs]); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + window.localStorage.setItem('syntaxBlogs.mutedAuthors', JSON.stringify(mutedAuthorIds)); + }, [mutedAuthorIds]); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + window.localStorage.setItem('syntaxBlogs.followedAuthors', JSON.stringify(followedAuthorIds)); + }, [followedAuthorIds]); + const derivedCategories = useMemo(() => { const categoryMap = new Map(); @@ -57,6 +111,7 @@ export function NewBlogsPage({ posts }: NewBlogsPageProps) { const publishedAt = post.publishedAt ?? null; return { + id: post.id, slug: post.slug, title: post.title, excerpt: post.excerpt ?? '', @@ -69,10 +124,15 @@ export function NewBlogsPage({ posts }: NewBlogsPageProps) { ? dateFormatter.format(new Date(publishedAt)) : 'Unscheduled', views: post.views ?? 0, + authorId: post.author?.id ?? null, + authorName: post.author?.displayName ?? null, }; }); }, [dateFormatter, posts]); + const hiddenSlugSet = useMemo(() => new Set(hiddenSlugs), [hiddenSlugs]); + const mutedAuthorSet = useMemo(() => new Set(mutedAuthorIds), [mutedAuthorIds]); + const recommendedTopics = useMemo( () => derivedCategories.map((category) => category.label), [derivedCategories], @@ -89,10 +149,12 @@ export function NewBlogsPage({ posts }: NewBlogsPageProps) { normalizedQuery.length === 0 || blog.title.toLowerCase().includes(normalizedQuery) || blog.excerpt.toLowerCase().includes(normalizedQuery); + const isHidden = hiddenSlugSet.has(blog.slug); + const isMuted = Boolean(blog.authorId && mutedAuthorSet.has(blog.authorId)); - return matchesCategory && matchesQuery; + return matchesCategory && matchesQuery && !isHidden && !isMuted; }); - }, [allBlogs, query, selectedCategories]); + }, [allBlogs, hiddenSlugSet, mutedAuthorSet, query, selectedCategories]); const sortedBlogs = useMemo(() => { const copy = [...filteredBlogs]; @@ -138,6 +200,7 @@ export function NewBlogsPage({ posts }: NewBlogsPageProps) { const end = start + PAGE_SIZE; return sortedBlogs.slice(start, end).map((blog) => ({ + id: blog.id, slug: blog.slug, title: blog.title, excerpt: blog.excerpt, @@ -145,9 +208,61 @@ export function NewBlogsPage({ posts }: NewBlogsPageProps) { accentColor: blog.accentColor, dateLabel: blog.dateLabel, views: blog.views, + authorId: blog.authorId, + authorName: blog.authorName, })); }, [page, sortedBlogs]); + const handleHidePost = (blog: BlogGridItem) => { + setHiddenSlugs((previous) => { + if (previous.includes(blog.slug)) { + return previous; + } + return [...previous, blog.slug]; + }); + setStatusMessage(`We'll show fewer stories like "${blog.title}".`); + }; + + const handleToggleFollow = ( + authorId: string, + action: 'follow' | 'unfollow', + authorName: string | null, + ) => { + setFollowedAuthorIds((previous) => { + const next = new Set(previous); + if (action === 'follow') { + next.add(authorId); + setStatusMessage(`You'll see more from ${authorName ?? 'this author'}.`); + } else { + next.delete(authorId); + setStatusMessage(`You will no longer follow ${authorName ?? 'this author'}.`); + } + return Array.from(next); + }); + }; + + const handleToggleMute = ( + authorId: string, + action: 'mute' | 'unmute', + authorName: string | null, + ) => { + setMutedAuthorIds((previous) => { + const next = new Set(previous); + if (action === 'mute') { + next.add(authorId); + setStatusMessage(`${authorName ?? 'This author'} has been muted.`); + } else { + next.delete(authorId); + setStatusMessage(`${authorName ?? 'This author'} has been unmuted.`); + } + return Array.from(next); + }); + + if (action === 'mute') { + setFollowedAuthorIds((previous) => previous.filter((id) => id !== authorId)); + } + }; + const handleToggleCategory = (categorySlug: string) => { setSelectedCategories((previous) => { if (previous.includes(categorySlug)) { @@ -163,6 +278,9 @@ export function NewBlogsPage({ posts }: NewBlogsPageProps) { return (
+ + {statusMessage} +
@@ -210,7 +328,14 @@ export function NewBlogsPage({ posts }: NewBlogsPageProps) { {selectedCategories.length > 0 ? `${selectedCategories.length} topic filter(s)` : 'All topics'}
- +