parent
13c55faa71
commit
8bf0b51065
8 changed files with 462 additions and 4 deletions
@ -0,0 +1,154 @@ |
||||
import Cookies from 'js-cookie'; |
||||
import { TOKEN_COOKIE_NAME } from '../lib/jwt.ts'; |
||||
import type { APIContext } from 'astro'; |
||||
|
||||
type HttpOptionsType = RequestInit | { headers: Record<string, any> }; |
||||
|
||||
type AppResponse = Record<string, any>; |
||||
|
||||
export type FetchError = { |
||||
status: number; |
||||
message: string; |
||||
}; |
||||
|
||||
export type AppError = { |
||||
status: number; |
||||
message: string; |
||||
errors?: { message: string; location: string }[]; |
||||
}; |
||||
|
||||
export type ApiReturn<ResponseType, ErrorType> = { |
||||
response?: ResponseType; |
||||
error?: ErrorType | FetchError; |
||||
}; |
||||
|
||||
export function api(context: APIContext) { |
||||
const token = context.cookies.get(TOKEN_COOKIE_NAME)?.value; |
||||
|
||||
async function apiCall<ResponseType = AppResponse, ErrorType = AppError>( |
||||
url: string, |
||||
options?: HttpOptionsType, |
||||
): Promise<ApiReturn<ResponseType, ErrorType>> { |
||||
try { |
||||
const response = await fetch(url, { |
||||
credentials: 'include', |
||||
...options, |
||||
headers: new Headers({ |
||||
'Content-Type': 'application/json', |
||||
Accept: 'application/json', |
||||
...(token ? { Authorization: `Bearer ${token}` } : {}), |
||||
...(options?.headers ?? {}), |
||||
}), |
||||
}); |
||||
|
||||
// @ts-ignore
|
||||
const doesAcceptHtml = options?.headers?.['Accept'] === 'text/html'; |
||||
|
||||
const data = doesAcceptHtml |
||||
? await response.text() |
||||
: await response.json(); |
||||
|
||||
if (response.ok) { |
||||
return { |
||||
response: data as ResponseType, |
||||
error: undefined, |
||||
}; |
||||
} |
||||
|
||||
// Logout user if token is invalid
|
||||
if (data.status === 401) { |
||||
context.cookies.delete(TOKEN_COOKIE_NAME); |
||||
context.redirect(context.request.url); |
||||
|
||||
return { response: undefined, error: data as ErrorType }; |
||||
} |
||||
|
||||
if (data.status === 403) { |
||||
return { response: undefined, error: data as ErrorType }; |
||||
} |
||||
|
||||
return { |
||||
response: undefined, |
||||
error: data as ErrorType, |
||||
}; |
||||
} catch (error: any) { |
||||
return { |
||||
response: undefined, |
||||
error: { |
||||
status: 0, |
||||
message: error.message, |
||||
}, |
||||
}; |
||||
} |
||||
} |
||||
|
||||
return { |
||||
get: function apiGet<ResponseType = AppResponse, ErrorType = AppError>( |
||||
url: string, |
||||
queryParams?: Record<string, any>, |
||||
options?: HttpOptionsType, |
||||
): Promise<ApiReturn<ResponseType, ErrorType>> { |
||||
const searchParams = new URLSearchParams(queryParams).toString(); |
||||
const queryUrl = searchParams ? `${url}?${searchParams}` : url; |
||||
|
||||
return apiCall<ResponseType, ErrorType>(queryUrl, { |
||||
...options, |
||||
method: 'GET', |
||||
}); |
||||
}, |
||||
post: async function apiPost< |
||||
ResponseType = AppResponse, |
||||
ErrorType = AppError, |
||||
>( |
||||
url: string, |
||||
body: Record<string, any>, |
||||
options?: HttpOptionsType, |
||||
): Promise<ApiReturn<ResponseType, ErrorType>> { |
||||
return apiCall<ResponseType, ErrorType>(url, { |
||||
...options, |
||||
method: 'POST', |
||||
body: JSON.stringify(body), |
||||
}); |
||||
}, |
||||
patch: async function apiPatch< |
||||
ResponseType = AppResponse, |
||||
ErrorType = AppError, |
||||
>( |
||||
url: string, |
||||
body: Record<string, any>, |
||||
options?: HttpOptionsType, |
||||
): Promise<ApiReturn<ResponseType, ErrorType>> { |
||||
return apiCall<ResponseType, ErrorType>(url, { |
||||
...options, |
||||
method: 'PATCH', |
||||
body: JSON.stringify(body), |
||||
}); |
||||
}, |
||||
put: async function apiPut< |
||||
ResponseType = AppResponse, |
||||
ErrorType = AppError, |
||||
>( |
||||
url: string, |
||||
body: Record<string, any>, |
||||
options?: HttpOptionsType, |
||||
): Promise<ApiReturn<ResponseType, ErrorType>> { |
||||
return apiCall<ResponseType, ErrorType>(url, { |
||||
...options, |
||||
method: 'PUT', |
||||
body: JSON.stringify(body), |
||||
}); |
||||
}, |
||||
delete: async function apiDelete< |
||||
ResponseType = AppResponse, |
||||
ErrorType = AppError, |
||||
>( |
||||
url: string, |
||||
options?: HttpOptionsType, |
||||
): Promise<ApiReturn<ResponseType, ErrorType>> { |
||||
return apiCall<ResponseType, ErrorType>(url, { |
||||
...options, |
||||
method: 'DELETE', |
||||
}); |
||||
}, |
||||
}; |
||||
} |
@ -0,0 +1,60 @@ |
||||
import { type APIContext } from 'astro'; |
||||
import { api } from './api.ts'; |
||||
|
||||
export interface UserDocument { |
||||
_id?: string; |
||||
name: string; |
||||
email: string; |
||||
username: string; |
||||
avatar?: string; |
||||
password: string; |
||||
isEnabled: boolean; |
||||
authProvider: 'github' | 'google' | 'email' | 'linkedin'; |
||||
metadata: Record<string, any>; |
||||
calculatedStats: { |
||||
activityCount: number; |
||||
totalVisitCount: number; |
||||
longestVisitStreak: number; |
||||
currentVisitStreak: number; |
||||
updatedAt: Date; |
||||
}; |
||||
verificationCode: string; |
||||
resetPasswordCode: string; |
||||
isSyncedWithSendy: boolean; |
||||
links?: { |
||||
github?: string; |
||||
linkedin?: string; |
||||
twitter?: string; |
||||
website?: string; |
||||
}; |
||||
resetPasswordCodeAt: Date; |
||||
verifiedAt: Date; |
||||
createdAt: Date; |
||||
updatedAt: Date; |
||||
} |
||||
|
||||
export type UserActivityCount = { |
||||
activityCount: Record<string, number>; |
||||
totalActivityCount: number; |
||||
}; |
||||
|
||||
export type GetUserByUsernameResponse = Omit< |
||||
UserDocument, |
||||
| 'password' |
||||
| 'verificationCode' |
||||
| 'resetPasswordCode' |
||||
| 'resetPasswordCodeAt' |
||||
| 'email' |
||||
> & { |
||||
activity: UserActivityCount; |
||||
}; |
||||
|
||||
export function userApi(context: APIContext) { |
||||
return { |
||||
getUserByUsername: async function (username: string) { |
||||
return api(context).get<GetUserByUsernameResponse>( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-by-username/${username}`, |
||||
); |
||||
}, |
||||
}; |
||||
} |
@ -0,0 +1,71 @@ |
||||
import CalendarHeatmap from 'react-calendar-heatmap'; |
||||
import { Tooltip as ReactTooltip } from 'react-tooltip'; |
||||
import 'react-calendar-heatmap/dist/styles.css'; |
||||
import 'react-tooltip/dist/react-tooltip.css'; |
||||
import { formatActivityDate } from '../../lib/date'; |
||||
import type { UserActivityCount } from '../../api/user'; |
||||
import dayjs from 'dayjs'; |
||||
|
||||
type UserActivityHeatmapProps = { |
||||
activity: UserActivityCount; |
||||
}; |
||||
export function UserActivityHeatmap(props: UserActivityHeatmapProps) { |
||||
const { activity } = props; |
||||
const data = Object.entries(activity.activityCount).map(([date, count]) => ({ |
||||
date, |
||||
count, |
||||
})); |
||||
|
||||
const startDate = dayjs().subtract(1, 'year').toDate(); |
||||
const endDate = dayjs().toDate(); |
||||
|
||||
return ( |
||||
<> |
||||
<h2 className="mb-4 text-xl font-bold">Activity</h2> |
||||
<CalendarHeatmap |
||||
startDate={startDate} |
||||
endDate={endDate} |
||||
values={data} |
||||
onClick={(value) => { |
||||
console.log('-'.repeat(20)); |
||||
console.log('Clicked on value', value); |
||||
console.log('-'.repeat(20)); |
||||
}} |
||||
classForValue={(value) => { |
||||
if (!value) { |
||||
return 'fill-gray-100 rounded-md [rx:2px]'; |
||||
} |
||||
|
||||
const { count } = value; |
||||
if (count >= 20) { |
||||
return 'fill-gray-800 rounded-md [rx:2px]'; |
||||
} else if (count >= 10) { |
||||
return 'fill-gray-600 rounded-md [rx:2px]'; |
||||
} else if (count >= 5) { |
||||
return 'fill-gray-500 rounded-md [rx:2px]'; |
||||
} else if (count >= 3) { |
||||
return 'fill-gray-300 rounded-md [rx:2px]'; |
||||
} else { |
||||
return 'fill-gray-200 rounded-md [rx:2px]'; |
||||
} |
||||
}} |
||||
tooltipDataAttrs={(value: any) => { |
||||
if (!value || !value.date) { |
||||
return null; |
||||
} |
||||
|
||||
const formattedDate = formatActivityDate(value.date); |
||||
return { |
||||
'data-tooltip-id': 'user-activity-tip', |
||||
'data-tooltip-content': `${value.count} activities on ${formattedDate}`, |
||||
}; |
||||
}} |
||||
/> |
||||
|
||||
<ReactTooltip |
||||
id="user-activity-tip" |
||||
className="!rounded-lg !bg-gray-900 !p-1 !px-2 !text-sm" |
||||
/> |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,57 @@ |
||||
import { Github, Globe, LinkedinIcon, Twitter } from 'lucide-react'; |
||||
import type { GetUserByUsernameResponse } from '../../api/user'; |
||||
|
||||
type UserDetailsProps = { |
||||
userDetails: GetUserByUsernameResponse; |
||||
}; |
||||
|
||||
export function UserDetails(props: UserDetailsProps) { |
||||
const { userDetails } = props; |
||||
const { name, username, links } = userDetails; |
||||
|
||||
return ( |
||||
<div> |
||||
<div className="flex items-center gap-4"> |
||||
<img |
||||
src="https://dodrc8eu8m09s.cloudfront.net/avatars/64ab82e214678473bb5d5ac2_1688961762495" |
||||
alt={name} |
||||
className="h-28 w-28 rounded-full" |
||||
/> |
||||
|
||||
<div> |
||||
<h1 className="text-2xl font-bold">{name}</h1> |
||||
<p className="text-gray-500">@{username}</p> |
||||
</div> |
||||
</div> |
||||
|
||||
<div className="mt-6 flex items-center gap-2 border-b pb-4"> |
||||
{links?.github && <UserLink href={links?.github} icon={Github} />} |
||||
{links?.linkedin && ( |
||||
<UserLink href={links?.linkedin} icon={LinkedinIcon} /> |
||||
)} |
||||
{links?.twitter && <UserLink href={links?.twitter} icon={Twitter} />} |
||||
{links?.website && <UserLink href={links?.website} icon={Globe} />} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
type UserLinkProps = { |
||||
href: string; |
||||
icon: typeof Github; |
||||
}; |
||||
|
||||
export function UserLink(props: UserLinkProps) { |
||||
const { href, icon: Icon } = props; |
||||
|
||||
return ( |
||||
<a |
||||
target="_blank" |
||||
href={href} |
||||
className="flex items-center gap-0.5 text-blue-700" |
||||
> |
||||
<Icon className="h-3.5 shrink-0 stroke-2" /> |
||||
<span className="truncate text-sm">{href}</span> |
||||
</a> |
||||
); |
||||
} |
@ -0,0 +1,28 @@ |
||||
--- |
||||
import { UserActivityHeatmap } from '../../../components/Account/UserActivityHeatmap'; |
||||
import { UserDetails } from '../../../components/Account/UserDetails'; |
||||
import { userApi } from '../../../api/user'; |
||||
import AccountLayout from '../../../layouts/AccountLayout.astro'; |
||||
|
||||
const { username } = Astro.params; |
||||
if (!username) { |
||||
return Astro.redirect('/404'); |
||||
} |
||||
|
||||
const userClient = userApi(Astro as any); |
||||
const { response: userDetails, error } = |
||||
await userClient.getUserByUsername(username); |
||||
|
||||
if (error || !userDetails) { |
||||
return Astro.redirect('/404'); |
||||
} |
||||
--- |
||||
|
||||
<AccountLayout title={userDetails?.name}> |
||||
<section class='container mt-5'> |
||||
<UserDetails userDetails={userDetails!} client:load /> |
||||
<div class='mt-6'> |
||||
<UserActivityHeatmap activity={userDetails?.activity!} client:load /> |
||||
</div> |
||||
</section> |
||||
</AccountLayout> |
Loading…
Reference in new issue