Skip to content

Commit 06ca764

Browse files
committed
feat: add user page layout with initial user song fetching
1 parent 1ebde28 commit 06ca764

File tree

6 files changed

+220
-67
lines changed

6 files changed

+220
-67
lines changed

web/src/app/(content)/user/[id]/page.tsx

Lines changed: 0 additions & 23 deletions
This file was deleted.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { ErrorBox } from '@web/src/modules/shared/components/client/ErrorBox';
2+
import UserProfile from '@web/src/modules/user/components/UserProfile';
3+
import {
4+
getUserProfileData,
5+
getUserSongs,
6+
} from '@web/src/modules/user/features/user.util';
7+
8+
const UserPage = async ({ params }: { params: { username: string } }) => {
9+
const { username } = params;
10+
11+
let userData = null;
12+
let songData = null;
13+
14+
try {
15+
userData = await getUserProfileData(username);
16+
} catch (e) {
17+
console.error('Failed to get user data:', e);
18+
}
19+
20+
try {
21+
songData = await getUserSongs(username);
22+
} catch (e) {
23+
console.error('Failed to get song data:', e);
24+
}
25+
26+
return !userData ? (
27+
<ErrorBox message='Failed to get user data' />
28+
) : (
29+
<UserProfile userData={userData} songData={songData} />
30+
);
31+
};
32+
33+
export default UserPage;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { faRocket, faSquareFull } from '@fortawesome/free-solid-svg-icons';
2+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
3+
4+
export const EarlySupporterBadge = () => {
5+
return (
6+
<span
7+
className='inline-flex items-center rounded-full p-1 bg-indigo-700 text-white group transition-all duration-300 ease-in-out focus:ring-1 focus:ring-indigo-500 focus:ring-offset-1 focus:outline-none'
8+
role='alert'
9+
tabIndex={0}
10+
>
11+
<FontAwesomeIcon
12+
className='text-indigo-700 text-xl bg-gradient-to-br from-green-200 via-teal-400 to-blue-600 scale-75 size-6 group-hover:scale-100 group-focus:scale-100 transition-all duration-300 ease-in-out'
13+
icon={faRocket}
14+
mask={faSquareFull}
15+
/>
16+
17+
<span className='whitespace-nowrap inline-block group-hover:max-w-screen-2xl group-focus:max-w-screen-2xl max-w-0 scale- group-hover:scale-100 overflow-hidden transition-all duration-300 ease-in-out group-hover:px-1.5 group-focus:px-1.5'>
18+
<h2 className='font-black tracking-tight text-md text-transparent bg-clip-text bg-gradient-to-r from-green-400 via-teal-400 to-blue-400'>
19+
Early Supporter
20+
</h2>
21+
</span>
22+
</span>
23+
);
24+
};

web/src/modules/user/components/UserProfile.tsx

Lines changed: 85 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,97 @@
1+
import { SongPreviewDto } from '@shared/validation/song/dto/SongPreview.dto';
12
import { UserProfileViewDto } from '@shared/validation/user/dto/UserProfileView.dto';
23
import Image from 'next/image';
34

5+
import { EarlySupporterBadge } from './UserBadges';
6+
import UserSocialIcon from './UserSocialIcon';
7+
import SongCard from '../../browse/components/SongCard';
8+
import SongCardGroup from '../../browse/components/SongCardGroup';
9+
import { formatTimeAgo } from '../../shared/util/format';
10+
411
type UserProfileProps = {
512
userData: UserProfileViewDto;
13+
songData: SongPreviewDto[] | null;
614
};
715

8-
const UserProfile = ({ userData }: UserProfileProps) => {
9-
const {
10-
lastSeen,
11-
loginStreak,
12-
playCount,
13-
publicName,
14-
description,
15-
profileImage,
16-
// socialLinks,
17-
} = userData;
16+
const UserProfile = ({ userData, songData }: UserProfileProps) => {
17+
const { lastSeen, username, description, profileImage } = userData;
1818

1919
return (
20-
<section className='w-full h-full'>
21-
<Image
22-
src={profileImage}
23-
alt={publicName}
24-
className='w-32 h-32 rounded-full'
25-
width={128}
26-
height={128}
27-
/>
28-
<h1 className='text-2xl font-bold'>{publicName}</h1>
29-
<p className='text-gray-500'>{description}</p>
30-
<p className='text-gray-500'>Last Login: {lastSeen.toLocaleString()}</p>
31-
<p className='text-gray-500'>Login Streak: {loginStreak}</p>
32-
<p className='text-gray-500'>Play Count: {playCount}</p>
33-
{/* <ul className='mt-4'>
34-
{Object.keys(socialLinks).map((key, index) => {
35-
const link = socialLinks[key as keyof UserLinks];
36-
if (!link) return null;
37-
38-
return (
39-
<li key={index}>
40-
<a href={link} className='text-blue-500 hover:underline'>
41-
{key}
42-
</a>
43-
</li>
44-
);
45-
})}
46-
</ul> */}
47-
</section>
20+
<div className='max-w-screen-lg mx-auto'>
21+
{/* HEADER */}
22+
<section>
23+
<div className='flex items-center gap-8'>
24+
<Image
25+
src={profileImage}
26+
alt={username}
27+
className='w-32 h-32 rounded-full'
28+
width={128}
29+
height={128}
30+
/>
31+
<div>
32+
{/* Display name */}
33+
<div className='flex items-center gap-8'>
34+
<h1 className='text-3xl font-bold mb-1 relative'>{username}</h1>
35+
<EarlySupporterBadge />
36+
</div>
37+
38+
{/* Username/handle */}
39+
<p className='text-zinc-400 my-1'>
40+
<span className='font-black text-zinc-200'>{`@${username}`}</span>
41+
{` • 5 songs • 2,534 plays`}
42+
</p>
43+
44+
{/* Description */}
45+
<p className='text-zinc-400 my-1 line-clamp-3'>
46+
Hello! This is my user description.
47+
</p>
48+
49+
{/* Social links */}
50+
<div className='flex-grow flex flex-row gap-1.5 mt-4'>
51+
<UserSocialIcon icon='twitter' href='#' />
52+
<UserSocialIcon icon='youtube' href='#' />
53+
<UserSocialIcon icon='github' href='#' />
54+
<UserSocialIcon icon='discord' href='#' />
55+
<UserSocialIcon icon='patreon' href='#' />
56+
</div>
57+
</div>
58+
<div className='flex-grow'></div>
59+
<div>
60+
{/* Joined */}
61+
<p className='text-zinc-500'>Joined</p>
62+
<p className='font-bold text-zinc-400 mb-4'>
63+
{/* TODO: lastSeen is supposed to be a date, but it's a string */}
64+
{new Date(lastSeen).toLocaleDateString('en-UK')}
65+
<span className='font-normal text-zinc-400'>{` (${formatTimeAgo(
66+
new Date(lastSeen),
67+
)})`}</span>
68+
</p>
69+
70+
{/* Last seen */}
71+
<p className='text-zinc-500'>Last seen</p>
72+
<p className='font-bold text-zinc-400'>
73+
{/* TODO: lastSeen is supposed to be a date, but it's a string */}
74+
{new Date(lastSeen).toLocaleDateString('en-UK')}
75+
<span className='font-normal text-zinc-400'>{` (${formatTimeAgo(
76+
new Date(lastSeen),
77+
)})`}</span>
78+
</p>
79+
</div>
80+
</div>
81+
</section>
82+
83+
<hr className='my-8 border-none bg-zinc-700 h-[3px]' />
84+
85+
{/* UPLOADED SONGS */}
86+
<section>
87+
<h2 className='flex-1 text-xl uppercase mb-4 text-zinc-200'>Songs</h2>
88+
<SongCardGroup>
89+
{songData?.map((song, i) => (
90+
<SongCard key={i} song={song} />
91+
))}
92+
</SongCardGroup>
93+
</section>
94+
</div>
4895
);
4996
};
5097

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {
2+
IconDefinition,
3+
faBandcamp,
4+
faDiscord,
5+
faFacebook,
6+
faGithub,
7+
faInstagram,
8+
faLinkedin,
9+
faPatreon,
10+
faPinterest,
11+
faReddit,
12+
faSnapchat,
13+
faSoundcloud,
14+
faSpotify,
15+
faSteam,
16+
faTiktok,
17+
faTwitch,
18+
faXTwitter,
19+
faYoutube,
20+
} from '@fortawesome/free-brands-svg-icons';
21+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
22+
import Link from 'next/link';
23+
24+
const iconLookup: Record<string, IconDefinition> = {
25+
twitter: faXTwitter,
26+
youtube: faYoutube,
27+
github: faGithub,
28+
discord: faDiscord,
29+
bandcamp: faBandcamp,
30+
soundcloud: faSoundcloud,
31+
instagram: faInstagram,
32+
facebook: faFacebook,
33+
patreon: faPatreon,
34+
twitch: faTwitch,
35+
spotify: faSpotify,
36+
tiktok: faTiktok,
37+
linkedin: faLinkedin,
38+
snapchat: faSnapchat,
39+
pinterest: faPinterest,
40+
reddit: faReddit,
41+
steam: faSteam,
42+
};
43+
44+
const UserSocialIcon = ({ icon, href }: { href: string; icon: string }) => (
45+
<Link
46+
href={href}
47+
className='text-zinc-500 hover:text-zinc-400 transition-colors duration-150'
48+
>
49+
<FontAwesomeIcon icon={iconLookup[icon]} className='text-xl' />
50+
</Link>
51+
);
52+
53+
export default UserSocialIcon;
Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,33 @@
1+
import { SongPreviewDto } from '@shared/validation/song/dto/SongPreview.dto';
2+
import { UserProfileViewDto } from '@shared/validation/user/dto/UserProfileView.dto';
3+
14
import axiosInstance from '../../../lib/axios';
2-
import { UserProfileData } from '../../auth/types/User';
35

4-
export const getUserProfileData = async (
5-
id: string,
6-
): Promise<UserProfileData | never> => {
6+
export const getUserProfileData = async (username: string) => {
77
try {
8-
const res = await axiosInstance.get(`/user/${id}`);
9-
if (res.status === 200) return res.data as UserProfileData;
8+
const res = await axiosInstance.get<UserProfileViewDto>(
9+
`/user/${username}`,
10+
);
11+
12+
if (res.status === 200) return res.data;
1013
else throw new Error('Failed to get user data');
1114
} catch {
1215
throw new Error('Failed to get user data');
1316
}
1417
};
18+
19+
export const getUserSongs = async (username: string) => {
20+
try {
21+
const res = await axiosInstance.get<SongPreviewDto[]>(`/song`, {
22+
params: {
23+
limit: 12,
24+
user: username,
25+
},
26+
});
27+
28+
if (res.status === 200) return res.data;
29+
else throw new Error('Failed to get user songs');
30+
} catch {
31+
throw new Error('Failed to get user songs');
32+
}
33+
};

0 commit comments

Comments
 (0)