Skip to content

Commit c72e2f0

Browse files
committed
Sync button
1 parent 3ee77da commit c72e2f0

27 files changed

+1415
-79
lines changed

app/components/search-profiile.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { useRouter } from 'next/navigation';
55
import { usePostHog } from 'posthog-js/react';
66
import { useState } from 'react';
77
import { ClipLoader } from 'react-spinners';
8-
import { toast } from 'sonner';
98

109
import { Button } from '@/components/ui/button';
1110
import { Input } from '@/components/ui/input';
@@ -33,15 +32,7 @@ export const SearchProfile = () => {
3332
profileFound,
3433
});
3534

36-
if (profileFound) {
37-
return router.push(`/profile/${login}`);
38-
}
39-
40-
setLoading(false);
41-
toast.error('User not found', {
42-
description:
43-
'Our database currently includes only users who own or have contributed to repositories with more than 5 stars.',
44-
});
35+
return router.push(`/profile/${login}`);
4536
};
4637

4738
return (

app/globals.css

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,30 @@
100100
--radius-md: calc(var(--radius) - 2px);
101101
--radius-lg: var(--radius);
102102
--radius-xl: calc(var(--radius) + 4px);
103+
104+
--animate-avatar-entry: avatarEntry 4s ease-out forwards;
105+
106+
@keyframes avatarEntry {
107+
0% {
108+
transform: translate(-100%, 100%);
109+
}
110+
25% {
111+
transform: translate(-50%, 50%);
112+
}
113+
75% {
114+
transform: translate(-50%, 50%);
115+
}
116+
100% {
117+
transform: translate(0, 0);
118+
}
119+
}
103120
}
104121

105122
@layer base {
123+
* {
124+
@apply border-border outline-ring/50;
125+
}
126+
106127
body {
107128
@apply bg-background text-foreground;
108129
}
Lines changed: 171 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,193 @@
11
'use client';
22

3+
import { RefreshCw } from 'lucide-react';
34
import { useParams } from 'next/navigation';
4-
import { FC } from 'react';
5+
import { signIn, useSession } from 'next-auth/react';
6+
import { usePostHog } from 'posthog-js/react';
7+
import {
8+
Children,
9+
cloneElement,
10+
ComponentProps,
11+
FC,
12+
ReactElement,
13+
useCallback,
14+
useEffect,
15+
useRef,
16+
useState,
17+
} from 'react';
18+
import { ClipLoader } from 'react-spinners';
19+
import { toast } from 'sonner';
520

621
import { Button } from '@/components/ui/button';
22+
import {
23+
Dialog,
24+
DialogClose,
25+
DialogContent,
26+
DialogDescription,
27+
DialogFooter,
28+
DialogHeader,
29+
DialogTitle,
30+
DialogTrigger,
31+
} from '@/components/ui/dialog';
32+
import { graphqlClient } from '@/lib/graphql/graphql-client';
733
import { cn } from '@/lib/utils';
34+
import { ProfileFetchingStatusDocument, UserFetchingStatus } from '@/types/generated/graphql';
835

936
type FetchUserButtonProps = {
10-
label?: string;
1137
className?: string;
38+
fetchingStatus?: UserFetchingStatus | null;
39+
fetchingUpdatedAt?: number | null;
40+
children: ReactElement<ComponentProps<'button'>>;
1241
};
1342

14-
export const FetchUserButton: FC<FetchUserButtonProps> = ({ label = 'Fetch user from GitHub', className }) => {
15-
// const { data: session } = useSession();
16-
const params = useParams<{ login: string }>();
43+
const REFRESH_INTERVAL = 1_000;
44+
const CHECK_STATUS_INTERVAL = 20_000;
45+
const REFRESH_TIMEOUT = 10 * 60 * 1_000;
46+
const FETCH_MESSAGES = ['Fetching…', 'Still fetching 😐', '👀 👀 👀', 'Ping! Still not ready…'];
47+
48+
export const FetchUserButton: FC<FetchUserButtonProps> = ({
49+
fetchingStatus,
50+
fetchingUpdatedAt,
51+
className,
52+
children,
53+
}) => {
54+
const { data: session } = useSession();
55+
const { login } = useParams<{ login: string }>();
56+
const now = Date.now();
57+
const posthog = usePostHog();
58+
const fetchAttempt = useRef(0);
59+
const timerRef = useRef<NodeJS.Timeout | null>(null);
60+
const [loadingLabel, setLoadingLabel] = useState(FETCH_MESSAGES[fetchAttempt.current]);
61+
const initialFetchingDuration = now - (fetchingUpdatedAt || now);
62+
const [fetchingDuration, setFetchingDuration] = useState(
63+
fetchingStatus === 'FETCHING' && initialFetchingDuration < REFRESH_TIMEOUT ? initialFetchingDuration : null,
64+
);
65+
66+
const checkFetchingStatus = useCallback(async () => {
67+
setLoadingLabel('Checking…');
68+
const data = await graphqlClient(ProfileFetchingStatusDocument, { login });
69+
70+
if (data.user === null) {
71+
if (timerRef.current) {
72+
clearTimeout(timerRef.current);
73+
}
74+
setFetchingDuration(null);
75+
toast.error('User not found on GitHub');
76+
return;
77+
}
78+
79+
if (data.user?.fetchingStatus === 'FETCHING') {
80+
fetchAttempt.current += 1;
81+
setLoadingLabel(FETCH_MESSAGES[fetchAttempt.current % FETCH_MESSAGES.length]);
82+
} else {
83+
window.location.reload();
84+
}
85+
}, [login]);
1786

1887
const fetchUser = async () => {
19-
const res = await fetch(`/api/profile/${params.login}`, { method: 'POST' });
88+
setFetchingDuration(0);
89+
90+
posthog.capture('profile.fetch', { login });
91+
92+
const res = await fetch(`/api/profile/${login}`, { method: 'POST' });
2093

2194
if (!res.ok) {
22-
throw new Error('Failed to fetch user');
95+
toast.error('Failed to fetch user profile');
96+
setFetchingDuration(null);
97+
}
98+
};
99+
100+
useEffect(() => {
101+
if (fetchingDuration === null) {
102+
return;
103+
}
104+
105+
const secondsPassed = Math.floor(fetchingDuration / 1000);
106+
if (secondsPassed > 0 && secondsPassed % (CHECK_STATUS_INTERVAL / 1000) === 0) {
107+
checkFetchingStatus();
23108
}
24109

25-
return res.json();
110+
if (fetchingDuration >= REFRESH_TIMEOUT) {
111+
toast.error('Fetching user profile took too long. Please try again later.');
112+
setFetchingDuration(null);
113+
return;
114+
}
115+
116+
timerRef.current = setTimeout(() => {
117+
setFetchingDuration((prev) => (prev || 0) + REFRESH_INTERVAL);
118+
}, REFRESH_INTERVAL);
119+
}, [fetchingDuration, checkFetchingStatus]);
120+
121+
const child = Children.only(children);
122+
const childWithOnClick = cloneElement(child, {
123+
onClick: session?.user ? fetchUser : undefined,
124+
disabled: fetchingDuration !== null,
125+
children:
126+
fetchingDuration !== null ? (
127+
<>
128+
{loadingLabel}
129+
<ClipLoader loading size={16} />
130+
</>
131+
) : (
132+
child.props.children
133+
),
134+
});
135+
136+
const attachDialogIfNeeded = () => {
137+
if (session?.user) {
138+
return childWithOnClick;
139+
}
140+
141+
return (
142+
<Dialog>
143+
<DialogTrigger asChild>{childWithOnClick}</DialogTrigger>
144+
<DialogContent>
145+
<DialogHeader>
146+
<DialogTitle>Hold up — need your OK!</DialogTitle>
147+
<DialogDescription>
148+
Fetching a GitHub profile actually fires off around 20–30 GraphQL calls under the hood. That’s no biggie
149+
for your personal token, but it’d blow through our shared quota.
150+
</DialogDescription>
151+
<DialogDescription>Mind signing in with GitHub so we can pull in that user on demand?</DialogDescription>
152+
</DialogHeader>
153+
<DialogFooter>
154+
<DialogClose asChild>
155+
<Button type="button" variant="secondary">
156+
Maybe later
157+
</Button>
158+
</DialogClose>
159+
<Button onClick={() => signIn('github')}>Sign in with GitHub</Button>
160+
</DialogFooter>
161+
</DialogContent>
162+
</Dialog>
163+
);
26164
};
27165

28166
return (
29-
<Button onClick={fetchUser} className={cn(className)}>
30-
{label}
31-
</Button>
167+
<div className={cn('flex flex-col gap-2', className)}>
168+
{attachDialogIfNeeded()}
169+
{fetchingDuration !== null && (
170+
<div className="text-xs text-muted-foreground">Time passed: {Math.floor(fetchingDuration / 1000)}s</div>
171+
)}
172+
</div>
173+
);
174+
};
175+
176+
export const FetchUserButtonForNotFoundPage: FC<Omit<FetchUserButtonProps, 'children'>> = (props) => {
177+
return (
178+
<FetchUserButton {...props}>
179+
<Button size="lg">Fetch profile from GitHub</Button>
180+
</FetchUserButton>
181+
);
182+
};
183+
184+
export const FetchUserButtonForProfilePage: FC<Omit<FetchUserButtonProps, 'children'>> = (props) => {
185+
return (
186+
<FetchUserButton {...props} className={cn('flex-grow', props.className)}>
187+
<Button size="sm" className="flex-grow">
188+
Refresh
189+
<RefreshCw className="size-4" />
190+
</Button>
191+
</FetchUserButton>
32192
);
33193
};

app/profile/[login]/components/layout-left-column.tsx

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,22 @@ import Image from 'next/image';
44
import Link from 'next/link';
55
import { FC, ReactNode } from 'react';
66

7-
import { Page } from '@/components/page/page';
8-
import { AspectRatio } from '@/components/ui/aspect-ratio';
97
import { Avatar, AvatarImage } from '@/components/ui/avatar';
108
import { Button } from '@/components/ui/button';
119
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
12-
import { cn } from '@/lib/utils';
1310
import { UserQuery } from '@/types/generated/graphql';
1411

12+
import { FetchUserButtonForProfilePage } from './fetch-user-button';
1513
import { ProfileListItem } from './profile-list-item';
16-
import { RefreshButton } from './refresh-button';
14+
import {
15+
ActionsContainer,
16+
AvatarAndNameContainer,
17+
AvatarContainer,
18+
DetailsContainer,
19+
LeftColumnContainer,
20+
NameContainer,
21+
PageContainer,
22+
} from './profile-page-backbone';
1723
import { getSocialIcon } from '../utils/get-social-icon';
1824

1925
type LayoutLeftColumnProps = Readonly<{
@@ -30,31 +36,32 @@ export const LayoutLeftColumn: FC<LayoutLeftColumnProps> = ({ user, children, cl
3036
}
3137

3238
return (
33-
<Page className={cn('gap-6 flex-col md:flex-row', className)}>
34-
<div className="w-full md:w-3xs xl:w-2xs flex flex-col shrink-0 gap-4">
35-
<div className="flex flex-row md:flex-col items-center md:items-start gap-4">
36-
<div className="w-[64] sm:w-[128] md:w-full">
37-
<AspectRatio ratio={1}>
38-
<Avatar className="w-full h-full rounded-full" asChild>
39-
<AvatarImage src={user.avatarUrl!} />
40-
</Avatar>
41-
</AspectRatio>
42-
</div>
43-
<div>
39+
<PageContainer className={className}>
40+
<LeftColumnContainer>
41+
<AvatarAndNameContainer>
42+
<AvatarContainer>
43+
<Avatar className="w-full h-full rounded-full" asChild>
44+
<AvatarImage src={user.avatarUrl!} />
45+
</Avatar>
46+
</AvatarContainer>
47+
<NameContainer>
4448
<h1 className="font-semibold text-2xl">{user.name}</h1>
4549
<h2 className="text-muted-foreground">@{user.login}</h2>
46-
</div>
47-
</div>
48-
<div className="flex flex-row md:flex-col gap-4">
49-
<RefreshButton />
50+
</NameContainer>
51+
</AvatarAndNameContainer>
52+
<ActionsContainer>
53+
<FetchUserButtonForProfilePage
54+
fetchingStatus={user.fetchingStatus}
55+
fetchingUpdatedAt={user.fetchingUpdatedAt}
56+
/>
5057
<Button size="sm" variant="secondary" className="flex-grow" asChild>
5158
<Link href={`https://github.com/${user.login}`} target="_blank" rel="noopener noreferrer">
5259
Open GitHub
5360
<ExternalLink className="size-4" />
5461
</Link>
5562
</Button>
56-
</div>
57-
<div className="flex flex-col gap-6">
63+
</ActionsContainer>
64+
<DetailsContainer>
5865
<div className="flex flex-col gap-1.5">
5966
<ProfileListItem value={user.location} Icon={MapPin} />
6067
<ProfileListItem value={user.company} Icon={BriefcaseBusiness} />
@@ -110,9 +117,9 @@ export const LayoutLeftColumn: FC<LayoutLeftColumnProps> = ({ user, children, cl
110117
</div>
111118
</div>
112119
)}
113-
</div>
114-
</div>
120+
</DetailsContainer>
121+
</LeftColumnContainer>
115122
{children}
116-
</Page>
123+
</PageContainer>
117124
);
118125
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
'use client';
2+
3+
import { useParams } from 'next/navigation';
4+
5+
export const ProfileLoginFromUrl = () => {
6+
const params = useParams<{ login: string }>();
7+
8+
return params.login;
9+
};

0 commit comments

Comments
 (0)