Skip to content

Commit f458c70

Browse files
committed
refactor: remove dup vertical scroll in mobile
1 parent 9110188 commit f458c70

File tree

12 files changed

+282
-133
lines changed

12 files changed

+282
-133
lines changed

app/[lang]/(common)/DataTable.tsx

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@ export function DataTable<T>(props: DataTableProps<T>): ReactElement {
3131
const {data, columns, onClickRow, tBodyRef} = props;
3232

3333
return (
34-
<table className={clsx(['flex-1 self-stretch', props.className])}>
34+
<table className={clsx(['flex-1 self-stretch shrink-0', props.className])}>
3535
<thead className={clsx('sticky top-0 z-10', props.classNames?.tHead)}>
3636
<tr
3737
className={clsx(
38-
'flex-1 px-1 flex items-center',
38+
'flex-1 px-1 flex items-center min-w-max',
3939
props.classNames?.tHeadRow,
4040
)}
4141
>
@@ -44,11 +44,8 @@ export function DataTable<T>(props: DataTableProps<T>): ReactElement {
4444
<th
4545
key={`${column}-${i}`}
4646
className={clsx(
47-
(i + 1) % 3 === 0
48-
? 'w-14 max-md:w-12'
49-
: `flex-1 ${i === 0 && 'pl-3'}`,
50-
'items-center flex',
51-
'truncate',
47+
'items-center flex min-w-0',
48+
i === 0 && 'pl-3',
5249
column.headerClassName,
5350
)}
5451
>
@@ -60,7 +57,7 @@ export function DataTable<T>(props: DataTableProps<T>): ReactElement {
6057
</thead>
6158
<tbody
6259
className={clsx(
63-
'pl-2 py-1 flex-1',
60+
'py-1 flex-1',
6461
'flex flex-col justify-center',
6562
props.classNames?.tBody,
6663
)}
@@ -71,7 +68,7 @@ export function DataTable<T>(props: DataTableProps<T>): ReactElement {
7168
<tr
7269
key={`${elm}-${i}`}
7370
className={clsx(
74-
'px-1 cursor-pointer flex-1 flex',
71+
'px-1 cursor-pointer flex-1 flex min-w-max',
7572
props.classNames?.tBodyRow,
7673
)}
7774
onClick={() => onClickRow?.(elm, i)}
@@ -80,8 +77,8 @@ export function DataTable<T>(props: DataTableProps<T>): ReactElement {
8077
<td
8178
key={column.id.toString()}
8279
className={clsx(
83-
(idx + 1) % 3 === 0 ? 'w-14 max-md:w-12 pl-2' : 'flex-1',
84-
'flex flex-row items-center',
80+
'flex flex-row items-center min-w-0',
81+
idx === 0 && 'pl-3',
8582
column.cellClassName,
8683
)}
8784
>

app/[lang]/(common)/Header/index.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ function MobileNavMenus(
177177
>
178178
<Link
179179
href={`${link.path}`}
180+
onClick={() => setIsNavCollapsed(true)}
180181
className={clsx(
181182
'text-body4 truncate flex-1 h-10 px-8',
182183
'flex items-center',
@@ -205,6 +206,7 @@ function MobileNavMenus(
205206
<div className={clsx('ml-[6px] mr-4', 'flex flex-row')}>
206207
<a
207208
href="https://github.com/hyochan/github-stats"
209+
onClick={() => setIsNavCollapsed(true)}
208210
className="flex flex-row items-center"
209211
>
210212
<Github className="h-6 body2 mr-2" />
@@ -233,6 +235,7 @@ function MobileNavMenus(
233235
text: 'body3 truncate',
234236
}}
235237
onClick={() => {
238+
setIsNavCollapsed(true);
236239
if (!!login) {
237240
supabase.auth.signOut();
238241

@@ -329,7 +332,8 @@ export default function Header(props: Props): ReactElement {
329332
return (
330333
<header
331334
className={clsx(
332-
'h-[56px] decoration-0 bg-basic sticky',
335+
'h-[56px] decoration-0 bg-basic',
336+
'sticky top-0 z-50',
333337
'flex flex-row items-center justify-between',
334338
'px-[28px]',
335339
'w-full min-w-0',

app/[lang]/layout.tsx

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,12 @@ export default async function LangLayout(props: Props): Promise<ReactElement> {
2626

2727
return (
2828
<RootProvider initialLocale={lang}>
29-
<main
29+
<div
3030
className={clsx(
31-
'text-center flex-1 self-stretch relative',
32-
'flex flex-col-reverse',
33-
'min-w-0 overflow-x-hidden',
31+
'h-screen max-[768px]:min-h-screen max-[768px]:h-auto w-full',
32+
'flex flex-col',
3433
)}
3534
>
36-
<div
37-
className={clsx(
38-
'h-[calc(100vh-56px)]',
39-
'flex w-full min-w-0',
40-
'overflow-y-auto overflow-x-hidden',
41-
)}
42-
>
43-
{children}
44-
</div>
4535
<Header
4636
t={nav}
4737
lang={lang}
@@ -50,7 +40,16 @@ export default async function LangLayout(props: Props): Promise<ReactElement> {
5040
ko: langs.ko,
5141
}}
5242
/>
53-
</main>
43+
<main
44+
className={clsx(
45+
'flex-1 w-full min-w-0',
46+
'flex flex-col',
47+
'overflow-hidden max-[768px]:overflow-visible',
48+
)}
49+
>
50+
{children}
51+
</main>
52+
</div>
5453
</RootProvider>
5554
);
5655
}

app/[lang]/leaderboards/GithubUserList.tsx

Lines changed: 101 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

33
import type {ReactElement, UIEventHandler} from 'react';
4-
import {useCallback, useMemo, useRef, useState} from 'react';
4+
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
55
import clsx from 'clsx';
66
import Image from 'next/image';
77

@@ -38,6 +38,7 @@ type Props = {
3838

3939
export default function GithubUserList({t, initialData}: Props): ReactElement {
4040
const tBodyRef = useRef<HTMLTableSectionElement>(null);
41+
const sentinelRef = useRef<HTMLDivElement>(null);
4142
const [data, setData] = useState(initialData);
4243
const [selectedTier, setSelectedTier] = useState<Tier | null>(null);
4344
const [tierData, setTierData] = useState<UserListItem[]>([]);
@@ -47,6 +48,9 @@ export default function GithubUserList({t, initialData}: Props): ReactElement {
4748
? new Date(initialData?.[initialData?.length - 1]?.createdAt)
4849
: null,
4950
);
51+
const [isLoadingMore, setIsLoadingMore] = useState(false);
52+
const [hasMore, setHasMore] = useState(true);
53+
const loadMoreRef = useRef<(() => Promise<void>) | null>(null);
5054

5155
const handleTierSelect = useCallback(async (tier: Tier | null) => {
5256
setSelectedTier(tier);
@@ -81,93 +85,134 @@ export default function GithubUserList({t, initialData}: Props): ReactElement {
8185
() => [
8286
{
8387
id: 'login',
84-
headerClassName: 'w-6/12 py-[12px]',
85-
cellClassName: 'w-6/12 h-[50px] py-[8px] text-default',
88+
headerClassName: 'flex-1 py-[12px] min-w-[150px]',
89+
cellClassName: 'flex-1 h-[50px] py-[8px] text-default min-w-[150px]',
8690
header: () => (
8791
<H5 fontWeight="semibold" className="text-start">
8892
{t.githubUsername}
8993
</H5>
9094
),
9195
cell: ({login, avatarUrl}) => (
92-
<div className="text-start flex gap-[8px] items-center">
96+
<div className="text-start flex gap-[8px] items-center min-w-0">
9397
<Image
9498
alt="avatar"
9599
src={avatarUrl}
96100
width={20}
97101
height={20}
98-
className="rounded-full"
102+
className="rounded-full shrink-0"
99103
/>
100-
<H4>{login}</H4>
104+
<H4 className="truncate">{login}</H4>
101105
</div>
102106
),
103107
},
104108
{
105109
id: 'tierName',
106-
headerClassName: 'w-3/12 py-[12px]',
107-
cellClassName: 'text-start w-3/12 h-[50px] py-[8px]',
110+
headerClassName: 'w-[120px] max-[480px]:w-[40px] py-[12px] shrink-0',
111+
cellClassName: 'w-[120px] max-[480px]:w-[40px] h-[50px] py-[8px] shrink-0',
108112
header: () => (
109-
<H5 fontWeight="semibold" className="text-start text-basic">
113+
<H5 fontWeight="semibold" className="text-start text-basic max-[480px]:hidden">
110114
{t.tier}
111115
</H5>
112116
),
113117
cell: ({tierName}) => <TierRowItem tier={tierName as Tier} />,
114118
},
115119
{
116120
id: 'score',
117-
headerClassName: 'w-3/12 py-[12px]',
118-
cellClassName: 'text-start w-3/12 h-[50px] py-[8px]',
121+
headerClassName: 'w-[80px] max-[480px]:w-[50px] py-[12px] shrink-0 justify-center',
122+
cellClassName: 'w-[80px] max-[480px]:w-[50px] h-[50px] py-[8px] shrink-0 justify-center',
119123
header: () => (
120-
<H5 fontWeight="semibold" className="text-start text-basic">
124+
<H5 fontWeight="semibold" className="text-center text-basic">
121125
{t.score}
122126
</H5>
123127
),
124-
cell: ({score}) => <div className="text-start text-basic">{score}</div>,
128+
cell: ({score}) => <div className="text-center text-basic">{score}</div>,
125129
},
126130
],
127131
[t.githubUsername, t.score, t.tier],
128132
);
129133

130-
const handleScroll: UIEventHandler<HTMLTableSectionElement> = async (
131-
e,
132-
): Promise<void> => {
133-
const hasEndReached =
134-
Math.ceil(e.currentTarget.scrollTop + e.currentTarget.clientHeight) >=
135-
e.currentTarget.scrollHeight;
134+
const loadMore = useCallback(async () => {
135+
if (!cursor || isLoadingMore || selectedTier || !hasMore) return;
136136

137-
if (hasEndReached) {
138-
if (!cursor) {
137+
setIsLoadingMore(true);
138+
try {
139+
const {users} = await fetchRecentList({
140+
pluginId: 'dooboo-github',
141+
take: 20,
142+
cursor,
143+
});
144+
145+
// No more data from API
146+
if (!users || users.length === 0) {
147+
setHasMore(false);
139148
return;
140149
}
141150

142-
try {
143-
const {users} = await fetchRecentList({
144-
pluginId: 'dooboo-github',
145-
take: 20,
146-
cursor,
147-
});
151+
// Less than requested means end of data
152+
if (users.length < 20) {
153+
setHasMore(false);
154+
}
155+
156+
setData((prevData) => {
157+
const existingLogins = new Set(prevData.map((u) => u.login));
158+
const filteredUsers = users.filter((el) => !existingLogins.has(el.login));
148159

149-
let nextCursor: Date | null = null;
150-
setData((prevData) => {
151-
const filteredUsers = users.filter(
152-
(el) => !prevData.some((existing) => existing.login === el.login),
153-
);
154-
if (filteredUsers.length === 0) return prevData;
155-
nextCursor = new Date(
156-
filteredUsers[filteredUsers.length - 1].createdAt,
157-
);
158-
return [...prevData, ...filteredUsers];
159-
});
160-
if (nextCursor) {
161-
setCursor(nextCursor);
160+
if (filteredUsers.length === 0) {
161+
return prevData;
162162
}
163-
} catch (error) {
164-
console.error('Failed to fetch more users:', error);
163+
164+
return [...prevData, ...filteredUsers];
165+
});
166+
167+
// Update cursor based on the last user from API response
168+
const lastUser = users[users.length - 1];
169+
if (lastUser) {
170+
setCursor(new Date(lastUser.createdAt));
165171
}
172+
} catch (error) {
173+
console.error('Failed to fetch more users:', error);
174+
} finally {
175+
setIsLoadingMore(false);
176+
}
177+
}, [cursor, isLoadingMore, selectedTier, hasMore]);
178+
179+
// Keep loadMore ref updated
180+
loadMoreRef.current = loadMore;
181+
182+
// Intersection Observer for infinite scroll (works on both mobile and desktop)
183+
useEffect(() => {
184+
const sentinel = sentinelRef.current;
185+
if (!sentinel) return;
186+
187+
const observer = new IntersectionObserver(
188+
(entries) => {
189+
if (entries[0].isIntersecting) {
190+
loadMoreRef.current?.();
191+
}
192+
},
193+
{
194+
root: null,
195+
rootMargin: '100px',
196+
threshold: 0,
197+
},
198+
);
199+
200+
observer.observe(sentinel);
201+
return () => observer.disconnect();
202+
}, []);
203+
204+
const handleScroll: UIEventHandler<HTMLDivElement> = (e) => {
205+
const hasEndReached =
206+
Math.ceil(e.currentTarget.scrollTop + e.currentTarget.clientHeight) >=
207+
e.currentTarget.scrollHeight;
208+
209+
if (hasEndReached) {
210+
loadMore();
166211
}
167212
};
168213

169214
return (
170-
<div className="flex-1 flex flex-col mx-6 mb-12 max-[480px]:mx-4 max-[480px]:mb-8 overflow-hidden">
215+
<div className="flex-1 flex flex-col mx-6 mb-12 max-[480px]:mx-4 max-[480px]:mb-8 overflow-hidden max-[768px]:overflow-visible">
171216
{/* Tier filter labels */}
172217
<div
173218
className={clsx(
@@ -227,14 +272,16 @@ export default function GithubUserList({t, initialData}: Props): ReactElement {
227272
{/* Data table */}
228273
<div
229274
className={clsx(
230-
'flex-1 overflow-y-scroll',
275+
'flex-1 max-[768px]:flex-none',
276+
'block w-full',
277+
'overflow-x-auto',
278+
'overflow-y-auto max-[768px]:overflow-y-visible',
231279
'rounded-[20px]',
232280
'bg-black/10 dark:bg-white/5',
233281
'backdrop-blur-xl',
234282
'border border-black/20 dark:border-white/10',
235283
'shadow-[0_8px_32px_0_rgba(31,38,135,0.15)]',
236284
'max-[480px]:rounded-[16px]',
237-
styles.scrollable,
238285
'transition-opacity duration-300',
239286
isLoadingTier && 'opacity-50',
240287
)}
@@ -248,14 +295,22 @@ export default function GithubUserList({t, initialData}: Props): ReactElement {
248295
const login = user.login;
249296
window.open(`/stats/${login}`, '_blank', 'noopener');
250297
}}
251-
className="p-6 max-[480px]:p-4"
298+
className="p-6 max-[480px]:p-4 w-full min-w-[500px]"
252299
classNames={{
253300
tHead:
254301
'bg-paper-light dark:bg-paper-dark backdrop-blur-xl border-b border-black/10 dark:border-white/10 px-2 pb-2 -mx-6 -mt-6 px-6 pt-6 rounded-t-[20px] max-[480px]:-mx-4 max-[480px]:-mt-4 max-[480px]:px-4 max-[480px]:pt-4 max-[480px]:rounded-t-[16px]',
255302
tBodyRow:
256303
'hover:bg-black/10 dark:hover:bg-white/5 transition-all duration-200 rounded-[8px] my-1',
257304
}}
258305
/>
306+
{/* Sentinel for infinite scroll */}
307+
{!selectedTier && (
308+
<div
309+
ref={sentinelRef}
310+
className="h-[1px] w-full"
311+
aria-hidden="true"
312+
/>
313+
)}
259314
</div>
260315
</div>
261316
);

0 commit comments

Comments
 (0)