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