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
110 changes: 107 additions & 3 deletions __tests__/components/projects/project-card.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";

// Mock next/link to render a plain <a> tag
vi.mock("next/link", () => ({
Expand All @@ -17,6 +17,21 @@ vi.mock("next-auth/react", () => ({
useSession: () => ({ data: mockSessionAccessToken ? { accessToken: mockSessionAccessToken } : null }),
}));

const favoriteMock = vi.fn();
const unfavoriteMock = vi.fn();

vi.mock("@/lib/api/projects", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api/projects")>();
return {
...actual,
projectApi: {
...actual.projectApi,
favorite: (...args: unknown[]) => favoriteMock(...args),
unfavorite: (...args: unknown[]) => unfavoriteMock(...args),
},
};
});

vi.mock("@/lib/stores/editorModeStore", () => ({
useEditorModeStore: <T,>(selector: (s: { preferEditMode: boolean }) => T) =>
selector({ preferEditMode: mockPreferEditMode }),
Expand All @@ -43,6 +58,10 @@ describe("ProjectCard", () => {
beforeEach(() => {
mockSessionAccessToken = undefined;
mockPreferEditMode = false;
favoriteMock.mockReset();
unfavoriteMock.mockReset();
favoriteMock.mockResolvedValue({});
unfavoriteMock.mockResolvedValue({});
});

// ── Basic rendering ─────────────────────────────────────────────
Expand Down Expand Up @@ -184,7 +203,92 @@ describe("ProjectCard", () => {
// ── Custom className ────────────────────────────────────────────
it("merges custom className onto the card", () => {
render(<ProjectCard project={makeProject()} className="extra-class" />);
const link = screen.getByRole("link");
expect(link.className).toContain("extra-class");
expect(screen.getByTestId("project-card").className).toContain("extra-class");
});

// ── Favorites ───────────────────────────────────────────────────
describe("favorite star", () => {
it("is hidden when no access token is supplied", () => {
render(<ProjectCard project={makeProject()} />);
expect(screen.queryByRole("button", { name: /favorites/i })).toBeNull();
});

it("is shown to authenticated users and reflects the unfavorited state", () => {
render(<ProjectCard project={makeProject()} accessToken="tok" />);
const star = screen.getByRole("button", { name: "Add to favorites" });
expect(star.getAttribute("aria-pressed")).toBe("false");
});

it("reflects the favorited state", () => {
render(
<ProjectCard project={makeProject({ is_favorited: true })} accessToken="tok" />
);
const star = screen.getByRole("button", { name: "Remove from favorites" });
expect(star.getAttribute("aria-pressed")).toBe("true");
});

it("is not nested inside the project link", () => {
render(<ProjectCard project={makeProject()} accessToken="tok" />);
const star = screen.getByRole("button", { name: "Add to favorites" });
expect(star.closest("a")).toBeNull();
});

it("optimistically notifies the parent before the request settles", async () => {
const onFavoriteChange = vi.fn();
let resolveFavorite: (value: unknown) => void = () => {};
favoriteMock.mockReturnValue(
new Promise((resolve) => {
resolveFavorite = resolve;
})
);

render(
<ProjectCard
project={makeProject()}
accessToken="tok"
onFavoriteChange={onFavoriteChange}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Add to favorites" }));

expect(onFavoriteChange).toHaveBeenCalledWith("proj-123", true);
expect(favoriteMock).toHaveBeenCalledWith("proj-123", "tok");

await act(async () => {
resolveFavorite({});
});
expect(onFavoriteChange).toHaveBeenCalledTimes(1);
});

it("rolls the parent back when the request fails", async () => {
const onFavoriteChange = vi.fn();
favoriteMock.mockRejectedValue(new Error("boom"));

render(
<ProjectCard
project={makeProject()}
accessToken="tok"
onFavoriteChange={onFavoriteChange}
/>
);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Add to favorites" }));
});

expect(onFavoriteChange).toHaveBeenNthCalledWith(1, "proj-123", true);
expect(onFavoriteChange).toHaveBeenNthCalledWith(2, "proj-123", false);
});

it("calls unfavorite when the project is already favorited", async () => {
unfavoriteMock.mockResolvedValue({});
render(
<ProjectCard project={makeProject({ is_favorited: true })} accessToken="tok" />
);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Remove from favorites" }));
});
expect(unfavoriteMock).toHaveBeenCalledWith("proj-123", "tok");
expect(favoriteMock).not.toHaveBeenCalled();
});
});
});
55 changes: 53 additions & 2 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { ProjectCard } from "@/components/projects/project-card";
import { projectApi } from "@/lib/api/projects";
import { cn } from "@/lib/utils";

/** Shared empty map so identity-mismatched renders keep a stable reference. */
const EMPTY_FAVORITE_OVERRIDES: ReadonlyMap<string, boolean> = new Map();

const PAGE_SIZE = 50;

type FilterType = "public" | "private" | "mine" | "all";
Expand Down Expand Up @@ -64,7 +67,46 @@ export default function HomePage() {
enabled: status !== "loading" && !((filter === "mine" || filter === "private") && !isAuthenticated),
});

const projects = data?.pages.flatMap((page) => page.items) ?? [];
// Optimistic overrides: only tracks favorite toggles made in this session.
// Server data (is_favorited) remains the source of truth for everything else.
//
// Favorites are per-user, so the overrides are stamped with the identity that
// produced them and discarded during render when that identity changes. This
// keeps a sign-out/sign-in from applying one account's toggles to another's
// list, without a setState-in-effect.
const favoriteIdentity = session?.user?.id ?? session?.accessToken ?? null;
const [favoriteState, setFavoriteState] = useState<{
identity: string | null;
overrides: ReadonlyMap<string, boolean>;
}>({ identity: null, overrides: EMPTY_FAVORITE_OVERRIDES });

const favoriteOverrides =
favoriteState.identity === favoriteIdentity
? favoriteState.overrides
: EMPTY_FAVORITE_OVERRIDES;

const handleFavoriteChange = useCallback(
(projectId: string, isFavorited: boolean) => {
setFavoriteState((prev) => {
const base =
prev.identity === favoriteIdentity ? prev.overrides : EMPTY_FAVORITE_OVERRIDES;
return {
identity: favoriteIdentity,
overrides: new Map(base).set(projectId, isFavorited),
};
});
},
[favoriteIdentity]
);

const rawProjects = data?.pages.flatMap((page) => page.items) ?? [];
// Array.prototype.sort is stable, so favorited projects float to the top while
// the server's created_at ordering is preserved within each group.
const projects = [...rawProjects].sort((a, b) => {
const aFav = favoriteOverrides.get(a.id) ?? a.is_favorited ?? false;
const bFav = 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;
Expand Down Expand Up @@ -245,7 +287,16 @@ 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.get(project.id) ?? project.is_favorited,
}}
accessToken={session?.accessToken}
onFavoriteChange={handleFavoriteChange}
/>
))}
</div>
{hasNextPage && (
Expand Down
108 changes: 86 additions & 22 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,12 +30,36 @@ export function ProjectCard({ project, className }: ProjectCardProps) {
? `/projects/${project.id}/editor`
: `/projects/${project.id}`;

const handleFavoriteClick = async () => {
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}
aria-label={`Open project ${project.name}`}
// The card is a plain container, not a link: the favorite control is a
// <button>, and a <button> inside an <a> is invalid HTML that breaks
// keyboard and screen-reader behaviour. Instead the project title holds
// the link and stretches its ::after over the whole card, so the card
// stays fully clickable while the star sits above it on its own z-layer.
<div
data-testid="project-card"
className={cn(
"block rounded-lg border border-slate-200 bg-white p-5 shadow-xs transition-all",
"relative rounded-lg border border-slate-200 bg-white p-5 shadow-xs transition-all",
"hover:border-primary-300 hover:shadow-md",
"dark:border-slate-700 dark:bg-slate-800 dark:hover:border-primary-600",
className
Expand All @@ -39,28 +68,63 @@ export function ProjectCard({ project, className }: ProjectCardProps) {
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="truncate text-lg font-semibold text-slate-900 dark:text-slate-100">
{project.name}
<Link
href={href}
aria-label={`Open project ${project.name}`}
className={cn(
"after:absolute after:inset-0 after:rounded-lg after:content-['']",
"focus-visible:outline-hidden focus-visible:after:ring-2 focus-visible:after:ring-primary-500"
)}
>
{project.name}
</Link>
</h3>
{project.description && (
<p className="mt-1 line-clamp-2 text-sm text-slate-600 dark:text-slate-400">
{project.description}
</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
type="button"
onClick={handleFavoriteClick}
disabled={isToggling}
aria-label={isFavorited ? "Remove from favorites" : "Add to favorites"}
aria-pressed={isFavorited}
className={cn(
// z-10 keeps the star above the title link's stretched ::after
"relative z-10 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 Expand Up @@ -103,6 +167,6 @@ export function ProjectCard({ project, className }: ProjectCardProps) {
)}
</div>
)}
</Link>
</div>
);
}
Loading
Loading