Skip to content

Commit 5b9bbcb

Browse files
committed
Showcase max pending submissions and voting/sorting
1 parent 7e4ed49 commit 5b9bbcb

13 files changed

Lines changed: 778 additions & 110 deletions

src/components/ShowcaseCard.tsx

Lines changed: 117 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { Link } from '@tanstack/react-router'
21
import { twMerge } from 'tailwind-merge'
3-
import { ExternalLink } from 'lucide-react'
4-
import { libraries } from '~/libraries'
2+
import { ExternalLink, ThumbsUp, ThumbsDown } from 'lucide-react'
3+
import { libraries, type LibraryId } from '~/libraries'
54
import type { Showcase } from '~/db/types'
65

76
interface ShowcaseCardProps {
@@ -11,75 +10,149 @@ interface ShowcaseCardProps {
1110
name: string | null
1211
image: string | null
1312
} | null
13+
currentUserVote?: 1 | -1 | null
14+
onVote?: (value: 1 | -1) => void
15+
isVoting?: boolean
1416
className?: string
1517
}
1618

1719
const libraryMap = new Map(libraries.map((lib) => [lib.id, lib]))
1820

19-
export function ShowcaseCard({ showcase, className }: ShowcaseCardProps) {
21+
export function ShowcaseCard({
22+
showcase,
23+
currentUserVote,
24+
onVote,
25+
isVoting,
26+
className,
27+
}: ShowcaseCardProps) {
28+
const displayScore = Math.max(0, showcase.voteScore)
29+
2030
return (
21-
<a
22-
href={showcase.url}
23-
target="_blank"
24-
rel="noopener noreferrer"
31+
<div
2532
className={twMerge(
26-
'group block rounded-xl overflow-hidden bg-white dark:bg-gray-800 shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-200 dark:border-gray-700',
33+
'group relative rounded-xl overflow-hidden bg-white dark:bg-gray-800 shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-200 dark:border-gray-700',
2734
className,
2835
)}
2936
>
30-
{/* Screenshot */}
31-
<div className="relative aspect-video overflow-hidden bg-gray-100 dark:bg-gray-900">
32-
<img
33-
src={showcase.screenshotUrl}
34-
alt={showcase.name}
35-
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
36-
/>
37-
{/* Logo overlay */}
38-
{showcase.logoUrl && (
39-
<div className="absolute bottom-3 left-3 w-10 h-10 rounded-lg bg-white dark:bg-gray-800 shadow-md overflow-hidden">
40-
<img
41-
src={showcase.logoUrl}
42-
alt=""
43-
className="w-full h-full object-cover"
44-
/>
37+
{/* Main link area */}
38+
<a
39+
href={showcase.url}
40+
target="_blank"
41+
rel="noopener noreferrer"
42+
className="block"
43+
>
44+
{/* Screenshot */}
45+
<div className="relative aspect-video overflow-hidden bg-gray-100 dark:bg-gray-900">
46+
<img
47+
src={showcase.screenshotUrl}
48+
alt={showcase.name}
49+
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
50+
/>
51+
{/* Logo overlay */}
52+
{showcase.logoUrl && (
53+
<div className="absolute bottom-3 left-3 w-10 h-10 rounded-lg bg-white dark:bg-gray-800 shadow-md overflow-hidden">
54+
<img
55+
src={showcase.logoUrl}
56+
alt=""
57+
className="w-full h-full object-cover"
58+
/>
59+
</div>
60+
)}
61+
{/* External link indicator */}
62+
<div className="absolute top-3 right-3 p-2 rounded-full bg-white/80 dark:bg-gray-800/80 opacity-0 group-hover:opacity-100 transition-opacity">
63+
<ExternalLink className="w-4 h-4 text-gray-600 dark:text-gray-300" />
4564
</div>
46-
)}
47-
{/* External link indicator */}
48-
<div className="absolute top-3 right-3 p-2 rounded-full bg-white/80 dark:bg-gray-800/80 opacity-0 group-hover:opacity-100 transition-opacity">
49-
<ExternalLink className="w-4 h-4 text-gray-600 dark:text-gray-300" />
5065
</div>
51-
</div>
5266

53-
{/* Content */}
54-
<div className="p-4">
55-
<h3 className="font-bold text-lg text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
56-
{showcase.name}
57-
</h3>
58-
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
59-
{showcase.tagline}
60-
</p>
67+
{/* Content */}
68+
<div className="p-4">
69+
<h3 className="font-bold text-lg text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
70+
{showcase.name}
71+
</h3>
72+
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
73+
{showcase.tagline}
74+
</p>
75+
</div>
76+
</a>
6177

78+
{/* Footer with libraries and voting */}
79+
<div className="px-4 pb-4 flex items-center justify-between gap-2">
6280
{/* Libraries */}
63-
<div className="flex flex-wrap gap-1.5 mt-3">
64-
{showcase.libraries.slice(0, 4).map((libId) => {
65-
const lib = libraryMap.get(libId)
81+
<div className="flex flex-wrap gap-1.5 min-w-0 flex-1">
82+
{showcase.libraries.slice(0, 3).map((libId) => {
83+
const lib = libraryMap.get(libId as LibraryId)
6684
return (
6785
<span
6886
key={libId}
69-
className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
87+
className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 truncate"
7088
>
7189
{lib?.name?.replace('TanStack ', '') || libId}
7290
</span>
7391
)
7492
})}
75-
{showcase.libraries.length > 4 && (
93+
{showcase.libraries.length > 3 && (
7694
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500">
77-
+{showcase.libraries.length - 4}
95+
+{showcase.libraries.length - 3}
7896
</span>
7997
)}
8098
</div>
99+
100+
{/* Voting */}
101+
<div className="flex items-center gap-1 shrink-0">
102+
<button
103+
type="button"
104+
onClick={(e) => {
105+
e.preventDefault()
106+
e.stopPropagation()
107+
onVote?.(1)
108+
}}
109+
disabled={isVoting}
110+
className={twMerge(
111+
'p-1.5 rounded-md transition-colors',
112+
currentUserVote === 1
113+
? 'text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/30'
114+
: 'text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-gray-100 dark:hover:bg-gray-700',
115+
isVoting && 'opacity-50 cursor-not-allowed',
116+
)}
117+
title="Upvote"
118+
>
119+
<ThumbsUp
120+
className="w-4 h-4"
121+
fill={currentUserVote === 1 ? 'currentColor' : 'none'}
122+
/>
123+
</button>
124+
125+
{displayScore > 0 && (
126+
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[1.5rem] text-center">
127+
{displayScore}
128+
</span>
129+
)}
130+
131+
<button
132+
type="button"
133+
onClick={(e) => {
134+
e.preventDefault()
135+
e.stopPropagation()
136+
onVote?.(-1)
137+
}}
138+
disabled={isVoting}
139+
className={twMerge(
140+
'p-1.5 rounded-md transition-colors',
141+
currentUserVote === -1
142+
? 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30'
143+
: 'text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-700',
144+
isVoting && 'opacity-50 cursor-not-allowed',
145+
)}
146+
title="Downvote"
147+
>
148+
<ThumbsDown
149+
className="w-4 h-4"
150+
fill={currentUserVote === -1 ? 'currentColor' : 'none'}
151+
/>
152+
</button>
153+
</div>
81154
</div>
82-
</a>
155+
</div>
83156
)
84157
}
85158

src/components/ShowcaseGallery.tsx

Lines changed: 159 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import * as React from 'react'
2-
import { useQuery } from '@tanstack/react-query'
2+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
33
import { Link, useNavigate, useSearch } from '@tanstack/react-router'
4-
import { getApprovedShowcasesQueryOptions } from '~/queries/showcases'
4+
import {
5+
getApprovedShowcasesQueryOptions,
6+
getMyShowcaseVotesQueryOptions,
7+
} from '~/queries/showcases'
8+
import { voteShowcase } from '~/utils/showcase.functions'
59
import { ShowcaseCard, ShowcaseCardSkeleton } from './ShowcaseCard'
610
import { SubmitShowcasePlaceholder } from './ShowcaseSection'
711
import { PaginationControls } from './PaginationControls'
@@ -10,10 +14,13 @@ import { SHOWCASE_USE_CASES, type ShowcaseUseCase } from '~/db/types'
1014
import { Plus } from 'lucide-react'
1115
import { USE_CASE_LABELS } from '~/utils/showcase.client'
1216
import { Button } from './Button'
17+
import { useCurrentUser } from '~/hooks/useCurrentUser'
1318

1419
export function ShowcaseGallery() {
1520
const navigate = useNavigate({ from: '/showcase/' })
1621
const search = useSearch({ from: '/showcase/' })
22+
const queryClient = useQueryClient()
23+
const currentUser = useCurrentUser()
1724

1825
const { data, isLoading } = useQuery(
1926
getApprovedShowcasesQueryOptions({
@@ -28,6 +35,150 @@ export function ShowcaseGallery() {
2835
}),
2936
)
3037

38+
const showcaseIds = React.useMemo(
39+
() => data?.showcases.map((s) => s.showcase.id) ?? [],
40+
[data?.showcases],
41+
)
42+
43+
const { data: votesData } = useQuery({
44+
...getMyShowcaseVotesQueryOptions(showcaseIds),
45+
enabled: !!currentUser && showcaseIds.length > 0,
46+
})
47+
48+
const votesMap = React.useMemo(() => {
49+
const map = new Map<string, 1 | -1>()
50+
votesData?.votes.forEach((v) => {
51+
if (v.value === 1 || v.value === -1) {
52+
map.set(v.showcaseId, v.value)
53+
}
54+
})
55+
return map
56+
}, [votesData])
57+
58+
const voteMutation = useMutation({
59+
mutationFn: (params: { showcaseId: string; value: 1 | -1 }) =>
60+
voteShowcase({ data: params }),
61+
onMutate: async ({ showcaseId, value }) => {
62+
// Cancel outgoing refetches
63+
await queryClient.cancelQueries({ queryKey: ['showcases'] })
64+
65+
// Snapshot previous values
66+
const previousShowcases = queryClient.getQueryData(
67+
getApprovedShowcasesQueryOptions({
68+
pagination: { page: search.page, pageSize: 24 },
69+
filters: {
70+
libraryId: search.libraryId,
71+
useCases: search.useCases as ShowcaseUseCase[],
72+
},
73+
}).queryKey,
74+
)
75+
const previousVotes = queryClient.getQueryData(
76+
getMyShowcaseVotesQueryOptions(showcaseIds).queryKey,
77+
)
78+
79+
// Optimistically update votes
80+
queryClient.setQueryData(
81+
getMyShowcaseVotesQueryOptions(showcaseIds).queryKey,
82+
(old: typeof votesData) => {
83+
if (!old) return { votes: [{ showcaseId, value }] }
84+
const existingVote = old.votes.find(
85+
(v) => v.showcaseId === showcaseId,
86+
)
87+
if (existingVote) {
88+
if (existingVote.value === value) {
89+
// Toggle off
90+
return {
91+
votes: old.votes.filter((v) => v.showcaseId !== showcaseId),
92+
}
93+
} else {
94+
// Change vote
95+
return {
96+
votes: old.votes.map((v) =>
97+
v.showcaseId === showcaseId ? { ...v, value } : v,
98+
),
99+
}
100+
}
101+
} else {
102+
// New vote
103+
return { votes: [...old.votes, { showcaseId, value }] }
104+
}
105+
},
106+
)
107+
108+
// Optimistically update showcase score
109+
queryClient.setQueryData(
110+
getApprovedShowcasesQueryOptions({
111+
pagination: { page: search.page, pageSize: 24 },
112+
filters: {
113+
libraryId: search.libraryId,
114+
useCases: search.useCases as ShowcaseUseCase[],
115+
},
116+
}).queryKey,
117+
(old: typeof data) => {
118+
if (!old) return old
119+
const currentVote = votesMap.get(showcaseId)
120+
return {
121+
...old,
122+
showcases: old.showcases.map((s) => {
123+
if (s.showcase.id !== showcaseId) return s
124+
let scoreDelta: number = value
125+
if (currentVote === value) {
126+
// Toggling off
127+
scoreDelta = -value
128+
} else if (currentVote) {
129+
// Changing vote
130+
scoreDelta = value * 2
131+
}
132+
return {
133+
...s,
134+
showcase: {
135+
...s.showcase,
136+
voteScore: s.showcase.voteScore + scoreDelta,
137+
},
138+
}
139+
}),
140+
}
141+
},
142+
)
143+
144+
return { previousShowcases, previousVotes }
145+
},
146+
onError: (_err, _vars, context) => {
147+
// Rollback on error
148+
if (context?.previousShowcases) {
149+
queryClient.setQueryData(
150+
getApprovedShowcasesQueryOptions({
151+
pagination: { page: search.page, pageSize: 24 },
152+
filters: {
153+
libraryId: search.libraryId,
154+
useCases: search.useCases as ShowcaseUseCase[],
155+
},
156+
}).queryKey,
157+
context.previousShowcases,
158+
)
159+
}
160+
if (context?.previousVotes) {
161+
queryClient.setQueryData(
162+
getMyShowcaseVotesQueryOptions(showcaseIds).queryKey,
163+
context.previousVotes,
164+
)
165+
}
166+
},
167+
onSettled: () => {
168+
// Refetch to sync with server
169+
queryClient.invalidateQueries({ queryKey: ['showcases'] })
170+
},
171+
})
172+
173+
const handleVote = (showcaseId: string, value: 1 | -1) => {
174+
if (!currentUser) {
175+
// Redirect to login
176+
navigate({ to: '/auth/login', search: { redirect: '/showcase' } })
177+
return
178+
}
179+
voteMutation.mutate({ showcaseId, value })
180+
}
181+
31182
const handleLibraryFilter = (libraryId: string | undefined) => {
32183
navigate({
33184
search: (prev: typeof search) => ({
@@ -182,6 +333,12 @@ export function ShowcaseGallery() {
182333
key={showcase.id}
183334
showcase={showcase}
184335
user={user}
336+
currentUserVote={votesMap.get(showcase.id)}
337+
onVote={(value) => handleVote(showcase.id, value)}
338+
isVoting={
339+
voteMutation.isPending &&
340+
voteMutation.variables?.showcaseId === showcase.id
341+
}
185342
/>
186343
))}
187344
</div>

0 commit comments

Comments
 (0)