feat: add profile avatar selection and enhance sidebar UI with icons

This commit is contained in:
2025-12-20 12:26:28 +05:30
parent e558e19512
commit 69bc5675dc
28 changed files with 166 additions and 15 deletions

View File

@@ -35,6 +35,7 @@ pub struct UpdateUserRequest {
pub smoking: Option<bool>, pub smoking: Option<bool>,
pub alcohol: Option<bool>, pub alcohol: Option<bool>,
pub diet_id: Option<i32>, pub diet_id: Option<i32>,
pub avatar_url: Option<String>,
} }
/// Response for a user. /// Response for a user.
@@ -50,6 +51,7 @@ pub struct UserResponse {
pub smoking: Option<bool>, pub smoking: Option<bool>,
pub alcohol: Option<bool>, pub alcohol: Option<bool>,
pub diet: Option<String>, pub diet: Option<String>,
pub avatar_url: Option<String>,
pub created_at: String, pub created_at: String,
} }
@@ -95,6 +97,7 @@ pub async fn list_users(
smoking: u.smoking, smoking: u.smoking,
alcohol: u.alcohol, alcohol: u.alcohol,
diet: u.diet_id.and_then(|id| diet_map.get(&id).cloned()), diet: u.diet_id.and_then(|id| diet_map.get(&id).cloned()),
avatar_url: u.avatar_url,
created_at: u.created_at.to_string(), created_at: u.created_at.to_string(),
}) })
.collect(); .collect();
@@ -141,6 +144,7 @@ pub async fn get_user(
smoking: u.smoking, smoking: u.smoking,
alcohol: u.alcohol, alcohol: u.alcohol,
diet: diet_name, diet: diet_name,
avatar_url: u.avatar_url,
created_at: u.created_at.to_string(), created_at: u.created_at.to_string(),
})) }))
} }
@@ -188,6 +192,7 @@ pub async fn create_user(
smoking: Set(req.smoking), smoking: Set(req.smoking),
alcohol: Set(req.alcohol), alcohol: Set(req.alcohol),
diet_id: Set(req.diet_id), diet_id: Set(req.diet_id),
avatar_url: Set(None), // Default to None on create for now, unless we want to support it in CreateUserRequest
created_at: Set(now), created_at: Set(now),
updated_at: Set(now), updated_at: Set(now),
..Default::default() ..Default::default()
@@ -223,6 +228,7 @@ pub async fn create_user(
smoking: inserted.smoking, smoking: inserted.smoking,
alcohol: inserted.alcohol, alcohol: inserted.alcohol,
diet: diet_name, diet: diet_name,
avatar_url: inserted.avatar_url,
created_at: inserted.created_at.to_string(), created_at: inserted.created_at.to_string(),
})) }))
} }
@@ -270,6 +276,9 @@ pub async fn update_user(
if req.diet_id.is_some() { if req.diet_id.is_some() {
active.diet_id = Set(req.diet_id); active.diet_id = Set(req.diet_id);
} }
if req.avatar_url.is_some() {
active.avatar_url = Set(req.avatar_url);
}
active.updated_at = Set(now); active.updated_at = Set(now);
let updated = active let updated = active
@@ -307,6 +316,7 @@ pub async fn update_user(
smoking: updated.smoking, smoking: updated.smoking,
alcohol: updated.alcohol, alcohol: updated.alcohol,
diet: diet_name, diet: diet_name,
avatar_url: updated.avatar_url,
created_at: updated.created_at.to_string(), created_at: updated.created_at.to_string(),
})) }))
} }

View File

@@ -41,6 +41,9 @@ pub struct Model {
/// Foreign key to diet types /// Foreign key to diet types
pub diet_id: Option<i32>, pub diet_id: Option<i32>,
/// URL to profile avatar icon
pub avatar_url: Option<String>,
pub created_at: DateTime, pub created_at: DateTime,
pub updated_at: DateTime, pub updated_at: DateTime,
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -6,6 +6,7 @@ interface User {
username: string username: string
name: string | null name: string | null
role: string role: string
avatar_url: string | null
} }
interface LayoutProps { interface LayoutProps {
@@ -67,10 +68,10 @@ export function Layout({ children }: LayoutProps) {
const displayName = user.name || user.username const displayName = user.name || user.username
const navItems = [ const navItems = [
{ path: '/', label: 'Dashboard', icon: '📊' }, { path: '/', label: 'Dashboard', icon: '/icons/healthcare/icons8-powerchart-50.png' },
{ path: '/profile', label: 'Profile', icon: '👤' }, { path: '/profile', label: 'Profile', icon: '/icons/general/icons8-user-50.png' },
{ path: '/insights', label: 'Insights', icon: '💡', disabled: true }, { path: '/insights', label: 'Insights', icon: '/icons/general/icons8-idea-50.png', disabled: true },
{ path: '/sources', label: 'Sources', icon: '📄' }, { path: '/sources', label: 'Sources', icon: '/icons/general/icons8-document-50.png' },
] ]
return ( return (
@@ -90,7 +91,9 @@ export function Layout({ children }: LayoutProps) {
className={`sidebar-link ${location.pathname === item.path ? 'active' : ''} ${item.disabled ? 'disabled' : ''}`} className={`sidebar-link ${location.pathname === item.path ? 'active' : ''} ${item.disabled ? 'disabled' : ''}`}
onClick={e => item.disabled && e.preventDefault()} onClick={e => item.disabled && e.preventDefault()}
> >
<span className="sidebar-icon">{item.icon}</span> <span className="sidebar-icon">
<img src={item.icon} alt={item.label} />
</span>
<span className="sidebar-label">{item.label}</span> <span className="sidebar-label">{item.label}</span>
{item.disabled && <span className="sidebar-badge">Soon</span>} {item.disabled && <span className="sidebar-badge">Soon</span>}
</Link> </Link>
@@ -105,7 +108,9 @@ export function Layout({ children }: LayoutProps) {
to="/admin" to="/admin"
className={`sidebar-link ${location.pathname === '/admin' ? 'active' : ''}`} className={`sidebar-link ${location.pathname === '/admin' ? 'active' : ''}`}
> >
<span className="sidebar-icon"></span> <span className="sidebar-icon">
<img src="/icons/user/icons8-add-user-group-woman-man-50.png" alt="Admin" />
</span>
<span className="sidebar-label">Manage Users</span> <span className="sidebar-label">Manage Users</span>
</Link> </Link>
</div> </div>
@@ -113,6 +118,14 @@ export function Layout({ children }: LayoutProps) {
<div className="sidebar-footer"> <div className="sidebar-footer">
<div className="sidebar-user"> <div className="sidebar-user">
<div className="sidebar-avatar">
{user.avatar_url ? (
<img src={user.avatar_url} alt={displayName} className="avatar-img" />
) : (
<div className="avatar-placeholder">{displayName[0].toUpperCase()}</div>
)}
</div>
<div className="sidebar-user-info">
<div className="sidebar-user-name">{displayName}</div> <div className="sidebar-user-name">{displayName}</div>
<div className="sidebar-user-role"> <div className="sidebar-user-role">
<span className={`indicator indicator-${user.role === 'admin' ? 'warning' : 'info'}`}> <span className={`indicator indicator-${user.role === 'admin' ? 'warning' : 'info'}`}>
@@ -120,20 +133,25 @@ export function Layout({ children }: LayoutProps) {
</span> </span>
</div> </div>
</div> </div>
</div>
<div className="sidebar-actions"> <div className="sidebar-actions">
<button <button
className="sidebar-btn" className="sidebar-btn"
onClick={toggleTheme} onClick={toggleTheme}
title={theme === 'dark' ? 'Light mode' : 'Dark mode'} title={theme === 'dark' ? 'Light mode' : 'Dark mode'}
> >
{theme === 'dark' ? '☀️' : '🌙'} {theme === 'dark' ? (
<img src="/icons/general/icons8-sun-50.png" alt="Light" className="theme-icon" />
) : (
<img src="/icons/general/icons8-waxing-crescent-50.png" alt="Dark" className="theme-icon" />
)}
</button> </button>
<button <button
className="sidebar-btn" className="sidebar-btn"
onClick={handleLogout} onClick={handleLogout}
title="Logout" title="Logout"
> >
🚪 <img src="/icons/general/icons8-cancel-50.png" alt="Logout" className="theme-icon" />
</button> </button>
</div> </div>
</div> </div>

View File

@@ -549,6 +549,42 @@ select.input {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-sm); gap: var(--space-sm);
flex: 1;
}
.sidebar-avatar {
width: 32px;
height: 32px;
flex-shrink: 0;
}
.avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--border);
}
.avatar-placeholder {
width: 100%;
height: 100%;
border-radius: 50%;
background: var(--accent);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
}
.sidebar-user-info {
min-width: 0;
display: flex;
flex-direction: row;
align-items: center;
gap: var(--space-xs);
} }
.sidebar-user-name { .sidebar-user-name {
@@ -559,6 +595,43 @@ select.input {
white-space: nowrap; white-space: nowrap;
} }
/* Avatar Picker in Profile */
.avatar-picker {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
gap: var(--space-sm);
margin-top: var(--space-xs);
}
.avatar-option {
width: 60px;
height: 60px;
padding: 4px;
border: 2px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color 0.2s, transform 0.1s;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-option:hover {
background: var(--bg-primary);
transform: scale(1.05);
}
.avatar-option.selected {
border-color: var(--accent);
background: var(--bg-primary);
}
.avatar-option img {
width: 100%;
height: 100%;
object-fit: contain;
}
.sidebar-actions { .sidebar-actions {
display: flex; display: flex;
gap: var(--space-xs); gap: var(--space-xs);
@@ -578,6 +651,22 @@ select.input {
background: var(--bg-primary); background: var(--bg-primary);
} }
.theme-icon {
width: 18px;
height: 18px;
display: block;
}
/* Invert icons in dark mode if they are dark-oriented */
[data-theme='dark'] .theme-icon {
filter: invert(1) brightness(2);
}
.sidebar-icon img {
width: 20px;
height: 20px;
}
/* Main Content */ /* Main Content */
.main-content { .main-content {
flex: 1; flex: 1;

View File

@@ -17,6 +17,7 @@ interface UserProfile {
smoking: boolean | null smoking: boolean | null
alcohol: boolean | null alcohol: boolean | null
diet: string | null diet: string | null
avatar_url: string | null
} }
export function ProfilePage() { export function ProfilePage() {
@@ -35,6 +36,12 @@ export function ProfilePage() {
const [smoking, setSmoking] = useState<boolean | null>(null) const [smoking, setSmoking] = useState<boolean | null>(null)
const [alcohol, setAlcohol] = useState<boolean | null>(null) const [alcohol, setAlcohol] = useState<boolean | null>(null)
const [dietId, setDietId] = useState<number | null>(null) const [dietId, setDietId] = useState<number | null>(null)
const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
const avatarOptions = [
...[1, 2, 3, 4, 5, 6, 7].map(i => `/icons/user/icons8-male-user-50${i === 1 ? '' : `-${i}`}.png`),
...['', '-2', '-3', '-4', '-5', '-7', '-8'].map(s => `/icons/user/icons8-user-50${s}.png`),
]
useEffect(() => { useEffect(() => {
// Fetch current user and diets (Layout already ensures auth) // Fetch current user and diets (Layout already ensures auth)
@@ -61,9 +68,17 @@ export function ProfilePage() {
// Find diet ID from name // Find diet ID from name
const diet = dietsData.find((d: Diet) => d.name === profile.diet) const diet = dietsData.find((d: Diet) => d.name === profile.diet)
setDietId(diet?.id || null) setDietId(diet?.id || null)
setAvatarUrl(profile.avatar_url)
}) })
}) })
.finally(() => setLoading(false)) .finally(() => {
setLoading(false)
// Show success message if we just saved
if (localStorage.getItem('profileSaved') === 'true') {
setMessage({ type: 'success', text: 'Profile updated successfully' })
localStorage.removeItem('profileSaved')
}
})
}, []) }, [])
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@@ -86,11 +101,13 @@ export function ProfilePage() {
smoking, smoking,
alcohol, alcohol,
diet_id: dietId, diet_id: dietId,
avatar_url: avatarUrl,
}), }),
}) })
if (res.ok) { if (res.ok) {
setMessage({ type: 'success', text: 'Profile updated successfully' }) localStorage.setItem('profileSaved', 'true')
window.location.reload()
} else { } else {
setMessage({ type: 'error', text: 'Failed to update profile' }) setMessage({ type: 'error', text: 'Failed to update profile' })
} }
@@ -131,6 +148,20 @@ export function ProfilePage() {
placeholder="Your full name" placeholder="Your full name"
/> />
</div> </div>
<div className="form-group">
<label>Profile Avatar</label>
<div className="avatar-picker">
{avatarOptions.map(option => (
<div
key={option}
className={`avatar-option ${avatarUrl === option ? 'selected' : ''}`}
onClick={() => setAvatarUrl(option)}
>
<img src={option} alt="Avatar option" />
</div>
))}
</div>
</div>
</div> </div>
{/* Physical Info */} {/* Physical Info */}