From 47d7d5a5caf82d2f68553957c5df64fdd13d0234 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Sep 2025 13:49:40 +0000 Subject: [PATCH 1/3] Initial plan From b47211dffce516bfb40ff1082c3115ff9c1481af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Sep 2025 14:01:37 +0000 Subject: [PATCH 2/3] Implement core highlights functionality with database, API, and UI components Co-authored-by: hoangsonww <124531104+hoangsonww@users.noreply.github.com> --- app/api/highlights/[id]/route.ts | 157 +++++++++ app/api/highlights/route.ts | 158 +++++++++ app/articles/[slug]/page.tsx | 11 +- app/layout.tsx | 55 ++-- app/notes/page.tsx | 340 ++++++++++++++++++++ components/highlights/ArticleHighlights.tsx | 225 +++++++++++++ components/highlights/HighlightRenderer.tsx | 145 +++++++++ components/highlights/HighlightsPanel.tsx | 328 +++++++++++++++++++ components/highlights/SelectionToolbar.tsx | 285 ++++++++++++++++ hooks/useHighlights.ts | 171 ++++++++++ lib/highlights/selectors.ts | 218 +++++++++++++ package-lock.json | 132 +++++++- package.json | 7 +- provider/QueryProvider.tsx | 26 ++ supabase/article_highlights.sql | 56 ++++ tests/highlights-api.spec.js | 263 +++++++++++++++ tests/highlights.spec.js | 199 ++++++++++++ 17 files changed, 2744 insertions(+), 32 deletions(-) create mode 100644 app/api/highlights/[id]/route.ts create mode 100644 app/api/highlights/route.ts create mode 100644 app/notes/page.tsx create mode 100644 components/highlights/ArticleHighlights.tsx create mode 100644 components/highlights/HighlightRenderer.tsx create mode 100644 components/highlights/HighlightsPanel.tsx create mode 100644 components/highlights/SelectionToolbar.tsx create mode 100644 hooks/useHighlights.ts create mode 100644 lib/highlights/selectors.ts create mode 100644 provider/QueryProvider.tsx create mode 100644 supabase/article_highlights.sql create mode 100644 tests/highlights-api.spec.js create mode 100644 tests/highlights.spec.js diff --git a/app/api/highlights/[id]/route.ts b/app/api/highlights/[id]/route.ts new file mode 100644 index 0000000..ac3f3d7 --- /dev/null +++ b/app/api/highlights/[id]/route.ts @@ -0,0 +1,157 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; +import { z } from "zod"; + +const updateHighlightSchema = z.object({ + note: z.string().optional(), + color: z.string().optional(), + is_public: z.boolean().optional(), +}); + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const cookieStore = await cookies(); + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ); + } catch {} + }, + }, + } + ); + + // Get current user + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const validatedData = updateHighlightSchema.parse(body); + + // Check if highlight exists and belongs to user + const { data: existingHighlight, error: fetchError } = await supabase + .from("article_highlights") + .select("id") + .eq("id", id) + .eq("user_id", user.id) + .single(); + + if (fetchError || !existingHighlight) { + return NextResponse.json( + { error: "Highlight not found" }, + { status: 404 } + ); + } + + const { data, error } = await supabase + .from("article_highlights") + .update(validatedData) + .eq("id", id) + .eq("user_id", user.id) + .select() + .single(); + + if (error) { + console.error("Error updating highlight:", error); + return NextResponse.json( + { error: "Failed to update highlight" }, + { status: 500 } + ); + } + + return NextResponse.json({ highlight: data }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request data", details: error.errors }, + { status: 400 } + ); + } + + console.error("Error in PATCH /api/highlights/[id]:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const cookieStore = await cookies(); + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ); + } catch {} + }, + }, + } + ); + + // Get current user + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { error } = await supabase + .from("article_highlights") + .delete() + .eq("id", id) + .eq("user_id", user.id); + + if (error) { + console.error("Error deleting highlight:", error); + return NextResponse.json( + { error: "Failed to delete highlight" }, + { status: 500 } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error in DELETE /api/highlights/[id]:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/highlights/route.ts b/app/api/highlights/route.ts new file mode 100644 index 0000000..8680bcc --- /dev/null +++ b/app/api/highlights/route.ts @@ -0,0 +1,158 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; +import { z } from "zod"; + +// Validation schemas +const createHighlightSchema = z.object({ + article_slug: z.string().min(1), + text_quote_exact: z.string().min(1), + text_quote_prefix: z.string().optional(), + text_quote_suffix: z.string().optional(), + text_position_start: z.number().int().min(0).optional(), + text_position_end: z.number().int().min(0).optional(), + note: z.string().optional(), + color: z.string().default("yellow"), + is_public: z.boolean().default(false), +}); + +const updateHighlightSchema = z.object({ + note: z.string().optional(), + color: z.string().optional(), + is_public: z.boolean().optional(), +}); + +export async function GET(request: NextRequest) { + try { + const cookieStore = await cookies(); + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ); + } catch {} + }, + }, + } + ); + const { searchParams } = new URL(request.url); + const slug = searchParams.get("slug"); + + // Get current user + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let query = supabase + .from("article_highlights") + .select("*") + .eq("user_id", user.id) + .order("created_at", { ascending: false }); + + if (slug) { + query = query.eq("article_slug", slug); + } + + const { data, error } = await query; + + if (error) { + console.error("Error fetching highlights:", error); + return NextResponse.json( + { error: "Failed to fetch highlights" }, + { status: 500 } + ); + } + + return NextResponse.json({ highlights: data }); + } catch (error) { + console.error("Error in GET /api/highlights:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const cookieStore = await cookies(); + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ); + } catch {} + }, + }, + } + ); + + // Get current user + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const validatedData = createHighlightSchema.parse(body); + + const { data, error } = await supabase + .from("article_highlights") + .insert([ + { + ...validatedData, + user_id: user.id, + }, + ]) + .select() + .single(); + + if (error) { + console.error("Error creating highlight:", error); + return NextResponse.json( + { error: "Failed to create highlight" }, + { status: 500 } + ); + } + + return NextResponse.json({ highlight: data }, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request data", details: error.errors }, + { status: 400 } + ); + } + + console.error("Error in POST /api/highlights:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/articles/[slug]/page.tsx b/app/articles/[slug]/page.tsx index 343a2d3..c95f997 100644 --- a/app/articles/[slug]/page.tsx +++ b/app/articles/[slug]/page.tsx @@ -4,6 +4,7 @@ import FavButton from "@/components/FavButton"; import React from "react"; import TopicsList from "@/components/TopicsList"; import TableOfContents from "@/components/TableOfContents"; +import { ArticleHighlights } from "@/components/highlights/ArticleHighlights"; import "./article.css"; interface Params { @@ -45,10 +46,12 @@ export default async function ArticlePage({ params }: PageProps) { return ( <> -
- - -
+ +
+ + +
+
diff --git a/app/layout.tsx b/app/layout.tsx index 3483e4b..cc6bb29 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,6 +7,7 @@ import { DarkModeProvider } from "@/provider/DarkModeProvider"; import { ToastContainer } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; import { Analytics } from "@vercel/analytics/react"; +import QueryProvider from "@/provider/QueryProvider"; const inter = Inter({ subsets: ["latin"], @@ -186,32 +187,34 @@ export default function RootLayout({ - -
- -
- -
- {children} -
-