Skip to content

Commit 7a19423

Browse files
committed
feat: enhance user profile editing with additional social links and validation
1 parent 906c7af commit 7a19423

File tree

8 files changed

+321
-20
lines changed

8 files changed

+321
-20
lines changed

NoteBlockWorld.code-workspace

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
},
4141
"cSpell.words": [
4242
"Bentroen",
43+
"spotify",
44+
"tiktok",
4345
"Tomast"
4446
]
4547
},

bun.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@
170170
"typescript": "^5.1.3",
171171
"zod": "^3.24.1",
172172
"zod-validation-error": "^3.4.0",
173+
"zustand": "^5.0.4",
173174
},
174175
"devDependencies": {
175176
"@shrutibalasa/tailwind-grid-auto-fit": "^1.1.0",
@@ -3102,6 +3103,8 @@
31023103

31033104
"zod-validation-error": ["[email protected]", "", { "peerDependencies": { "zod": "^3.18.0" } }, "sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ=="],
31043105

3106+
"zustand": ["[email protected]", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-39VFTN5InDtMd28ZhjLyuTnlytDr9HfwO512Ai4I8ZABCoyAj4F1+sr7sD1jP/+p7k77Iko0Pb5NhgBFDCX0kQ=="],
3107+
31053108
"zwitch": ["[email protected]", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
31063109

31073110
"@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],

shared/validation/user/dto/UserProfileView.dto.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,21 @@
1-
import { SocialLinks, UserDocument } from '@server/user/entity/user.entity';
1+
export class SocialLinks {
2+
bandcamp?: string;
3+
discord?: string;
4+
facebook?: string;
5+
github?: string;
6+
instagram?: string;
7+
reddit?: string;
8+
snapchat?: string;
9+
soundcloud?: string;
10+
spotify?: string;
11+
steam?: string;
12+
telegram?: string;
13+
tiktok?: string;
14+
threads?: string;
15+
twitch?: string;
16+
x?: string;
17+
youtube?: string;
18+
}
219

320
export class UserProfileViewDto {
421
username: string;
@@ -12,7 +29,7 @@ export class UserProfileViewDto {
1229

1330
socialLinks: InstanceType<typeof SocialLinks>;
1431

15-
public static fromUserDocument(user: UserDocument): UserProfileViewDto {
32+
public static fromUserDocument(user: UserProfileViewDto): UserProfileViewDto {
1633
return new UserProfileViewDto({
1734
username: user.username,
1835
publicName: user.publicName,

web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
"typescript": "^5.1.3",
5656
"zod": "^3.24.1",
5757
"zod-validation-error": "^3.4.0",
58-
"zustand": "^5.0.3"
58+
"zustand": "^5.0.4"
5959
},
6060
"devDependencies": {
6161
"@shrutibalasa/tailwind-grid-auto-fit": "^1.1.0",
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
'use client';
2+
3+
import {
4+
faExclamationCircle,
5+
faExternalLink,
6+
} from '@fortawesome/free-solid-svg-icons';
7+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
8+
import { zodResolver } from '@hookform/resolvers/zod';
9+
import { deepFreeze } from '@shared/validation/common/deepFreeze';
10+
import type { UserProfileViewDto } from '@shared/validation/user/dto/UserProfileView.dto';
11+
import Link from 'next/link';
12+
import { useEffect } from 'react';
13+
import { useForm } from 'react-hook-form';
14+
import { z as zod } from 'zod';
15+
import { create } from 'zustand';
16+
17+
import {
18+
Input,
19+
TextArea,
20+
} from '@web/src/modules/shared/components/client/FormElements';
21+
22+
type UserProfileEditStore = {
23+
isLoading: boolean;
24+
isLocked: boolean;
25+
userData: UserProfileViewDto | null;
26+
setUserData: (data: UserProfileViewDto) => void;
27+
updateUserData: (data: Partial<UserProfileViewDto>) => void;
28+
};
29+
30+
export const LinkRegexes = deepFreeze({
31+
bandcamp: /https?:\/\/[a-zA-Z0-9_-]+\.bandcamp\.com\/?/,
32+
discord: /https?:\/\/(www\.)?discord\.com\/[a-zA-Z0-9_]+/,
33+
facebook: /https?:\/\/(www\.)?facebook\.com\/[a-zA-Z0-9_]+/,
34+
github: /https?:\/\/(www\.)?github\.com\/[a-zA-Z0-9_-]+/,
35+
instagram: /https?:\/\/(www\.)?instagram\.com\/[a-zA-Z0-9_]+/,
36+
reddit: /https?:\/\/(www\.)?reddit\.com\/user\/[a-zA-Z0-9_-]+/,
37+
snapchat: /https?:\/\/(www\.)?snapchat\.com\/add\/[a-zA-Z0-9_-]+/,
38+
soundcloud: /https?:\/\/(www\.)?soundcloud\.com\/[a-zA-Z0-9_-]+/,
39+
spotify: /https?:\/\/open\.spotify\.com\/artist\/[a-zA-Z0-9?&=]+/,
40+
steam: /https?:\/\/steamcommunity\.com\/id\/[a-zA-Z0-9_-]+/,
41+
telegram: /https?:\/\/(www\.)?t\.me\/[a-zA-Z0-9_]+/,
42+
tiktok: /https?:\/\/(www\.)?tiktok\.com\/@?[a-zA-Z0-9_]+/,
43+
threads: /https?:\/\/(www\.)?threads\.net\/@?[a-zA-Z0-9_]+/,
44+
twitch: /https?:\/\/(www\.)?twitch\.tv\/[a-zA-Z0-9_]+/,
45+
x: /https?:\/\/(www\.)?x\.com\/[a-zA-Z0-9_]+/,
46+
youtube: /https?:\/\/(www\.)?youtube\.com\/@?[a-zA-Z0-9_-]+/,
47+
});
48+
49+
const socialLinksSchema = zod.object({
50+
bandcamp: zod.string().regex(LinkRegexes.bandcamp).optional(),
51+
discord: zod.string().regex(LinkRegexes.discord).optional(),
52+
facebook: zod.string().regex(LinkRegexes.facebook).optional(),
53+
github: zod.string().regex(LinkRegexes.github).optional(),
54+
instagram: zod.string().regex(LinkRegexes.instagram).optional(),
55+
reddit: zod.string().regex(LinkRegexes.reddit).optional(),
56+
snapchat: zod.string().regex(LinkRegexes.snapchat).optional(),
57+
soundcloud: zod.string().regex(LinkRegexes.soundcloud).optional(),
58+
spotify: zod.string().regex(LinkRegexes.spotify).optional(),
59+
steam: zod.string().regex(LinkRegexes.steam).optional(),
60+
telegram: zod.string().regex(LinkRegexes.telegram).optional(),
61+
tiktok: zod.string().regex(LinkRegexes.tiktok).optional(),
62+
threads: zod.string().regex(LinkRegexes.threads).optional(),
63+
twitch: zod.string().regex(LinkRegexes.twitch).optional(),
64+
x: zod.string().regex(LinkRegexes.x).optional(),
65+
youtube: zod.string().regex(LinkRegexes.youtube).optional(),
66+
});
67+
68+
const userProfileEditFormSchema = zod.object({
69+
description: zod.string().optional(),
70+
socialLinks: socialLinksSchema,
71+
});
72+
73+
type UserProfileEditFormSchema = zod.infer<typeof userProfileEditFormSchema>;
74+
75+
const useUserProfileEdit = create<UserProfileEditStore>((set, get) => {
76+
return {
77+
isLoading: true,
78+
isLocked: true,
79+
userData: null,
80+
setUserData: (data: UserProfileViewDto) => {
81+
set({ userData: data });
82+
set({ isLoading: false });
83+
set({ isLocked: false });
84+
},
85+
updateUserData: (data: Partial<UserProfileViewDto>) => {
86+
set({ isLoading: true });
87+
set({ isLocked: true });
88+
89+
// TODO: do some fetch to update the user data
90+
91+
// update the user data in the store
92+
set({
93+
userData: {
94+
...get().userData,
95+
...data,
96+
} as UserProfileViewDto,
97+
});
98+
99+
set({ isLoading: false });
100+
set({ isLocked: false });
101+
},
102+
};
103+
});
104+
105+
type UserEditProfileProps = {
106+
initialUserData: UserProfileViewDto;
107+
};
108+
109+
export const UserEditProfile: React.FC<UserEditProfileProps> = ({
110+
initialUserData,
111+
}) => {
112+
const { setUserData, isLoading, isLocked } = useUserProfileEdit();
113+
114+
useEffect(() => {
115+
setUserData(initialUserData);
116+
// eslint-disable-next-line react-hooks/exhaustive-deps
117+
}, [initialUserData]);
118+
119+
const formMethods = useForm<UserProfileEditFormSchema>({
120+
resolver: zodResolver(userProfileEditFormSchema),
121+
mode: 'onBlur',
122+
});
123+
124+
const {
125+
register,
126+
formState: { errors },
127+
} = formMethods;
128+
129+
const LinkFields: {
130+
key: keyof UserProfileEditFormSchema['socialLinks'];
131+
label: string;
132+
description: string;
133+
}[] = [
134+
{
135+
key: 'bandcamp',
136+
label: 'Bandcamp',
137+
description: 'Link to your Bandcamp profile',
138+
},
139+
{
140+
key: 'discord',
141+
label: 'Discord',
142+
description: 'Link to your Discord profile',
143+
},
144+
{
145+
key: 'facebook',
146+
label: 'Facebook',
147+
description: 'Link to your Facebook page',
148+
},
149+
{
150+
key: 'github',
151+
label: 'Github',
152+
description: 'Link to your Github profile',
153+
},
154+
{
155+
key: 'instagram',
156+
label: 'Instagram',
157+
description: 'Link to your Instagram profile',
158+
},
159+
{
160+
key: 'reddit',
161+
label: 'Reddit',
162+
description: 'Link to your Reddit profile',
163+
},
164+
{
165+
key: 'snapchat',
166+
label: 'Snapchat',
167+
description: 'Link to your Snapchat profile',
168+
},
169+
{
170+
key: 'soundcloud',
171+
label: 'Sound Cloud',
172+
description: 'Link to your Sound Cloud profile',
173+
},
174+
{
175+
key: 'spotify',
176+
label: 'Spotify',
177+
description: 'Link to your Spotify profile',
178+
},
179+
{ key: 'steam', label: 'Steam', description: 'Link to your Steam profile' },
180+
{
181+
key: 'telegram',
182+
label: 'Telegram',
183+
description: 'Link to your Telegram profile',
184+
},
185+
{
186+
key: 'tiktok',
187+
label: 'Tiktok',
188+
description: 'Link to your Tiktok profile',
189+
},
190+
{
191+
key: 'threads',
192+
label: 'Threads',
193+
description: 'Link to your Threads profile',
194+
},
195+
{
196+
key: 'twitch',
197+
label: 'Twitch',
198+
description: 'Link to your Twitch profile',
199+
},
200+
{ key: 'x', label: 'X', description: 'Link to your X profile' },
201+
{
202+
key: 'youtube',
203+
label: 'Youtube',
204+
description: 'Link to your Youtube channel',
205+
},
206+
];
207+
208+
return (
209+
<div className='max-w-screen-lg mx-auto'>
210+
<section>
211+
<h1>Edit Profile</h1>
212+
{/* Add your edit profile form here */}
213+
214+
<form
215+
className={`flex flex-col gap-6`}
216+
onSubmit={formMethods.handleSubmit(() => {
217+
// Handle form submission
218+
console.log('Form submitted');
219+
})}
220+
>
221+
<div className='flex items-center justify-center gap-2 my-3 bg-cyan-800 border-cyan-400 text-cyan-300 border-2 rounded-lg px-3 py-2 text-sm'>
222+
<FontAwesomeIcon icon={faExclamationCircle} className='h-5' />
223+
<p>
224+
Please make sure to carefully review our{' '}
225+
<Link
226+
href='/guidelines'
227+
target='_blank'
228+
className='text-blue-400 hover:text-blue-300 hover:underline'
229+
>
230+
Community Guidelines
231+
</Link>
232+
<FontAwesomeIcon
233+
className='text-blue-400 ml-1 mr-1'
234+
size='xs'
235+
icon={faExternalLink}
236+
/>{' '}
237+
before sharing your profile. We want to ensure a safe and positive
238+
environment for all users.
239+
</p>
240+
</div>
241+
{/* Description */}
242+
<div>
243+
<TextArea
244+
id='description'
245+
label='Description'
246+
tooltip={
247+
<>
248+
<p>
249+
This is a short description of yourself. It will be shown on
250+
your profile page.
251+
</p>
252+
</>
253+
}
254+
isLoading={isLoading}
255+
disabled={isLocked}
256+
errorMessage={errors.description?.message}
257+
{...register('description')}
258+
/>
259+
</div>
260+
{/* Social Links */}
261+
<div>
262+
<h2 className='text-lg font-semibold'>Social Links</h2>
263+
<p className='text-sm text-gray-500'>
264+
Add links to your social media profiles. These links will be shown
265+
on your profile page.
266+
</p>
267+
<div className='flex-row gap-4 mt-2'>
268+
{LinkFields.map((link) => (
269+
<div key={link.key} className='flex-1 min-w-[200px]'>
270+
<Input
271+
id={link.key}
272+
label={link.label}
273+
tooltip={link.description}
274+
isLoading={isLoading}
275+
disabled={isLocked}
276+
errorMessage={errors.socialLinks?.[link.key]?.message}
277+
{...register(`socialLinks.${link.key}`)}
278+
/>
279+
</div>
280+
))}
281+
</div>
282+
</div>
283+
</form>
284+
</section>
285+
</div>
286+
);
287+
};

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

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,21 @@
11
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';
2+
import { getUserProfileData } from '@web/src/modules/user/features/user.util';
3+
4+
import { UserEditProfile } from './UserEditProfile';
75

86
const UserPageEdit = async ({ params }: { params: { username: string } }) => {
97
const { username } = params;
108

119
let userData = null;
12-
let songData = null;
1310

1411
try {
1512
userData = await getUserProfileData(username);
1613
} catch (e) {
1714
console.error('Failed to get user data:', e);
1815
}
1916

20-
try {
21-
songData = await getUserSongs(username);
22-
} catch (e) {
23-
console.error('Failed to get song data:', e);
24-
}
25-
2617
if (userData) {
27-
// set the page title to the user's name
28-
29-
return <UserProfile userData={userData} songData={songData} />;
18+
return <UserEditProfile initialUserData={userData} />;
3019
} else {
3120
return <ErrorBox message='Failed to get user data' />;
3221
}

0 commit comments

Comments
 (0)