Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions app/api/bookmarks/[bountyId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/server-auth";
import { graphqlRequest } from "@/lib/server-graphql";
import { ToggleBookmarkDocument } from "@/lib/graphql/generated";
import type { Bookmark } from "@/lib/graphql/generated";

/**
* POST /api/bookmarks/[bountyId]
* Toggles bookmark state for the given bounty
* If bookmarked → removes and returns null
* If not bookmarked → adds and returns bookmark
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ bountyId: string }> },
) {
try {
const user = await getCurrentUser();

if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { bountyId } = await params;

if (!bountyId) {
return NextResponse.json(
{ error: "bountyId is required" },
{ status: 400 },
);
}

// Use generated GraphQL mutation document
const data = await graphqlRequest<{ toggleBookmark: Bookmark | null }>(
ToggleBookmarkDocument,
{ input: { bountyId } },
);

// GraphQL mutation returns the bookmark when added, null when removed
if (data.toggleBookmark) {
return NextResponse.json(data.toggleBookmark);
} else {
return NextResponse.json(null, { status: 200 });
}
} catch (error: unknown) {
console.error("Error toggling bookmark:", error);
const message =
error instanceof Error ? error.message : "Failed to toggle bookmark";
return NextResponse.json({ error: message }, { status: 500 });
}
}
42 changes: 42 additions & 0 deletions app/api/bookmarks/ids/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/server-auth";
import { graphqlRequest } from "@/lib/server-graphql";

/**
* GET /api/bookmarks/ids
* Returns an array of bookmarked bounty IDs for the current user
* Optimized for O(1) bookmark existence checks
*/
export async function GET() {
try {
const user = await getCurrentUser();

if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

// Query GraphQL for bookmarks - we only need the IDs
const BOOKMARKS_QUERY = `
query GetBookmarkIds {
bookmarks {
bountyId
}
}
`;

const data = await graphqlRequest<{
bookmarks: Array<{ bountyId: string }>;
}>(BOOKMARKS_QUERY);

// Extract just the bounty IDs as a simple array
const bountyIds = data.bookmarks.map((b) => b.bountyId);

return NextResponse.json(bountyIds);
} catch (error) {
console.error("Error fetching bookmark IDs:", error);
return NextResponse.json(
{ error: "Failed to fetch bookmark IDs" },
{ status: 500 },
);
}
}
32 changes: 32 additions & 0 deletions app/api/bookmarks/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/server-auth";
import { graphqlRequest } from "@/lib/server-graphql";
import { BookmarksDocument } from "@/lib/graphql/generated";
import type { Bookmark } from "@/lib/graphql/generated";

/**
* GET /api/bookmarks
* Returns all bookmarked bounties for the current user
*/
export async function GET() {
try {
const user = await getCurrentUser();

if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

// Use generated GraphQL document for type safety
const data = await graphqlRequest<{ bookmarks: Bookmark[] }>(
BookmarksDocument,
);

return NextResponse.json(data.bookmarks);
} catch (error) {
console.error("Error fetching bookmarks:", error);
return NextResponse.json(
{ error: "Failed to fetch bookmarks" },
{ status: 500 },
);
}
}
13 changes: 13 additions & 0 deletions app/saved/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { getCurrentUser } from "@/lib/server-auth";
import { redirect } from "next/navigation";
import SavedBountiesClient from "./saved-client";

export default async function SavedPage() {
const user = await getCurrentUser();

if (!user) {
redirect("/auth");
}

return <SavedBountiesClient />;
}
77 changes: 77 additions & 0 deletions app/saved/saved-client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"use client";

import { useBookmarks } from "@/hooks/use-bookmarks";
import { BountyCard } from "@/components/bounty/bounty-card";
import { Skeleton } from "@/components/ui/skeleton";
import { Bookmark } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import type {
Bookmark as BookmarkType,
Bounty as BountyType,
} from "@/lib/graphql/generated";

function SavedBountiesClient() {
const { data: bookmarks, isLoading, error } = useBookmarks();

if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-3">
<Skeleton className="h-48 w-full" />
</div>
))}
</div>
);
}

if (error) {
return (
<div className="text-center py-12">
<p className="text-destructive mb-4">
Failed to load saved bounties. Please try again.
</p>
<Button onClick={() => window.location.reload()}>Retry</Button>
</div>
);
}

// Filter out any bookmarks with null bounty (defensive check)
const bookmarkedBounties = (bookmarks ?? [])
.filter((b): b is BookmarkType => b.bounty !== null)
.map((b) => b.bounty as BountyType);

if (bookmarkedBounties.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<Bookmark className="h-8 w-8 text-muted-foreground" />
</div>
<h2 className="text-xl font-semibold mb-2">No saved bounties yet</h2>
<p className="text-muted-foreground max-w-sm mb-6">
Bookmark interesting bounties to save them here for later review.
</p>
<Button asChild>
<Link href="/">Explore Bounties</Link>
</Button>
</div>
);
}

return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{bookmarkedBounties.map((bounty) => (
<BountyCard
key={bounty.id}
bounty={bounty}
onClick={() => {
window.location.href = `/bounty/${bounty.id}`;
}}
/>
))}
</div>
);
}

export default SavedBountiesClient;
6 changes: 6 additions & 0 deletions codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ const config: CodegenConfig = {
generates: {
"./lib/graphql/generated.ts": {
plugins: [
{
add: {
content: "import { gql } from 'graphql-tag';",
},
},
"typescript",
"typescript-operations",
"typescript-react-query",
Expand All @@ -24,6 +29,7 @@ const config: CodegenConfig = {
},
exposeQueryKeys: true,
reactQueryVersion: 5,
documentMode: "graphQLTag",
scalars: {
DateTime: "string",
JSON: "Record<string, any>",
Expand Down
16 changes: 15 additions & 1 deletion components/bounty-detail/bounty-detail-header-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,27 @@ import { ExternalLink, GitBranch } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { BountyFieldsFragment } from "@/lib/graphql/generated";
import { StatusBadge, TypeBadge } from "./bounty-badges";
import { BookmarkButton } from "@/components/bounty/bookmark-button";

export function HeaderCard({ bounty }: { bounty: BountyFieldsFragment }) {
const orgName = bounty.organization?.name ?? "Unknown";
const orgLogo = bounty.organization?.logo;

return (
<div className="p-6 rounded-xl border border-gray-800 bg-background-card backdrop-blur-xl shadow-sm">
<div className="p-6 rounded-xl border border-gray-800 bg-background-card backdrop-blur-xl shadow-sm relative">
{/* Bookmark button - top right corner */}
<div
className="absolute right-4 top-4"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
}
}}
>
<BookmarkButton bountyId={bounty.id} size="md" />
</div>

{/* Badges */}
<div className="flex items-center gap-2 flex-wrap mb-4">
<StatusBadge status={bounty.status} type={bounty.type} />
Expand Down
121 changes: 121 additions & 0 deletions components/bounty/bookmark-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"use client";

import { useMemo } from "react";
import { Bookmark, BookmarkCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { toast } from "sonner";
import { useBookmarkIds, useToggleBookmark } from "@/hooks/use-bookmarks";
import { authClient } from "@/lib/auth-client";

interface BookmarkButtonProps {
bountyId: string;
size?: "sm" | "md" | "lg" | "icon";
className?: string;
showLabel?: boolean;
}

/**
* BookmarkButton component for toggling bookmark state on a bounty.
*
* Features:
* - Instant visual feedback (filled/outline icon)
* - Optimistic UI updates via React Query
* - Accessible: aria-pressed, keyboard operable
* - Handles loading state and errors
* - Shows disabled state with login tooltip for unauthenticated users
*
* @param bountyId - The bounty ID to toggle bookmark for
* @param size - Button size (default: "md")
* @param className - Additional CSS classes
* @param showLabel - Whether to show "Save" / "Saved" label (default: false, icon only)
*/
export function BookmarkButton({
bountyId,
size = "md",
className,
showLabel = false,
}: BookmarkButtonProps) {
const { data: session } = authClient.useSession();
const { data: bookmarkedIds } = useBookmarkIds();
const toggleMutation = useToggleBookmark();

const isAuthenticated = Boolean(session?.user);
const bookmarkedIdsSet = useMemo(() => {
return new Set(bookmarkedIds ?? []);
}, [bookmarkedIds]);

const isBookmarked = useMemo(() => {
return bookmarkedIdsSet.has(bountyId);
}, [bookmarkedIdsSet, bountyId]);

const handleToggle = async (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent card click

if (!isAuthenticated) {
toast.error("Please log in to save bounties");
return;
}

try {
await toggleMutation.mutateAsync(bountyId);
} catch {
// Error handled by mutation hook
}
};

const isLoading = toggleMutation.isPending;

const iconSize = size === "sm" ? 16 : size === "lg" ? 24 : 20;
const buttonSize = size === "md" ? "default" : size;

const button = (
<Button
type="button"
variant="ghost"
size={
buttonSize as "default" | "sm" | "lg" | "icon" | "icon-sm" | "icon-lg"
}
className={className}
aria-pressed={isBookmarked}
aria-label={isBookmarked ? "Remove bookmark" : "Add bookmark"}
onClick={handleToggle}
disabled={isLoading}
>
{isLoading ? (
<span className="animate-pulse">...</span>
) : isBookmarked ? (
<BookmarkCheck
className={`text-primary fill-current`}
style={{ width: iconSize, height: iconSize }}
/>
) : (
<Bookmark
className="text-muted-foreground"
style={{ width: iconSize, height: iconSize }}
/>
)}
{showLabel && (
<span className="ml-2">{isBookmarked ? "Saved" : "Save"}</span>
)}
</Button>
);

// If not authenticated, wrap in tooltip to prompt login
if (!isAuthenticated) {
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>Log in to save bounties</p>
</TooltipContent>
</Tooltip>
);
}

return button;
}
Loading
Loading