feat: username route

pull/5494/head
Arik Chakma 1 year ago
parent 13c55faa71
commit 8bf0b51065
  1. 4
      package.json
  2. 85
      pnpm-lock.yaml
  3. 154
      src/api/api.ts
  4. 60
      src/api/user.ts
  5. 71
      src/components/Account/UserActivityHeatmap.tsx
  6. 57
      src/components/Account/UserDetails.tsx
  7. 7
      src/lib/date.ts
  8. 28
      src/pages/u/[username]/index.astro

@ -33,6 +33,7 @@
"astro": "^4.0.7",
"astro-compress": "^2.2.3",
"clsx": "^2.0.0",
"dayjs": "^1.11.10",
"dracula-prism": "^2.1.13",
"express": "^4.18.2",
"jose": "^5.1.3",
@ -44,8 +45,10 @@
"npm-check-updates": "^16.14.12",
"prismjs": "^1.29.0",
"react": "^18.2.0",
"react-calendar-heatmap": "^1.9.0",
"react-confetti": "^6.1.0",
"react-dom": "^18.2.0",
"react-tooltip": "^5.26.0",
"reactflow": "^11.10.1",
"rehype-external-links": "^3.0.0",
"roadmap-renderer": "^1.0.6",
@ -59,6 +62,7 @@
"@tailwindcss/typography": "^0.5.10",
"@types/js-cookie": "^3.0.6",
"@types/prismjs": "^1.26.3",
"@types/react-calendar-heatmap": "^1.6.7",
"csv-parser": "^3.0.0",
"gh-pages": "^6.1.1",
"js-yaml": "^4.1.0",

@ -38,6 +38,9 @@ dependencies:
clsx:
specifier: ^2.0.0
version: 2.0.0
dayjs:
specifier: ^1.11.10
version: 1.11.10
dracula-prism:
specifier: ^2.1.13
version: 2.1.13
@ -71,12 +74,18 @@ dependencies:
react:
specifier: ^18.2.0
version: 18.2.0
react-calendar-heatmap:
specifier: ^1.9.0
version: 1.9.0(react@18.2.0)
react-confetti:
specifier: ^6.1.0
version: 6.1.0(react@18.2.0)
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
react-tooltip:
specifier: ^5.26.0
version: 5.26.0(react-dom@18.2.0)(react@18.2.0)
reactflow:
specifier: ^11.10.1
version: 11.10.1(@types/react@18.2.45)(react-dom@18.2.0)(react@18.2.0)
@ -112,6 +121,9 @@ devDependencies:
'@types/prismjs':
specifier: ^1.26.3
version: 1.26.3
'@types/react-calendar-heatmap':
specifier: ^1.6.7
version: 1.6.7
csv-parser:
specifier: ^3.0.0
version: 3.0.0
@ -923,6 +935,23 @@ packages:
tslib: 2.6.2
dev: false
/@floating-ui/core@1.6.0:
resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==}
dependencies:
'@floating-ui/utils': 0.2.1
dev: false
/@floating-ui/dom@1.6.1:
resolution: {integrity: sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==}
dependencies:
'@floating-ui/core': 1.6.0
'@floating-ui/utils': 0.2.1
dev: false
/@floating-ui/utils@0.2.1:
resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==}
dev: false
/@isaacs/cliui@8.0.2:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@ -1673,7 +1702,12 @@ packages:
/@types/prop-types@15.7.9:
resolution: {integrity: sha512-n1yyPsugYNSmHgxDFjicaI2+gCNjsBck8UX9kuofAKlc0h1bL+20oSF72KeNaW2DUlesbEVCFgyV2dPGTiY42g==}
dev: false
/@types/react-calendar-heatmap@1.6.7:
resolution: {integrity: sha512-xWBS9iOvw+aCidPk8QwCH69OCO7jnj6/9TjooqGQ9W+rA5m1aw36GjQMlSYKAg86otDeg9dzA+hSAIcvw/y9Rg==}
dependencies:
'@types/react': 18.2.45
dev: true
/@types/react-dom@18.2.18:
resolution: {integrity: sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==}
@ -1687,7 +1721,6 @@ packages:
'@types/prop-types': 15.7.9
'@types/scheduler': 0.16.5
csstype: 3.1.2
dev: false
/@types/sax@1.2.6:
resolution: {integrity: sha512-A1mpYCYu1aHFayy8XKN57ebXeAbh9oQIZ1wXcno6b1ESUAfMBDMx7mf/QGlYwcMRaFryh9YBuH03i/3FlPGDkQ==}
@ -1697,7 +1730,6 @@ packages:
/@types/scheduler@0.16.5:
resolution: {integrity: sha512-s/FPdYRmZR8SjLWGMCuax7r3qCWQw9QKHzXVukAuuIJkXkDRwp+Pu5LMIVFi0Fxbav35WURicYr8u1QsoybnQw==}
dev: false
/@types/unist@2.0.9:
resolution: {integrity: sha512-zC0iXxAv1C1ERURduJueYzkzZ2zaGyc+P2c95hgkikHPr3z8EdUZOlgEQ5X0DRmwDZn+hekycQnoeiiRVrmilQ==}
@ -2284,6 +2316,10 @@ packages:
resolution: {integrity: sha512-sbpkOw6z413p+HDGcBENe498WM9woqWHiJxCq7nvmxe9WmrUmqfAcxpIwAiMtM5Q3AhYkzXcNQHqsWq0mND51g==}
dev: false
/classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
dev: false
/clean-css@5.3.2:
resolution: {integrity: sha512-JVJbM+f3d3Q704rF4bqQ5UUyTtuJ0JRKNbTKVEeujCCBoMdkEi+V+e8oktO9qGQNSvHrFTM6JZRXrUvGR1czww==}
engines: {node: '>= 10.0'}
@ -2532,7 +2568,6 @@ packages:
/csstype@3.1.2:
resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==}
dev: false
/csv-parser@3.0.0:
resolution: {integrity: sha512-s6OYSXAK3IdKqYO33y09jhypG/bSDHPuyCme/IdEHfWpLf/jKcpitVFyOC6UemgGk8v7Q5u2XE0vvwmanxhGlQ==}
@ -2607,6 +2642,10 @@ packages:
d3-transition: 3.0.1(d3-selection@3.0.0)
dev: false
/dayjs@1.11.10:
resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==}
dev: false
/debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
@ -4508,6 +4547,10 @@ packages:
engines: {node: '>= 0.6'}
dev: false
/memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
dev: false
/merge-descriptors@1.0.1:
resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==}
dev: false
@ -5739,6 +5782,14 @@ packages:
sisteransi: 1.0.5
dev: false
/prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
dev: false
/property-information@6.3.0:
resolution: {integrity: sha512-gVNZ74nqhRMiIUYWGQdosYetaKc83x8oT41a0LlV3AAFCAZwCpg4vmGkq8t34+cUhp3cnM4XDiU/7xlgK7HGrg==}
dev: false
@ -5831,6 +5882,16 @@ packages:
strip-json-comments: 2.0.1
dev: false
/react-calendar-heatmap@1.9.0(react@18.2.0):
resolution: {integrity: sha512-mGed9any6QLOVckxwxC/eeP9s9wE8mTUW/FCE0V27xF9WOaCGuOftGSRH8DSDoSwgzMSVF6uuH7M1xvc+aZ8sg==}
peerDependencies:
react: ^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
dependencies:
memoize-one: 5.2.1
prop-types: 15.8.1
react: 18.2.0
dev: false
/react-confetti@6.1.0(react@18.2.0):
resolution: {integrity: sha512-7Ypx4vz0+g8ECVxr88W9zhcQpbeujJAVqL14ZnXJ3I23mOI9/oBVTQ3dkJhUmB0D6XOtCZEM6N0Gm9PMngkORw==}
engines: {node: '>=10.18'}
@ -5851,11 +5912,27 @@ packages:
scheduler: 0.23.0
dev: false
/react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: false
/react-refresh@0.14.0:
resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==}
engines: {node: '>=0.10.0'}
dev: false
/react-tooltip@5.26.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-UBbwy3fo1KYDwRCOWwM6AEfQsk9shgVfNkXFqgwS33QHplzg7xao/7mX/6wd+lE6KSZzhUNTkB5TNk9SMaBV/A==}
peerDependencies:
react: '>=16.14.0'
react-dom: '>=16.14.0'
dependencies:
'@floating-ui/dom': 1.6.1
classnames: 2.5.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/react@18.2.0:
resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
engines: {node: '>=0.10.0'}

@ -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>
);
}

@ -28,3 +28,10 @@ export function getRelativeTimeString(date: string): string {
return relativeTime;
}
export function formatActivityDate(date: string): string {
return new Date(date).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
});
}

@ -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…
Cancel
Save