Skip to content

Commit 44175bc

Browse files
committed
refactor: optimize state management and error handling across components
1 parent 01e3a47 commit 44175bc

File tree

19 files changed

+133
-154
lines changed

19 files changed

+133
-154
lines changed

app/(app)/[username]/[slug]/_linkContentDetail.tsx

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState, useEffect } from "react";
3+
import { useState, useMemo } from "react";
44
import Link from "next/link";
55
import {
66
ArrowTopRightOnSquareIcon,
@@ -66,20 +66,38 @@ const LinkContentDetail = ({ sourceSlug, contentSlug }: Props) => {
6666
{ enabled: !!linkContent?.id },
6767
);
6868

69-
// Vote state management
70-
const [userVote, setUserVote] = useState<"up" | "down" | null>(null);
71-
const [votes, setVotes] = useState({ upvotes: 0, downvotes: 0 });
69+
// Vote state management - derive initial values from query data
70+
const initialVoteState = useMemo(
71+
() => ({
72+
userVote: linkContent?.userVote ?? null,
73+
upvotes: linkContent?.upvotes ?? 0,
74+
downvotes: linkContent?.downvotes ?? 0,
75+
}),
76+
[linkContent?.userVote, linkContent?.upvotes, linkContent?.downvotes],
77+
);
7278

73-
// Initialize vote state when data loads
74-
useEffect(() => {
75-
if (linkContent) {
76-
setUserVote(linkContent.userVote ?? null);
77-
setVotes({
78-
upvotes: linkContent.upvotes,
79-
downvotes: linkContent.downvotes,
80-
});
81-
}
82-
}, [linkContent]);
79+
const [userVote, setUserVote] = useState<"up" | "down" | null>(
80+
initialVoteState.userVote,
81+
);
82+
const [votes, setVotes] = useState({
83+
upvotes: initialVoteState.upvotes,
84+
downvotes: initialVoteState.downvotes,
85+
});
86+
87+
// Sync state when server data changes (e.g., after mutation invalidation)
88+
const currentUserVote = linkContent?.userVote ?? null;
89+
const currentUpvotes = linkContent?.upvotes ?? 0;
90+
const currentDownvotes = linkContent?.downvotes ?? 0;
91+
92+
// Use refs to track if we need to sync
93+
const serverVoteKey = `${currentUserVote}-${currentUpvotes}-${currentDownvotes}`;
94+
const [lastSyncedKey, setLastSyncedKey] = useState(serverVoteKey);
95+
96+
if (serverVoteKey !== lastSyncedKey && linkContent) {
97+
setUserVote(currentUserVote);
98+
setVotes({ upvotes: currentUpvotes, downvotes: currentDownvotes });
99+
setLastSyncedKey(serverVoteKey);
100+
}
83101

84102
const { mutate: vote, status: voteStatus } = api.content.vote.useMutation({
85103
onMutate: async ({ voteType }) => {

app/(app)/[username]/[slug]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
301301
const parseJSON = (str: string): JSONContent | null => {
302302
try {
303303
return JSON.parse(str);
304-
} catch (e) {
304+
} catch {
305305
return null;
306306
}
307307
};

app/(app)/admin/_client.tsx

Lines changed: 62 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -8,68 +8,68 @@ import {
88
RssIcon,
99
ShieldExclamationIcon,
1010
NewspaperIcon,
11-
LinkIcon,
1211
} from "@heroicons/react/24/outline";
1312
import { api } from "@/server/trpc/react";
1413

15-
const AdminDashboard = () => {
16-
const { data: stats, isLoading } = api.admin.getStats.useQuery();
17-
const { data: reportCounts } = api.report.getCounts.useQuery();
18-
19-
const StatCard = ({
20-
title,
21-
value,
22-
icon: Icon,
23-
href,
24-
color = "blue",
25-
}: {
26-
title: string;
27-
value: number | undefined;
28-
icon: React.ComponentType<{ className?: string }>;
29-
href?: string;
30-
color?: "blue" | "green" | "yellow" | "red" | "purple" | "orange";
31-
}) => {
32-
const colorClasses = {
33-
blue: "bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400",
34-
green:
35-
"bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400",
36-
yellow:
37-
"bg-yellow-50 text-yellow-600 dark:bg-yellow-900/30 dark:text-yellow-400",
38-
red: "bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400",
39-
purple:
40-
"bg-purple-50 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400",
41-
orange:
42-
"bg-orange-50 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400",
43-
};
14+
const colorClasses = {
15+
blue: "bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400",
16+
green: "bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400",
17+
yellow:
18+
"bg-yellow-50 text-yellow-600 dark:bg-yellow-900/30 dark:text-yellow-400",
19+
red: "bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400",
20+
purple:
21+
"bg-purple-50 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400",
22+
orange:
23+
"bg-orange-50 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400",
24+
};
4425

45-
const content = (
46-
<div className="rounded-lg border border-neutral-200 bg-white p-4 transition-colors hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600">
47-
<div className="flex items-center gap-3">
48-
<div className={`rounded-lg p-2 ${colorClasses[color]}`}>
49-
<Icon className="h-5 w-5" />
50-
</div>
51-
<div>
52-
<p className="text-sm text-neutral-500 dark:text-neutral-400">
53-
{title}
54-
</p>
55-
<p className="text-2xl font-bold text-neutral-900 dark:text-white">
56-
{isLoading ? (
57-
<span className="inline-block h-8 w-16 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700" />
58-
) : (
59-
(value ?? 0)
60-
)}
61-
</p>
62-
</div>
26+
const StatCard = ({
27+
title,
28+
value,
29+
icon: Icon,
30+
href,
31+
color = "blue",
32+
isLoading,
33+
}: {
34+
title: string;
35+
value: number | undefined;
36+
icon: React.ComponentType<{ className?: string }>;
37+
href?: string;
38+
color?: "blue" | "green" | "yellow" | "red" | "purple" | "orange";
39+
isLoading?: boolean;
40+
}) => {
41+
const content = (
42+
<div className="rounded-lg border border-neutral-200 bg-white p-4 transition-colors hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600">
43+
<div className="flex items-center gap-3">
44+
<div className={`rounded-lg p-2 ${colorClasses[color]}`}>
45+
<Icon className="h-5 w-5" />
46+
</div>
47+
<div>
48+
<p className="text-sm text-neutral-500 dark:text-neutral-400">
49+
{title}
50+
</p>
51+
<p className="text-2xl font-bold text-neutral-900 dark:text-white">
52+
{isLoading ? (
53+
<span className="inline-block h-8 w-16 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700" />
54+
) : (
55+
(value ?? 0)
56+
)}
57+
</p>
6358
</div>
6459
</div>
65-
);
60+
</div>
61+
);
62+
63+
if (href) {
64+
return <Link href={href}>{content}</Link>;
65+
}
6666

67-
if (href) {
68-
return <Link href={href}>{content}</Link>;
69-
}
67+
return content;
68+
};
7069

71-
return content;
72-
};
70+
const AdminDashboard = () => {
71+
const { data: stats, isLoading } = api.admin.getStats.useQuery();
72+
const { data: reportCounts } = api.report.getCounts.useQuery();
7373

7474
return (
7575
<div className="mx-auto max-w-6xl px-4 py-8">
@@ -90,25 +90,29 @@ const AdminDashboard = () => {
9090
icon={UsersIcon}
9191
color="blue"
9292
href="/admin/users"
93+
isLoading={isLoading}
9394
/>
9495
<StatCard
9596
title="Published Posts"
9697
value={stats?.publishedPosts}
9798
icon={DocumentTextIcon}
9899
color="green"
100+
isLoading={isLoading}
99101
/>
100102
<StatCard
101103
title="Aggregated Articles"
102104
value={stats?.aggregatedArticles}
103105
icon={NewspaperIcon}
104106
color="purple"
107+
isLoading={isLoading}
105108
/>
106109
<StatCard
107110
title="Active Feed Sources"
108111
value={stats?.activeFeedSources}
109112
icon={RssIcon}
110113
color="orange"
111114
href="/admin/sources"
115+
isLoading={isLoading}
112116
/>
113117
</div>
114118

@@ -124,25 +128,29 @@ const AdminDashboard = () => {
124128
icon={FlagIcon}
125129
color="yellow"
126130
href="/admin/moderation"
131+
isLoading={isLoading}
127132
/>
128133
<StatCard
129134
title="Actioned Reports"
130135
value={reportCounts?.actioned}
131136
icon={ShieldExclamationIcon}
132137
color="red"
138+
isLoading={isLoading}
133139
/>
134140
<StatCard
135141
title="Banned Users"
136142
value={stats?.bannedUsers}
137143
icon={ShieldExclamationIcon}
138144
color="red"
139145
href="/admin/users?filter=banned"
146+
isLoading={isLoading}
140147
/>
141148
<StatCard
142149
title="Dismissed Reports"
143150
value={reportCounts?.dismissed}
144151
icon={FlagIcon}
145152
color="green"
153+
isLoading={isLoading}
146154
/>
147155
</div>
148156
</div>

app/(app)/feed/_client.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { Fragment, useEffect } from "react";
44
import { useInView } from "react-intersection-observer";
55
import { useSearchParams, useRouter } from "next/navigation";
6+
import Link from "next/link";
67
import { api } from "@/server/trpc/react";
78
import { useSession } from "next-auth/react";
89
import { FeedItemLoading, FeedFilters } from "@/components/Feed";
@@ -302,12 +303,12 @@ const SavedArticlesPreview = () => {
302303
/>
303304
))}
304305
{data.items.length > 3 && (
305-
<a
306+
<Link
306307
href="/saved"
307308
className="block text-center text-sm text-orange-600 hover:text-orange-500 dark:text-orange-400"
308309
>
309310
View all saved
310-
</a>
311+
</Link>
311312
)}
312313
</div>
313314
);

components/Discussion/DiscussionArea.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import React, { useEffect, useState } from "react";
3+
import React, { useState } from "react";
44
import {
55
Menu,
66
MenuButton,
@@ -39,7 +39,6 @@ const DiscussionArea = ({ contentId, noWrapper = false }: Props) => {
3939
const [showCommentBoxId, setShowCommentBoxId] = useState<string | null>(null);
4040
const [editCommentBoxId, setEditCommentBoxId] = useState<string | null>(null);
4141
const [editContent, setEditContent] = useState<string>("");
42-
const [initiallyLoaded, setInitiallyLoaded] = useState<boolean>(false);
4342
const [sortOrder, setSortOrder] = useState<SortOrder>("top");
4443

4544
const { data: session } = useSession();
@@ -114,12 +113,8 @@ const DiscussionArea = ({ contentId, noWrapper = false }: Props) => {
114113
return sorted as typeof items;
115114
};
116115

117-
useEffect(() => {
118-
if (initiallyLoaded) {
119-
return;
120-
}
121-
setInitiallyLoaded(true);
122-
}, [discussionStatus, initiallyLoaded]);
116+
// Derive initial load state from query status - data exists means loaded at least once
117+
const initiallyLoaded = discussionStatus === "success" || !!discussions;
123118

124119
const handleCreateComment = async (body: string, parentId?: string) => {
125120
// validate markdoc syntax

components/Discussion/DiscussionEditor/Editor.tsx

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState, useEffect } from "react";
3+
import { useState } from "react";
44
import { EditorContent } from "@tiptap/react";
55
import TextareaAutosize from "react-textarea-autosize";
66
import { InformationCircleIcon } from "@heroicons/react/20/solid";
@@ -40,13 +40,6 @@ export function DiscussionEditor({
4040
onSubmit,
4141
});
4242

43-
// Reset toolbar visibility when editor collapses so it opens clean
44-
useEffect(() => {
45-
if (!isExpanded) {
46-
setShowToolbar(false);
47-
}
48-
}, [isExpanded]);
49-
5043
// Collapsed state
5144
if (!isExpanded) {
5245
return (

components/ReportModal/ReportModal.tsx

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import React, { useRef, useState, useEffect, useCallback } from "react";
3+
import React, { useRef, useState, useCallback } from "react";
44
import { XMarkIcon, FlagIcon } from "@heroicons/react/20/solid";
55
import { toast } from "sonner";
66
import { signIn, useSession } from "next-auth/react";
@@ -93,16 +93,22 @@ export function useReportModal() {
9393
// Global modal component that reads from URL
9494
export function ReportModalProvider() {
9595
const { data: session } = useSession();
96-
const { isOpen, reportData, closeReport } = useReportModal();
96+
const { isOpen, reportData, closeReport: closeReportUrl } = useReportModal();
9797
const [reportBody, setReportBody] = useState("");
9898
const [loading, setLoading] = useState(false);
9999
const textAreaRef = useRef<HTMLTextAreaElement>(null);
100100

101+
// Wrap closeReport to also reset form state
102+
const closeReport = useCallback(() => {
103+
closeReportUrl();
104+
setReportBody("");
105+
setLoading(false);
106+
}, [closeReportUrl]);
107+
101108
const { mutate: sendReport } = api.report.send.useMutation({
102109
onSuccess: () => {
103110
toast.success("Report submitted successfully");
104111
closeReport();
105-
setReportBody("");
106112
},
107113
onError: () => {
108114
toast.error("Failed to submit report. Please try again.");
@@ -117,7 +123,6 @@ export function ReportModalProvider() {
117123
onSuccess: () => {
118124
toast.success("Report submitted successfully");
119125
closeReport();
120-
setReportBody("");
121126
},
122127
onError: (error) => {
123128
if (error.message === "You have already reported this item") {
@@ -131,14 +136,6 @@ export function ReportModalProvider() {
131136
},
132137
});
133138

134-
// Reset form when modal closes
135-
useEffect(() => {
136-
if (!isOpen) {
137-
setReportBody("");
138-
setLoading(false);
139-
}
140-
}, [isOpen]);
141-
142139
const handleSubmit = async (e: React.FormEvent) => {
143140
e.preventDefault();
144141
if (loading || !reportData) return;

server/api/router/content.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
comments,
3030
} from "@/server/db/schema";
3131
import { and, eq, desc, lt, lte, gt, sql, isNotNull, count } from "drizzle-orm";
32-
import { increment, decrement } from "./utils";
32+
import { increment } from "./utils";
3333
import crypto from "crypto";
3434

3535
// Helper to generate slug from title

0 commit comments

Comments
 (0)