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
52 changes: 47 additions & 5 deletions app/api/ai/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,57 @@ export const POST = async (
},
);

const data: OpenRouterResponse = await response.json();
if (!response.ok) {
let errorText = "";
try {
errorText = await response.text();
} catch {
// ignore body parsing errors for non-OK responses
}
const status =
response.status >= 400 && response.status <= 599
? response.status
: 502;
return NextResponse.json<AIErrorResponse>(
{
error:
errorText ||
`Upstream OpenRouter error: ${response.status} ${response.statusText}`,
},
{ status },
);
}

let rawData: unknown;
try {
rawData = await response.json();
} catch {
return NextResponse.json<AIErrorResponse>(
{ error: "Failed to parse response from AI provider" },
{ status: 502 },
);
}

const data = rawData as Partial<OpenRouterResponse>;
const choice = data.choices?.[0];
const messageContent =
choice?.message && typeof choice.message.content === "string"
? choice.message.content
: undefined;

if (typeof data.created !== "number" || !messageContent) {
return NextResponse.json<AIErrorResponse>(
{ error: "Invalid response from AI provider" },
{ status: 502 },
);
}

return NextResponse.json(
{
created: data.created,
content: data.choices[0].message.content,
},
{
status: 200,
content: messageContent,
},
{ status: 200 },
);
} catch (error) {
if (error instanceof ZodError) {
Expand Down
3 changes: 1 addition & 2 deletions app/wiki/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ export default async function ViewArticlePage({
}: ViewArticlePageProps) {
const { id } = await params;
const user = await stackServerApp.getUser({ or: "redirect" });
// Mock permission check - in a real app, this would come from auth/user context
const userId = user?.id ?? "mockUserId";
const userId = user.id;
const [article, canEdit] = await Promise.all([
getArticleByIdFromDB(id),
authorizeUserToEditArticle(userId, id),
Expand Down
2 changes: 1 addition & 1 deletion components/features/navbar/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const Navbar: React.FC = async () => {
{user ? (
<>
<NavigationMenuItem>
<LinkButton href={Routes.ARTICLES} text="New Article" />
<LinkButton href={Routes.NEW_ARTICLE} text="New Article" />
</NavigationMenuItem>
<NavigationMenuItem>
<UserButton />
Expand Down
53 changes: 39 additions & 14 deletions components/features/wikicards/wiki-article-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,22 +56,49 @@ const WikiArticleViewer: React.FC<WikiArticleViewerProps> = ({
const [summary, setSummary] = React.useState<string | null>(null);
const [isSummarizing, setIsSummarizing] = React.useState(false);
const articleRef = React.useRef<HTMLDivElement>(null);
const handleCopyText = () => {
const abortControllerRef = React.useRef<AbortController | null>(null);

useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);

const handleCopyText = async () => {
if (summary) {
navigator.clipboard.writeText(summary);
toast.success("Summary copied to clipboard!", {
position: "bottom-left",
duration: 2500,
});
if (!navigator?.clipboard?.writeText) {
toast.error("Copy to clipboard is not supported in this browser.", {
position: "bottom-left",
duration: 2500,
});
return;
}
try {
await navigator.clipboard.writeText(summary);
toast.success("Summary copied to clipboard!", {
position: "bottom-left",
duration: 2500,
});
} catch {
toast.error("Failed to copy summary to clipboard.", {
position: "bottom-left",
duration: 2500,
});
}
}
};
useEffect(() => {
const sessionKey = `pageview_counted_${article.id}`;
if (sessionStorage.getItem(sessionKey)) {
return;
}
let isMounted = true;

const fetchPageView = async () => {
const newCount = await incrementPageViews(article.id);
if (isMounted) {
setLocalPageViews(newCount);
sessionStorage.setItem(sessionKey, "1");
Comment on lines 96 to +101
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The page-view dedup key is only written to sessionStorage after incrementPageViews() resolves. If the server action increments successfully but the client request fails/aborts before the response is received, the key won’t be set and a refresh/back-nav can increment again (still causing inflated counts / milestone email spam). Set the session key before starting the increment request and wrap the call in try/catch (optionally clearing the key on failure) to ensure dedup holds even under network errors.

Suggested change
const fetchPageView = async () => {
const newCount = await incrementPageViews(article.id);
if (isMounted) {
setLocalPageViews(newCount);
sessionStorage.setItem(sessionKey, "1");
// Mark this article's page view as counted for this session before starting the request
sessionStorage.setItem(sessionKey, "1");
const fetchPageView = async () => {
try {
const newCount = await incrementPageViews(article.id);
if (isMounted) {
setLocalPageViews(newCount);
}
} catch (error) {
// Clear the key on failure so a later navigation can retry
sessionStorage.removeItem(sessionKey);

Copilot uses AI. Check for mistakes.
}
};

Expand All @@ -87,7 +114,9 @@ const WikiArticleViewer: React.FC<WikiArticleViewerProps> = ({
}
}, [summary]);
const handleSummarizeArticle = async () => {
const controller = new AbortController(); // ✅ create controller
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;

try {
setIsSummarizing(true);
Expand All @@ -102,12 +131,12 @@ const WikiArticleViewer: React.FC<WikiArticleViewerProps> = ({
content: article.content,
},
}),
signal: controller.signal, // ✅ attach signal to fetch
signal: controller.signal,
});

if (!response.ok) {
const err = await response.json();
throw new Error(err.error); // ✅ now caught by catch block
throw new Error(err.error);
}

const data: AISuccessResponse = await response.json();
Expand All @@ -123,7 +152,6 @@ const WikiArticleViewer: React.FC<WikiArticleViewerProps> = ({
} finally {
setIsSummarizing(false);
}
return () => controller.abort(); // ✅ return cleanup function to abort on unmount
};

return (
Expand Down Expand Up @@ -197,7 +225,7 @@ const WikiArticleViewer: React.FC<WikiArticleViewerProps> = ({

{/* Article Content */}
<Card>
<CardContent className="pt-20 relative max-h-150 overflow-auto">
<CardContent className="pt-20 relative max-h-[600px] overflow-auto">
{/* Article Image - Display if exists */}
<Tooltip>
<TooltipTrigger
Expand Down Expand Up @@ -367,9 +395,6 @@ const WikiArticleViewer: React.FC<WikiArticleViewerProps> = ({
hideCursorWhileTyping={true}
cursorCharacter="_"
deletingSpeed={50}
variableSpeedEnabled={false}
variableSpeedMin={60}
variableSpeedMax={120}
cursorBlinkDuration={0.5}
variableSpeed={undefined}
onSentenceComplete={undefined}
Expand Down
4 changes: 3 additions & 1 deletion components/features/wikicards/wiki-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@ const WikiEditor: React.FC<WikiEditorProps> = ({
variant={"default"}
render={(props) => (
<Link
href={!isEditing ? Routes.HOME : `/wiki/${articleId}`}
href={
!isEditing || !articleId ? Routes.HOME : `/wiki/${articleId}`
}
{...props}
/>
)}
Expand Down
2 changes: 2 additions & 0 deletions components/ui/shiny-text.jsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import {
motion,
useAnimationFrame,
Expand Down
4 changes: 3 additions & 1 deletion components/ui/text-type.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ const TextType = ({

setCurrentTextIndex((prev) => (prev + 1) % textArray.length);
setCurrentCharIndex(0);
timeout = setTimeout(() => {}, pauseDuration);
timeout = setTimeout(() => {
executeTypingAnimation();
}, pauseDuration);
Comment on lines 111 to +115
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new setTimeout(() => executeTypingAnimation(), pauseDuration) is very likely cleared immediately by the effect cleanup when setIsDeleting(false) / setCurrentTextIndex triggers a re-render, so the inter-sentence pause still won’t be observed. Additionally, the scheduled callback closes over stale isDeleting/displayedText values. Consider implementing the pause by delaying the state transition to the next sentence (or using a ref-based timer that isn’t cleared on the next render) rather than calling executeTypingAnimation() from the old closure.

Copilot uses AI. Check for mistakes.
} else {
timeout = setTimeout(() => {
setDisplayedText((prev) => prev.slice(0, -1));
Expand Down
Loading