From 1cbb9d7c2fe0f0f97364033b28e2cb989312ad08 Mon Sep 17 00:00:00 2001 From: suvajit Date: Sat, 26 Oct 2024 00:35:43 +0530 Subject: [PATCH] feat: merge landing, dashboard and app --- app/{(legal) => (others)}/expired/page.tsx | 0 app/{(legal) => (others)}/privacy/page.tsx | 0 app/{(legal) => (others)}/terms/page.tsx | 0 app/globals.css | 2 +- app/links/components/LinksPage.tsx | 5 +- app/links/page.tsx | 52 +-- app/web/charts/[id]/page.tsx | 34 ++ .../clans/[tag]/capital-contribution/page.tsx | 15 + app/web/clans/[tag]/page.tsx | 7 + app/web/clans/[tag]/wars/[id]/page.tsx | 15 + app/web/components/AttackLog.tsx | 222 +++++++++ app/web/components/AttackLogPage.tsx | 44 ++ .../components/CapitalContributionPage.tsx | 46 ++ app/web/components/CapitalDonation.tsx | 116 +++++ app/web/components/ChartPage.tsx | 219 +++++++++ app/web/components/History.tsx | 434 ++++++++++++++++++ app/web/components/Loader.tsx | 60 +++ app/web/components/PlayerPage.tsx | 71 +++ app/web/components/Stars.tsx | 48 ++ app/web/players/[tag]/page.tsx | 7 + app/web/players/[tag]/wars/page.tsx | 15 + next.config.mjs | 27 +- package-lock.json | 68 +++ package.json | 11 +- util/access-token.helper.ts | 52 +++ 25 files changed, 1517 insertions(+), 53 deletions(-) rename app/{(legal) => (others)}/expired/page.tsx (100%) rename app/{(legal) => (others)}/privacy/page.tsx (100%) rename app/{(legal) => (others)}/terms/page.tsx (100%) create mode 100644 app/web/charts/[id]/page.tsx create mode 100644 app/web/clans/[tag]/capital-contribution/page.tsx create mode 100644 app/web/clans/[tag]/page.tsx create mode 100644 app/web/clans/[tag]/wars/[id]/page.tsx create mode 100644 app/web/components/AttackLog.tsx create mode 100644 app/web/components/AttackLogPage.tsx create mode 100644 app/web/components/CapitalContributionPage.tsx create mode 100644 app/web/components/CapitalDonation.tsx create mode 100644 app/web/components/ChartPage.tsx create mode 100644 app/web/components/History.tsx create mode 100644 app/web/components/Loader.tsx create mode 100644 app/web/components/PlayerPage.tsx create mode 100644 app/web/components/Stars.tsx create mode 100644 app/web/players/[tag]/page.tsx create mode 100644 app/web/players/[tag]/wars/page.tsx create mode 100644 util/access-token.helper.ts diff --git a/app/(legal)/expired/page.tsx b/app/(others)/expired/page.tsx similarity index 100% rename from app/(legal)/expired/page.tsx rename to app/(others)/expired/page.tsx diff --git a/app/(legal)/privacy/page.tsx b/app/(others)/privacy/page.tsx similarity index 100% rename from app/(legal)/privacy/page.tsx rename to app/(others)/privacy/page.tsx diff --git a/app/(legal)/terms/page.tsx b/app/(others)/terms/page.tsx similarity index 100% rename from app/(legal)/terms/page.tsx rename to app/(others)/terms/page.tsx diff --git a/app/globals.css b/app/globals.css index 47fc103..dea98ff 100644 --- a/app/globals.css +++ b/app/globals.css @@ -6,7 +6,7 @@ } body { - background: #23272A; + background-color: #23272A; padding: 0; margin: 0; } diff --git a/app/links/components/LinksPage.tsx b/app/links/components/LinksPage.tsx index 7f9e168..8028546 100644 --- a/app/links/components/LinksPage.tsx +++ b/app/links/components/LinksPage.tsx @@ -1,8 +1,5 @@ 'use client'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { Fragment, useEffect, useState } from 'react'; - import AddLinkIcon from '@mui/icons-material/AddLink'; import LinkIcon from '@mui/icons-material/Link'; import LinkOffIcon from '@mui/icons-material/LinkOff'; @@ -28,6 +25,8 @@ import TableCell from '@mui/material/TableCell'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Typography from '@mui/material/Typography'; +import { useRouter, useParams, useSearchParams } from 'next/navigation'; +import { Fragment, useEffect, useState } from 'react'; import { ActionModal } from '@/components/ActionModal'; import { authCookieKey } from '@/lib/constants'; diff --git a/app/links/page.tsx b/app/links/page.tsx index a8654cd..ef1fccd 100644 --- a/app/links/page.tsx +++ b/app/links/page.tsx @@ -1,49 +1,9 @@ -import jwt from 'jsonwebtoken'; -import { headers } from 'next/headers'; -import { redirect } from 'next/navigation'; +import { getAccessToken } from '@/util/access-token.helper'; +import { NextPage } from 'next'; import { LinksPage } from './components/LinksPage'; -const getServerSideProps = async () => { - const headersList = headers(); - const query = new URLSearchParams(headersList.get('x-search') as string); - const authToken = query.get('token'); - - try { - const decoded = jwt.verify( - authToken as string, - process.env.JWT_DECODE_SECRET as string - ) as { - guild_id: string; - user_id: string; - }; - - const token = jwt.sign( - { - jti: 'vercel-uid', - sub: decoded.user_id, - roles: ['viewer', 'user'], - version: 'v1' - }, - process.env.JWT_SECRET_V2 as string, - { - expiresIn: '100m' - } - ); - - return { - token, - userId: decoded.user_id, - guildId: decoded.guild_id, - isPublicBot: query.get('bot') !== 'custom' - }; - } catch (e) { - console.error(e); - return redirect('/expired'); - } -}; - -export default async function Links() { - const { guildId, userId, token, isPublicBot } = await getServerSideProps(); +const Page: NextPage = async () => { + const { guildId, userId, token, isPublicBot } = await getAccessToken(); return ( ); -} +}; + +export default Page; diff --git a/app/web/charts/[id]/page.tsx b/app/web/charts/[id]/page.tsx new file mode 100644 index 0000000..9ce51cd --- /dev/null +++ b/app/web/charts/[id]/page.tsx @@ -0,0 +1,34 @@ +import { NextPage } from 'next'; +import { headers } from 'next/headers'; +import { notFound } from 'next/navigation'; +import { ChartPage } from '../../components/ChartPage'; + +const Page: NextPage = async () => { + const headersList = headers(); + const query = new URLSearchParams(headersList.get('x-search') as string); + const chartId = (headersList.get('x-pathname') as string) + .split('/') + .at(-1) as string; + + const res = await fetch(`https://chart.clashperk.com/${chartId}/json`); + const data = await res.json(); + + if (!res.ok) { + return notFound(); + } + + return ( + <> + + + ); +}; + +export default Page; diff --git a/app/web/clans/[tag]/capital-contribution/page.tsx b/app/web/clans/[tag]/capital-contribution/page.tsx new file mode 100644 index 0000000..d059f09 --- /dev/null +++ b/app/web/clans/[tag]/capital-contribution/page.tsx @@ -0,0 +1,15 @@ +import { CapitalContributionPage } from '@/app/web/components/CapitalContributionPage'; +import { getSignedToken } from '@/util/access-token.helper'; +import { NextPage } from 'next'; + +const Page: NextPage = async () => { + const result = await getSignedToken(); + + return ( + <> + + + ); +}; + +export default Page; diff --git a/app/web/clans/[tag]/page.tsx b/app/web/clans/[tag]/page.tsx new file mode 100644 index 0000000..f6f5400 --- /dev/null +++ b/app/web/clans/[tag]/page.tsx @@ -0,0 +1,7 @@ +import { NextPage } from 'next'; + +const Page: NextPage = () => { + return <>; +}; + +export default Page; diff --git a/app/web/clans/[tag]/wars/[id]/page.tsx b/app/web/clans/[tag]/wars/[id]/page.tsx new file mode 100644 index 0000000..8a44b5b --- /dev/null +++ b/app/web/clans/[tag]/wars/[id]/page.tsx @@ -0,0 +1,15 @@ +import { AttackLogPage } from '@/app/web/components/AttackLogPage'; +import { getSignedToken } from '@/util/access-token.helper'; +import { NextPage } from 'next'; + +const Page: NextPage = async () => { + const result = await getSignedToken(); + + return ( + <> + + + ); +}; + +export default Page; diff --git a/app/web/components/AttackLog.tsx b/app/web/components/AttackLog.tsx new file mode 100644 index 0000000..64611f3 --- /dev/null +++ b/app/web/components/AttackLog.tsx @@ -0,0 +1,222 @@ +import Avatar from '@mui/material/Avatar'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableRow from '@mui/material/TableRow'; +import Typography from '@mui/material/Typography'; +import moment from 'moment'; +import { getStars } from './Stars'; + +interface ClanWar { + endTime: string; + result: string; + clan: { + stars: number; + destructionPercentage: number; + name: string; + tag: string; + members: { + name: string; + tag: string; + mapPosition: number; + townhallLevel: number; + attacks: { + stars: number; + oldStars: number; + destructionPercentage: number; + order: number; + defender: { + name: string; + tag: string; + mapPosition: number; + townhallLevel: number; + }; + }[]; + defenses: { + stars: number; + destructionPercentage: number; + order: number; + defender: { + name: string; + tag: string; + mapPosition: number; + townhallLevel: number; + }; + }[]; + }[]; + }; + opponent: { + name: string; + tag: string; + }; +} + +export default function AttackLog({ data }: { data: ClanWar }) { + return ( + + + + + {data.clan.name} vs {data.opponent.name} + + + + {data.clan.stars} stars,{' '} + {data.clan.destructionPercentage.toFixed(2)}% destruction ( + {data.result}) | {moment(data.endTime).format('D MMM YYYY')} + + + + + + + + + {data.clan.members.map((row, index) => ( + + + + {row.mapPosition} + + + + + + {row.townhallLevel} + + + + + + {row.name} + + + + {row.tag} + + + + + {(row.attacks ?? []).map((atk, n) => ( + + {getStars(atk.oldStars, atk.stars)} + + ))} + + + + {row.attacks!.map((atk, n) => ( + + {atk.destructionPercentage.toFixed(0)}% + + ))} + + + + + {row.attacks.length > 0 ? 'v' : ''} + + + + + {row.attacks!.map((atk, n) => ( + + {atk.defender.mapPosition} + + ))} + + + + {row.attacks!.map((atk, n) => ( + + {atk.defender.townhallLevel} + + ))} + + + ))} + +
+
+ ); +} diff --git a/app/web/components/AttackLogPage.tsx b/app/web/components/AttackLogPage.tsx new file mode 100644 index 0000000..9e2646a --- /dev/null +++ b/app/web/components/AttackLogPage.tsx @@ -0,0 +1,44 @@ +'use client'; + +import AttackLog from '@/app/web/components/AttackLog'; +import Loader from '@/app/web/components/Loader'; +import Container from '@mui/material/Container'; +import { useParams } from 'next/navigation'; +import * as React from 'react'; + +export function AttackLogPage({ token }: { token: string }) { + const [data, setData] = React.useState(); + const [loading, setLoading] = React.useState(true); + const params = useParams(); + + const getWar = async (id: string, clanTag: string) => { + const res = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/clans/${clanTag}/wars/${id}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + } + ); + const data = await res.json(); + setData(data); + setLoading(false); + }; + + React.useEffect(() => { + if (params.id) { + getWar(params.id as string, params.tag as string); + } + }, [params]); // eslint-disable-line react-hooks/exhaustive-deps + + if (!data) + return ; + + return ( + + + + ); +} diff --git a/app/web/components/CapitalContributionPage.tsx b/app/web/components/CapitalContributionPage.tsx new file mode 100644 index 0000000..2a0e6cf --- /dev/null +++ b/app/web/components/CapitalContributionPage.tsx @@ -0,0 +1,46 @@ +'use client'; + +import CapitalDonation from '@/app/web/components/CapitalDonation'; +import Loader from '@/app/web/components/Loader'; +import Container from '@mui/material/Container'; +import { useParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +export function CapitalContributionPage({ token }: { token: string }) { + const [loading, setLoading] = useState(true); + const [logs, setLogs] = useState([]); + const params = useParams(); + + const getHistory = async (tag: string) => { + const res = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/clans/${tag}/capital-contribution`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + } + ); + const data = await res.json(); + setLogs(data); + setLoading(false); + }; + + useEffect(() => { + if (params.tag) { + getHistory(params.tag as string); + } + }, [params]); + + if (!logs.length) + return ; + + return ( + <> + + + + + ); +} diff --git a/app/web/components/CapitalDonation.tsx b/app/web/components/CapitalDonation.tsx new file mode 100644 index 0000000..5e4a034 --- /dev/null +++ b/app/web/components/CapitalDonation.tsx @@ -0,0 +1,116 @@ +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableRow from '@mui/material/TableRow'; +import Typography from '@mui/material/Typography'; +import moment from 'moment'; +import 'moment-duration-format'; + +interface Logs { + name: string; + tag: string; + season: string; + initial: number; + current: number; + clan: { name: string; tag: string }; + createdAt: string; +} + +export default function CapitalDonation({ logs }: { logs: Logs[] }) { + const clan = logs[0]?.clan; + return ( + + + + + {`${clan.name} (${clan.tag})`} | Capital Contribution Logs (last 10 + days) + + + + + + {logs.map((row, index) => ( + + + + {row.name} + + + {/* + {row.warType === 3 ? "CWL" : row.warType === 2 ? "Friendly" : null} + */} + + {' '} + {moment + .duration(Date.now() - new Date(row.createdAt).getTime()) + .format('d[d] h[h] m[m]', { trim: 'both mid' })}{' '} + ago + + + + + + + {row.current - row.initial} + + + + ))} + +
+
+ ); +} diff --git a/app/web/components/ChartPage.tsx b/app/web/components/ChartPage.tsx new file mode 100644 index 0000000..39cc02f --- /dev/null +++ b/app/web/components/ChartPage.tsx @@ -0,0 +1,219 @@ +'use client'; + +import { + ArcElement, + BarController, + BarElement, + CategoryScale, + Chart as ChartJS, + Legend, + LinearScale, + LineElement, + PointElement, + TimeScale, + Title, + Tooltip +} from 'chart.js'; +import 'chartjs-adapter-moment'; +import Head from 'next/head'; +import { Line } from 'react-chartjs-2'; + +ChartJS.register( + ArcElement, + Title, + Tooltip, + Legend, + CategoryScale, + LinearScale, + BarController, + BarElement, + PointElement, + LineElement, + TimeScale +); + +const colors = [ + '#266ef7', + '#c63304', + '#ffc107', + '#50c878', + '#ffac75', + '#4dc1fa', + '#cb5aff', + '#808000', + '#C9CBCF', + '#FF6384', + // + '#3A87AD', + '#F79256', + '#8DC2E9', + '#D1AED2', + '#62C370', + '#E36F8A', + '#A4BF96', + '#F0D96B', + '#4F576C', + '#B94C4C', + '#5E7D7D', + '#9A59B5', + '#7EC08C', + '#E39C4A', + '#31859B', + '#C58A6D', + '#6D4D9E', + '#92A1CF', + '#C1B07E', + '#486E76' +]; + +const randomNum = () => Math.floor(Math.random() * (235 - 52 + 1) + 52); +const randomRGB = () => `rgb(${randomNum()}, ${randomNum()}, ${randomNum()})`; + +export const ChartPage = ({ + labels, + datasets, + unit, + offset = 0, + title, + desktopOnly +}: { + id: string; + unit: string; + offset: number; + title: string; + labels: string[]; + desktopOnly: boolean; + datasets: { name: string; data: number[] }[]; +}) => { + return ( + <> + {desktopOnly && ( + + + + )} + + = 24 ? labels.length / 2 : labels.length, + font: function (context) { + if (context.tick && context.tick.major) + return { weight: 'bold' }; + } + }, + grid: { + display: true + }, + type: 'time', + time: { + unit: (unit || 'hour') as 'hour' | 'day', + parser: function (ts) { + return new Date(ts as string).getTime() - offset; + } + } + }, + y: { + display: true, + ticks: { + // count: 10, + color: '#000000', + precision: 0 + }, + border: { + display: false + }, + grid: { + display: true + } + }, + y1: { + display: true, + position: 'right', + ticks: { + callback: function (value) { + return `${value} ${unit}`; + } + }, + afterDataLimits: function (axis) { + const y = axis.chart.scales.y; + y.determineDataLimits(); // update y.min/max + axis.min = y.min; + axis.max = y.max; + } + } + }, + plugins: { + legend: { + display: true, + position: 'top', + labels: { + font: { + size: 14 + }, + boxWidth: 14, + padding: 4 + }, + fullSize: true + }, + title: { + position: 'top', + display: true, + padding: 3, + text: title + }, + tooltip: { + displayColors: false + } + } + }} + title={title} + data={{ + labels: labels, + datasets: datasets.slice(0, 20).map((data, idx) => { + const color = colors[idx] || randomRGB(); + return { + ...data, + label: data.name, + tension: 0.1, + cubicInterpolationMode: 'monotone', + backgroundColor: color, + borderColor: color, + borderWidth: 4, + pointBorderColor: color, + pointBackgroundColor: color, + pointRadius: 2 + }; + }) + }} + /> + + ); +}; diff --git a/app/web/components/History.tsx b/app/web/components/History.tsx new file mode 100644 index 0000000..31787ba --- /dev/null +++ b/app/web/components/History.tsx @@ -0,0 +1,434 @@ +import StarIcon from '@mui/icons-material/Star'; +import Avatar from '@mui/material/Avatar'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import Typography from '@mui/material/Typography'; +import moment from 'moment'; +import { getStars } from './Stars'; + +interface WarHistory { + id: number; + warType: number; + startTime: string; + endTime: string; + clan: { + name: string; + tag: string; + }; + opponent: { + name: string; + tag: string; + }; + attacker: { + name: string; + tag: string; + townhallLevel: number; + mapPosition: number; + }; + attacks: { + stars: number; + oldStars: number; + defenderTag: string; + destructionPercentage: number; + defender: { + tag: string; + townhallLevel: number; + mapPosition: number; + }; + }[]; +} + +interface Summary { + season: string; + wars: number; + rounds: number; + stars: number; + attacks: number; + missed: number; + destruction: number; +} + +export default function History({ + wars, + attacker, + summary +}: { + wars: WarHistory[]; + attacker: WarHistory['attacker']; + summary: Summary[]; +}) { + return ( + + + + + {`${attacker.name} (${attacker.tag})`} + + + 12 ? `-5` : '' + }.png`} + variant="square" + /> + + + + {wars.map((row, index) => ( + + + + {row.clan.name} vs {row.opponent.name} + + + + {row.warType === 3 + ? 'CWL' + : row.warType === 2 + ? 'Friendly' + : null} + + + {' '} + {moment(new Date(row.startTime)).format('D MMM YYYY')} + + + + + + {row.attacks.map((atk, i) => ( + + {row.attacker.mapPosition} + + ))} + + + + {row.attacks.map((atk, i) => ( + + {row.attacker.townhallLevel} + + ))} + + + + {row.attacks!.map((atk) => ( + + {getStars(atk.oldStars, atk.stars)} + + ))} + + + + {row.attacks!.map((atk, i) => ( + + {atk.destructionPercentage.toFixed(0)}% + + ))} + + + + + {row.attacks.length > 0 ? 'v' : null} + + + + + {row.attacks!.map((atk, i) => ( + + {atk.defender.mapPosition} + + ))} + + + + {row.attacks!.map((atk, i) => ( + + {atk.defender.townhallLevel} + + ))} + + + ))} + +
+ + {summary.length > 0 && ( + <> + + + CWL Summary + + + + + + + + Season + + + + + Star + + + + + Destruction + + + + + Missed + + + + + Wars + + + {/* + + Wars + + */} + + + + {summary.map((row, n) => ( + + + + {moment(row.season).format('MMMM YYYY')} + + + + + + + + {row.stars} + + + + + + + {row.destruction.toFixed(0)}% + + + + + 0 ? 'red' : '#dcddde', + fontSize: '12px' + }} + > + {row.missed} + + + + + + {row.wars} + + + + {/* + + {`${row.wars}`} + + */} + + ))} + +
+ + )} +
+ ); +} + +export const fakeWar = Array.from({ length: 50 }).map((_, index) => { + const oldStars = Math.floor(Math.random() * 3); + const newStars = Math.floor(Math.random() * 3); + const percentage = Math.floor(Math.random() * 100); + + return { + oldStars, + newStars, + percentage, + townHall: Math.floor(Math.random() * 5) + 10, + opponent: { + townHall: Math.floor(Math.random() * 5) + 10, + index: Math.floor(Math.random() * 50) + } + }; +}); + +// export const fakeRounds = Array.from({ length: 10 }).map((_, index) => { +// // generate random name +// const name = Math.random().toString(36).substring(7); +// return { +// name: "Air Hounds vs War Snippers", +// date: "10/10/2022", +// index: Math.floor(Math.random() * 50), +// war: fakeWar, +// }; +// }); diff --git a/app/web/components/Loader.tsx b/app/web/components/Loader.tsx new file mode 100644 index 0000000..05863f0 --- /dev/null +++ b/app/web/components/Loader.tsx @@ -0,0 +1,60 @@ +import Paper from '@mui/material/Paper'; +import Skeleton from '@mui/material/Skeleton'; +import Typography from '@mui/material/Typography'; +import { Container, Stack } from '@mui/system'; + +const Loader = ({ + loading, + message +}: { + loading: boolean; + message?: string; +}) => { + return ( + + + + + + + + {!loading && message && ( + {message} + )} + + + {Array(20) + .fill(0) + .map((_, i) => ( + + + + + + ))} + + + + + + + + ); +}; +export default Loader; diff --git a/app/web/components/PlayerPage.tsx b/app/web/components/PlayerPage.tsx new file mode 100644 index 0000000..82404fc --- /dev/null +++ b/app/web/components/PlayerPage.tsx @@ -0,0 +1,71 @@ +'use client'; + +import History from '@/app/web/components/History'; +import Loader from '@/app/web/components/Loader'; +import Container from '@mui/material/Container'; +import { useParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +export function PlayerPage({ token }: { token: string }) { + const [loading, setLoading] = useState(true); + const [wars, setWars] = useState([]); + const [summary, setSummary] = useState([]); + const params = useParams(); + + const getHistory = async (tag: string) => { + const res = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/players/${tag}/wars?months=6`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + } + ); + const wars = await res.json(); + setWars(wars); + }; + + const getCwlSummary = async (tag: string) => { + const res = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/players/${tag}/cwl-stats`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + } + ); + const summary = await res.json(); + setSummary(summary); + }; + + const fetchData = async (playerTag: string) => { + try { + await Promise.all([getHistory(playerTag), getCwlSummary(playerTag)]); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (params.tag) { + fetchData(params.tag as string); + } + }, [params]); // eslint-disable-line react-hooks/exhaustive-deps + + if (!wars.length) + return ; + + const attacker = wars?.[0]?.attacker; + + return ( + <> + + + + + ); +} diff --git a/app/web/components/Stars.tsx b/app/web/components/Stars.tsx new file mode 100644 index 0000000..5bcef1b --- /dev/null +++ b/app/web/components/Stars.tsx @@ -0,0 +1,48 @@ +import StarIcon from '@mui/icons-material/Star'; +import * as React from 'react'; + +export const STARS: Record = { + NEW: , + OLD: ( + + ), + EMPTY: ( + + ) +}; + +export const WAR_STARS = { + OLD: 'OLD-', + NEW: 'NEW-', + EMPTY: 'EMPTY-' +}; + +export function getStars(oldStars: number, newStars: number) { + if (oldStars > newStars) { + return [ + WAR_STARS.OLD.repeat(newStars), + WAR_STARS.EMPTY.repeat(3 - newStars) + ] + .join('') + .split('-') + .filter((_) => _.length) + .map((star, i) => ( + + {STARS[star as keyof typeof STARS]} + + )); + } + return [ + WAR_STARS.OLD.repeat(oldStars), + WAR_STARS.NEW.repeat(newStars - oldStars), + WAR_STARS.EMPTY.repeat(3 - newStars) + ] + .join('') + .split('-') + .filter((_) => _.length) + .map((star, i) => ( + + {STARS[star as keyof typeof STARS]} + + )); +} diff --git a/app/web/players/[tag]/page.tsx b/app/web/players/[tag]/page.tsx new file mode 100644 index 0000000..f6f5400 --- /dev/null +++ b/app/web/players/[tag]/page.tsx @@ -0,0 +1,7 @@ +import { NextPage } from 'next'; + +const Page: NextPage = () => { + return <>; +}; + +export default Page; diff --git a/app/web/players/[tag]/wars/page.tsx b/app/web/players/[tag]/wars/page.tsx new file mode 100644 index 0000000..fd0fbaa --- /dev/null +++ b/app/web/players/[tag]/wars/page.tsx @@ -0,0 +1,15 @@ +import { PlayerPage } from '@/app/web/components/PlayerPage'; +import { getSignedToken } from '@/util/access-token.helper'; +import { NextPage } from 'next'; + +const Page: NextPage = async () => { + const result = await getSignedToken(); + + return ( + <> + + + ); +}; + +export default Page; diff --git a/next.config.mjs b/next.config.mjs index 7f51c06..52d0b45 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -23,7 +23,32 @@ const nextConfig = { source: '/guide', destination: 'https://docs.clashperk.com/overview/getting-set-up', permanent: true - } + }, + { + source: '/clans/:path*', + destination: '/web/clans/:path*', + permanent: true + }, + { + source: '/charts/:path*', + destination: '/web/charts/:path*', + permanent: true + }, + { + source: '/players/:path*', + destination: '/web/players/:path*', + permanent: true + }, + { + source: '/members/:tag', + destination: '/web/players/:tag/wars', + permanent: true + }, + { + source: '/capital/:tag', + destination: '/web/clans/:tag/capital-contribution', + permanent: true + }, ]; } }; diff --git a/package-lock.json b/package-lock.json index 761b162..7244a86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,14 +17,20 @@ "@mui/material-nextjs": "^5.16.6", "@mui/x-charts": "^7.12.1", "@mui/x-date-pickers": "^7.12.0", + "@types/moment-duration-format": "^2.2.6", + "chart.js": "^4.4.5", + "chartjs-adapter-moment": "^1.0.1", "cookies-next": "^4.2.1", "dayjs": "^1.11.12", "discord-api-types": "^0.37.101", "framer-motion": "^11.3.24", "jsonwebtoken": "^9.0.2", + "moment": "^2.30.1", + "moment-duration-format": "^2.3.2", "next": "14.2.5", "radash": "^12.1.0", "react": "^18.3.1", + "react-chartjs-2": "^5.2.0", "react-dom": "^18.3.1", "react-hot-toast": "^2.4.1", "react-icons": "^5.3.0" @@ -602,6 +608,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==", + "license": "MIT" + }, "node_modules/@mui/base": { "version": "5.0.0-beta.40", "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40.tgz", @@ -1417,6 +1429,15 @@ "@types/node": "*" } }, + "node_modules/@types/moment-duration-format": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@types/moment-duration-format/-/moment-duration-format-2.2.6.tgz", + "integrity": "sha512-Qw+6ys3sQCatqukjoIBsL1UJq6S7ep4ixrNvDkdViSgzw2ZG3neArXNu3ww7mEG8kwP32ZDoON/esWb7OUckRQ==", + "license": "MIT", + "dependencies": { + "moment": ">=2.14.0" + } + }, "node_modules/@types/node": { "version": "20.14.14", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz", @@ -2091,6 +2112,28 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.5.tgz", + "integrity": "sha512-CVVjg1RYTJV9OCC8WeJPMx8gsV8K6WIyIEQUE3ui4AR9Hfgls9URri6Ja3hyMVBbTF8Q2KFa19PE815gWcWhng==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chartjs-adapter-moment": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/chartjs-adapter-moment/-/chartjs-adapter-moment-1.0.1.tgz", + "integrity": "sha512-Uz+nTX/GxocuqXpGylxK19YG4R3OSVf8326D+HwSTsNw1LgzyIGRo+Qujwro1wy6X+soNSnfj5t2vZ+r6EaDmA==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=3.0.0", + "moment": "^2.10.2" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -4549,6 +4592,21 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-duration-format": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/moment-duration-format/-/moment-duration-format-2.3.2.tgz", + "integrity": "sha512-cBMXjSW+fjOb4tyaVHuaVE/A5TqkukDWiOfxxAjY+PEqmmBQlLwn+8OzwPiG3brouXKY5Un4pBjAeB6UToXHaQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -5039,6 +5097,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz", + "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", diff --git a/package.json b/package.json index 9fbe5dc..73b3838 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint", - "generate": "graphql-codegen" + "lint": "next lint --fix" }, "dependencies": { "@emotion/cache": "^11.13.1", @@ -19,14 +18,20 @@ "@mui/material-nextjs": "^5.16.6", "@mui/x-charts": "^7.12.1", "@mui/x-date-pickers": "^7.12.0", + "@types/moment-duration-format": "^2.2.6", + "chart.js": "^4.4.5", + "chartjs-adapter-moment": "^1.0.1", "cookies-next": "^4.2.1", "dayjs": "^1.11.12", "discord-api-types": "^0.37.101", "framer-motion": "^11.3.24", "jsonwebtoken": "^9.0.2", + "moment": "^2.30.1", + "moment-duration-format": "^2.3.2", "next": "14.2.5", "radash": "^12.1.0", "react": "^18.3.1", + "react-chartjs-2": "^5.2.0", "react-dom": "^18.3.1", "react-hot-toast": "^2.4.1", "react-icons": "^5.3.0" @@ -45,4 +50,4 @@ "prettier": "^3.3.3", "typescript": "^5.5.4" } -} \ No newline at end of file +} diff --git a/util/access-token.helper.ts b/util/access-token.helper.ts new file mode 100644 index 0000000..877c566 --- /dev/null +++ b/util/access-token.helper.ts @@ -0,0 +1,52 @@ +'use server'; + +import jwt from 'jsonwebtoken'; +import { headers } from 'next/headers'; + +export const getAccessToken = async () => { + const headersList = headers(); + const query = new URLSearchParams(headersList.get('x-search') as string); + const authToken = query.get('token'); + + const decoded = jwt.verify( + authToken as string, + process.env.JWT_DECODE_SECRET as string + ) as { + guild_id: string; + user_id: string; + }; + + const token = jwt.sign( + { + jti: 'vercel-uid', + sub: decoded.user_id, + roles: ['viewer', 'user'], + version: 'v1' + }, + process.env.JWT_SECRET_V2 as string, + { + expiresIn: '100m' + } + ); + + return { + token, + userId: decoded.user_id, + guildId: decoded.guild_id, + isPublicBot: query.get('bot') !== 'custom' + }; +}; + +export const getSignedToken = async () => { + const token = jwt.sign( + { jti: 'vercel-uid', sub: 'vercel-user', roles: ['viewer'], version: 'v1' }, + process.env.JWT_SECRET_V2 as string, + { + expiresIn: '1m' + } + ); + + return { + token + }; +};