From 8bf0b51065d209b3b8d35b804d0ac4ef0c0cfe48 Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Sun, 11 Feb 2024 04:50:16 +0600 Subject: [PATCH] feat: username route --- package.json | 4 + pnpm-lock.yaml | 85 +++++++++- src/api/api.ts | 154 ++++++++++++++++++ src/api/user.ts | 60 +++++++ .../Account/UserActivityHeatmap.tsx | 71 ++++++++ src/components/Account/UserDetails.tsx | 57 +++++++ src/lib/date.ts | 7 + src/pages/u/[username]/index.astro | 28 ++++ 8 files changed, 462 insertions(+), 4 deletions(-) create mode 100644 src/api/api.ts create mode 100644 src/api/user.ts create mode 100644 src/components/Account/UserActivityHeatmap.tsx create mode 100644 src/components/Account/UserDetails.tsx create mode 100644 src/pages/u/[username]/index.astro diff --git a/package.json b/package.json index 9f50030d9..d07e52b5a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b2f0b979..5acd9a1e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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'} diff --git a/src/api/api.ts b/src/api/api.ts new file mode 100644 index 000000000..8917e95be --- /dev/null +++ b/src/api/api.ts @@ -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 }; + +type AppResponse = Record; + +export type FetchError = { + status: number; + message: string; +}; + +export type AppError = { + status: number; + message: string; + errors?: { message: string; location: string }[]; +}; + +export type ApiReturn = { + response?: ResponseType; + error?: ErrorType | FetchError; +}; + +export function api(context: APIContext) { + const token = context.cookies.get(TOKEN_COOKIE_NAME)?.value; + + async function apiCall( + url: string, + options?: HttpOptionsType, + ): Promise> { + 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( + url: string, + queryParams?: Record, + options?: HttpOptionsType, + ): Promise> { + const searchParams = new URLSearchParams(queryParams).toString(); + const queryUrl = searchParams ? `${url}?${searchParams}` : url; + + return apiCall(queryUrl, { + ...options, + method: 'GET', + }); + }, + post: async function apiPost< + ResponseType = AppResponse, + ErrorType = AppError, + >( + url: string, + body: Record, + options?: HttpOptionsType, + ): Promise> { + return apiCall(url, { + ...options, + method: 'POST', + body: JSON.stringify(body), + }); + }, + patch: async function apiPatch< + ResponseType = AppResponse, + ErrorType = AppError, + >( + url: string, + body: Record, + options?: HttpOptionsType, + ): Promise> { + return apiCall(url, { + ...options, + method: 'PATCH', + body: JSON.stringify(body), + }); + }, + put: async function apiPut< + ResponseType = AppResponse, + ErrorType = AppError, + >( + url: string, + body: Record, + options?: HttpOptionsType, + ): Promise> { + return apiCall(url, { + ...options, + method: 'PUT', + body: JSON.stringify(body), + }); + }, + delete: async function apiDelete< + ResponseType = AppResponse, + ErrorType = AppError, + >( + url: string, + options?: HttpOptionsType, + ): Promise> { + return apiCall(url, { + ...options, + method: 'DELETE', + }); + }, + }; +} diff --git a/src/api/user.ts b/src/api/user.ts new file mode 100644 index 000000000..dc07daeaa --- /dev/null +++ b/src/api/user.ts @@ -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; + 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; + 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( + `${import.meta.env.PUBLIC_API_URL}/v1-get-user-by-username/${username}`, + ); + }, + }; +} diff --git a/src/components/Account/UserActivityHeatmap.tsx b/src/components/Account/UserActivityHeatmap.tsx new file mode 100644 index 000000000..4fc217699 --- /dev/null +++ b/src/components/Account/UserActivityHeatmap.tsx @@ -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 ( + <> +

Activity

+ { + 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}`, + }; + }} + /> + + + + ); +} diff --git a/src/components/Account/UserDetails.tsx b/src/components/Account/UserDetails.tsx new file mode 100644 index 000000000..9038a1637 --- /dev/null +++ b/src/components/Account/UserDetails.tsx @@ -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 ( +
+
+ {name} + +
+

{name}

+

@{username}

+
+
+ +
+ {links?.github && } + {links?.linkedin && ( + + )} + {links?.twitter && } + {links?.website && } +
+
+ ); +} + +type UserLinkProps = { + href: string; + icon: typeof Github; +}; + +export function UserLink(props: UserLinkProps) { + const { href, icon: Icon } = props; + + return ( + + + {href} + + ); +} diff --git a/src/lib/date.ts b/src/lib/date.ts index 07a0b4d40..e9d3ad044 100644 --- a/src/lib/date.ts +++ b/src/lib/date.ts @@ -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', + }); +} diff --git a/src/pages/u/[username]/index.astro b/src/pages/u/[username]/index.astro new file mode 100644 index 000000000..fa155226b --- /dev/null +++ b/src/pages/u/[username]/index.astro @@ -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'); +} +--- + + +
+ +
+ +
+
+