diff --git a/package-lock.json b/package-lock.json
index c2a3b47..7f122f8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@neondatabase/serverless": "^1.0.2",
+ "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
@@ -34,6 +35,7 @@
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
@@ -14119,6 +14121,16 @@
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
+ "node_modules/sonner": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
+ "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
diff --git a/package.json b/package.json
index 7fed50d..680d5a4 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
},
"dependencies": {
"@neondatabase/serverless": "^1.0.2",
+ "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
@@ -36,6 +37,7 @@
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
diff --git a/src/app/api/favorites/[id]/route.ts b/src/app/api/favorites/[id]/route.ts
new file mode 100644
index 0000000..dfcae20
--- /dev/null
+++ b/src/app/api/favorites/[id]/route.ts
@@ -0,0 +1,59 @@
+import { getUserIdFromRequest } from "@/lib/auth-server";
+import { db, schema } from "@/lib/db";
+import { and, eq } from "drizzle-orm";
+import { NextRequest, NextResponse } from "next/server";
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const userId = await getUserIdFromRequest(request);
+ if (!userId) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ if (!db) {
+ return NextResponse.json(
+ { error: "Database not configured" },
+ { status: 500 }
+ );
+ }
+
+ try {
+ const { id } = await params;
+ const favoriteId = parseInt(id);
+
+ if (isNaN(favoriteId)) {
+ return NextResponse.json(
+ { error: "Invalid favorite ID" },
+ { status: 400 }
+ );
+ }
+
+ // 削除実行 (自分のものだけ削除可能)
+ const deleted = await db
+ .delete(schema.favorites)
+ .where(
+ and(
+ eq(schema.favorites.id, favoriteId),
+ eq(schema.favorites.userId, userId)
+ )
+ )
+ .returning();
+
+ if (deleted.length === 0) {
+ return NextResponse.json(
+ { error: "Favorite not found or not authorized" },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json({ success: true, deleted: deleted[0] });
+ } catch (error) {
+ console.error("Error deleting favorite:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/favorites/route.ts b/src/app/api/favorites/route.ts
new file mode 100644
index 0000000..7b98343
--- /dev/null
+++ b/src/app/api/favorites/route.ts
@@ -0,0 +1,155 @@
+import { getUserIdFromRequest } from "@/lib/auth-server";
+import { db, schema } from "@/lib/db";
+import { and, desc, eq, isNull } from "drizzle-orm";
+import { NextRequest, NextResponse } from "next/server";
+
+// GET /api/favorites - お気に入り一覧を取得 (フォルダ情報、論文情報込み)
+export async function GET(request: NextRequest) {
+ const userId = await getUserIdFromRequest(request);
+ if (!userId) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ if (!db) {
+ return NextResponse.json(
+ { error: "Database not configured" },
+ { status: 500 }
+ );
+ }
+
+ try {
+ // フォルダIDでフィルタリングする場合
+ const { searchParams } = new URL(request.url);
+ const folderId = searchParams.get("folderId");
+
+ const conditions = [eq(schema.favorites.userId, userId)];
+
+ if (folderId) {
+ if (folderId === "null") {
+ // 未分類
+ conditions.push(isNull(schema.favorites.folderId));
+ } else {
+ const fid = parseInt(folderId);
+ if (!isNaN(fid)) {
+ conditions.push(eq(schema.favorites.folderId, fid));
+ }
+ }
+ }
+
+ const results = await db
+ .select({
+ favorite: schema.favorites,
+ paper: schema.papers,
+ folder: schema.folders,
+ })
+ .from(schema.favorites)
+ .innerJoin(schema.papers, eq(schema.favorites.paperId, schema.papers.id))
+ .leftJoin(
+ schema.folders,
+ eq(schema.favorites.folderId, schema.folders.id)
+ )
+ .where(and(...conditions))
+ .orderBy(desc(schema.favorites.createdAt));
+
+ // 整形して返す
+ const favorites = results.map((r) => ({
+ ...r.favorite,
+ paper: r.paper,
+ folder: r.folder,
+ }));
+
+ return NextResponse.json(favorites);
+ } catch (error) {
+ console.error("Error fetching favorites:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
+
+// POST /api/favorites - お気に入り追加
+export async function POST(request: NextRequest) {
+ const userId = await getUserIdFromRequest(request);
+ if (!userId) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ if (!db) {
+ return NextResponse.json(
+ { error: "Database not configured" },
+ { status: 500 }
+ );
+ }
+
+ try {
+ const body = await request.json();
+ const { paperId, folderId } = body;
+
+ if (!paperId) {
+ return NextResponse.json(
+ { error: "Paper ID is required" },
+ { status: 400 }
+ );
+ }
+
+ // folderId が指定されている場合、自分のフォルダか検証
+ if (folderId) {
+ const folderCheck = await db
+ .select()
+ .from(schema.folders)
+ .where(
+ and(
+ eq(schema.folders.id, folderId),
+ eq(schema.folders.userId, userId)
+ )
+ );
+ if (folderCheck.length === 0) {
+ return NextResponse.json(
+ { error: "Folder not found or not authorized" },
+ { status: 403 }
+ );
+ }
+ }
+
+ // 既に全く同じ組み合わせ(paperId, folderId)が存在するかチェック
+ const existing = await db
+ .select()
+ .from(schema.favorites)
+ .where(
+ and(
+ eq(schema.favorites.userId, userId),
+ eq(schema.favorites.paperId, paperId),
+ folderId
+ ? eq(schema.favorites.folderId, folderId)
+ : isNull(schema.favorites.folderId)
+ )
+ );
+
+ if (existing.length > 0) {
+ // 既に存在する場合は何もしない(冪等性)
+ return NextResponse.json(existing[0]);
+ }
+
+ // 新規登録
+ // "Default" (null) folder acts as a regular folder now, so we don't delete it
+ // when adding to other folders.
+
+ const newFavorite = await db
+ .insert(schema.favorites)
+ .values({
+ userId,
+ paperId,
+ folderId: folderId || null,
+ })
+ .returning();
+
+ return NextResponse.json(newFavorite[0], { status: 201 });
+ } catch (error) {
+ console.error("Error adding favorite:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/folders/[id]/route.ts b/src/app/api/folders/[id]/route.ts
new file mode 100644
index 0000000..086603e
--- /dev/null
+++ b/src/app/api/folders/[id]/route.ts
@@ -0,0 +1,174 @@
+import { getUserIdFromRequest } from "@/lib/auth-server";
+import { db, schema } from "@/lib/db";
+import { and, eq, inArray, isNull } from "drizzle-orm";
+import { NextRequest, NextResponse } from "next/server";
+
+// PATCH /api/folders/[id] - フォルダ名を変更
+export async function PATCH(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const userId = await getUserIdFromRequest(request);
+ if (!userId) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ if (!db) {
+ return NextResponse.json(
+ { error: "Database not configured" },
+ { status: 500 }
+ );
+ }
+
+ try {
+ const { id } = await params;
+ const folderId = parseInt(id);
+ if (isNaN(folderId)) {
+ return NextResponse.json({ error: "Invalid folder ID" }, { status: 400 });
+ }
+
+ const body = await request.json();
+ const { name } = body;
+
+ if (!name || typeof name !== "string" || !name.trim()) {
+ return NextResponse.json(
+ { error: "Valid folder name is required" },
+ { status: 400 }
+ );
+ }
+
+ // 自分のフォルダかつIDが一致するものを更新
+ const updatedFolder = await db
+ .update(schema.folders)
+ .set({ name: name.trim() })
+ .where(
+ and(eq(schema.folders.id, folderId), eq(schema.folders.userId, userId))
+ )
+ .returning();
+
+ if (updatedFolder.length === 0) {
+ return NextResponse.json(
+ { error: "Folder not found or unauthorized" },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json(updatedFolder[0]);
+ } catch (error) {
+ console.error("Error updating folder:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
+
+// DELETE /api/folders/[id] - フォルダを削除
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const userId = await getUserIdFromRequest(request);
+ if (!userId) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ if (!db) {
+ return NextResponse.json(
+ { error: "Database not configured" },
+ { status: 500 }
+ );
+ }
+
+ try {
+ const { id } = await params;
+ const folderId = parseInt(id);
+ if (isNaN(folderId)) {
+ return NextResponse.json({ error: "Invalid folder ID" }, { status: 400 });
+ }
+
+ // フォルダの所有者確認
+ const folder = await db
+ .select()
+ .from(schema.folders)
+ .where(
+ and(eq(schema.folders.id, folderId), eq(schema.folders.userId, userId))
+ );
+
+ if (folder.length === 0) {
+ return NextResponse.json(
+ { error: "Folder not found or unauthorized" },
+ { status: 404 }
+ );
+ }
+
+ // このフォルダに属するお気に入りを取得
+ const favoritesInFolder = await db
+ .select()
+ .from(schema.favorites)
+ .where(
+ and(
+ eq(schema.favorites.userId, userId),
+ eq(schema.favorites.folderId, folderId)
+ )
+ );
+
+ if (favoritesInFolder.length > 0) {
+ const paperIdsInFolder = favoritesInFolder.map((f) => f.paperId);
+
+ // 既にデフォルト(folderId=null)に存在する論文を取得
+ const existingDefaults = await db
+ .select()
+ .from(schema.favorites)
+ .where(
+ and(
+ eq(schema.favorites.userId, userId),
+ isNull(schema.favorites.folderId),
+ inArray(schema.favorites.paperId, paperIdsInFolder)
+ )
+ );
+
+ const alreadyInDefaultPaperIds = new Set(
+ existingDefaults.map((f) => f.paperId)
+ );
+
+ // 既にデフォルトにある論文のお気に入りは削除(重複防止)
+ const toDelete = favoritesInFolder
+ .filter((f) => alreadyInDefaultPaperIds.has(f.paperId))
+ .map((f) => f.id);
+
+ // デフォルトにない論文のお気に入りは folderId を null に更新
+ const toUpdate = favoritesInFolder
+ .filter((f) => !alreadyInDefaultPaperIds.has(f.paperId))
+ .map((f) => f.id);
+
+ if (toDelete.length > 0) {
+ await db
+ .delete(schema.favorites)
+ .where(inArray(schema.favorites.id, toDelete));
+ }
+
+ if (toUpdate.length > 0) {
+ await db
+ .update(schema.favorites)
+ .set({ folderId: null })
+ .where(inArray(schema.favorites.id, toUpdate));
+ }
+ }
+
+ // フォルダ本体を削除
+ await db
+ .delete(schema.folders)
+ .where(
+ and(eq(schema.folders.id, folderId), eq(schema.folders.userId, userId))
+ );
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error("Error deleting folder:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/folders/route.ts b/src/app/api/folders/route.ts
new file mode 100644
index 0000000..df46de5
--- /dev/null
+++ b/src/app/api/folders/route.ts
@@ -0,0 +1,80 @@
+import { getUserIdFromRequest } from "@/lib/auth-server";
+import { db, schema } from "@/lib/db";
+import { desc, eq } from "drizzle-orm";
+import { NextRequest, NextResponse } from "next/server";
+
+// GET /api/folders - フォルダ一覧を取得
+export async function GET(request: NextRequest) {
+ const userId = await getUserIdFromRequest(request);
+ if (!userId) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ if (!db) {
+ return NextResponse.json(
+ { error: "Database not configured" },
+ { status: 500 }
+ );
+ }
+
+ try {
+ const userFolders = await db
+ .select()
+ .from(schema.folders)
+ .where(eq(schema.folders.userId, userId))
+ .orderBy(desc(schema.folders.createdAt));
+
+ return NextResponse.json(userFolders);
+ } catch (error) {
+ console.error("Error fetching folders:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
+
+// POST /api/folders - フォルダを作成
+export async function POST(request: NextRequest) {
+ const userId = await getUserIdFromRequest(request);
+ if (!userId) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ if (!db) {
+ return NextResponse.json(
+ { error: "Database not configured" },
+ { status: 500 }
+ );
+ }
+
+ try {
+ const body = await request.json();
+ const { name } = body;
+
+ if (!name || typeof name !== "string" || !name.trim()) {
+ return NextResponse.json(
+ { error: "Valid folder name is required" },
+ { status: 400 }
+ );
+ }
+
+ const trimmedName = name.trim();
+
+ const newFolder = await db
+ .insert(schema.folders)
+ .values({
+ userId,
+ name: trimmedName,
+ })
+ .returning();
+
+ return NextResponse.json(newFolder[0], { status: 201 });
+ } catch (error) {
+ console.error("Error creating folder:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/favorites/page.tsx b/src/app/favorites/page.tsx
new file mode 100644
index 0000000..11ec2c4
--- /dev/null
+++ b/src/app/favorites/page.tsx
@@ -0,0 +1,231 @@
+"use client";
+
+import { PapersTable } from "@/components/papers-table";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { SidebarInset, SidebarTrigger } from "@/components/ui/sidebar";
+import { Spinner } from "@/components/ui/spinner";
+import { useAuth } from "@/contexts/AuthContext";
+import { useFavorites } from "@/hooks/use-favorites";
+import { Paper } from "@/lib/types";
+import { Check, Pencil, Trash2, X } from "lucide-react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { Suspense, useMemo, useState } from "react";
+
+export default function FavoritesPage() {
+ return (
+