From 9c51d84f7bea76b50893c01963cc130980f96b7c Mon Sep 17 00:00:00 2001 From: halilibrahimcelik Date: Fri, 20 Feb 2026 00:25:59 +0000 Subject: [PATCH 01/14] feat: refactor WikiArticleViewer to simplify page view tracking; add "Go Back" button in WikiEditor and update dependencies --- .../wikicards/wiki-article-viewer.tsx | 18 -------------- components/features/wikicards/wiki-card.tsx | 2 +- components/features/wikicards/wiki-editor.tsx | 24 +++++++++++++------ package.json | 1 + pnpm-lock.yaml | 10 ++++++++ 5 files changed, 29 insertions(+), 26 deletions(-) diff --git a/components/features/wikicards/wiki-article-viewer.tsx b/components/features/wikicards/wiki-article-viewer.tsx index d89327e..c136b0d 100644 --- a/components/features/wikicards/wiki-article-viewer.tsx +++ b/components/features/wikicards/wiki-article-viewer.tsx @@ -36,28 +36,10 @@ const WikiArticleViewer: React.FC = ({ pageviews ?? 0, ); - console.log("Initial page views:", pageviews); useEffect(() => { - const storageKey = `wiki-article-viewed-${article.id}`; - - // Only increment page views once per session for this article - const hasWindow = typeof window !== "undefined"; - const hasSessionStorage = - hasWindow && typeof window.sessionStorage !== "undefined"; - const alreadyViewed = - hasSessionStorage && window.sessionStorage.getItem(storageKey) === "true"; - - if (alreadyViewed) { - return; - } - const fetchPageView = async () => { const newCount = await incrementPageViews(article.id); setLocalPageViews(newCount); - - if (hasSessionStorage) { - window.sessionStorage.setItem(storageKey, "true"); - } }; fetchPageView(); }, [article.id]); diff --git a/components/features/wikicards/wiki-card.tsx b/components/features/wikicards/wiki-card.tsx index 8977834..94332da 100644 --- a/components/features/wikicards/wiki-card.tsx +++ b/components/features/wikicards/wiki-card.tsx @@ -28,7 +28,7 @@ const WikiCard: React.FC = ({ {title} - + {summary} diff --git a/components/features/wikicards/wiki-editor.tsx b/components/features/wikicards/wiki-editor.tsx index 95bc920..f50372e 100644 --- a/components/features/wikicards/wiki-editor.tsx +++ b/components/features/wikicards/wiki-editor.tsx @@ -16,6 +16,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import Link from "next/link"; interface WikiEditorProps { initialTitle?: string; @@ -135,13 +136,22 @@ const WikiEditor: React.FC = ({ return (
-
-

{pageTitle}

- {isEditing && articleId && ( -

- Editing article ID: {articleId} -

- )} +
+
+

{pageTitle}

+ {isEditing && articleId && ( +

+ Editing article ID: {articleId} +

+ )} +
+ +
diff --git a/package.json b/package.json index 6f3413a..204661e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@aws-sdk/client-s3": "^3.993.0", "@base-ui/react": "^1.2.0", "@neondatabase/serverless": "^1.0.2", + "@openrouter/sdk": "^0.8.0", "@react-email/render": "^2.0.4", "@reduxjs/toolkit": "^2.11.2", "@stackframe/stack": "^2.8.67", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc5470a..bcd5b03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@neondatabase/serverless': specifier: ^1.0.2 version: 1.0.2 + '@openrouter/sdk': + specifier: ^0.8.0 + version: 0.8.0 '@react-email/render': specifier: ^2.0.4 version: 2.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1353,6 +1356,9 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@openrouter/sdk@0.8.0': + resolution: {integrity: sha512-hmCY5CE/adBmwZmrwuSx7Pw5UngcyuA9FvgpouyWEX8m283omLD1BqJlYYkLdVoUdI8iGNFHKezH2CeJqsVymw==} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -6482,6 +6488,10 @@ snapshots: '@open-draft/until@2.1.0': {} + '@openrouter/sdk@0.8.0': + dependencies: + zod: 3.25.76 + '@opentelemetry/api@1.9.0': {} '@oslojs/asn1@1.0.0': From 55d27ac57d1c957088bb0d6681756bd7ff49459b Mon Sep 17 00:00:00 2001 From: halilibrahimcelik Date: Fri, 20 Feb 2026 14:07:56 +0000 Subject: [PATCH 02/14] feat: add LinkButton component for navigation; refactor Navbar and remove unused SignUp button --- app/api/ai/route.ts | 59 +++++++++++++++++++ components/features/navbar/navbar.tsx | 9 ++- components/features/navbar/signup-button.tsx | 21 ------- .../features/wikicards/articles-list.tsx | 26 +++++--- components/ui/button.tsx | 1 - components/ui/link-button.tsx | 27 +++++++++ types/index.ts | 1 + 7 files changed, 113 insertions(+), 31 deletions(-) create mode 100644 app/api/ai/route.ts delete mode 100644 components/features/navbar/signup-button.tsx create mode 100644 components/ui/link-button.tsx diff --git a/app/api/ai/route.ts b/app/api/ai/route.ts new file mode 100644 index 0000000..a5a9500 --- /dev/null +++ b/app/api/ai/route.ts @@ -0,0 +1,59 @@ +"use server"; +import { NextRequest, NextResponse } from "next/server"; + +export const POST = async (request: NextRequest) => { + const { prompt } = await request.json(); + try { + const response = await fetch( + "https://openrouter.ai/api/v1/chat/completions", + { + method: "POST", + headers: { + Authorization: "Bearer ", + "HTTP-Referer": "", // Optional. Site URL for rankings on openrouter.ai. + "X-Title": "", // Optional. Site title for rankings on openrouter.ai. + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "openai/gpt-5-nano", + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: "What is in this image?", + }, + { + type: "image_url", + image_url: { + url: "https://live.staticflickr.com/3851/14825276609_098cac593d_b.jpg", + }, + }, + ], + }, + ], + }), + }, + ); + console.log(response.json()); + return NextResponse.json( + { + message: `You sent: ${prompt}`, + }, + { + status: 200, + }, + ); + } catch (error) { + console.error("API Error:", error); + return NextResponse.json( + { + error: "Failed to process the prompt", + }, + { + status: 500, + }, + ); + } +}; diff --git a/components/features/navbar/navbar.tsx b/components/features/navbar/navbar.tsx index f3c0f97..bffbda4 100644 --- a/components/features/navbar/navbar.tsx +++ b/components/features/navbar/navbar.tsx @@ -8,6 +8,8 @@ import { import { stackServerApp } from "@/stack/server"; import { SignInButton } from "./signin-button"; import { SignOutButton } from "./signup-button"; +import LinkButton from "@/components/ui/link-button"; +import { Routes } from "@/types"; export const Navbar: React.FC = async () => { const user = await stackServerApp.getUser(); @@ -26,6 +28,9 @@ export const Navbar: React.FC = async () => { + + + {user ? ( @@ -33,10 +38,10 @@ export const Navbar: React.FC = async () => { ) : ( <> - + - + )} diff --git a/components/features/navbar/signup-button.tsx b/components/features/navbar/signup-button.tsx deleted file mode 100644 index c8e6f77..0000000 --- a/components/features/navbar/signup-button.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; -import Link from "next/link"; -import { Button, ButtonProps } from "@/components/ui/button"; -import { Routes } from "@/types"; - -interface SignOutButtonProps extends ButtonProps {} -export const SignOutButton: React.FC = ({ - ...buttonProps -}) => { - return ( - - ); -}; diff --git a/components/features/wikicards/articles-list.tsx b/components/features/wikicards/articles-list.tsx index 2721768..1c91cd7 100644 --- a/components/features/wikicards/articles-list.tsx +++ b/components/features/wikicards/articles-list.tsx @@ -13,6 +13,24 @@ type Props = { export function ArticlesList({ serverData }: Props) { // Skip RTK Query if we have server data + const handleTestPrompt = () => { + fetch("/api/ai", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ prompt: "Hello, AI!" }), + }) + .then((response) => response.json()) + .then((data) => { + console.log("AI Response:", data); + alert(`AI Response: ${data.message}`); + }) + .catch((error) => { + console.error("Error:", error); + alert("Failed to get AI response"); + }); + }; const { data: clientArticles, isLoading, @@ -51,15 +69,9 @@ export function ArticlesList({ serverData }: Props) { return (
+

All Articles

- -
{articles.map((article) => ( diff --git a/components/ui/button.tsx b/components/ui/button.tsx index c69d7fc..5fe632a 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -52,7 +52,6 @@ function Button({ }: ButtonProps) { return ( = ({ + href, + text, + ...buttonProps +}) => { + return ( + + ); +}; + +export default LinkButton; diff --git a/types/index.ts b/types/index.ts index fcc12fb..cc66988 100644 --- a/types/index.ts +++ b/types/index.ts @@ -2,4 +2,5 @@ export enum Routes { SIGNIN = "/handler/signin", SIGNUP = "/handler/signup", SIGNOUT = "/handler/signout", + ARTICLES = "/wiki/edit/new", } From a8d61d4bb8c14ad5eaf267ca33f507503de4b2c4 Mon Sep 17 00:00:00 2001 From: halilibrahimcelik Date: Fri, 20 Feb 2026 14:09:27 +0000 Subject: [PATCH 03/14] feat: refactor Navbar component; remove SignInButton and clean up imports in ArticlesList and WikiEditor --- components/features/navbar/navbar.tsx | 4 +--- components/features/navbar/signin-button.tsx | 21 ------------------- .../features/wikicards/articles-list.tsx | 2 -- components/features/wikicards/wiki-editor.tsx | 2 +- 4 files changed, 2 insertions(+), 27 deletions(-) delete mode 100644 components/features/navbar/signin-button.tsx diff --git a/components/features/navbar/navbar.tsx b/components/features/navbar/navbar.tsx index bffbda4..b720771 100644 --- a/components/features/navbar/navbar.tsx +++ b/components/features/navbar/navbar.tsx @@ -1,14 +1,12 @@ import { UserButton } from "@stackframe/stack"; import Link from "next/link"; +import LinkButton from "@/components/ui/link-button"; import { NavigationMenu, NavigationMenuItem, NavigationMenuList, } from "@/components/ui/navigation-menu"; import { stackServerApp } from "@/stack/server"; -import { SignInButton } from "./signin-button"; -import { SignOutButton } from "./signup-button"; -import LinkButton from "@/components/ui/link-button"; import { Routes } from "@/types"; export const Navbar: React.FC = async () => { diff --git a/components/features/navbar/signin-button.tsx b/components/features/navbar/signin-button.tsx deleted file mode 100644 index 45b3b1a..0000000 --- a/components/features/navbar/signin-button.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; -import Link from "next/link"; -import { Button, ButtonProps } from "@/components/ui/button"; -import { Routes } from "@/types"; - -interface SignInButtonProps extends ButtonProps {} -export const SignInButton: React.FC = ({ - ...buttonProps -}) => { - return ( - - ); -}; diff --git a/components/features/wikicards/articles-list.tsx b/components/features/wikicards/articles-list.tsx index 1c91cd7..5031cf8 100644 --- a/components/features/wikicards/articles-list.tsx +++ b/components/features/wikicards/articles-list.tsx @@ -1,6 +1,4 @@ "use client"; - -import Link from "next/link"; import { useMemo } from "react"; import { Button } from "@/components/ui/button"; import { useGetArticlesQuery } from "@/lib/redux/features/articles/articlesApiSlice"; diff --git a/components/features/wikicards/wiki-editor.tsx b/components/features/wikicards/wiki-editor.tsx index f50372e..ae43b24 100644 --- a/components/features/wikicards/wiki-editor.tsx +++ b/components/features/wikicards/wiki-editor.tsx @@ -3,6 +3,7 @@ import { useUser } from "@stackframe/stack"; import MDEditor from "@uiw/react-md-editor"; import { Upload, X } from "lucide-react"; +import Link from "next/link"; import type React from "react"; import { useState } from "react"; import { @@ -16,7 +17,6 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import Link from "next/link"; interface WikiEditorProps { initialTitle?: string; From 80a34195a9014dd0fa66b97b7317b26f4c688def Mon Sep 17 00:00:00 2001 From: halilibrahimcelik Date: Fri, 20 Feb 2026 14:46:18 +0000 Subject: [PATCH 04/14] feat: enhance Navbar and Articles components; add ShinyText component and update dependencies --- components.json | 8 +- components/features/navbar/index.ts | 2 - components/features/navbar/navbar.tsx | 14 +- .../features/wikicards/articles-list.tsx | 21 +-- .../wikicards/wiki-article-viewer.tsx | 56 ++++---- components/features/wikicards/wiki-editor.tsx | 9 +- components/ui/shiny-text.jsx | 124 ++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 60 +++++++++ types/index.ts | 1 + 10 files changed, 237 insertions(+), 59 deletions(-) create mode 100644 components/ui/shiny-text.jsx diff --git a/components.json b/components.json index c588d33..cebf8c6 100644 --- a/components.json +++ b/components.json @@ -12,6 +12,8 @@ }, "iconLibrary": "tabler", "rtl": false, + "menuColor": "default", + "menuAccent": "bold", "aliases": { "components": "@/components", "utils": "@/lib/utils", @@ -19,7 +21,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "menuColor": "default", - "menuAccent": "bold", - "registries": {} + "registries": { + "@react-bits": "https://reactbits.dev/r/{name}.json" + } } diff --git a/components/features/navbar/index.ts b/components/features/navbar/index.ts index 031c735..4f09288 100644 --- a/components/features/navbar/index.ts +++ b/components/features/navbar/index.ts @@ -1,3 +1 @@ export { Navbar } from "./navbar"; -export { SignInButton } from "./signin-button"; -export { SignOutButton } from "./signup-button"; diff --git a/components/features/navbar/navbar.tsx b/components/features/navbar/navbar.tsx index b720771..2ccb9d5 100644 --- a/components/features/navbar/navbar.tsx +++ b/components/features/navbar/navbar.tsx @@ -26,13 +26,15 @@ export const Navbar: React.FC = async () => { - - - {user ? ( - - - + <> + + + + + + + ) : ( <> diff --git a/components/features/wikicards/articles-list.tsx b/components/features/wikicards/articles-list.tsx index 5031cf8..f9a2423 100644 --- a/components/features/wikicards/articles-list.tsx +++ b/components/features/wikicards/articles-list.tsx @@ -1,6 +1,5 @@ "use client"; import { useMemo } from "react"; -import { Button } from "@/components/ui/button"; import { useGetArticlesQuery } from "@/lib/redux/features/articles/articlesApiSlice"; import type { ArticleWikiData } from "@/types/api"; import WikiCard from "./wiki-card"; @@ -11,24 +10,7 @@ type Props = { export function ArticlesList({ serverData }: Props) { // Skip RTK Query if we have server data - const handleTestPrompt = () => { - fetch("/api/ai", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ prompt: "Hello, AI!" }), - }) - .then((response) => response.json()) - .then((data) => { - console.log("AI Response:", data); - alert(`AI Response: ${data.message}`); - }) - .catch((error) => { - console.error("Error:", error); - alert("Failed to get AI response"); - }); - }; + const { data: clientArticles, isLoading, @@ -67,7 +49,6 @@ export function ArticlesList({ serverData }: Props) { return (
-

All Articles

diff --git a/components/features/wikicards/wiki-article-viewer.tsx b/components/features/wikicards/wiki-article-viewer.tsx index c136b0d..d660a41 100644 --- a/components/features/wikicards/wiki-article-viewer.tsx +++ b/components/features/wikicards/wiki-article-viewer.tsx @@ -20,6 +20,8 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { formatDate } from "@/lib/utils"; import { ArticleWikiData } from "@/types/api"; +import ShinyText from "@/components/ui/shiny-text"; +import { Routes } from "@/types"; interface WikiArticleViewerProps { article: ArticleWikiData; @@ -115,8 +117,27 @@ const WikiArticleViewer: React.FC = ({ {/* Article Content */} - + {/* Article Image - Display if exists */} + {article.imageUrl && (
@@ -228,32 +249,13 @@ const WikiArticleViewer: React.FC = ({ {/* Footer Actions */}
- - - - - {canEdit && ( -
- - - - - - - - -
- )} +
); diff --git a/components/features/wikicards/wiki-editor.tsx b/components/features/wikicards/wiki-editor.tsx index ae43b24..1a90a29 100644 --- a/components/features/wikicards/wiki-editor.tsx +++ b/components/features/wikicards/wiki-editor.tsx @@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Routes } from "@/types"; interface WikiEditorProps { initialTitle?: string; @@ -147,8 +148,14 @@ const WikiEditor: React.FC = ({
diff --git a/components/ui/shiny-text.jsx b/components/ui/shiny-text.jsx new file mode 100644 index 0000000..6f4ab76 --- /dev/null +++ b/components/ui/shiny-text.jsx @@ -0,0 +1,124 @@ +import { + motion, + useAnimationFrame, + useMotionValue, + useTransform, +} from "motion/react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +const ShinyText = ({ + text, + disabled = false, + speed = 2, + className = "", + color = "#b5b5b5", + shineColor = "#ffffff", + spread = 120, + yoyo = false, + pauseOnHover = false, + direction = "left", + delay = 0, +}) => { + const [isPaused, setIsPaused] = useState(false); + const progress = useMotionValue(0); + const elapsedRef = useRef(0); + const lastTimeRef = useRef(null); + const directionRef = useRef(direction === "left" ? 1 : -1); + + const animationDuration = speed * 1000; + const delayDuration = delay * 1000; + + useAnimationFrame((time) => { + if (disabled || isPaused) { + lastTimeRef.current = null; + return; + } + + if (lastTimeRef.current === null) { + lastTimeRef.current = time; + return; + } + + const deltaTime = time - lastTimeRef.current; + lastTimeRef.current = time; + + elapsedRef.current += deltaTime; + + // Animation goes from 0 to 100 + if (yoyo) { + const cycleDuration = animationDuration + delayDuration; + const fullCycle = cycleDuration * 2; + const cycleTime = elapsedRef.current % fullCycle; + + if (cycleTime < animationDuration) { + // Forward animation: 0 -> 100 + const p = (cycleTime / animationDuration) * 100; + progress.set(directionRef.current === 1 ? p : 100 - p); + } else if (cycleTime < cycleDuration) { + // Delay at end + progress.set(directionRef.current === 1 ? 100 : 0); + } else if (cycleTime < cycleDuration + animationDuration) { + // Reverse animation: 100 -> 0 + const reverseTime = cycleTime - cycleDuration; + const p = 100 - (reverseTime / animationDuration) * 100; + progress.set(directionRef.current === 1 ? p : 100 - p); + } else { + // Delay at start + progress.set(directionRef.current === 1 ? 0 : 100); + } + } else { + const cycleDuration = animationDuration + delayDuration; + const cycleTime = elapsedRef.current % cycleDuration; + + if (cycleTime < animationDuration) { + // Animation phase: 0 -> 100 + const p = (cycleTime / animationDuration) * 100; + progress.set(directionRef.current === 1 ? p : 100 - p); + } else { + // Delay phase - hold at end (shine off-screen) + progress.set(directionRef.current === 1 ? 100 : 0); + } + } + }); + + useEffect(() => { + directionRef.current = direction === "left" ? 1 : -1; + elapsedRef.current = 0; + progress.set(0); + }, [direction, progress]); + + // Transform: p=0 -> 150% (shine off right), p=100 -> -50% (shine off left) + const backgroundPosition = useTransform( + progress, + (p) => `${150 - p * 2}% center`, + ); + + const handleMouseEnter = useCallback(() => { + if (pauseOnHover) setIsPaused(true); + }, [pauseOnHover]); + + const handleMouseLeave = useCallback(() => { + if (pauseOnHover) setIsPaused(false); + }, [pauseOnHover]); + + const gradientStyle = { + backgroundImage: `linear-gradient(${spread}deg, ${color} 0%, ${color} 35%, ${shineColor} 50%, ${color} 65%, ${color} 100%)`, + backgroundSize: "200% auto", + WebkitBackgroundClip: "text", + backgroundClip: "text", + WebkitTextFillColor: "transparent", + }; + + return ( + + {text} + + ); +}; + +export default ShinyText; diff --git a/package.json b/package.json index 204661e..afe63d6 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "drizzle-orm": "^0.45.1", "drizzle-seed": "^0.3.1", "lucide-react": "^0.564.0", + "motion": "^12.34.3", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcd5b03..89a8a2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: lucide-react: specifier: ^0.564.0 version: 0.564.0(react@19.2.3) + motion: + specifier: ^12.34.3 + version: 12.34.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next: specifier: 16.1.6 version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -3285,6 +3288,20 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + framer-motion@12.34.3: + resolution: {integrity: sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -3955,6 +3972,26 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + motion-dom@12.34.3: + resolution: {integrity: sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==} + + motion-utils@12.29.2: + resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} + + motion@12.34.3: + resolution: {integrity: sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -8628,6 +8665,15 @@ snapshots: forwarded@0.2.0: {} + framer-motion@12.34.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + motion-dom: 12.34.3 + motion-utils: 12.29.2 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + fresh@2.0.0: {} fs-extra@11.3.3: @@ -9499,6 +9545,20 @@ snapshots: minipass@7.1.2: {} + motion-dom@12.34.3: + dependencies: + motion-utils: 12.29.2 + + motion-utils@12.29.2: {} + + motion@12.34.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + framer-motion: 12.34.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + ms@2.1.3: {} msw@2.12.10(@types/node@20.19.33)(typescript@5.9.3): diff --git a/types/index.ts b/types/index.ts index cc66988..5d98172 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,4 +1,5 @@ export enum Routes { + HOME = "/", SIGNIN = "/handler/signin", SIGNUP = "/handler/signup", SIGNOUT = "/handler/signout", From 69eb0d01a0f3e1c692e488d3300ab0aa030f7e8b Mon Sep 17 00:00:00 2001 From: halilibrahimcelik Date: Sat, 21 Feb 2026 16:40:23 +0000 Subject: [PATCH 05/14] feat: implement article summarization feature in WikiArticleViewer; add TextType component for animated text display; update dependencies --- .vscode/settings.json | 6 +- app/api/ai/route.ts | 71 +++++-- .../wikicards/wiki-article-viewer.tsx | 102 ++++++++- components/ui/text-type.jsx | 193 ++++++++++++++++++ package.json | 4 +- pnpm-lock.yaml | 18 +- 6 files changed, 370 insertions(+), 24 deletions(-) create mode 100644 components/ui/text-type.jsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 3d9a6a3..c8fdd58 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,7 @@ { - "cSpell.words": ["neondatabase", "Wikimasters"] + "cSpell.words": [ + "gsap", + "neondatabase", + "Wikimasters" + ] } diff --git a/app/api/ai/route.ts b/app/api/ai/route.ts index a5a9500..7730e91 100644 --- a/app/api/ai/route.ts +++ b/app/api/ai/route.ts @@ -1,17 +1,64 @@ "use server"; import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; -export const POST = async (request: NextRequest) => { - const { prompt } = await request.json(); +const RequestBodySchema = z.object({ + prompt: z.object({ + text: z.string(), + content: z.string(), + }), +}); +// ✅ define response types +export type AISuccessResponse = { + created: number; + content: string; +}; + +export type AIErrorResponse = { + error: string; +}; + +type AIResponse = AISuccessResponse | AIErrorResponse; + +type OpenRouterMessage = { + role: string; + content: string; +}; + +type OpenRouterChoice = { + index: number; + finish_reason: string; + message: OpenRouterMessage; + logprobs: null | object; +}; +type OpenRouterResponse = { + id: string; + model: string; + object: string; + created: number; + choices: OpenRouterChoice[]; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + cost: number; + }; +}; +export const POST = async ( + request: NextRequest, +): Promise> => { + const { + prompt: { text, content }, + } = RequestBodySchema.parse(await request.json()); // ✅ validated + typed try { const response = await fetch( "https://openrouter.ai/api/v1/chat/completions", { method: "POST", headers: { - Authorization: "Bearer ", - "HTTP-Referer": "", // Optional. Site URL for rankings on openrouter.ai. - "X-Title": "", // Optional. Site title for rankings on openrouter.ai. + Authorization: `Bearer ${process.env.AI_GATEWAY_API_KEY}`, + "HTTP-Referer": process.env.SITE_URL ?? "", // Optional. Site URL for rankings on openrouter.ai. + "X-Title": process.env.SITE_NAME ?? "", // Optional. Site title for rankings on openrouter.ai. "Content-Type": "application/json", }, body: JSON.stringify({ @@ -22,13 +69,11 @@ export const POST = async (request: NextRequest) => { content: [ { type: "text", - text: "What is in this image?", + text, }, { - type: "image_url", - image_url: { - url: "https://live.staticflickr.com/3851/14825276609_098cac593d_b.jpg", - }, + type: "text", + text: content, }, ], }, @@ -36,10 +81,12 @@ export const POST = async (request: NextRequest) => { }), }, ); - console.log(response.json()); + + const data: OpenRouterResponse = await response.json(); return NextResponse.json( { - message: `You sent: ${prompt}`, + created: data.created, + content: data.choices[0].message.content, }, { status: 200, diff --git a/components/features/wikicards/wiki-article-viewer.tsx b/components/features/wikicards/wiki-article-viewer.tsx index d660a41..8265081 100644 --- a/components/features/wikicards/wiki-article-viewer.tsx +++ b/components/features/wikicards/wiki-article-viewer.tsx @@ -17,12 +17,13 @@ import { deleteArticleForm } from "@/app/actions/articles"; import { incrementPageViews } from "@/app/actions/pageViews"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { formatDate } from "@/lib/utils"; -import { ArticleWikiData } from "@/types/api"; +import { Card, CardContent, CardTitle } from "@/components/ui/card"; import ShinyText from "@/components/ui/shiny-text"; +import { formatDate } from "@/lib/utils"; import { Routes } from "@/types"; - +import { ArticleWikiData } from "@/types/api"; +import TextType from "@/components/ui/text-type"; +import { AISuccessResponse } from "@/app/api/ai/route"; interface WikiArticleViewerProps { article: ArticleWikiData; canEdit?: boolean; @@ -37,14 +38,66 @@ const WikiArticleViewer: React.FC = ({ const [localPageViews, setLocalPageViews] = React.useState( pageviews ?? 0, ); - + const [summary, setSummary] = React.useState(null); + const [isSummarizing, setIsSummarizing] = React.useState(false); + const articleRef = React.useRef(null); useEffect(() => { + let isMounted = true; + const fetchPageView = async () => { const newCount = await incrementPageViews(article.id); - setLocalPageViews(newCount); + if (isMounted) { + setLocalPageViews(newCount); + } }; + fetchPageView(); + + return () => { + isMounted = false; + }; }, [article.id]); + useEffect(() => { + if (summary && articleRef.current) { + articleRef.current.scrollIntoView({ behavior: "smooth", block: "end" }); + } + }, [summary]); + const handleSummarizeArticle = async () => { + const controller = new AbortController(); // ✅ create controller + + try { + setIsSummarizing(true); + const response = await fetch("/api/ai/", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + prompt: { + text: "Summarize the following article content in 2-3 sentences:\n\n", + content: article.content, + }, + }), + signal: controller.signal, // ✅ attach signal to fetch + }); + + const data: AISuccessResponse = await response.json(); + if (data.created) { + setIsSummarizing(false); + setSummary(data.content); + } + // const delay = (ms: number) => + // new Promise((resolve) => setTimeout(resolve, ms)); + // await delay(1000); // Simulate network delay + + // Mocked summary response for testing + } catch (error) { + console.log("Error summarizing article:", error); + } finally { + setIsSummarizing(false); + } + return () => controller.abort(); // ✅ return cleanup function to abort on unmount + }; return (
@@ -117,16 +170,18 @@ const WikiArticleViewer: React.FC = ({ {/* Article Content */} - + {/* Article Image - Display if exists */}
+ {summary && !isSummarizing && ( + + + + Article Summary + + + + + )} {/* Footer Actions */}
diff --git a/components/ui/text-type.jsx b/components/ui/text-type.jsx new file mode 100644 index 0000000..ea469af --- /dev/null +++ b/components/ui/text-type.jsx @@ -0,0 +1,193 @@ +"use client"; + +import { gsap } from "gsap"; +import { + createElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +const TextType = ({ + text, + as: Component = "div", + typingSpeed = 50, + initialDelay = 0, + pauseDuration = 2000, + deletingSpeed = 30, + loop = true, + className = "", + showCursor = true, + hideCursorWhileTyping = false, + cursorCharacter = "|", + cursorClassName = "", + cursorBlinkDuration = 0.5, + textColors = [], + variableSpeed, + onSentenceComplete, + startOnVisible = false, + reverseMode = false, + ...props +}) => { + const [displayedText, setDisplayedText] = useState(""); + const [currentCharIndex, setCurrentCharIndex] = useState(0); + const [isDeleting, setIsDeleting] = useState(false); + const [currentTextIndex, setCurrentTextIndex] = useState(0); + const [isVisible, setIsVisible] = useState(!startOnVisible); + const cursorRef = useRef(null); + const containerRef = useRef(null); + + const textArray = useMemo( + () => (Array.isArray(text) ? text : [text]), + [text], + ); + + const getRandomSpeed = useCallback(() => { + if (!variableSpeed) return typingSpeed; + const { min, max } = variableSpeed; + return Math.random() * (max - min) + min; + }, [variableSpeed, typingSpeed]); + + const getCurrentTextColor = () => { + if (textColors.length === 0) return "inherit"; + return textColors[currentTextIndex % textColors.length]; + }; + + useEffect(() => { + if (!startOnVisible || !containerRef.current) return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setIsVisible(true); + } + }); + }, + { threshold: 0.1 }, + ); + + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, [startOnVisible]); + + useEffect(() => { + if (showCursor && cursorRef.current) { + gsap.set(cursorRef.current, { opacity: 1 }); + gsap.to(cursorRef.current, { + opacity: 0, + duration: cursorBlinkDuration, + repeat: -1, + yoyo: true, + ease: "power2.inOut", + }); + } + }, [showCursor, cursorBlinkDuration]); + + useEffect(() => { + if (!isVisible) return; + + let timeout; + + const currentText = textArray[currentTextIndex]; + const processedText = reverseMode + ? currentText.split("").reverse().join("") + : currentText; + + const executeTypingAnimation = () => { + if (isDeleting) { + if (displayedText === "") { + setIsDeleting(false); + if (currentTextIndex === textArray.length - 1 && !loop) { + return; + } + + if (onSentenceComplete) { + onSentenceComplete(textArray[currentTextIndex], currentTextIndex); + } + + setCurrentTextIndex((prev) => (prev + 1) % textArray.length); + setCurrentCharIndex(0); + timeout = setTimeout(() => {}, pauseDuration); + } else { + timeout = setTimeout(() => { + setDisplayedText((prev) => prev.slice(0, -1)); + }, deletingSpeed); + } + } else { + if (currentCharIndex < processedText.length) { + timeout = setTimeout( + () => { + setDisplayedText( + (prev) => prev + processedText[currentCharIndex], + ); + setCurrentCharIndex((prev) => prev + 1); + }, + variableSpeed ? getRandomSpeed() : typingSpeed, + ); + } else if (textArray.length >= 1) { + if (!loop && currentTextIndex === textArray.length - 1) return; + timeout = setTimeout(() => { + setIsDeleting(true); + }, pauseDuration); + } + } + }; + + if (currentCharIndex === 0 && !isDeleting && displayedText === "") { + timeout = setTimeout(executeTypingAnimation, initialDelay); + } else { + executeTypingAnimation(); + } + + return () => clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + currentCharIndex, + displayedText, + isDeleting, + typingSpeed, + deletingSpeed, + pauseDuration, + textArray, + currentTextIndex, + loop, + initialDelay, + isVisible, + reverseMode, + variableSpeed, + onSentenceComplete, + getRandomSpeed, + ]); + + const shouldHideCursor = + hideCursorWhileTyping && + (currentCharIndex < textArray[currentTextIndex].length || isDeleting); + + return createElement( + Component, + { + ref: containerRef, + className: `inline-block whitespace-pre-wrap tracking-tight ${className}`, + ...props, + }, + + {displayedText} + , + showCursor && ( + + {cursorCharacter} + + ), + ); +}; + +export default TextType; diff --git a/package.json b/package.json index afe63d6..94b20b1 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "dotenv": "^17.3.1", "drizzle-orm": "^0.45.1", "drizzle-seed": "^0.3.1", + "gsap": "^3.14.2", "lucide-react": "^0.564.0", "motion": "^12.34.3", "next": "16.1.6", @@ -42,7 +43,8 @@ "resend": "^6.9.2", "shadcn": "^3.8.4", "tailwind-merge": "^3.4.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "zod": "^4.3.6" }, "devDependencies": { "@biomejs/biome": "2.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89a8a2e..b91ae61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: drizzle-seed: specifier: ^0.3.1 version: 0.3.1(drizzle-orm@0.45.1(@neondatabase/serverless@1.0.2)(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(@upstash/redis@1.36.2)) + gsap: + specifier: ^3.14.2 + version: 3.14.2 lucide-react: specifier: ^0.564.0 version: 0.564.0(react@19.2.3) @@ -86,6 +89,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@biomejs/biome': specifier: 2.2.0 @@ -3385,6 +3391,9 @@ packages: resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + gsap@3.14.2: + resolution: {integrity: sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -4965,6 +4974,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -6527,7 +6539,7 @@ snapshots: '@openrouter/sdk@0.8.0': dependencies: - zod: 3.25.76 + zod: 4.3.6 '@opentelemetry/api@1.9.0': {} @@ -8748,6 +8760,8 @@ snapshots: graphql@16.12.0: {} + gsap@3.14.2: {} + has-symbols@1.1.0: {} hash.js@1.1.7: @@ -10673,4 +10687,6 @@ snapshots: zod@3.25.76: {} + zod@4.3.6: {} + zwitch@2.0.4: {} From f3ed570fd16f81c001141bd05c72a483588b944a Mon Sep 17 00:00:00 2001 From: halilibrahimcelik Date: Sat, 21 Feb 2026 16:55:01 +0000 Subject: [PATCH 06/14] feat: enhance AI response handling in route.ts; add validation error handling and improve error logging in WikiArticleViewer --- app/api/ai/route.ts | 27 ++++++++++++------- .../wikicards/wiki-article-viewer.tsx | 16 ++++++----- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/app/api/ai/route.ts b/app/api/ai/route.ts index 7730e91..d0609f8 100644 --- a/app/api/ai/route.ts +++ b/app/api/ai/route.ts @@ -1,11 +1,14 @@ "use server"; import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; +import { z, ZodError } from "zod"; const RequestBodySchema = z.object({ prompt: z.object({ text: z.string(), - content: z.string(), + content: z + .string() + .min(1, "Content is required") + .max(7000, "Content too large"), // ✅ validation rules }), }); // ✅ define response types @@ -47,10 +50,10 @@ type OpenRouterResponse = { export const POST = async ( request: NextRequest, ): Promise> => { - const { - prompt: { text, content }, - } = RequestBodySchema.parse(await request.json()); // ✅ validated + typed try { + const { + prompt: { text, content }, + } = RequestBodySchema.parse(await request.json()); // ✅ validated + typed const response = await fetch( "https://openrouter.ai/api/v1/chat/completions", { @@ -93,14 +96,20 @@ export const POST = async ( }, ); } catch (error) { + if (error instanceof ZodError) { + return NextResponse.json( + { + error: error.message, + }, + { status: 400 }, + ); + } console.error("API Error:", error); - return NextResponse.json( + return NextResponse.json( { error: "Failed to process the prompt", }, - { - status: 500, - }, + { status: 500 }, ); } }; diff --git a/components/features/wikicards/wiki-article-viewer.tsx b/components/features/wikicards/wiki-article-viewer.tsx index 8265081..aaec67a 100644 --- a/components/features/wikicards/wiki-article-viewer.tsx +++ b/components/features/wikicards/wiki-article-viewer.tsx @@ -41,6 +41,7 @@ const WikiArticleViewer: React.FC = ({ const [summary, setSummary] = React.useState(null); const [isSummarizing, setIsSummarizing] = React.useState(false); const articleRef = React.useRef(null); + console.log(article.content.length); useEffect(() => { let isMounted = true; @@ -81,18 +82,21 @@ const WikiArticleViewer: React.FC = ({ signal: controller.signal, // ✅ attach signal to fetch }); + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error); // ✅ now caught by catch block + } + const data: AISuccessResponse = await response.json(); if (data.created) { setIsSummarizing(false); setSummary(data.content); } - // const delay = (ms: number) => - // new Promise((resolve) => setTimeout(resolve, ms)); - // await delay(1000); // Simulate network delay - - // Mocked summary response for testing } catch (error) { - console.log("Error summarizing article:", error); + console.log( + "Error summarizing article:", + error instanceof Error ? error.message : error, + ); } finally { setIsSummarizing(false); } From eda69f49dffe57c5429a620afcba25b424e2d766 Mon Sep 17 00:00:00 2001 From: halilibrahimcelik Date: Wed, 25 Feb 2026 17:18:27 +0000 Subject: [PATCH 07/14] feat: update content validation in RequestBodySchema; add Tooltip component for enhanced user interaction in WikiArticleViewer --- app/api/ai/route.ts | 2 +- app/layout.tsx | 3 +- .../wikicards/wiki-article-viewer.tsx | 70 +++++++++++++------ components/ui/tooltip.tsx | 66 +++++++++++++++++ 4 files changed, 116 insertions(+), 25 deletions(-) create mode 100644 components/ui/tooltip.tsx diff --git a/app/api/ai/route.ts b/app/api/ai/route.ts index d0609f8..a0b5c41 100644 --- a/app/api/ai/route.ts +++ b/app/api/ai/route.ts @@ -7,7 +7,7 @@ const RequestBodySchema = z.object({ text: z.string(), content: z .string() - .min(1, "Content is required") + .min(10, "Content is too short") .max(7000, "Content too large"), // ✅ validation rules }), }); diff --git a/app/layout.tsx b/app/layout.tsx index ee81a52..86e927e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,5 @@ import { StackProvider, StackTheme } from "@stackframe/stack"; +import { TooltipProvider } from "@/components/ui/tooltip"; import type { Metadata } from "next"; import { JetBrains_Mono } from "next/font/google"; import { stackClientApp } from "../stack/client"; @@ -29,7 +30,7 @@ export default function RootLayout({ - {children} + {children} diff --git a/components/features/wikicards/wiki-article-viewer.tsx b/components/features/wikicards/wiki-article-viewer.tsx index aaec67a..ea5f1e7 100644 --- a/components/features/wikicards/wiki-article-viewer.tsx +++ b/components/features/wikicards/wiki-article-viewer.tsx @@ -3,6 +3,7 @@ import { Calendar, ChevronRight, + CopyIcon, Edit, Eye, Home, @@ -24,6 +25,11 @@ import { Routes } from "@/types"; import { ArticleWikiData } from "@/types/api"; import TextType from "@/components/ui/text-type"; import { AISuccessResponse } from "@/app/api/ai/route"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; interface WikiArticleViewerProps { article: ArticleWikiData; canEdit?: boolean; @@ -75,7 +81,7 @@ const WikiArticleViewer: React.FC = ({ }, body: JSON.stringify({ prompt: { - text: "Summarize the following article content in 2-3 sentences:\n\n", + text: "Summarize the following article content in 2-3 sentences:Focus on the main idea and the most important details a reader should remember. Do not add opinions or unrelated information. The point is that readers can see the summary a glance and decide if they want to read more\n\n", content: article.content, }, }), @@ -176,27 +182,38 @@ const WikiArticleViewer: React.FC = ({ {/* Article Image - Display if exists */} - + + ( + + )} + > + +

You can summarize the article by clicking the button above.

+
+
+ {article.imageUrl && (
@@ -309,8 +326,15 @@ const WikiArticleViewer: React.FC = ({ + Article Summary diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx new file mode 100644 index 0000000..f18c04c --- /dev/null +++ b/components/ui/tooltip.tsx @@ -0,0 +1,66 @@ +"use client" + +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delay = 0, + ...props +}: TooltipPrimitive.Provider.Props) { + return ( + + ) +} + +function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { + return +} + +function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { + return +} + +function TooltipContent({ + className, + side = "top", + sideOffset = 4, + align = "center", + alignOffset = 0, + children, + ...props +}: TooltipPrimitive.Popup.Props & + Pick< + TooltipPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + {children} + + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } From a9bff8e0b0e320d44fad509c2067dc58a9a43987 Mon Sep 17 00:00:00 2001 From: halilibrahimcelik Date: Mon, 2 Mar 2026 16:59:29 +0000 Subject: [PATCH 08/14] feat: integrate Sonner for toast notifications in WikiArticleViewer and WikiEditor; update layout to include Toaster component --- app/layout.tsx | 2 ++ .../wikicards/wiki-article-viewer.tsx | 24 ++++++++++++++++--- components/features/wikicards/wiki-editor.tsx | 20 +++++++--------- package.json | 1 + pnpm-lock.yaml | 14 +++++++++++ 5 files changed, 47 insertions(+), 14 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 86e927e..93cee2e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import { StackProvider, StackTheme } from "@stackframe/stack"; import { TooltipProvider } from "@/components/ui/tooltip"; import type { Metadata } from "next"; import { JetBrains_Mono } from "next/font/google"; +import { Toaster } from "sonner"; import { stackClientApp } from "../stack/client"; import "./globals.css"; import { Navbar } from "@/components/features/navbar"; @@ -31,6 +32,7 @@ export default function RootLayout({ {children} + diff --git a/components/features/wikicards/wiki-article-viewer.tsx b/components/features/wikicards/wiki-article-viewer.tsx index ea5f1e7..b63444f 100644 --- a/components/features/wikicards/wiki-article-viewer.tsx +++ b/components/features/wikicards/wiki-article-viewer.tsx @@ -12,7 +12,7 @@ import { } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; -import React, { useEffect } from "react"; +import React, { useActionState, useEffect } from "react"; import ReactMarkdown from "react-markdown"; import { deleteArticleForm } from "@/app/actions/articles"; import { incrementPageViews } from "@/app/actions/pageViews"; @@ -30,6 +30,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { toast } from "sonner"; interface WikiArticleViewerProps { article: ArticleWikiData; canEdit?: boolean; @@ -41,13 +42,28 @@ const WikiArticleViewer: React.FC = ({ canEdit = false, pageviews, }) => { + const [deleteState, deleteAction] = useActionState(deleteArticleForm, null); const [localPageViews, setLocalPageViews] = React.useState( pageviews ?? 0, ); + + useEffect(() => { + if (deleteState?.error) { + toast.error(deleteState.error); + } + }, [deleteState]); const [summary, setSummary] = React.useState(null); const [isSummarizing, setIsSummarizing] = React.useState(false); const articleRef = React.useRef(null); - console.log(article.content.length); + const handleCopyText = () => { + if (summary) { + navigator.clipboard.writeText(summary); + toast.success("Summary copied to clipboard!", { + position: "bottom-left", + duration: 2500, + }); + } + }; useEffect(() => { let isMounted = true; @@ -163,7 +179,7 @@ const WikiArticleViewer: React.FC = ({ {/* Delete form calls the server action wrapper */} -
+