diff --git a/src/app/(with-header)/chuni/dashboard/page.tsx b/src/app/(with-header)/chuni/dashboard/page.tsx
new file mode 100644
index 0000000..17d6fd8
--- /dev/null
+++ b/src/app/(with-header)/chuni/dashboard/page.tsx
@@ -0,0 +1,34 @@
+import { getPlaylog } from '@/actions/chuni/playlog';
+import { ChuniNameplate } from '@/components/chuni/nameplate';
+import { ChuniPlaylogCard } from '@/components/chuni/playlog-card';
+import { getUserData, getUserRating } from '@/actions/chuni/profile';
+import { requireUser } from '@/actions/auth';
+import { notFound } from 'next/navigation';
+import { ChuniTopRating } from '@/components/chuni/top-rating';
+import { ChuniTopRatingSidebar } from '@/components/chuni/top-rating-sidebar';
+
+export default async function ChuniDashboard() {
+ const user = await requireUser();
+ const [profile, rating, playlog] = await Promise.all([
+ getUserData(user),
+ getUserRating(user),
+ getPlaylog({ limit: 72 })
+ ])
+
+ if (!profile) return notFound();
+
+ return (
+
+
+
+
+
+
+
Playlog
+
+ {playlog.data.map((entry, i) => )}
+
+
+
);
+}
+
diff --git a/src/components/chuni/top-rating-sidebar.tsx b/src/components/chuni/top-rating-sidebar.tsx
new file mode 100644
index 0000000..ed35db0
--- /dev/null
+++ b/src/components/chuni/top-rating-sidebar.tsx
@@ -0,0 +1,38 @@
+'use client';
+
+import { ChuniTopRating, ChuniTopRatingProps } from '@/components/chuni/top-rating';
+import { getUserRating } from '@/actions/chuni/profile';
+import { useState } from 'react';
+import { Button, ButtonGroup } from '@nextui-org/react';
+
+export const ChuniTopRatingSidebar = ({ rating }: { rating: Awaited> }) => {
+ const [shownRating, setShownRating] = useState<'top' | 'recent' | null>('recent');
+
+ return (
+
+
+
+
+
+
+
+ Ratings
+
+
+
+
+
+
+ {shownRating &&
}
+
+
);
+};
diff --git a/src/components/chuni/top-rating.tsx b/src/components/chuni/top-rating.tsx
new file mode 100644
index 0000000..00f7178
--- /dev/null
+++ b/src/components/chuni/top-rating.tsx
@@ -0,0 +1,40 @@
+import { getUserRating } from '@/actions/chuni/profile';
+import { getJacketUrl } from '@/helpers/assets';
+import { ChuniRating } from '@/components/chuni/rating';
+import { floorToDp } from '@/helpers/floor-dp';
+import { ChuniScoreBadge, getVariantFromRank, getVariantFromScore } from '@/components/chuni/score-badge';
+import { ChuniDifficultyContainer } from '@/components/chuni/difficulty-container';
+import { Tooltip } from '@nextui-org/react';
+import { ChuniLevelBadge } from '@/components/chuni/level-badge';
+import Link from 'next/link';
+
+export type ChuniTopRatingProps = {
+ className?: string,
+ rating: Awaited>['recent' | 'top']
+};
+
+export const ChuniTopRating = ({ rating, className }: ChuniTopRatingProps) => {
+ return (
+ {rating.map((music, i) =>
+
+
+
})
+
+
+
+
+
+
{i + 1}:
{music.title}
+
+ RATING
+ {floorToDp(music.rating, 2)}
+
+
+
{music.scoreMax?.toLocaleString()}
+ {('pastIndex' in music) &&
-{music.pastIndex+1}
}
+
+
+
)}
+
)
+};
diff --git a/src/helpers/floor-dp.ts b/src/helpers/floor-dp.ts
new file mode 100644
index 0000000..e0fd2ec
--- /dev/null
+++ b/src/helpers/floor-dp.ts
@@ -0,0 +1,8 @@
+export const floorToDp = (num: number | string, decimals: number) => {
+ if (typeof num === 'string') {
+ return num.slice(0, num.indexOf('.') + decimals + 1);
+ }
+
+ const mult = (10 ** decimals);
+ return (Math.floor(num * mult) / mult).toFixed(decimals);
+};