Skip to content

Commit f2929ac

Browse files
committed
feat: Complete Phase 5 - Social Features (Favorites)
Implement comprehensive favorites functionality: - Created favorite mutations and hooks in queries-client.ts - useAddFavoriteTechnology/useRemoveFavoriteTechnology - useAddFavoriteTechStack/useRemoveFavoriteTechStack - useFavoriteTechnologies/useFavoriteTechStacks - Automatic query invalidation on mutations - Built FavoriteButton component with optimistic updates - Heart icon with filled state - Real-time count updates - Auth check with sign-in prompt - Error handling with rollback - Created TechnologyHeader and StackHeader components - Client components wrapping page headers - Integrated FavoriteButton with proper props - Updated detail pages to use new header components - /tech/[slug] with technology favorite button - /stacks/[slug] with stack favorite button - Completely rebuilt /favorites page - Fetches real user favorites from API - Displays favorited technologies and stacks - Auth check with sign-in prompt - Empty state with browse action buttons - Loading states with GridSkeleton All features include optimistic UI updates and proper error handling. Build: 12 routes, 175 kB largest page
1 parent e45db3b commit f2929ac

File tree

7 files changed

+362
-45
lines changed

7 files changed

+362
-45
lines changed

app-next/src/app/(browse)/favorites/page.tsx

Lines changed: 101 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,52 @@
1+
'use client'
2+
3+
import Link from 'next/link'
14
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
5+
import { GridSkeleton } from '@/components/shared/loading-skeleton'
6+
import { useFavoriteTechnologies, useFavoriteTechStacks } from '@/lib/api/queries-client'
7+
import { useAuth } from '@/lib/api/auth'
8+
import { Button } from '@/components/ui/button'
29

310
export default function FavoritesPage() {
4-
// Mock favorites - in real app, this would come from user's session
5-
const favoriteTechs = [
6-
{ id: 1, name: 'React', tier: 'Frontend Library' },
7-
{ id: 2, name: 'TypeScript', tier: 'Programming Language' },
8-
{ id: 3, name: 'Next.js', tier: 'Frontend Framework' },
9-
]
11+
const { isAuthenticated, signInWithGitHub } = useAuth()
12+
const { data: techResponse, isLoading: techLoading } = useFavoriteTechnologies()
13+
const { data: stackResponse, isLoading: stackLoading } = useFavoriteTechStacks()
1014

11-
const favoriteStacks = [
12-
{ id: 1, name: 'Modern Web Stack', techCount: 8 },
13-
{ id: 2, name: 'E-commerce Platform', techCount: 12 },
14-
]
15+
if (!isAuthenticated) {
16+
return (
17+
<div className="container py-8">
18+
<Card>
19+
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
20+
<div className="text-6xl mb-4">🔐</div>
21+
<h3 className="text-xl font-semibold mb-2">Sign in to view favorites</h3>
22+
<p className="text-muted-foreground mb-6">
23+
You need to sign in to view your favorite technologies and stacks.
24+
</p>
25+
<Button onClick={signInWithGitHub} size="lg">
26+
Sign in with GitHub
27+
</Button>
28+
</CardContent>
29+
</Card>
30+
</div>
31+
)
32+
}
33+
34+
if (techLoading || stackLoading) {
35+
return (
36+
<div className="container py-8">
37+
<div className="mb-8">
38+
<h1 className="text-4xl font-bold tracking-tight">My Favorites</h1>
39+
<p className="text-lg text-muted-foreground mt-2">
40+
Technologies and stacks you&apos;ve favorited
41+
</p>
42+
</div>
43+
<GridSkeleton />
44+
</div>
45+
)
46+
}
47+
48+
const favoriteTechs = techResponse?.results || []
49+
const favoriteStacks = stackResponse?.results || []
1550

1651
return (
1752
<div className="container py-8">
@@ -27,40 +62,80 @@ export default function FavoritesPage() {
2762
<CardContent className="flex flex-col items-center justify-center py-16">
2863
<div className="text-4xl mb-4">❤️</div>
2964
<h3 className="text-xl font-semibold mb-2">No favorites yet</h3>
30-
<p className="text-muted-foreground text-center max-w-md">
31-
Start exploring technologies and stacks, then favorite the ones you love to see them here!
65+
<p className="text-muted-foreground text-center max-w-md mb-6">
66+
Start exploring technologies and stacks, then favorite the ones you love to see them
67+
here!
3268
</p>
69+
<div className="flex gap-4">
70+
<Button asChild>
71+
<Link href="/tech">Browse Technologies</Link>
72+
</Button>
73+
<Button variant="outline" asChild>
74+
<Link href="/stacks">Browse Stacks</Link>
75+
</Button>
76+
</div>
3377
</CardContent>
3478
</Card>
3579
) : (
3680
<div className="space-y-8">
3781
{favoriteTechs.length > 0 && (
3882
<div>
39-
<h2 className="text-2xl font-bold mb-4">Favorite Technologies</h2>
83+
<h2 className="text-2xl font-bold mb-4">
84+
Favorite Technologies ({favoriteTechs.length})
85+
</h2>
4086
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
4187
{favoriteTechs.map((tech) => (
42-
<Card key={tech.id}>
43-
<CardHeader>
44-
<CardTitle>{tech.name}</CardTitle>
45-
<CardDescription>{tech.tier}</CardDescription>
46-
</CardHeader>
47-
</Card>
88+
<Link key={tech.id} href={`/tech/${tech.slug}`}>
89+
<Card className="h-full hover:shadow-lg transition-shadow cursor-pointer">
90+
<CardHeader>
91+
<CardTitle>{tech.name}</CardTitle>
92+
<CardDescription>
93+
{tech.tier || 'Technology'}
94+
{tech.vendorName && ` by ${tech.vendorName}`}
95+
</CardDescription>
96+
</CardHeader>
97+
<CardContent>
98+
<p className="text-sm text-muted-foreground line-clamp-2">
99+
{tech.description || 'No description available'}
100+
</p>
101+
<div className="flex items-center gap-4 mt-4 text-xs text-muted-foreground">
102+
<span>❤️ {tech.favCount || 0} favorites</span>
103+
<span>👁️ {tech.viewCount || 0} views</span>
104+
</div>
105+
</CardContent>
106+
</Card>
107+
</Link>
48108
))}
49109
</div>
50110
</div>
51111
)}
52112

53113
{favoriteStacks.length > 0 && (
54114
<div>
55-
<h2 className="text-2xl font-bold mb-4">Favorite Stacks</h2>
115+
<h2 className="text-2xl font-bold mb-4">
116+
Favorite Stacks ({favoriteStacks.length})
117+
</h2>
56118
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
57119
{favoriteStacks.map((stack) => (
58-
<Card key={stack.id}>
59-
<CardHeader>
60-
<CardTitle>{stack.name}</CardTitle>
61-
<CardDescription>{stack.techCount} technologies</CardDescription>
62-
</CardHeader>
63-
</Card>
120+
<Link key={stack.id} href={`/stacks/${stack.slug}`}>
121+
<Card className="h-full hover:shadow-lg transition-shadow cursor-pointer">
122+
<CardHeader>
123+
<CardTitle>{stack.name}</CardTitle>
124+
<CardDescription>
125+
{stack.vendorName && `by ${stack.vendorName}`}
126+
</CardDescription>
127+
</CardHeader>
128+
<CardContent>
129+
<p className="text-sm text-muted-foreground line-clamp-2">
130+
{stack.description || 'No description available'}
131+
</p>
132+
<div className="flex items-center gap-4 mt-4 text-xs text-muted-foreground">
133+
<span>❤️ {stack.favCount || 0} favorites</span>
134+
<span>👁️ {stack.viewCount || 0} views</span>
135+
</div>
136+
</CardContent>
137+
</Card>
138+
</Link>
64139
))}
65140
</div>
66141
</div>

app-next/src/app/(browse)/stacks/[slug]/page.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Link from 'next/link'
33
import { getTechnologyStack } from '@/lib/api/queries-server'
44
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
55
import { Button } from '@/components/ui/button'
6+
import { StackHeader } from '@/components/stack/stack-header'
67

78
export const dynamic = 'force-dynamic'
89

@@ -31,15 +32,12 @@ export default async function StackDetailPage({
3132
return (
3233
<div className="container py-8">
3334
<div className="mb-8">
34-
<div className="flex items-start justify-between">
35-
<div>
36-
<h1 className="text-4xl font-bold tracking-tight">{stack.name}</h1>
37-
<p className="text-lg text-muted-foreground mt-2">
38-
{stack.vendorName && `by ${stack.vendorName}`}
39-
</p>
40-
</div>
41-
<Button size="lg">❤️ Favorite</Button>
42-
</div>
35+
<StackHeader
36+
name={stack.name}
37+
vendorName={stack.vendorName}
38+
id={stack.id}
39+
favCount={stack.favCount || 0}
40+
/>
4341
</div>
4442

4543
<div className="grid gap-8 lg:grid-cols-3">

app-next/src/app/(browse)/tech/[slug]/page.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Link from 'next/link'
33
import { getTechnology } from '@/lib/api/queries-server'
44
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
55
import { Button } from '@/components/ui/button'
6+
import { TechnologyHeader } from '@/components/technology/technology-header'
67

78
export const dynamic = 'force-dynamic'
89

@@ -31,16 +32,13 @@ export default async function TechDetailPage({
3132
return (
3233
<div className="container py-8">
3334
<div className="mb-8">
34-
<div className="flex items-start justify-between">
35-
<div>
36-
<h1 className="text-4xl font-bold tracking-tight">{technology.name}</h1>
37-
<p className="text-lg text-muted-foreground mt-2">
38-
{technology.tier}
39-
{technology.vendorName && ` by ${technology.vendorName}`}
40-
</p>
41-
</div>
42-
<Button size="lg">❤️ Favorite</Button>
43-
</div>
35+
<TechnologyHeader
36+
name={technology.name}
37+
tier={technology.tier}
38+
vendorName={technology.vendorName}
39+
id={technology.id!}
40+
favCount={technology.favCount || 0}
41+
/>
4442
</div>
4543

4644
<div className="grid gap-8 lg:grid-cols-3">
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { Heart } from 'lucide-react'
5+
import { Button } from '@/components/ui/button'
6+
import { useAuth } from '@/lib/api/auth'
7+
import {
8+
useAddFavoriteTechnology,
9+
useRemoveFavoriteTechnology,
10+
useAddFavoriteTechStack,
11+
useRemoveFavoriteTechStack,
12+
} from '@/lib/api/queries-client'
13+
14+
interface FavoriteButtonProps {
15+
type: 'technology' | 'stack'
16+
itemId: number
17+
initialFavorited?: boolean
18+
initialCount?: number
19+
size?: 'sm' | 'default' | 'lg'
20+
variant?: 'default' | 'outline' | 'ghost'
21+
}
22+
23+
export function FavoriteButton({
24+
type,
25+
itemId,
26+
initialFavorited = false,
27+
initialCount = 0,
28+
size = 'default',
29+
variant = 'outline',
30+
}: FavoriteButtonProps) {
31+
const { isAuthenticated, signInWithGitHub } = useAuth()
32+
const [isFavorited, setIsFavorited] = useState(initialFavorited)
33+
const [favCount, setFavCount] = useState(initialCount)
34+
35+
const addTechMutation = useAddFavoriteTechnology()
36+
const removeTechMutation = useRemoveFavoriteTechnology()
37+
const addStackMutation = useAddFavoriteTechStack()
38+
const removeStackMutation = useRemoveFavoriteTechStack()
39+
40+
const handleClick = async () => {
41+
if (!isAuthenticated) {
42+
signInWithGitHub()
43+
return
44+
}
45+
46+
// Optimistic update
47+
const newFavorited = !isFavorited
48+
setIsFavorited(newFavorited)
49+
setFavCount((prev) => (newFavorited ? prev + 1 : prev - 1))
50+
51+
try {
52+
if (type === 'technology') {
53+
if (newFavorited) {
54+
await addTechMutation.mutateAsync(itemId)
55+
} else {
56+
await removeTechMutation.mutateAsync(itemId)
57+
}
58+
} else {
59+
if (newFavorited) {
60+
await addStackMutation.mutateAsync(itemId)
61+
} else {
62+
await removeStackMutation.mutateAsync(itemId)
63+
}
64+
}
65+
} catch (error) {
66+
// Revert optimistic update on error
67+
setIsFavorited(!newFavorited)
68+
setFavCount((prev) => (newFavorited ? prev - 1 : prev + 1))
69+
console.error('Failed to update favorite:', error)
70+
}
71+
}
72+
73+
const isPending =
74+
addTechMutation.isPending ||
75+
removeTechMutation.isPending ||
76+
addStackMutation.isPending ||
77+
removeStackMutation.isPending
78+
79+
return (
80+
<Button
81+
variant={isFavorited ? 'default' : variant}
82+
size={size}
83+
onClick={handleClick}
84+
disabled={isPending}
85+
className="gap-2"
86+
>
87+
<Heart
88+
className={`h-4 w-4 ${isFavorited ? 'fill-current' : ''}`}
89+
aria-hidden="true"
90+
/>
91+
<span>{favCount}</span>
92+
<span className="sr-only">{isFavorited ? 'Unfavorite' : 'Favorite'}</span>
93+
</Button>
94+
)
95+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
'use client'
2+
3+
import { FavoriteButton } from '@/components/shared/favorite-button'
4+
5+
interface StackHeaderProps {
6+
name: string
7+
vendorName?: string
8+
id: number
9+
favCount: number
10+
}
11+
12+
export function StackHeader({ name, vendorName, id, favCount }: StackHeaderProps) {
13+
return (
14+
<div className="flex items-start justify-between">
15+
<div>
16+
<h1 className="text-4xl font-bold tracking-tight">{name}</h1>
17+
{vendorName && <p className="text-lg text-muted-foreground mt-2">by {vendorName}</p>}
18+
</div>
19+
<FavoriteButton type="stack" itemId={id} initialCount={favCount} size="lg" />
20+
</div>
21+
)
22+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
'use client'
2+
3+
import { FavoriteButton } from '@/components/shared/favorite-button'
4+
5+
interface TechnologyHeaderProps {
6+
name: string
7+
tier?: string
8+
vendorName?: string
9+
id: number
10+
favCount: number
11+
}
12+
13+
export function TechnologyHeader({
14+
name,
15+
tier,
16+
vendorName,
17+
id,
18+
favCount,
19+
}: TechnologyHeaderProps) {
20+
return (
21+
<div className="flex items-start justify-between">
22+
<div>
23+
<h1 className="text-4xl font-bold tracking-tight">{name}</h1>
24+
<p className="text-lg text-muted-foreground mt-2">
25+
{tier}
26+
{vendorName && ` by ${vendorName}`}
27+
</p>
28+
</div>
29+
<FavoriteButton type="technology" itemId={id} initialCount={favCount} size="lg" />
30+
</div>
31+
)
32+
}

0 commit comments

Comments
 (0)