feat: add user avatar functionality and update related routes

- Implemented avatar upload and deletion in the Auth context.
- Updated UserProfile component to handle avatar display and actions.
- Modified backend routes to return anonymous user ID when auth is disabled.
- Added avatar_url column to users table in the database.
- Enhanced UI for user menu and profile modal to support avatar display.
- Updated translations for new avatar-related strings.
- Improved stock status calculation for medications in the planner.
This commit is contained in:
Daniel Volz
2025-12-28 00:43:45 +01:00
parent be68fb5dad
commit bd5c864e84
14 changed files with 745 additions and 113 deletions
+69 -8
View File
@@ -184,7 +184,7 @@ function userStorageKey(userId: number | undefined, key: string): string {
function AppContent() {
const { t, i18n } = useTranslation();
const { user, authState } = useAuth();
const { user, authState, logout } = useAuth();
const [showProfile, setShowProfile] = useState(false);
const [meds, setMeds] = useState<Medication[]>([]);
const [plannerRows, setPlannerRows] = useState<PlannerRow[]>([]);
@@ -408,6 +408,33 @@ function AppContent() {
const coverage = useMemo(() => calculateCoverage(meds, schedule.events, i18n.language, settings.reminderDaysBefore), [meds, schedule.events, i18n.language, settings.reminderDaysBefore]);
const depletionByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c.depletionTime])), [coverage.all]);
const coverageByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c])), [coverage.all]);
// Get worst stock status for a day's medications (for coloring day blocks)
const getDayStockStatus = (dayMeds: { medName: string; lastWhen: number }[]) => {
const statuses = dayMeds.map((item) => {
const cov = coverageByMed[item.medName];
const depletionTime = depletionByMed[item.medName];
// Will be out of stock by this day?
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) {
return "danger";
}
if (!cov) return "success";
const { daysLeft, medsLeft } = cov;
// Currently out of stock
if (medsLeft <= 0 || daysLeft === 0) return "danger";
// No schedule (can't calculate)
if (daysLeft === null) return "success";
// Low stock: < lowStockDays (warning)
if (daysLeft < settings.lowStockDays) return "warning";
// Normal/High stock
return "success";
});
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success";
};
const groupedSchedule = useMemo(() => {
type DoseInfo = { id: string; timeStr: string; when: number; usage: number };
const days = new Map<string, { dateStr: string; date: Date; isPast: boolean; meds: Map<string, { medName: string; total: number; doses: DoseInfo[]; lastWhen: number }> }>();
@@ -906,15 +933,47 @@ function AppContent() {
<button className={currentPath === "/medications" ? "pill primary" : "pill"} onClick={() => navigate("/medications")}>{t('nav.medications')}</button>
<button className={currentPath === "/planner" ? "pill primary" : "pill"} onClick={() => navigate("/planner")}>{t('nav.planner')}</button>
</div>
<button className={`icon-btn ${currentPath === "/settings" ? "active" : ""}`} onClick={() => navigate("/settings")} title={t('nav.settings')}></button>
{/* Settings button only shown when auth is disabled (no user dropdown available) */}
{!authState?.authEnabled && (
<button className={`icon-btn ${currentPath === "/settings" ? "active" : ""}`} onClick={() => navigate("/settings")} title={t('nav.settings')}></button>
)}
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? t('tooltips.lightMode') : t('tooltips.darkMode')}>
{theme === "dark" ? "☀️" : "🌙"}
</button>
{authState?.authEnabled && user && (
<button className="user-menu-btn" onClick={() => setShowProfile(true)} title={t('auth.profile', 'Profile')}>
<span className="user-avatar">{user.username.charAt(0).toUpperCase()}</span>
<span>{user.username}</span>
</button>
<div className="user-menu">
<button className="user-menu-btn">
{user.avatarUrl ? (
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="user-avatar-img" />
) : (
<span className="user-avatar">{user.username.charAt(0).toUpperCase()}</span>
)}
</button>
<div className="user-dropdown">
<div className="dropdown-header">
{user.avatarUrl ? (
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="dropdown-avatar-img" />
) : (
<div className="dropdown-avatar">{user.username.charAt(0).toUpperCase()}</div>
)}
<span className="dropdown-username">{user.username}</span>
</div>
<div className="dropdown-menu">
<button className="dropdown-item" onClick={() => setShowProfile(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
{t('auth.profile', 'Profile')}
</button>
<button className="dropdown-item" onClick={() => navigate('/settings')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
{t('nav.settings', 'Settings')}
</button>
<button className="dropdown-item danger" onClick={() => logout()}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
{t('auth.signOut', 'Sign Out')}
</button>
</div>
</div>
</div>
)}
</div>
</header>
@@ -1123,9 +1182,10 @@ function AppContent() {
const isAutoCollapsed = true; // Past days are always auto-collapsed
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
const worstStatus = getDayStockStatus(day.meds);
return (
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""}`}>
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
@@ -1891,9 +1951,10 @@ function AppContent() {
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
const worstStatus = getDayStockStatus(day.meds);
return (
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""}`}>
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, true)}
+162 -52
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, createContext, useContext, ReactNode, useCallback } from "react";
import { useState, useEffect, createContext, useContext, ReactNode, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
// =============================================================================
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
export interface User {
id: number;
username: string;
avatarUrl?: string | null;
}
export interface AuthState {
@@ -27,6 +28,8 @@ interface AuthContextType {
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
updateProfile: (data: { currentPassword?: string; newPassword?: string }) => Promise<void>;
uploadAvatar: (file: File) => Promise<void>;
deleteAvatar: () => Promise<void>;
authFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
@@ -195,6 +198,40 @@ export function AuthProvider({ children }: { children: ReactNode }) {
await refreshUser();
}
// Upload avatar
async function uploadAvatar(file: File) {
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/auth/avatar", {
method: "POST",
credentials: "include",
body: formData,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Upload failed" }));
throw new Error(err.error || "Upload failed");
}
await refreshUser();
}
// Delete avatar
async function deleteAvatar() {
const res = await fetch("/api/auth/avatar", {
method: "DELETE",
credentials: "include",
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Delete failed" }));
throw new Error(err.error || "Delete failed");
}
await refreshUser();
}
// Fetch wrapper that automatically refreshes token on 401
const authFetch = useCallback(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const options: RequestInit = {
@@ -220,7 +257,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
return (
<AuthContext.Provider value={{ user, authState, loading, authError, login, register, logout, refreshUser, updateProfile, authFetch }}>
<AuthContext.Provider value={{ user, authState, loading, authError, login, register, logout, refreshUser, updateProfile, uploadAvatar, deleteAvatar, authFetch }}>
{children}
</AuthContext.Provider>
);
@@ -424,13 +461,45 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
// =============================================================================
export function UserProfile({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation();
const { user, logout, updateProfile } = useAuth();
const { user, updateProfile, uploadAvatar, deleteAvatar } = useAuth();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [loading, setLoading] = useState(false);
const [avatarLoading, setAvatarLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setAvatarLoading(true);
setError("");
try {
await uploadAvatar(file);
setSuccess(t("auth.avatarUpdated", "Avatar updated"));
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
} finally {
setAvatarLoading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
async function handleAvatarDelete() {
setAvatarLoading(true);
setError("");
try {
await deleteAvatar();
setSuccess(t("auth.avatarRemoved", "Avatar removed"));
} catch (err) {
setError(err instanceof Error ? err.message : "Delete failed");
} finally {
setAvatarLoading(false);
}
}
async function handleUpdate(e: React.FormEvent) {
e.preventDefault();
@@ -442,6 +511,11 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
return;
}
if (!currentPassword || !newPassword) {
setError(t("auth.fillAllFields", "Please fill in all password fields"));
return;
}
setLoading(true);
try {
@@ -460,69 +534,105 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
}
}
async function handleLogout() {
await logout();
onClose?.();
}
if (!user) return null;
const hasChanges = currentPassword || newPassword || confirmPassword;
return (
<div className="profile-container">
<div className="profile-header">
<h2>{t("auth.profile", "Profile")}</h2>
</div>
<div className="profile-info">
<p><strong>{t("auth.username", "Username")}:</strong> {user.username}</p>
<div className="profile-user-section">
<div className="profile-avatar-wrapper">
{user.avatarUrl ? (
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="profile-avatar-img" />
) : (
<div className="profile-avatar">
{user.username.charAt(0).toUpperCase()}
</div>
)}
<input
type="file"
ref={fileInputRef}
onChange={handleAvatarUpload}
accept="image/jpeg,image/png,image/webp,image/gif"
style={{ display: "none" }}
/>
<div className="profile-avatar-actions">
<button
type="button"
className="avatar-btn"
onClick={() => fileInputRef.current?.click()}
disabled={avatarLoading}
title={t("auth.uploadAvatar", "Upload avatar")}
>
📷
</button>
{user.avatarUrl && (
<button
type="button"
className="avatar-btn danger"
onClick={handleAvatarDelete}
disabled={avatarLoading}
title={t("auth.removeAvatar", "Remove avatar")}
>
🗑
</button>
)}
</div>
</div>
<span className="profile-username">{user.username}</span>
</div>
<form onSubmit={handleUpdate} className="profile-form">
{error && <div className="auth-error">{error}</div>}
{success && <div className="auth-success">{success}</div>}
<div className="profile-section">
<h3 className="profile-section-title">{t("auth.changePassword", "Change Password")}</h3>
{error && <div className="auth-error">{error}</div>}
{success && <div className="auth-success">{success}</div>}
<h3>{t("auth.changePassword", "Change Password")}</h3>
<div className="form-group">
<label htmlFor="current-password">{t("auth.currentPassword", "Current Password")}</label>
<input
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
placeholder="••••••••"
/>
</div>
<div className="form-group">
<label htmlFor="current-password">{t("auth.currentPassword", "Current Password")}</label>
<input
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<div className="form-group">
<label htmlFor="new-password">{t("auth.newPassword", "New Password")}</label>
<input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
placeholder="••••••••"
/>
</div>
<div className="form-group">
<label htmlFor="new-password">{t("auth.newPassword", "New Password")}</label>
<input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
/>
</div>
<div className="form-group">
<label htmlFor="confirm-new-password">{t("auth.confirmPassword", "Confirm Password")}</label>
<input
id="confirm-new-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
<div className="form-group">
<label htmlFor="confirm-new-password">{t("auth.confirmPassword", "Confirm Password")}</label>
<input
id="confirm-new-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
placeholder="••••••••"
/>
</div>
</div>
<div className="profile-actions">
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? t("common.loading", "Loading...") : t("common.save", "Save")}
<button type="button" className="btn btn-ghost" onClick={onClose}>
{t("common.cancel", "Cancel")}
</button>
<button type="button" className="btn btn-danger" onClick={handleLogout}>
{t("auth.logout", "Logout")}
<button type="submit" className="btn btn-primary" disabled={loading || !hasChanges}>
{loading ? t("common.saving", "Saving...") : t("auth.updatePassword", "Update Password")}
</button>
</div>
</form>
+9 -1
View File
@@ -259,7 +259,15 @@
"passwordReset": "Passwort zurückgesetzt",
"passwordResetSuccess": "Ihr Passwort wurde zurückgesetzt. Weiterleitung zur Anmeldung...",
"profileUpdated": "Profil erfolgreich aktualisiert",
"rememberMe": "Angemeldet bleiben"
"rememberMe": "Angemeldet bleiben",
"localAccount": "Lokales Konto",
"updatePassword": "Passwort ändern",
"fillAllFields": "Bitte alle Passwortfelder ausfüllen",
"signOut": "Abmelden",
"uploadAvatar": "Avatar hochladen",
"removeAvatar": "Avatar entfernen",
"avatarUpdated": "Avatar aktualisiert",
"avatarRemoved": "Avatar entfernt"
},
"common": {
"loading": "Wird geladen...",
+9 -1
View File
@@ -261,7 +261,15 @@
"passwordReset": "Password Reset",
"passwordResetSuccess": "Your password has been reset. Redirecting to login...",
"profileUpdated": "Profile updated successfully",
"rememberMe": "Remember me"
"rememberMe": "Remember me",
"localAccount": "Local Account",
"updatePassword": "Update Password",
"fillAllFields": "Please fill in all password fields",
"signOut": "Sign Out",
"uploadAvatar": "Upload avatar",
"removeAvatar": "Remove avatar",
"avatarUpdated": "Avatar updated",
"avatarRemoved": "Avatar removed"
},
"common": {
"loading": "Loading...",
+337 -14
View File
@@ -493,6 +493,9 @@ textarea {
}
.day-divider.clickable { cursor: pointer; user-select: none; }
.day-divider.clickable:hover { color: var(--accent); }
/* Keep warning/danger colors on hover */
.day-block.stock-warning .day-divider.clickable:hover { color: var(--warning); }
.day-block.stock-danger .day-divider.clickable:hover { color: var(--danger); }
.day-collapse-icon {
font-size: 0.7rem;
opacity: 0.6;
@@ -2624,29 +2627,29 @@ h3 .reminder-icon.info-tooltip {
flex: 1;
}
/* User Menu Button in Header */
/* User Menu Dropdown in Header */
.user-menu {
position: relative;
}
.user-menu-btn {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 100px;
padding: 0.375rem 0.75rem;
color: var(--text-primary);
justify-content: center;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s ease;
transition: transform 0.2s ease;
}
.user-menu-btn:hover {
background: var(--accent-bg);
border-color: var(--accent);
transform: scale(1.05);
}
.user-menu-btn .user-avatar {
width: 28px;
height: 28px;
width: 36px;
height: 36px;
background: var(--accent);
color: white;
border-radius: 50%;
@@ -2654,7 +2657,148 @@ h3 .reminder-icon.info-tooltip {
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.9rem;
border: 2px solid transparent;
transition: border-color 0.2s ease;
}
.user-menu-btn .user-avatar-img {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
border: 2px solid transparent;
transition: border-color 0.2s ease;
}
.user-menu:hover .user-menu-btn .user-avatar,
.user-menu:hover .user-menu-btn .user-avatar-img {
border-color: var(--accent);
}
.user-dropdown {
position: absolute;
top: calc(100% + 0.75rem);
right: 0;
width: 260px;
background: rgba(15, 23, 42, 0.75);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
box-shadow: 0 16px 48px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.05);
opacity: 0;
visibility: hidden;
transform: translateY(-8px) scale(0.95);
transition: all 0.2s ease;
z-index: 1000;
overflow: hidden;
}
[data-theme="light"] .user-dropdown {
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 16px 48px rgba(0,0,0,0.15);
}
.user-menu:hover .user-dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
.dropdown-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.25rem;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(147, 51, 234, 0.1) 100%);
border-bottom: 1px solid var(--border-primary);
}
.dropdown-avatar {
width: 48px;
height: 48px;
background: var(--accent);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.25rem;
flex-shrink: 0;
}
.dropdown-avatar-img {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.dropdown-user-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.dropdown-username {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dropdown-role {
font-size: 0.75rem;
color: var(--text-secondary);
}
.dropdown-menu {
padding: 0.5rem;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 0.875rem;
width: 100%;
padding: 0.75rem 1rem;
border: none;
background: none;
color: var(--text-primary);
font-size: 0.9rem;
cursor: pointer;
border-radius: 10px;
transition: background 0.15s ease, color 0.15s ease;
text-align: left;
}
.dropdown-item:hover {
background: rgba(59, 130, 246, 0.15);
color: var(--text-primary);
}
.dropdown-item:hover svg {
opacity: 1;
color: var(--accent);
}
.dropdown-item.danger:hover {
background: rgba(239, 68, 68, 0.15);
color: var(--danger);
}
.dropdown-item svg {
width: 20px;
height: 20px;
flex-shrink: 0;
opacity: 0.7;
}
@media (max-width: 600px) {
@@ -2669,8 +2813,187 @@ h3 .reminder-icon.info-tooltip {
/* Profile Modal */
.profile-modal {
max-width: 480px;
max-width: 420px;
padding: 0;
overflow: hidden;
}
.profile-container {
padding: 0;
}
.profile-user-section {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.5rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-primary);
}
.profile-avatar-wrapper {
position: relative;
}
.profile-avatar {
width: 64px;
height: 64px;
background: var(--text-tertiary);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.75rem;
flex-shrink: 0;
}
.profile-avatar-img {
width: 64px;
height: 64px;
border-radius: 50%;
object-fit: cover;
}
.profile-avatar-actions {
position: absolute;
bottom: -4px;
right: -4px;
display: flex;
gap: 0.25rem;
}
.avatar-btn {
width: 28px;
height: 28px;
min-width: 28px;
min-height: 28px;
border-radius: 50%;
border: 2px solid var(--bg-secondary);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: all 0.15s ease;
}
.avatar-btn:hover {
background: var(--accent);
color: white;
}
.avatar-btn.danger:hover {
background: var(--danger);
}
.avatar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.profile-username {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.profile-form {
padding: 1.5rem;
}
.profile-section {
margin-bottom: 1.5rem;
}
.profile-section-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-primary);
}
.profile-form .form-group {
margin-bottom: 1rem;
}
.profile-form .form-group label {
display: block;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.375rem;
color: var(--text-primary);
}
.profile-form .form-group input {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-input);
color: var(--text-primary);
font-size: 0.9rem;
transition: border-color 0.2s ease;
}
.profile-form .form-group input:focus {
outline: none;
border-color: var(--accent);
}
.profile-form .form-group input::placeholder {
color: var(--text-tertiary);
}
.profile-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
padding-top: 0.5rem;
border-top: 1px solid var(--border-primary);
margin-top: 1rem;
}
.profile-actions .btn {
padding: 0.5rem 1rem;
font-size: 0.875rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.profile-actions .btn-ghost {
background: none;
border: 1px solid var(--border-primary);
color: var(--text-secondary);
}
.profile-actions .btn-ghost:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.profile-actions .btn-primary {
background: var(--accent);
border: none;
color: white;
}
.profile-actions .btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.profile-actions .btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* =============================================================================