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 ( + + + + } + > + + + ); +} + +function FavoritesContent() { + const { user, loading: authLoading } = useAuth(); + const { + favorites, + loading: favoritesLoading, + folders, + renameFolder, + deleteFolder, + } = useFavorites(); + const searchParams = useSearchParams(); + const router = useRouter(); + const folderIdParam = searchParams.get("folderId"); + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(""); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + const canRename = useMemo(() => { + if (!folderIdParam || folderIdParam === "null") return false; + const fid = parseInt(folderIdParam); + return !isNaN(fid); + }, [folderIdParam]); + + const handleRename = async () => { + if (!editName.trim() || !canRename || !folderIdParam) return; + const fid = parseInt(folderIdParam); + await renameFolder(fid, editName); + setIsEditing(false); + }; + + const handleDelete = async () => { + if (!canRename || !folderIdParam) return; + const fid = parseInt(folderIdParam); + const success = await deleteFolder(fid); + setShowDeleteDialog(false); + if (success) { + router.push("/favorites"); + } + }; + + const filteredFavorites = useMemo(() => { + if (!folderIdParam) { + const uniqueMap = new Map(); + favorites.forEach((f) => { + if (!uniqueMap.has(f.paperId)) { + uniqueMap.set(f.paperId, f); + } + }); + return Array.from(uniqueMap.values()); + } + if (folderIdParam === "null") { + return favorites.filter((f) => f.folderId === null); + } + const fid = parseInt(folderIdParam); + if (isNaN(fid)) return []; + return favorites.filter((f) => f.folderId === fid); + }, [favorites, folderIdParam]); + + const currentFolderName = useMemo(() => { + if (!folderIdParam) return "すべて"; + if (folderIdParam === "null") return "デフォルト"; + const folder = folders.find((f) => f.id === parseInt(folderIdParam)); + return folder ? folder.name : "不明なフォルダ"; + }, [folders, folderIdParam]); + + if (authLoading || favoritesLoading) { + return ( +
+ +
+ ); + } + + if (!user) { + return ( +
+ お気に入りを表示するにはログインしてください。 +
+ ); + } + + const papers = filteredFavorites.map( + (f): Paper => ({ + ...f.paper, + cosineSimilarity: null, + }) + ); + + return ( + +
+
+ +
+
+ {isEditing ? ( +
+ setEditName(e.target.value)} + className="h-8 w-48 text-center" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") handleRename(); + if (e.key === "Escape") setIsEditing(false); + }} + /> + + +
+ ) : ( +
+ {currentFolderName} ({papers.length}) + {canRename && ( + <> + + + + )} +
+ )} +
+
+ +
+
+ +
+ {papers.length > 0 ? ( + + ) : ( +
+ このフォルダにお気に入りは登録されていません。 +
+ )} +
+ + + + + フォルダを削除しますか? + + フォルダ「{currentFolderName}」を削除します。 + フォルダ内の論文はデフォルトに移動されます。 + + + + キャンセル + 削除 + + + +
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 00a0046..f392efa 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import { AppSidebar } from "@/components/app-sidebar"; import { SidebarProvider } from "@/components/ui/sidebar"; +import { Toaster } from "@/components/ui/sonner"; import { AuthProvider } from "@/contexts/AuthContext"; import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; @@ -47,6 +48,7 @@ export default function RootLayout({ {children} + diff --git a/src/app/page.tsx b/src/app/page.tsx index de29fa4..3d9f2da 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -138,7 +138,7 @@ export default function Home() { return (
-
+
@@ -146,7 +146,7 @@ export default function Home() {
-
+
@@ -227,7 +227,7 @@ export default function Home() { className="flex flex-col items-center gap-8 w-full max-w-7xl px-4" >
-

+

興味のある論文を検索しましょう

diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 61fc459..846ff3d 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -1,19 +1,28 @@ "use client"; import { useAuth } from "@/contexts/AuthContext"; -// TODO: サイドバーを更新 -// import { -// Bot, -// ChevronsUpDown, -// History, -// LogOut, -// LucideIcon, -// Settings2, -// SquareTerminal, -// Star, -// } from "lucide-react"; -import { ChevronsUpDown, LogOut, LucideIcon } from "lucide-react"; +import { + ChevronsUpDown, + Folder, + LogOut, + LucideIcon, + Search, + Star, + Trash2, +} from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Collapsible, @@ -40,92 +49,31 @@ import { SidebarMenuSubItem, useSidebar, } from "@/components/ui/sidebar"; +import { useFavorites } from "@/hooks/use-favorites"; interface NavItem { title: string; url: string; icon?: LucideIcon; isActive?: boolean; + folderId?: number; items?: { title: string; url: string; icon?: LucideIcon; + folderId?: number; }[]; } -const data: { navMain: NavItem[] } = { - navMain: [ - // { - // title: "Playground", - // url: "#", - // icon: SquareTerminal, - // isActive: true, - // items: [ - // { - // title: "History", - // url: "#", - // icon: History, - // }, - // { - // title: "Starred", - // url: "#", - // icon: Star, - // }, - // { - // title: "Settings", - // url: "#", - // icon: Settings2, - // }, - // ], - // }, - // { - // title: "Models", - // url: "#", - // icon: Bot, - // items: [ - // { - // title: "Genesis", - // url: "#", - // }, - // { - // title: "Explorer", - // url: "#", - // }, - // { - // title: "Quantum", - // url: "#", - // }, - // ], - // }, - // { - // title: "Settings", - // url: "#", - // icon: Settings2, - // items: [ - // { - // title: "General", - // url: "#", - // }, - // { - // title: "Team", - // url: "#", - // }, - // { - // title: "Billing", - // url: "#", - // }, - // { - // title: "Limits", - // url: "#", - // }, - // ], - // }, - ], -}; - export function AppSidebar() { const { user, signOut, loading } = useAuth(); const { isMobile } = useSidebar(); + const { folders, deleteFolder } = useFavorites(); + + const [deletingFolder, setDeletingFolder] = useState<{ + id: number; + name: string; + } | null>(null); if (loading) return null; @@ -133,93 +81,202 @@ export function AppSidebar() { const userInitials = user.email ? user.email.slice(0, 2).toUpperCase() : "CN"; + const navMain: NavItem[] = [ + { + title: "論文検索", + url: "/", + icon: Search, + }, + { + title: "保存した論文", + url: "#", + icon: Star, + isActive: true, + items: [ + { + title: "すべて", + url: "/favorites", + icon: Star, + }, + { + title: "デフォルト", + url: "/favorites?folderId=null", + icon: Folder, + }, + ...folders.map((folder) => ({ + title: folder.name, + url: `/favorites?folderId=${folder.id}`, + icon: Folder, + folderId: folder.id, + })), + ], + }, + ]; + + const handleDeleteClick = ( + e: React.MouseEvent, + folderId: number, + folderName: string + ) => { + e.preventDefault(); + e.stopPropagation(); + setDeletingFolder({ id: folderId, name: folderName }); + }; + + const handleConfirmDelete = async () => { + if (deletingFolder) { + await deleteFolder(deletingFolder.id); + setDeletingFolder(null); + } + }; + return ( - - - - RAPID Agent - - {data.navMain.map((item) => ( - - - - - {item.icon && } - {item.title} - - - - - - {item.items?.map((subItem) => ( - - - - {subItem.icon && ( - + <> + + + + + + RAPID Agent + + + + {navMain.map((item) => { + if (!item.items?.length) { + return ( + + + + {item.icon && } + {item.title} + + + + ); + } + + return ( + + + + + {item.icon && } + {item.title} + + + + + + {item.items?.map((subItem) => ( + + + + {subItem.icon && ( + + )} + {subItem.title} + + + {subItem.folderId && ( + )} - {subItem.title} - - - - ))} - - - - - ))} - - - - - - - - - + ))} + + + + + ); + })} + + + + + + + + + + + + + {userInitials} + + +

+ + {user.displayName || "User"} + + + {user.email || "user@example.com"} + +
+ + + + - - - - {userInitials} - - -
- - {user.displayName || "User"} - - - {user.email || "user@example.com"} - -
- - - - - - - Log out - - - - - - - + + + ログアウト + +
+ + + + + + + !open && setDeletingFolder(null)} + > + + + フォルダを削除しますか? + + フォルダ「{deletingFolder?.name}」を削除します。 + フォルダ内の論文はデフォルトに移動されます。 + + + + キャンセル + + 削除 + + + + + ); } diff --git a/src/components/categorization-results.tsx b/src/components/categorization-results.tsx index 8825c61..7b758cf 100644 --- a/src/components/categorization-results.tsx +++ b/src/components/categorization-results.tsx @@ -37,7 +37,7 @@ export function CategorizationResults({ return (
- +
@@ -88,7 +88,7 @@ export function CategorizationResults({ {groupedPapers["other"] && groupedPapers["other"].length > 0 && (
- +
diff --git a/src/components/favorite-button.tsx b/src/components/favorite-button.tsx new file mode 100644 index 0000000..7b50e72 --- /dev/null +++ b/src/components/favorite-button.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Folder } from "@/db/schema"; +import { cn } from "@/lib/utils"; +import { Check, FolderPlus, Star, Trash2 } from "lucide-react"; +import { useState } from "react"; + +interface FavoriteButtonProps { + paperId: number; + isFavorited: boolean; + folders: Folder[]; + // currentFolderId? は不要になる(複数選択可のため) + // 代わりに、この論文が所属しているフォルダIDのSetを受け取る + selectedFolderIds?: Set; + isDefault?: boolean; + + onToggleFolder: ( + paperId: number, + folderId: number | null, + customFolderName?: string + ) => Promise; + onRemove: (paperId: number) => Promise; + onCreateFolder: (name: string) => Promise; + className?: string; +} + +export function FavoriteButton({ + paperId, + isFavorited, + folders, + selectedFolderIds = new Set(), + isDefault = false, + + onToggleFolder, + onRemove, + onCreateFolder, + className, +}: FavoriteButtonProps) { + const [open, setOpen] = useState(false); + const [inputValue, setInputValue] = useState(""); + + const handleSelectFolder = async (folderId: number | null) => { + await onToggleFolder(paperId, folderId); + // ポップアップは閉じない(複数選択のため) + }; + + const handleCreateFolder = async () => { + if (!inputValue.trim()) return; + const newFolder = await onCreateFolder(inputValue); + if (newFolder) { + await onToggleFolder(paperId, newFolder.id, newFolder.name); + // 作成時は閉じる?続けて操作したいかも?一旦閉じるか。 + // setOpen(false); + setInputValue(""); + } + }; + + const handleRemove = async () => { + await onRemove(paperId); + setOpen(false); + }; + + return ( + + + + + e.stopPropagation()} + > + + + + + {inputValue ? ( +
+

「{inputValue}」は見つかりません

+ +
+ ) : ( + "フォルダ名を入力" + )} +
+ + handleSelectFolder(null)} + className="text-xs cursor-pointer" + > +
+ +
+ デフォルト +
+ {folders.map((folder) => ( + handleSelectFolder(folder.id)} + className="text-xs cursor-pointer" + > +
+ +
+ {folder.name} +
+ ))} +
+ {isFavorited && ( + <> + + + + + すべてのお気に入りから削除 + + + + )} +
+
+
+
+ ); +} diff --git a/src/components/introduction.tsx b/src/components/introduction.tsx index 47d766a..ce7132c 100644 --- a/src/components/introduction.tsx +++ b/src/components/introduction.tsx @@ -111,7 +111,7 @@ export function Introduction() { スマートフィルタリング

- CVPR, ICCV, ECCVなどの主要カンファレンスや開催年で絞り込み、 + CVPR, ICCV, WACVなどの主要カンファレンスや開催年で絞り込み、 トレンドや質の高い研究を素早く見つけ出します。

diff --git a/src/components/papers-table.tsx b/src/components/papers-table.tsx index 2788aab..9c40a0e 100644 --- a/src/components/papers-table.tsx +++ b/src/components/papers-table.tsx @@ -1,6 +1,8 @@ "use client"; +import { FavoriteButton } from "@/components/favorite-button"; import { Button } from "@/components/ui/button"; +import { useFavorites } from "@/hooks/use-favorites"; import { Paper } from "@/lib/types"; import { useState } from "react"; @@ -31,6 +33,17 @@ export function PapersTable({ new Set() ); + const { + favoritePaperIds, + favorites, // used for getFolderId logic replacement helpers + folders, + removeFavoriteByPaperId, + createFolder, + toggleFolder, + getPaperFolderIds, + isPaperInDefault, + } = useFavorites(); + const toggleAbstract = (id: number) => { setExpandedAbstracts((prev) => { const next = new Set(prev); @@ -74,13 +87,16 @@ export function PapersTable({
)} + + 保存 + 論文タイトル 学会名・年 - + 概要 {showSimilarity && ( @@ -115,6 +131,18 @@ export function PapersTable({ /> )} + + + - + {steps.map((step, index) => (
@@ -64,7 +69,7 @@ export function SearchStepper({ currentStep }: SearchStepperProps) { )}
- + {step.title}
@@ -72,7 +77,7 @@ export function SearchStepper({ currentStep }: SearchStepperProps) { {steps.length > index + 1 && ( - + )} ))} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..d934609 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client"; + +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import * as React from "react"; + +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/src/components/ui/animated-gradient-text.tsx b/src/components/ui/animated-gradient-text.tsx index 3bfe939..5a9b325 100644 --- a/src/components/ui/animated-gradient-text.tsx +++ b/src/components/ui/animated-gradient-text.tsx @@ -14,8 +14,8 @@ export function AnimatedGradientText({ children, className, speed = 1, - colorFrom = "#3ba5f6ff", - colorTo = "#f45cf6ff", + colorFrom = "#3b79f6ff", + colorTo = "#d82ff6ff", darkColorFrom = "#54e3f3ff", darkColorTo = "#c75cf1ff", ...props diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..7818d21 --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { Toaster as Sonner } from "sonner"; + +type ToasterProps = React.ComponentProps; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + + ); +}; + +export { Toaster }; diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 01c866c..ff2b2c4 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -15,6 +15,7 @@ import { useEffect, useState, } from "react"; +import { toast } from "sonner"; interface AuthContextType { user: User | null; @@ -72,8 +73,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { } try { await signInWithPopup(auth, googleProvider); + toast.success("ログインしました"); } catch (error) { console.error("Google sign-in error:", error); + toast.error("ログインに失敗しました"); } }; @@ -84,8 +87,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { } try { await firebaseSignOut(auth); + toast.success("ログアウトしました"); } catch (error) { console.error("Sign-out error:", error); + toast.error("ログアウトに失敗しました"); } }; diff --git a/src/db/schema.ts b/src/db/schema.ts index 5d30104..341fbd2 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -6,6 +6,7 @@ import { serial, text, timestamp, + uniqueIndex, varchar, vector, } from "drizzle-orm/pg-core"; @@ -45,3 +46,41 @@ export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert; export type Paper = typeof papers.$inferSelect; export type NewPaper = typeof papers.$inferInsert; + +// フォルダテーブル +export const folders = pgTable("folders", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 128 }).notNull(), // User.id (Firebase UID) + name: varchar("name", { length: 255 }).notNull(), + createdAt: timestamp("created_at").defaultNow(), +}); + +// お気に入りテーブル +export const favorites = pgTable( + "favorites", + { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 128 }).notNull(), // User.id (Firebase UID) + paperId: integer("paper_id") + .notNull() + .references(() => papers.id, { onDelete: "cascade" }), + folderId: integer("folder_id").references(() => folders.id, { + onDelete: "set null", + }), // Nullable (未分類など) + createdAt: timestamp("created_at").defaultNow(), + }, + (table) => ({ + userIdIdx: index("idx_favorites_user_id").on(table.userId), + folderIdIdx: index("idx_favorites_folder_id").on(table.folderId), + uniqueFavorite: uniqueIndex("unique_favorite_idx").on( + table.userId, + table.paperId, + sql`coalesce(${table.folderId}, -1)` + ), + }) +); + +export type Folder = typeof folders.$inferSelect; +export type NewFolder = typeof folders.$inferInsert; +export type Favorite = typeof favorites.$inferSelect; +export type NewFavorite = typeof favorites.$inferInsert; diff --git a/src/hooks/use-favorites.ts b/src/hooks/use-favorites.ts new file mode 100644 index 0000000..ec2ba03 --- /dev/null +++ b/src/hooks/use-favorites.ts @@ -0,0 +1,397 @@ +import { useAuth } from "@/contexts/AuthContext"; +import { Favorite, Folder, Paper } from "@/db/schema"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; + +export type FavoriteWithPaper = Favorite & { + paper: Paper; + folder: Folder | null; +}; + +export function useFavorites() { + const { user } = useAuth(); + const [favorites, setFavorites] = useState([]); + const [folders, setFolders] = useState([]); + const [loading, setLoading] = useState(true); + const [favoritePaperIds, setFavoritePaperIds] = useState>( + new Set() + ); + + const fetchFavorites = useCallback(async () => { + if (!user) return; + try { + const token = await user.getIdToken(); + const res = await fetch("/api/favorites", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data: FavoriteWithPaper[] = await res.json(); + setFavorites(data); + setFavoritePaperIds(new Set(data.map((f) => f.paperId))); + } + } catch (error) { + console.error("Failed to fetch favorites:", error); + } + }, [user]); + + const fetchFolders = useCallback(async () => { + if (!user) return; + try { + const token = await user.getIdToken(); + const res = await fetch("/api/folders", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data: Folder[] = await res.json(); + setFolders(data); + } + } catch (error) { + console.error("Failed to fetch folders:", error); + } + }, [user]); + + useEffect(() => { + if (user) { + const fetchData = async () => { + setLoading(true); + try { + await Promise.all([fetchFavorites(), fetchFolders()]); + } finally { + setLoading(false); + } + }; + fetchData(); + } else { + setFavorites([]); + setFolders([]); + setFavoritePaperIds(new Set()); + setLoading(false); + } + }, [user, fetchFavorites, fetchFolders]); + + const addFavorite = async ( + paperId: number, + folderId?: number | null, + customFolderName?: string + ): Promise => { + if (!user) return false; + + // Snapshot for revert + const previousFavorites = [...favorites]; + const previousIds = new Set(favoritePaperIds); + + // Optimistic update + const tempId = Date.now(); // Temporary ID + // Note: We use dummy data for properties we don't have. + // This is safe for "isFavorited" checks (based on ID) but listing these optimistically created items + // might show empty titles until refresh. + const newFavorite: FavoriteWithPaper = { + id: tempId, + userId: user.uid, + paperId, + folderId: folderId ?? null, + createdAt: new Date(), + paper: { + id: paperId, + title: "", + url: "", + abstract: null, + authors: null, + conferenceName: null, + conferenceYear: null, + embedding: null, + createdAt: new Date(), + }, + folder: null, + }; + + try { + const token = await user.getIdToken(); + + setFavorites((prev) => [...prev, newFavorite]); + setFavoritePaperIds((prev) => new Set(prev).add(paperId)); + + const res = await fetch("/api/favorites", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ paperId, folderId }), + }); + if (res.ok) { + // Find folder name if folderId is provided + let folderName = "デフォルト"; + if (folderId) { + const folder = folders.find((f) => f.id === folderId); + if (folder) { + folderName = folder.name; + } else if (customFolderName) { + folderName = customFolderName; + } + } + toast.success(`「${folderName}」に保存しました`); + await fetchFavorites(); // Refresh to get real data + return true; + } + + // Revert on failure + setFavorites(previousFavorites); + setFavoritePaperIds(previousIds); + toast.error("保存に失敗しました"); + return false; + } catch (error) { + console.error("Failed to add favorite:", error); + // Revert logic + setFavorites(previousFavorites); + setFavoritePaperIds(previousIds); + toast.error("保存に失敗しました"); + return false; + } + }; + + const removeFavorite = async (favoriteId: number): Promise => { + if (!user) return false; + + // Snapshot for revert + const previousFavorites = [...favorites]; + const previousIds = new Set(favoritePaperIds); + + // Optimistic delete + setFavorites((prev) => prev.filter((f) => f.id !== favoriteId)); + // Check if any other entries remain for this paper + const removedItem = favorites.find((f) => f.id === favoriteId); + if (removedItem) { + const remaining = favorites.filter( + (f) => f.id !== favoriteId && f.paperId === removedItem.paperId + ); + if (remaining.length === 0) { + setFavoritePaperIds((prev) => { + const next = new Set(prev); + next.delete(removedItem.paperId); + return next; + }); + } + } + + try { + const token = await user.getIdToken(); + const res = await fetch(`/api/favorites/${favoriteId}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + // Find folder name if exists (using snapshot) + let folderName = "デフォルト"; + const removedFavorite = previousFavorites.find( + (f) => f.id === favoriteId + ); + if (removedFavorite?.folderId) { + const folder = folders.find((f) => f.id === removedFavorite.folderId); + if (folder) folderName = folder.name; + } + + toast.success(`「${folderName}」から削除しました`); + return true; + } + // Revert + setFavorites(previousFavorites); + setFavoritePaperIds(previousIds); + toast.error("削除に失敗しました"); + return false; + } catch (error) { + console.error("Failed to remove favorite:", error); + setFavorites(previousFavorites); + setFavoritePaperIds(previousIds); + toast.error("削除に失敗しました"); + return false; + } + }; + + // フォルダのトグル処理 + // フォルダのトグル処理 + const toggleFolder = async ( + paperId: number, + folderId: number | null, + customFolderName?: string + ) => { + const existing = favorites.find( + (f) => f.paperId === paperId && f.folderId === folderId + ); + + if (existing) { + // 既にある場合は削除 + return removeFavorite(existing.id); + } else { + // ない場合は追加 + return addFavorite(paperId, folderId, customFolderName); + } + }; + + // 論文IDからお気に入りを削除するヘルパー (全削除) + const removeFavoriteByPaperId = async (paperId: number) => { + if (!user) return false; + + // Snapshot + const previousFavorites = [...favorites]; + const previousIds = new Set(favoritePaperIds); + + // Optimistic delete + setFavorites((prev) => prev.filter((f) => f.paperId !== paperId)); + setFavoritePaperIds((prev) => { + const next = new Set(prev); + next.delete(paperId); + return next; + }); + + // この論文に関連する全てのエントリを削除 IDs needed for API calls + const relatedFavorites = favorites.filter((f) => f.paperId === paperId); + if (relatedFavorites.length === 0) return false; + + // Use Promise.allSettled or just try loop. + // `removeFavorite` inside here would preserve its own optimistic logic which is confusing. + // We should call API directly or use a raw remove helper. + // But `removeFavorite` has the logic. + // For `removeFavoriteByPaperId` specifically, let's just do the API calls and if any fail, revert ALL. + // Actually, `removeFavorite` is now optimistic. Calling it multiple times will trigger multiple state updates. + // That might be messy. + // Better to implement raw API calls here. + + try { + const token = await user.getIdToken(); + const results = await Promise.all( + relatedFavorites.map((f) => + fetch(`/api/favorites/${f.id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }).then((res) => res.ok) + ) + ); + + if (results.every((r) => r)) { + toast.success("すべてのお気に入りから削除しました"); + return true; + } else { + // Partial failure? Revert all for safety or just re-fetch. + await fetchFavorites(); + toast.error("一部の削除に失敗しました"); + return false; + } + } catch (e) { + console.error("Failed removeFavoriteByPaperId", e); + setFavorites(previousFavorites); + setFavoritePaperIds(previousIds); + toast.error("削除に失敗しました"); + return false; + } + }; + + const createFolder = async (name: string) => { + if (!user) return null; + try { + const token = await user.getIdToken(); + const res = await fetch("/api/folders", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ name }), + }); + if (res.ok) { + const newFolder: Folder = await res.json(); + await fetchFolders(); // Refresh + toast.success(`フォルダ「${name}」を作成しました`); + return newFolder; + } + return null; + } catch (error) { + console.error("Failed to create folder:", error); + toast.error("フォルダの作成に失敗しました"); + return null; + } + }; + + const renameFolder = async (folderId: number, name: string) => { + if (!user) return null; + try { + const token = await user.getIdToken(); + const res = await fetch(`/api/folders/${folderId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ name }), + }); + if (res.ok) { + const updatedFolder: Folder = await res.json(); + await fetchFolders(); // Refresh + toast.success(`フォルダ名を「${name}」に変更しました`); + return updatedFolder; + } + return null; + } catch (error) { + console.error("Failed to rename folder:", error); + toast.error("フォルダ名の変更に失敗しました"); + return null; + } + }; + + const deleteFolder = async (folderId: number) => { + if (!user) return false; + + // フォルダ名を取得(トースト用) + const folder = folders.find((f) => f.id === folderId); + const folderName = folder?.name ?? "フォルダ"; + + try { + const token = await user.getIdToken(); + const res = await fetch(`/api/folders/${folderId}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + await Promise.all([fetchFolders(), fetchFavorites()]); + toast.success(`フォルダ「${folderName}」を削除しました`); + return true; + } + toast.error("フォルダの削除に失敗しました"); + return false; + } catch (error) { + console.error("Failed to delete folder:", error); + toast.error("フォルダの削除に失敗しました"); + return false; + } + }; + + return { + favorites, + folders, + loading, + favoritePaperIds, + addFavorite, + removeFavorite, + removeFavoriteByPaperId, + toggleFolder, + createFolder, + deleteFolder, + renameFolder, + refreshFavorites: fetchFavorites, + refreshFolders: fetchFolders, + // ヘルパー: 特定の論文が所属するフォルダID一覧を取得 + getPaperFolderIds: (paperId: number) => { + return new Set( + favorites + .filter((f) => f.paperId === paperId && f.folderId !== null) + .map((f) => f.folderId as number) + ); + }, + // ヘルパー: 特定の論文がデフォルト(未分類)に含まれるか + isPaperInDefault: (paperId: number) => { + return favorites.some( + (f) => f.paperId === paperId && f.folderId === null + ); + }, + }; +}