feat(user-settings): avatar delete + accent color

- DELETE /users/me/avatar endpoint with file cleanup
- PATCH /users/me/accent-color with hex validation (#rrggbb)
- auth.service.ts: include accentColor in user select
- AccountSettingsForm: delete-avatar button + accent color picker/save/reset
- auth-actions.ts: deleteAvatarAction + updateAccentColorAction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 08:57:31 +02:00
parent cc95395889
commit be3680d0df
4 changed files with 208 additions and 23 deletions
@@ -6,9 +6,13 @@ import {
fetchCurrentUser,
changePasswordAction,
uploadAvatarAction,
deleteAvatarAction,
updateAccentColorAction,
} from '@/lib/auth-actions';
import { useAuthStore } from '@/lib/stores/auth-store';
const DEFAULT_ACCENT = '#ffed00';
export function AccountSettingsForm() {
const t = useTranslations('settings');
const tAuth = useTranslations('auth');
@@ -17,7 +21,6 @@ export function AccountSettingsForm() {
const [isLocalUser, setIsLocalUser] = useState<boolean | null>(null);
const [username, setUsername] = useState<string>('');
const [hasAvatar, setHasAvatar] = useState(false);
const [avatarSrc, setAvatarSrc] = useState('/api-proxy/users/me/avatar');
const [avatarKey, setAvatarKey] = useState(0);
// Password change state
@@ -27,17 +30,24 @@ export function AccountSettingsForm() {
const [isPwPending, startPwTransition] = useTransition();
const pwFormRef = useRef<HTMLFormElement>(null);
// Avatar upload state
// Avatar upload/delete state
const [avatarError, setAvatarError] = useState<string | null>(null);
const [avatarSuccess, setAvatarSuccess] = useState(false);
const [isAvatarPending, startAvatarTransition] = useTransition();
// Accent color state
const [accentColor, setAccentColor] = useState<string>(DEFAULT_ACCENT);
const [accentSuccess, setAccentSuccess] = useState(false);
const [accentError, setAccentError] = useState<string | null>(null);
const [isAccentPending, startAccentTransition] = useTransition();
useEffect(() => {
fetchCurrentUser().then((u) => {
if (u) {
setIsLocalUser(u.isLocalUser ?? false);
setUsername(u.displayName ?? u.username ?? '');
setHasAvatar(!!u.hasAvatar);
setAccentColor(u.accentColor ?? DEFAULT_ACCENT);
}
});
}, []);
@@ -60,30 +70,23 @@ export function AccountSettingsForm() {
startPwTransition(async () => {
const result = await changePasswordAction(currentPassword, newPassword);
// changePasswordAction redirects to '/' on success, so we only reach here on error
if (result) {
setPwError(result.error);
}
if (result) setPwError(result.error);
});
}
async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setAvatarError(null);
setAvatarSuccess(false);
const formData = new FormData();
formData.append('file', file);
startAvatarTransition(async () => {
const result = await uploadAvatarAction(formData);
if (result.success) {
setAvatarSuccess(true);
setHasAvatar(true);
setAvatarKey((k) => k + 1);
// Sync to auth store so header avatar updates immediately
bumpAvatarVersion();
if (user) setUser({ ...user, hasAvatar: true });
} else {
@@ -92,6 +95,49 @@ export function AccountSettingsForm() {
});
}
function handleDeleteAvatar() {
setAvatarError(null);
setAvatarSuccess(false);
startAvatarTransition(async () => {
const result = await deleteAvatarAction();
if (result.success) {
setHasAvatar(false);
setAvatarSuccess(true);
setAvatarKey((k) => k + 1);
if (user) setUser({ ...user, hasAvatar: false });
} else {
setAvatarError(result.error ?? 'deleteError');
}
});
}
function handleSaveAccentColor() {
setAccentSuccess(false);
setAccentError(null);
startAccentTransition(async () => {
const result = await updateAccentColorAction(accentColor);
if (result.success) {
setAccentSuccess(true);
if (user) setUser({ ...user, accentColor });
} else {
setAccentError(result.error ?? 'saveError');
}
});
}
function handleResetAccentColor() {
setAccentColor(DEFAULT_ACCENT);
setAccentSuccess(false);
setAccentError(null);
startAccentTransition(async () => {
const result = await updateAccentColorAction(null);
if (result.success) {
setAccentSuccess(true);
if (user) setUser({ ...user, accentColor: null });
}
});
}
const userInitial = username.charAt(0).toUpperCase() || '?';
return (
@@ -102,7 +148,6 @@ export function AccountSettingsForm() {
{t('account.avatarLabel')}
</h2>
<div className="flex items-center gap-4 mb-4">
{/* Avatar preview with initial fallback */}
<div className="relative h-16 w-16 shrink-0">
{hasAvatar ? (
<img
@@ -125,27 +170,82 @@ export function AccountSettingsForm() {
{avatarSuccess && (
<div className="rounded-md bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 px-4 py-3 text-sm text-green-800 dark:text-green-200 mb-3">
{t('account.uploadSuccess')}
{hasAvatar ? t('account.uploadSuccess') : t('account.deleteAvatarSuccess')}
</div>
)}
{avatarError && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive mb-3">
{t('account.uploadError')}
{avatarError === 'deleteError' ? t('account.deleteAvatarError') : t('account.uploadError')}
</div>
)}
<label className="inline-flex items-center gap-2 cursor-pointer">
<div className="flex items-center gap-2">
<label className="inline-flex items-center gap-2 cursor-pointer">
<input
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
disabled={isAvatarPending}
onChange={handleAvatarUpload}
/>
<span className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50">
{isAvatarPending ? '...' : t('account.uploadCta')}
</span>
</label>
{hasAvatar && (
<button
type="button"
disabled={isAvatarPending}
onClick={handleDeleteAvatar}
className="inline-flex items-center justify-center rounded-md border border-destructive/40 px-4 py-2 text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50"
>
{t('account.deleteAvatarCta')}
</button>
)}
</div>
</section>
{/* ── Accent color section ────────────────────────────────── */}
<section>
<h2 className="text-sm font-semibold text-foreground mb-1">
{t('account.accentColorLabel')}
</h2>
<p className="text-xs text-muted-foreground mb-4">{t('account.accentColorHelp')}</p>
<div className="flex items-center gap-3 mb-4">
<input
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
disabled={isAvatarPending}
onChange={handleAvatarUpload}
type="color"
value={accentColor}
onChange={(e) => setAccentColor(e.target.value)}
className="h-10 w-16 cursor-pointer rounded-md border border-input p-1"
/>
<span className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50">
{isAvatarPending ? '...' : t('account.uploadCta')}
</span>
</label>
<span className="font-mono text-sm text-muted-foreground">{accentColor}</span>
<button
type="button"
onClick={handleResetAccentColor}
disabled={isAccentPending}
className="text-sm text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
{t('account.accentColorReset')}
</button>
</div>
{accentSuccess && (
<div className="rounded-md bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 px-4 py-3 text-sm text-green-800 dark:text-green-200 mb-3">
{t('account.accentColorSuccess')}
</div>
)}
{accentError && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive mb-3">
{t('account.accentColorError')}
</div>
)}
<button
type="button"
onClick={handleSaveAccentColor}
disabled={isAccentPending}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
>
{isAccentPending ? '...' : t('account.accentColorSave')}
</button>
</section>
{/* ── Password section ────────────────────────────────────── */}