Skip to content

Commit

Permalink
added user setting page, enable profile update (#31)
Browse files Browse the repository at this point in the history
Signed-off-by: owenowenisme <[email protected]>
  • Loading branch information
owenowenisme authored Mar 4, 2025
1 parent 675a280 commit bfadc22
Show file tree
Hide file tree
Showing 15 changed files with 505 additions and 9 deletions.
2 changes: 2 additions & 0 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import '@/styles/globals.css';
import { GlobalNav } from '@/components/GlobalNav';
import { Toaster } from '@/ui/Toast';
export default function RootLayout({
children,
}: {
Expand All @@ -11,6 +12,7 @@ export default function RootLayout({
<GlobalNav />
<div className="mx-auto max-w-4xl space-y-8 px-2 pt-20">
<div className="p-3.5 lg:p-6">{children}</div>
<Toaster icons={{}} />
</div>
</body>
</html>
Expand Down
91 changes: 91 additions & 0 deletions frontend/app/user/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
'use client';
import { useAuthentication } from '@/hooks/useAuthentication';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import Image from 'next/image';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/icons';
export default function UserLayout({
children,
}: {
children: React.ReactNode;
}) {
const { currentUser, handleRefreshProfile } = useAuthentication();
const router = useRouter();

useEffect(() => {
if (!currentUser) {
handleRefreshProfile();
}
}, []);

if (!currentUser) {
return (
<div className="flex h-screen items-center justify-center">
<p>請先登入</p>
</div>
);
}
const UserBar = () => {
return (
<div className="bg-white p-6 shadow-sm">
<div>
<div className="mb-4 flex items-start space-x-4">
<div className="relative flex h-24 w-24 items-center">
<Image
src={currentUser?.avatar || ''}
alt="Profile"
fill
className="rounded-full object-cover"
/>
</div>
<div className="flex min-h-[96px] flex-1 items-center">
<div className="my-auto space-y-2">
<div className="text-mb-4 text-h2">{currentUser?.userName}</div>
<div className="flex items-center space-x-2 text-secondary-500 text-subtle">
<Icon name="user" size={16} />
科系{' '}
{currentUser?.department == null
? '尚未設定'
: currentUser?.department}
</div>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<Button
variant="link"
className="text-large-content"
onClick={() => router.push('/user/upload')}
>
上傳過的考古題
</Button>
<div className="text-gray-300 text-large-content">|</div>
<Button
variant="link"
className="text-large-content"
onClick={() => router.push('/user/bookmark')}
>
收藏的考古題
</Button>
<div className="text-gray-300 text-large-content">|</div>
<Button
variant="link"
className="text-large-content"
onClick={() => router.push('/user/setting')}
>
個人設定
</Button>
</div>
</div>
</div>
);
};

return (
<div className="container mx-auto mt-24 px-4">
<UserBar />
{children}
</div>
);
}
76 changes: 76 additions & 0 deletions frontend/app/user/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use client';
import { useAuthentication } from '@/hooks/useAuthentication';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { Button } from '@/ui/Button';
import Image from 'next/image';

export default function SettingPage() {
const { currentUser, handleRefreshProfile } = useAuthentication();
const router = useRouter();

useEffect(() => {
if (!currentUser) {
handleRefreshProfile();
}
}, []);

if (!currentUser) {
return (
<div className="flex h-screen items-center justify-center">
<p>請先登入</p>
</div>
);
}

return (
<div className="container mx-auto mt-24 px-4">
<h1 className="mb-8 text-2xl font-bold">個人設定</h1>

<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-6">
<h2 className="mb-4 text-xl font-semibold">個人資料</h2>
<div className="flex items-start space-x-4">
<div className="relative h-24 w-24">
<Image
src={currentUser.avatar}
alt="Profile"
fill
className="rounded-full object-cover"
/>
</div>
<div className="flex-1">
<div className="mb-4">
<label className="mb-1 block text-sm font-medium text-gray-700">
使用者名稱
</label>
<p className="text-gray-900">{currentUser.userName}</p>
</div>
<div className="mb-4">
<label className="mb-1 block text-sm font-medium text-gray-700">
電子郵件
</label>
<p className="text-gray-900">{currentUser.email}</p>
</div>
</div>
</div>
</div>

<div className="border-t pt-6">
<h2 className="mb-4 text-xl font-semibold">帳號設定</h2>
<div className="space-y-4">
<Button
className="w-full justify-start text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() => {
// TODO: Implement delete account functionality
console.log('Delete account clicked');
}}
>
刪除帳號
</Button>
</div>
</div>
</div>
</div>
);
}
121 changes: 121 additions & 0 deletions frontend/app/user/setting/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
'use client';
import { useAuthentication } from '@/hooks/useAuthentication';
import { Input } from '@/ui/Input';
import { Label } from '@/ui/Label';
import { Button } from '@/ui/Button';
import Image from 'next/image';
import { useState } from 'react';
import { z } from 'zod';
import { userAPI } from '@/module/api';
import { toast } from 'sonner';

const userSchema = z.object({
userName: z.string().min(1, '姓名不能為空').max(50, '姓名不能超過50個字'),
department: z.string().min(1, '系所不能為空').max(100, '系所不能超過100個字'),
});

export default function SettingPage() {
const { currentUser, handleRefreshProfile } = useAuthentication();
const [userName, setUserName] = useState(currentUser?.userName || '');
const [department, setDepartment] = useState(currentUser?.department || '');
const [errors, setErrors] = useState<{
userName?: string;
department?: string;
}>({});

const validateField = (field: 'userName' | 'department', value: string) => {
try {
userSchema.shape[field].parse(value);
setErrors((prev) => ({ ...prev, [field]: undefined }));
} catch (error) {
if (error instanceof z.ZodError) {
setErrors((prev) => ({ ...prev, [field]: error.errors[0].message }));
}
}
};

const handleSave = async () => {
const res = await userAPI.updateProfile({
username: userName,
department: department,
});
if (res.data.status === 'success') {
toast('修改成功');
handleRefreshProfile();
} else {
toast('修改失敗');
}
};

return (
<div className="container mx-auto mt-24 flex flex-col gap-16 px-16">
<div className="flex gap-8">
<div className="flex w-1/2 flex-col gap-8">
<div className="flex flex-col gap-2">
<Label>姓名</Label>
<Input
placeholder="請輸入姓名"
value={userName}
onChange={(e) => {
setUserName(e.target.value);
validateField('userName', e.target.value);
}}
variant={errors.userName ? 'error' : 'default'}
className="w-full"
/>
{errors.userName && (
<span className="text-sm text-red-500">{errors.userName}</span>
)}
</div>
<div className="flex flex-col gap-2">
<Label>系所</Label>
<Input
placeholder="請輸入系所"
value={department}
onChange={(e) => {
setDepartment(e.target.value);
validateField('department', e.target.value);
}}
variant={errors.department ? 'error' : 'default'}
className="w-full"
/>
{errors.department && (
<span className="text-sm text-red-500">{errors.department}</span>
)}
</div>
<div className="flex flex-col gap-2">
<Label className="w-full">Email</Label>
<Input
value={currentUser?.email || ''}
disabled={true}
className="w-full"
/>
</div>
</div>
<div className="w-1/2">
<div className="flex h-full flex-col items-center gap-4">
<div className="relative">
<Image
src={currentUser?.avatar || ''}
alt="user"
width={250}
height={250}
className="rounded-lg"
/>
<button className="absolute inset-x-0 bottom-0 flex h-1/5 items-center justify-center bg-black/30 text-white transition-opacity duration-200">
更換頭像
</button>
</div>
</div>
</div>
</div>
<Button
className="w-1/6 self-end"
onClick={handleSave}
disabled={!!errors.userName || !!errors.department}
>
完成修改
</Button>
</div>
);
}
8 changes: 7 additions & 1 deletion frontend/components/GlobalNav/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,13 @@ export const GlobalNav = () => {
<div className="fixed top-0 z-10 w-full border-b border-gray-800 lg:w-full lg:border-b-0 lg:border-r lg:border-gray-800">
<div className="flex w-full items-center justify-between px-16 py-4">
<div className="flex items-center space-x-4">
<Image src={logoImage} alt="logo" width={78} height={48} />
<Button
variant="ghost"
className="hover:bg-transparent"
onClick={() => router.push('/')}
>
<Image src={logoImage} alt="logo" width={78} height={48} />
</Button>
</div>
<div className="mr-16 flex items-center space-x-4">
<div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/module/api/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { axiosInstance } from './axios';

interface UserUpdateData {
username?: string;
email?: string;
department?: string;
avatar?: string;
}

Expand Down
2 changes: 2 additions & 0 deletions frontend/module/zustand/user/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ async function login() {
userName: profile.data.data.username,
email: profile.data.data.email,
avatar: profile.data.data.avatar,
department: profile.data.data.department,
isProfileCompleted: profile.data.data.is_profile_completed,
};
useUserStore.getState().setUser(userData);
Expand All @@ -53,6 +54,7 @@ async function refreshProfile() {
userName: profile.data.data.username,
email: profile.data.data.email,
avatar: profile.data.data.avatar,
department: profile.data.data.department,
isProfileCompleted: profile.data.data.is_profile_completed,
};
useUserStore.getState().setUser(userData);
Expand Down
1 change: 1 addition & 0 deletions frontend/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const nextConfig = {
pathname: '/**',
},
],
domains: ['lh3.googleusercontent.com'],
},
};

Expand Down
6 changes: 6 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,28 @@
"@heroicons/react": "2.1.3",
"@radix-ui/react-avatar": "1.1.1",
"@radix-ui/react-dropdown-menu": "2.1.6",
"@radix-ui/react-label": "2.1.2",
"@radix-ui/react-toast": "1.2.6",
"@svgr/webpack": "8.1.0",
"axios": "1.7.9",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"date-fns": "3.6.0",
"dinero.js": "2.0.0-alpha.10",
"lucide-react": "0.475.0",
"ms": "3.0.0-canary.1",
"next": "14.3.0-canary.33",
"next-themes": "0.4.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-dropzone": "14.2.10",
"server-only": "0.0.1",
"sonner": "2.0.1",
"styled-components": "6.1.8",
"tailwind-merge": "3.0.2",
"use-count-up": "3.0.1",
"vercel": "34.0.0",
"zod": "3.24.2",
"zustand": "5.0.2"
},
"devDependencies": {
Expand Down
Loading

0 comments on commit bfadc22

Please sign in to comment.