Skip to content

Commit cd33229

Browse files
committed
add pagination to dashboard
1 parent b77843a commit cd33229

File tree

12 files changed

+310
-126
lines changed

12 files changed

+310
-126
lines changed

packages/jsrepl/src/app/dashboard/page.tsx

+3-19
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import { useState } from 'react'
44
import { useRouter, useSearchParams } from 'next/navigation'
5-
import { usePrefetchQuery } from '@tanstack/react-query'
65
import { LucideLibrary, LucidePlus } from 'lucide-react'
76
import IconLanguageCss from '~icons/mdi/language-css3.jsx'
87
import IconLanguageHtml from '~icons/mdi/language-html5.jsx'
@@ -15,14 +14,10 @@ import ReplStarterDialog from '@/components/repl-starter-dialog'
1514
import { Button } from '@/components/ui/button'
1615
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
1716
import { useAuthHelpers } from '@/hooks/useAuthHelpers'
18-
import { useSupabaseClient } from '@/hooks/useSupabaseClient'
1917
import { useUser } from '@/hooks/useUser'
20-
import { loadRecentlyViewedRepls, loadUserRepls } from '@/lib/repl-stored-state/adapter-supabase'
2118
import { RecentlyViewed } from './recently-viewed'
2219
import { YourWork } from './your-work'
2320

24-
export const dynamic = 'force-dynamic'
25-
2621
enum Tab {
2722
RecentlyViewed = 'recently-viewed',
2823
YourWork = 'your-work',
@@ -34,35 +29,24 @@ export default function DashboardPage() {
3429
const searchParams = useSearchParams()
3530
const router = useRouter()
3631
const user = useUser()
37-
const supabase = useSupabaseClient()
3832
const { signInWithGithub } = useAuthHelpers()
3933
const [starterDialogOpen, setStarterDialogOpen] = useState(false)
4034

4135
const activeTab: Tab = (searchParams.get('tab') as Tab | null) ?? defaultTab
4236

4337
function setActiveTab(tab: Tab) {
4438
const newSearchParams = new URLSearchParams(searchParams)
39+
newSearchParams.delete('page')
40+
4541
if (tab === defaultTab) {
4642
newSearchParams.delete('tab')
4743
} else {
4844
newSearchParams.set('tab', tab)
4945
}
5046

51-
router.push(`?${newSearchParams.toString()}`)
47+
router.push(`?${newSearchParams.toString()}`, { scroll: false })
5248
}
5349

54-
usePrefetchQuery({
55-
queryKey: ['recently-viewed-repls', user?.id],
56-
queryFn: ({ signal }) => (user ? loadRecentlyViewedRepls({ supabase, signal }) : []),
57-
staleTime: 60_000,
58-
})
59-
60-
usePrefetchQuery({
61-
queryKey: ['user-repls', user?.id],
62-
queryFn: ({ signal }) => (user ? loadUserRepls(user.id, { supabase, signal }) : []),
63-
staleTime: 60_000,
64-
})
65-
6650
if (!user) {
6751
return (
6852
<>
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,50 @@
11
'use client'
22

3+
import { useRef } from 'react'
34
import { useQuery } from '@tanstack/react-query'
45
import { LucideTelescope } from 'lucide-react'
56
import ErrorComponent from '@/components/error'
6-
import { RelativeTime } from '@/components/relative-time'
7+
import { Pagination } from '@/components/pagination'
8+
import { useSearchParamsPagination } from '@/hooks/useSearchParamsPagination'
79
import { useSupabaseClient } from '@/hooks/useSupabaseClient'
810
import { useUser } from '@/hooks/useUser'
911
import { loadRecentlyViewedRepls } from '@/lib/repl-stored-state/adapter-supabase'
10-
import { ReplStoredState } from '@/types'
1112
import ReplCard from './repl-card'
1213
import { ReplCardSkeleton } from './repl-card-skeleton'
1314

1415
export function RecentlyViewed() {
1516
const user = useUser()
1617
const supabase = useSupabaseClient()
18+
const containerRef = useRef<HTMLDivElement>(null)
19+
20+
const [pagination, consumePageData] = useSearchParamsPagination({
21+
scroll() {
22+
containerRef.current?.scrollIntoView({ behavior: 'smooth' })
23+
},
24+
})
1725

1826
const { data, isLoading, error } = useQuery({
19-
queryKey: ['recently-viewed-repls', user?.id],
20-
queryFn: ({ signal }) => (user ? loadRecentlyViewedRepls({ supabase, signal }) : []),
27+
queryKey: ['recently-viewed-repls', user?.id, pagination.page, pagination.pageSize],
28+
queryFn: ({ signal }) =>
29+
user
30+
? loadRecentlyViewedRepls({
31+
supabase,
32+
page: pagination.page,
33+
pageSize: pagination.pageSize,
34+
signal,
35+
})
36+
: { data: [], hasMore: false },
2137
staleTime: 60_000,
2238
})
2339

40+
const repls = data?.data
41+
const hasMore = data?.hasMore ?? false
42+
consumePageData({ hasMore })
43+
2444
return (
25-
<>
45+
<div ref={containerRef} className="scroll-mt-24">
2646
{error && <ErrorComponent error={error} />}
27-
{data && data.length === 0 && (
47+
{repls && repls.length === 0 && (
2848
<div className="flex flex-col items-center justify-center gap-2">
2949
<LucideTelescope size={84} className="my-8 opacity-10" />
3050
<p className="text-muted-foreground/60">
@@ -33,28 +53,14 @@ export function RecentlyViewed() {
3353
</div>
3454
)}
3555
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
36-
{data &&
37-
data.map((repl) => (
38-
<ReplCard
39-
key={repl.id}
40-
repl={repl}
41-
customTimestamp={<RecentlyViewedTimestamp repl={repl} />}
42-
/>
56+
{repls &&
57+
repls.map((repl) => <ReplCard key={repl.id} repl={repl} mode="recently-viewed" />)}
58+
{isLoading &&
59+
Array.from({ length: pagination.pageSize }).map((_, index) => (
60+
<ReplCardSkeleton key={index} />
4361
))}
44-
{isLoading && Array.from({ length: 8 }).map((_, index) => <ReplCardSkeleton key={index} />)}
4562
</div>
46-
</>
47-
)
48-
}
49-
50-
function RecentlyViewedTimestamp({ repl }: { repl: ReplStoredState & { viewed_at: string } }) {
51-
return (
52-
<>
53-
{repl.viewed_at && (
54-
<span className="text-muted-foreground text-nowrap text-xs">
55-
<RelativeTime date={new Date(repl.viewed_at)} />
56-
</span>
57-
)}
58-
</>
63+
<Pagination value={pagination} className="mt-10" />
64+
</div>
5965
)
6066
}

packages/jsrepl/src/app/dashboard/repl-card.tsx

+24-31
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
'use client'
2+
13
import { Dispatch, SetStateAction, useMemo, useState } from 'react'
24
import Link from 'next/link'
35
import { useRouter } from 'next/navigation'
@@ -26,13 +28,19 @@ import * as ReplFS from '@/lib/repl-fs'
2628
import { fork, getPageUrl, remove } from '@/lib/repl-stored-state/adapter-supabase'
2729
import { ReplStoredState } from '@/types'
2830

29-
export default function ReplCard({
30-
repl,
31-
customTimestamp,
32-
}: {
33-
repl: ReplStoredState
34-
customTimestamp?: React.ReactNode
35-
}) {
31+
type Props =
32+
| {
33+
repl: ReplStoredState
34+
mode?: undefined
35+
}
36+
| {
37+
repl: ReplStoredState & {
38+
viewed_at: string
39+
}
40+
mode: 'recently-viewed'
41+
}
42+
43+
export default function ReplCard({ repl, mode }: Props) {
3644
const user = useUser()
3745
const supabase = useSupabaseClient()
3846
const queryClient = useQueryClient()
@@ -46,6 +54,7 @@ export default function ReplCard({
4654
const url = useMemo(() => getPageUrl(repl), [repl])
4755

4856
const filePath = repl.activeModel
57+
const datetime = mode === 'recently-viewed' ? repl.viewed_at : repl.updated_at
4958

5059
const code = useMemo(() => {
5160
const file = filePath ? ReplFS.getFile(repl.fs, filePath) : null
@@ -102,10 +111,7 @@ export default function ReplCard({
102111
},
103112
})
104113

105-
const queryKey = ['user-repls', user?.id]
106-
queryClient.setQueryData<ReplStoredState[]>(queryKey, (prev) => {
107-
return prev ? [forkedRepl, ...prev] : prev
108-
})
114+
queryClient.invalidateQueries({ queryKey: ['user-repls', user?.id] })
109115
} catch (error) {
110116
console.error(error)
111117
toast.error('Failed to fork.')
@@ -151,7 +157,11 @@ export default function ReplCard({
151157
<h2 className="font-medium">{repl.title || 'Untitled'}</h2>
152158
<p className="text-muted-foreground line-clamp-3 text-sm">{repl.description}</p>
153159
<div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-2">
154-
{customTimestamp ?? <Timestamp repl={repl} />}
160+
{datetime && (
161+
<span className="text-muted-foreground text-nowrap text-xs">
162+
<RelativeTime date={new Date(datetime)} />
163+
</span>
164+
)}
155165
{repl.user && (
156166
<div className="text-muted-foreground flex min-w-0 items-center gap-1 text-xs">
157167
<UserAvatar user={repl.user} size={18} />
@@ -166,18 +176,6 @@ export default function ReplCard({
166176
)
167177
}
168178

169-
function Timestamp({ repl }: { repl: ReplStoredState }) {
170-
return (
171-
<>
172-
{repl.updated_at && (
173-
<span className="text-muted-foreground text-nowrap text-xs">
174-
<RelativeTime date={new Date(repl.updated_at)} />
175-
</span>
176-
)}
177-
</>
178-
)
179-
}
180-
181179
function RemoveButton({
182180
repl,
183181
isBusy,
@@ -203,13 +201,8 @@ function RemoveButton({
203201
await remove(repl.id, { supabase })
204202
toast('Deleted.')
205203

206-
queryClient.setQueryData<ReplStoredState[]>(['user-repls', user?.id], (prev) => {
207-
return prev ? prev.filter((x) => x.id !== repl.id) : prev
208-
})
209-
210-
queryClient.setQueryData<ReplStoredState[]>(['recently-viewed-repls', user?.id], (prev) => {
211-
return prev ? prev.filter((x) => x.id !== repl.id) : prev
212-
})
204+
queryClient.invalidateQueries({ queryKey: ['user-repls', user?.id] })
205+
queryClient.invalidateQueries({ queryKey: ['recently-viewed-repls', user?.id] })
213206
} catch (error) {
214207
console.error(error)
215208
toast.error('Failed to delete.')
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import { useRef } from 'react'
12
import { useQuery } from '@tanstack/react-query'
23
import { LucideLampDesk } from 'lucide-react'
34
import ErrorComponent from '@/components/error'
5+
import { Pagination } from '@/components/pagination'
6+
import { useSearchParamsPagination } from '@/hooks/useSearchParamsPagination'
47
import { useSupabaseClient } from '@/hooks/useSupabaseClient'
58
import { useUser } from '@/hooks/useUser'
69
import { loadUserRepls } from '@/lib/repl-stored-state/adapter-supabase'
@@ -10,17 +13,36 @@ import { ReplCardSkeleton } from './repl-card-skeleton'
1013
export function YourWork() {
1114
const user = useUser()
1215
const supabase = useSupabaseClient()
16+
const containerRef = useRef<HTMLDivElement>(null)
17+
18+
const [pagination, consumePageData] = useSearchParamsPagination({
19+
scroll() {
20+
containerRef.current?.scrollIntoView({ behavior: 'smooth' })
21+
},
22+
})
1323

1424
const { data, isLoading, error } = useQuery({
15-
queryKey: ['user-repls', user?.id],
16-
queryFn: ({ signal }) => (user ? loadUserRepls(user.id, { supabase, signal }) : []),
25+
queryKey: ['user-repls', user?.id, pagination.page, pagination.pageSize],
26+
queryFn: ({ signal }) =>
27+
user
28+
? loadUserRepls(user.id, {
29+
supabase,
30+
page: pagination.page,
31+
pageSize: pagination.pageSize,
32+
signal,
33+
})
34+
: { data: [], hasMore: false },
1735
staleTime: 60_000,
1836
})
1937

38+
const repls = data?.data
39+
const hasMore = data?.hasMore ?? false
40+
consumePageData({ hasMore })
41+
2042
return (
21-
<>
43+
<div ref={containerRef} className="scroll-mt-24">
2244
{error && <ErrorComponent error={error} />}
23-
{data && data.length === 0 && (
45+
{repls && repls.length === 0 && (
2446
<div className="flex flex-col items-center justify-center gap-2">
2547
<LucideLampDesk size={84} className="my-8 opacity-10" />
2648
<p className="text-muted-foreground/60">
@@ -29,9 +51,13 @@ export function YourWork() {
2951
</div>
3052
)}
3153
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
32-
{data && data.map((repl) => <ReplCard key={repl.id} repl={repl} />)}
33-
{isLoading && Array.from({ length: 8 }).map((_, index) => <ReplCardSkeleton key={index} />)}
54+
{repls && repls.map((repl) => <ReplCard key={repl.id} repl={repl} />)}
55+
{isLoading &&
56+
Array.from({ length: pagination.pageSize }).map((_, index) => (
57+
<ReplCardSkeleton key={index} />
58+
))}
3459
</div>
35-
</>
60+
<Pagination value={pagination} className="mt-10" />
61+
</div>
3662
)
3763
}

packages/jsrepl/src/app/layout.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export default async function RootLayout({
3636

3737
return (
3838
// suppressHydrationWarning: https://github.com/pacocoursey/next-themes?tab=readme-ov-file#with-app
39-
<html lang="en" suppressHydrationWarning>
39+
<html lang="en" suppressHydrationWarning className="scroll-pt-24">
4040
<body>
4141
<NavigationGuardProvider>
4242
<SessionProvider initialSession={session}>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { LucideChevronLeft, LucideChevronRight } from 'lucide-react'
2+
import { Button } from '@/components/ui/button'
3+
import { PaginationProps } from '@/hooks/useSearchParamsPagination'
4+
import { cn } from '@/lib/utils'
5+
6+
type Props = {
7+
value: PaginationProps
8+
className?: string
9+
}
10+
11+
export function Pagination({ value, className }: Props) {
12+
const { page, goPrevious, goNext, hasPrevious, hasNext, scroll } = value
13+
14+
function handleScroll(e: React.MouseEvent<HTMLButtonElement>) {
15+
if (typeof scroll === 'function') {
16+
const button = e.target as HTMLButtonElement
17+
button.blur()
18+
19+
requestAnimationFrame(() => {
20+
scroll()
21+
})
22+
}
23+
}
24+
25+
function onNextClick(e: React.MouseEvent<HTMLButtonElement>) {
26+
goNext()
27+
handleScroll(e)
28+
}
29+
30+
function onPreviousClick(e: React.MouseEvent<HTMLButtonElement>) {
31+
goPrevious()
32+
handleScroll(e)
33+
}
34+
35+
return (
36+
<div className={cn('flex items-center justify-center gap-2', className)}>
37+
<Button
38+
variant="ghost-primary"
39+
onClick={onPreviousClick}
40+
disabled={!hasPrevious}
41+
className="min-w-28 justify-end"
42+
>
43+
<LucideChevronLeft size={18} className="mr-1" />
44+
Previous
45+
</Button>
46+
47+
<span className="text-muted-foreground flex h-9 items-center justify-center rounded border px-3 py-1 text-sm">
48+
Page {page}
49+
</span>
50+
51+
<Button
52+
variant="ghost-primary"
53+
onClick={onNextClick}
54+
disabled={!hasNext}
55+
className="min-w-28 justify-start"
56+
>
57+
Next
58+
<LucideChevronRight size={18} className="ml-1" />
59+
</Button>
60+
</div>
61+
)
62+
}

0 commit comments

Comments
 (0)