From bff236e5e6ad2f47d056d7c22bd103601132fd03 Mon Sep 17 00:00:00 2001 From: R-Hart80 Date: Mon, 13 Apr 2026 16:52:02 -0300 Subject: [PATCH 1/2] feat: add project favorites with star icon and sort-to-top - Project interface: add is_favorited?: boolean field - projectApi: add favorite() and unfavorite() methods calling POST/DELETE /api/v1/projects/{id}/favorite - ProjectCard: star button visible to authenticated users only; optimistic toggle with rollback on error; accessible with aria-label and aria-pressed attributes - HomePage: track favorited IDs in local state, sort favorited projects to the top within any active filter/tab; pass accessToken and onFavoriteChange to ProjectCard Closes #78 Co-Authored-By: Claude Sonnet 4.6 --- app/page.tsx | 28 +++++++++- components/projects/project-card.tsx | 83 ++++++++++++++++++++++------ lib/api/projects.ts | 18 ++++++ 3 files changed, 111 insertions(+), 18 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index a2817afb..2f57b315 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -64,7 +64,26 @@ export default function HomePage() { enabled: status !== "loading" && !((filter === "mine" || filter === "private") && !isAuthenticated), }); - const projects = data?.pages.flatMap((page) => page.items) ?? []; + const [favoritedIds, setFavoritedIds] = useState>(() => { + const items = data?.pages.flatMap((page) => page.items) ?? []; + return new Set(items.filter((p) => p.is_favorited).map((p) => p.id)); + }); + + const handleFavoriteChange = (projectId: string, isFavorited: boolean) => { + setFavoritedIds((prev) => { + const next = new Set(prev); + if (isFavorited) next.add(projectId); + else next.delete(projectId); + return next; + }); + }; + + const rawProjects = data?.pages.flatMap((page) => page.items) ?? []; + const projects = [...rawProjects].sort((a, b) => { + const aFav = favoritedIds.has(a.id) ? 1 : 0; + const bFav = favoritedIds.has(b.id) ? 1 : 0; + return bFav - aFav; + }); const total = data?.pages.at(-1)?.total ?? 0; const unfilteredTotal = data?.pages.at(-1)?.unfiltered_total ?? 0; const isFiltered = (!!debouncedSearch || filter !== "all") && unfilteredTotal > total; @@ -245,7 +264,12 @@ export default function HomePage() {
{projects.map((project) => ( - + ))}
{hasNextPage && ( diff --git a/components/projects/project-card.tsx b/components/projects/project-card.tsx index 69b82033..c7ab2eb6 100644 --- a/components/projects/project-card.tsx +++ b/components/projects/project-card.tsx @@ -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, setIsFavorited] = useState(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 @@ -25,6 +30,28 @@ 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; + + const previous = isFavorited; + setIsFavorited(!previous); // optimistic update + setIsToggling(true); + + try { + if (previous) { + await projectApi.unfavorite(project.id, accessToken); + } else { + await projectApi.favorite(project.id, accessToken); + } + onFavoriteChange?.(project.id, !previous); + } catch { + setIsFavorited(previous); // rollback on error + } finally { + setIsToggling(false); + } + }; + return ( )} -
- {project.is_public ? ( - - ) : ( - +
+ {accessToken && ( + )} +
+ {project.is_public ? ( + + ) : ( + + )} +
diff --git a/lib/api/projects.ts b/lib/api/projects.ts index 21b7d543..13f41de2 100644 --- a/lib/api/projects.ts +++ b/lib/api/projects.ts @@ -51,6 +51,8 @@ export interface Project { is_exemplar?: boolean; exemplar_slug?: string; exemplar_source_url?: string; + // Favorites + is_favorited?: boolean; } export interface ProjectListResponse { @@ -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(`/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(`/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 From bad9a80f3c7928135b61cb37bf7113874ca2fd54 Mon Sep 17 00:00:00 2001 From: R-Hart80 Date: Mon, 13 Apr 2026 16:57:16 -0300 Subject: [PATCH 2/2] refactor: derive favorite state from React Query data via optimistic overrides Map Previously favoritedIds was a Set initialized once from the first page load, causing newly paginated or refetched projects to miss their is_favorited values from the server. Replace it with a favoriteOverrides Map that only stores changes made this session. The sort and display logic merges server data (source of truth) with the override map, so all pages stay correct. On API error, the rollback now calls onFavoriteChange back to the previous value, keeping the override map as the single state location. Co-Authored-By: Claude Sonnet 4.6 --- app/page.tsx | 27 +++++++++++++-------------- components/projects/project-card.tsx | 11 +++++------ 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 2f57b315..e5cf63a8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -64,25 +64,19 @@ export default function HomePage() { enabled: status !== "loading" && !((filter === "mine" || filter === "private") && !isAuthenticated), }); - const [favoritedIds, setFavoritedIds] = useState>(() => { - const items = data?.pages.flatMap((page) => page.items) ?? []; - return new Set(items.filter((p) => p.is_favorited).map((p) => p.id)); - }); + // 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>(new Map()); const handleFavoriteChange = (projectId: string, isFavorited: boolean) => { - setFavoritedIds((prev) => { - const next = new Set(prev); - if (isFavorited) next.add(projectId); - else next.delete(projectId); - return next; - }); + setFavoriteOverrides((prev) => new Map(prev).set(projectId, isFavorited)); }; const rawProjects = data?.pages.flatMap((page) => page.items) ?? []; const projects = [...rawProjects].sort((a, b) => { - const aFav = favoritedIds.has(a.id) ? 1 : 0; - const bFav = favoritedIds.has(b.id) ? 1 : 0; - return bFav - aFav; + 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); }); const total = data?.pages.at(-1)?.total ?? 0; const unfilteredTotal = data?.pages.at(-1)?.unfiltered_total ?? 0; @@ -266,7 +260,12 @@ export default function HomePage() { {projects.map((project) => ( diff --git a/components/projects/project-card.tsx b/components/projects/project-card.tsx index c7ab2eb6..39a789d5 100644 --- a/components/projects/project-card.tsx +++ b/components/projects/project-card.tsx @@ -20,7 +20,7 @@ export function ProjectCard({ project, className, accessToken, onFavoriteChange const { data: session } = useSession(); const { canSuggest } = derivePermissions(project, session?.accessToken); const preferEditMode = useEditorModeStore((s) => s.preferEditMode); - const [isFavorited, setIsFavorited] = useState(project.is_favorited ?? false); + 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, @@ -34,19 +34,18 @@ export function ProjectCard({ project, className, accessToken, onFavoriteChange e.preventDefault(); if (!accessToken || isToggling) return; - const previous = isFavorited; - setIsFavorited(!previous); // optimistic update + // Optimistic update via parent — parent holds the source of truth + onFavoriteChange?.(project.id, !isFavorited); setIsToggling(true); try { - if (previous) { + if (isFavorited) { await projectApi.unfavorite(project.id, accessToken); } else { await projectApi.favorite(project.id, accessToken); } - onFavoriteChange?.(project.id, !previous); } catch { - setIsFavorited(previous); // rollback on error + onFavoriteChange?.(project.id, isFavorited); // rollback in parent } finally { setIsToggling(false); }