diff --git a/app/page.tsx b/app/page.tsx index a2817afb..e5cf63a8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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>(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); + }); 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 +258,17 @@ export default function HomePage() {
{projects.map((project) => ( - + ))}
{hasNextPage && ( diff --git a/components/projects/project-card.tsx b/components/projects/project-card.tsx index 69b82033..39a789d5 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 = 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,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 ( )} -
- {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