Skip to content
Open
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
27 changes: 25 additions & 2 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,20 @@ export default function HomePage() {
enabled: status !== "loading" && !((filter === "mine" || filter === "private") && !isAuthenticated),
});

const projects = data?.pages.flatMap((page) => page.items) ?? [];
// Optimistic overrides: only tracks changes made this session.
// Server data (is_favorited) remains the source of truth for all other projects.
const [favoriteOverrides, setFavoriteOverrides] = useState<Map<string, boolean>>(new Map());

const handleFavoriteChange = (projectId: string, isFavorited: boolean) => {
setFavoriteOverrides((prev) => new Map(prev).set(projectId, isFavorited));
};

const rawProjects = data?.pages.flatMap((page) => page.items) ?? [];
const projects = [...rawProjects].sort((a, b) => {
const aFav = favoriteOverrides.has(a.id) ? favoriteOverrides.get(a.id)! : (a.is_favorited ?? false);
const bFav = favoriteOverrides.has(b.id) ? favoriteOverrides.get(b.id)! : (b.is_favorited ?? false);
return (bFav ? 1 : 0) - (aFav ? 1 : 0);
});
Comment on lines +69 to +80
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset favoriteOverrides when auth identity changes.

favoriteOverrides persists across session changes, so signing out/in as another user can apply stale overrides to the wrong account’s list ordering/state. Clear overrides on access-token (or user-id) change.

Suggested fix
   const [favoriteOverrides, setFavoriteOverrides] = useState<Map<string, boolean>>(new Map());
+
+  useEffect(() => {
+    setFavoriteOverrides(new Map());
+  }, [session?.accessToken]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/page.tsx` around lines 69 - 80, favoriteOverrides persists across
sessions and needs to be cleared when the authenticated identity changes; add an
effect that watches the auth identity (e.g., accessToken or userId from your
auth context/props) and calls setFavoriteOverrides(new Map()) when that identity
changes, so stale overrides aren't applied to a different user. Locate the state
favoriteOverrides and setter setFavoriteOverrides and the handler
handleFavoriteChange, then implement a useEffect that depends on the chosen
identity value and resets favoriteOverrides on change. Ensure the dependency
uses the exact auth value available in this component (accessToken or userId).

const total = data?.pages.at(-1)?.total ?? 0;
const unfilteredTotal = data?.pages.at(-1)?.unfiltered_total ?? 0;
const isFiltered = (!!debouncedSearch || filter !== "all") && unfilteredTotal > total;
Expand Down Expand Up @@ -245,7 +258,17 @@ export default function HomePage() {
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
<ProjectCard
key={project.id}
project={{
...project,
is_favorited: favoriteOverrides.has(project.id)
? favoriteOverrides.get(project.id)
: project.is_favorited,
}}
accessToken={session?.accessToken}
onFavoriteChange={handleFavoriteChange}
/>
))}
</div>
{hasNextPage && (
Expand Down
82 changes: 66 additions & 16 deletions components/projects/project-card.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
"use client";

import { useState } from "react";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { Globe, Lock, Users } from "lucide-react";
import { Globe, Lock, Users, Star } from "lucide-react";
import { cn, formatDate } from "@/lib/utils";
import type { Project } from "@/lib/api/projects";
import { projectApi, type Project } from "@/lib/api/projects";
import { derivePermissions } from "@/lib/hooks/useProject";
import { useEditorModeStore } from "@/lib/stores/editorModeStore";

interface ProjectCardProps {
project: Project;
className?: string;
accessToken?: string;
onFavoriteChange?: (projectId: string, isFavorited: boolean) => void;
}

export function ProjectCard({ project, className }: ProjectCardProps) {
export function ProjectCard({ project, className, accessToken, onFavoriteChange }: ProjectCardProps) {
const { data: session } = useSession();
const { canSuggest } = derivePermissions(project, session?.accessToken);
const preferEditMode = useEditorModeStore((s) => s.preferEditMode);
const isFavorited = project.is_favorited ?? false;
const [isToggling, setIsToggling] = useState(false);

// When the user has prefer-edit-mode on AND has at least suggester rights,
// open the project straight to the editor. Anyone without edit/suggest
Expand All @@ -25,6 +30,27 @@ export function ProjectCard({ project, className }: ProjectCardProps) {
? `/projects/${project.id}/editor`
: `/projects/${project.id}`;

const handleFavoriteClick = async (e: React.MouseEvent) => {
e.preventDefault();
if (!accessToken || isToggling) return;

// Optimistic update via parent — parent holds the source of truth
onFavoriteChange?.(project.id, !isFavorited);
setIsToggling(true);

try {
if (isFavorited) {
await projectApi.unfavorite(project.id, accessToken);
} else {
await projectApi.favorite(project.id, accessToken);
}
} catch {
onFavoriteChange?.(project.id, isFavorited); // rollback in parent
} finally {
setIsToggling(false);
}
};

return (
<Link
href={href}
Expand All @@ -47,20 +73,44 @@ export function ProjectCard({ project, className }: ProjectCardProps) {
</p>
)}
</div>
<div
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
project.is_public
? "bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400"
: "bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400"
)}
title={project.is_public ? "Public project" : "Private project"}
>
{project.is_public ? (
<Globe className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
<div className="flex items-center gap-1.5 shrink-0">
{accessToken && (
<button
onClick={handleFavoriteClick}
disabled={isToggling}
aria-label={isFavorited ? "Remove from favorites" : "Add to favorites"}
aria-pressed={isFavorited}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full transition-colors",
"hover:bg-amber-50 dark:hover:bg-amber-900/20",
isToggling && "opacity-50 cursor-not-allowed"
)}
>
<Star
className={cn(
"h-4 w-4 transition-colors",
isFavorited
? "fill-amber-400 text-amber-400"
: "text-slate-300 dark:text-slate-600 hover:text-amber-400"
)}
/>
</button>
)}
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full",
project.is_public
? "bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400"
: "bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400"
)}
title={project.is_public ? "Public project" : "Private project"}
>
{project.is_public ? (
<Globe className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
)}
</div>
</div>
</div>

Expand Down
18 changes: 18 additions & 0 deletions lib/api/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export interface Project {
is_exemplar?: boolean;
exemplar_slug?: string;
exemplar_source_url?: string;
// Favorites
is_favorited?: boolean;
}

export interface ProjectListResponse {
Expand Down Expand Up @@ -300,6 +302,22 @@ export const projectApi = {
headers: { Authorization: `Bearer ${token}` },
}),

/**
* Add a project to the current user's favorites
*/
favorite: (id: string, token: string) =>
api.post<Project>(`/api/v1/projects/${id}/favorite`, {}, {
headers: { Authorization: `Bearer ${token}` },
}),

/**
* Remove a project from the current user's favorites
*/
unfavorite: (id: string, token: string) =>
api.delete<Project>(`/api/v1/projects/${id}/favorite`, {
headers: { Authorization: `Bearer ${token}` },
}),

/**
* Transfer project ownership to an admin member
* @param force - If true, proceed even if GitHub integration will be disconnected
Expand Down
Loading