Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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 thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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