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 (
<>
-