Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add demo profile #21

Open
wants to merge 3 commits into
base: dev/[email protected]
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions frontend/app/profile/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use client';
import React from 'react';
import { ReactNode } from 'react';
import ProfileSidebar from '@/components/ProfileSidebar';

interface ProfileLayoutProps {
children: ReactNode;
}

export default function ProfileLayout({ children }: ProfileLayoutProps) {
return (
<div className="flex">
<ProfileSidebar />
<main className="flex-1 p-4">{children}</main>
</div>
);
}
11 changes: 11 additions & 0 deletions frontend/app/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// /app/profile/page.tsx
import React from 'react';
import ProfileEdit from '@/components/ProfileEdit';

export default function ProfilePage() {
return (
<div className="space-y-8">
<ProfileEdit />
</div>
);
}
217 changes: 217 additions & 0 deletions frontend/components/ProfileEdit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import Button from '@/ui/button';
import { Input } from '@/ui/input';
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from '@/ui/Form';

// Define the validation schema using Zod
const ProfileSchema = z.object({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit pick: zod schema 等 zod相關的東西 可以放入 module/zod/xxxSchema.ts之中,未來會比較好管理所有驗證的部分

email: z.string().email({ message: '無效的電子郵件地址' }),
avatar: z
.instanceof(FileList)
.or(z.undefined())
.refine((fileList) => !fileList || fileList.length === 1, {
message: '請選擇一個圖片檔案',
})
.refine(
(fileList) => {
if (!fileList) return true;
const file = fileList[0];
return file.type.startsWith('image/');
},
{ message: '請上傳有效的圖片檔案' },
),
username: z
.string()
.min(1, { message: '使用者名稱為必填項目' })
.max(20, { message: '使用者名稱不得超過 20 個字元' })
.regex(/^[a-zA-Z0-9_]+$/, { message: '使用者名稱僅允許字母、數字和底線' }),
});

type ProfileFormData = z.infer<typeof ProfileSchema>;

export default function ProfileEdit() {
const {
register,
handleSubmit,
setValue,
formState: { errors, isSubmitting },
} = useForm<ProfileFormData>({
resolver: zodResolver(ProfileSchema),
defaultValues: {
email: '',
username: '',
avatar: undefined,
},
});

const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let mounted = true;
const fetchUserData = async () => {
try {
setIsLoading(true);
const userData = await mockFetchUserData();
if (!mounted) return;
setValue('email', userData.email);
setValue('username', userData.username);
} catch (error) {
console.error('Failed to fetch user data:', error);
// Use proper error notification system
} finally {
if (mounted) setIsLoading(false);
}
};
fetchUserData();
return () => {
mounted = false;
};
}, [setValue]);
Comment on lines +58 to +79
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add error state handling for failed data fetches

While the loading state is handled well, there's no UI feedback for fetch errors.

 export default function ProfileEdit() {
+  const [error, setError] = useState<string | null>(null);
   // ...
   const fetchUserData = async () => {
     try {
       setIsLoading(true);
+      setError(null);
       const userData = await mockFetchUserData();
       if (!mounted) return;
       setValue('email', userData.email);
       setValue('username', userData.username);
     } catch (error) {
       console.error('Failed to fetch user data:', error);
+      if (mounted) {
+        setError('Failed to load profile data. Please try again later.');
+      }
     } finally {
       if (mounted) setIsLoading(false);
     }
   };
   // ...
+  if (error) {
+    return <div role="alert" className="text-red-500">{error}</div>;
+  }

Committable suggestion skipped: line range outside the PR's diff.


const onSubmit = async (data: ProfileFormData) => {
try {
const validatedData = ProfileSchema.parse(data);
let updates = [];

if (data.avatar && data.avatar.length > 0) {
updates.push(mockUploadAvatar(data.avatar[0]));
}

updates.push(mockUpdateUsername(data.username));

// Wait for all async operations to complete
await Promise.all(updates);

// Use proper toast/notification system instead of alert
console.log('驗證通過:', validatedData);
// Example: Toast.success('個人資料更新成功!');
} catch (error) {
console.error(error);

if (error instanceof z.ZodError) {
console.error('Validation error:', error.errors);
} else if (error instanceof Error) {
console.error('Update error:', error.message);
}

// Example: Toast.error('更新個人資料失敗');
}
};

return (
<Form>
<form
onSubmit={handleSubmit(onSubmit)}
className="space-y-4"
aria-busy={isSubmitting}
>
{/* Email 欄位(僅顯示,不可編輯) */}
<FormField>
<FormItem>
<FormLabel htmlFor="email">Email</FormLabel>
<FormControl>
<Input
id="email"
type="email"
{...register('email')}
disabled
aria-readonly="true"
aria-label="Email address"
className="mt-1 block w-full rounded border border-gray-300 p-2 text-black"
/>
</FormControl>
<FormMessage role="alert">{errors.email?.message}</FormMessage>
</FormItem>
</FormField>

{/* Avatar 上傳欄位 */}
<FormField>
<FormItem>
<FormLabel htmlFor="avatar">Avatar</FormLabel>
<FormControl>
<input
type="file"
accept="image/*"
{...register('avatar')}
disabled={isSubmitting}
className="mt-1 block w-full rounded border border-gray-300 p-2"
/>
</FormControl>
<FormMessage>{errors.avatar?.message}</FormMessage>
</FormItem>
</FormField>

{/* Username 編輯欄位 */}
<FormField>
<FormItem>
<FormLabel htmlFor="username">Username</FormLabel>
<FormControl>
<Input
className="mt-1 block w-full rounded border border-gray-300 p-2 text-black"
type="text"
{...register('username')}
disabled={isSubmitting}
placeholder="輸入您的使用者名稱"
/>
</FormControl>
<FormMessage>{errors.username?.message}</FormMessage>
</FormItem>
</FormField>

{/* 提交按鈕 */}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<span className="loading-spinner" aria-hidden="true" />
<span>儲存中...</span>
</>
) : (
'儲存變更'
)}
</Button>
</form>
</Form>
);
Comment on lines +111 to +184
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add loading state UI for initial data fetch

While the form submission loading state is handled, the initial data fetch loading state isn't visible to users.

 return (
+  {isLoading ? (
+    <div role="status" className="animate-pulse space-y-4">
+      <div className="h-10 bg-gray-200 rounded"></div>
+      <div className="h-10 bg-gray-200 rounded"></div>
+      <div className="h-10 bg-gray-200 rounded"></div>
+    </div>
+  ) : (
   <Form>
     <form
       onSubmit={handleSubmit(onSubmit)}
       className="space-y-4"
       aria-busy={isSubmitting}
     >
       {/* ... form fields ... */}
     </form>
   </Form>
+  )}
 );

Committable suggestion skipped: line range outside the PR's diff.

}

// 模擬 Avatar 上傳 API
const mockUploadAvatar = (file: File): Promise<void> => {
return new Promise((resolve) => {
setTimeout(() => {
console.log('Avatar uploaded:', file);
resolve();
}, 2000);
});
};

// 模擬 Username 更新 API
const mockUpdateUsername = (username: string): Promise<void> => {
return new Promise((resolve) => {
setTimeout(() => {
console.log('Username updated to:', username);
resolve();
}, 1000);
});
};

// 模擬獲取使用者資料 API
const mockFetchUserData = (): Promise<{ email: string; username: string }> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
email: '[email protected]',
username: 'user_name',
});
}, 1000);
});
};
35 changes: 35 additions & 0 deletions frontend/components/ProfileSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';

export default function ProfileSidebar() {
const pathname = usePathname();

// Function to determine the active class based on the current path
const getLinkClassName = (path: string) => `
block rounded px-4 py-2
${pathname === path ? 'bg-gray-700' : 'hover:bg-gray-700'}
`;

return (
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

這個也希望可以把資料抽到別的檔案 然後以xxx.map的方式來引入

<aside className="w-64 bg-gray-800 p-4 text-white">
<nav className="space-y-2">
<Link href="/profile" className={getLinkClassName('/profile')}>
編輯個人資料
</Link>
<Link
href="/profile/uploaded-exams"
className={getLinkClassName('/profile/uploaded-exams')}
>
上傳的考題清單
</Link>
<Link
href="/profile/starred-exams"
className={getLinkClassName('/profile/starred-exams')}
>
收藏的考題清單
</Link>
</nav>
</aside>
);
}
5 changes: 4 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
},
"dependencies": {
"@heroicons/react": "2.1.3",
"@hookform/resolvers": "3.9.1",
"clsx": "2.1.1",
"date-fns": "3.6.0",
"dinero.js": "2.0.0-alpha.10",
Expand All @@ -31,10 +32,12 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"react-dropzone": "14.2.10",
"react-hook-form": "7.53.2",
"server-only": "0.0.1",
"styled-components": "6.1.8",
"use-count-up": "3.0.1",
"vercel": "34.0.0"
"vercel": "34.0.0",
"zod": "3.23.8"
},
"devDependencies": {
"@eslint/eslintrc": "3.1.0",
Expand Down
33 changes: 33 additions & 0 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading