feat: Add change password modal, small avatar images, fix email verification

- Add Change Password popup modal in Island profile
- Convert avatar URLs to small variant (160x160) for thumbnails
- Add toSmallImageUrl utility function in client.js
- Update markerManager, IslandViewer, FriendsList, TopBar to use small images
- Backend now stores small variant for avatar uploads

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
SocioWire 2026-01-10 23:37:37 +00:00
parent 177f0034d6
commit 75c7b6e483
11 changed files with 567 additions and 25 deletions

View File

@ -9,9 +9,9 @@
<link rel="sitemap" type="application/xml" title="Sitemap" href="/sitemap.xml" />
<!-- Favicon -->
<link rel="icon" type="image/png" href="/logo2026.png" />
<link rel="apple-touch-icon" href="/logo2026.png" />
<link rel="shortcut icon" type="image/png" href="/logo2026.png" />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="apple-touch-icon" href="/logo.png" />
<link rel="shortcut icon" type="image/png" href="/logo.png" />
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" />
@ -19,7 +19,7 @@
<!-- Meta pour partage (logo + titre + description) -->
<meta property="og:title" content="SocioWire.com - Wired to life" />
<meta property="og:description" content="Local news, events, market deals & friends on the map." />
<meta property="og:image" content="https://sociowire.com/logo2026.png" />
<meta property="og:image" content="https://sociowire.com/logo.png" />
<meta property="og:url" content="https://sociowire.com" />
<meta property="og:type" content="website" />
<meta name="facebook-domain-verification" content="o4iph92mh9fgqx58sn1b77ia08h8cp" />
@ -29,7 +29,7 @@
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="SocioWire.com - Wired to life" />
<meta name="twitter:description" content="Local news, events, market deals & friends on the map." />
<meta name="twitter:image" content="https://sociowire.com/logo2026.png" />
<meta name="twitter:image" content="https://sociowire.com/logo.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
@ -54,7 +54,7 @@
"@type": "Organization",
"name": "SocioWire",
"url": "https://sociowire.com",
"logo": "https://sociowire.com/logo2026.png",
"logo": "https://sociowire.com/logo.png",
"sameAs": []
}
</script>

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -4,6 +4,21 @@
import { loadAuth } from "../auth/authStorage";
/**
* Convert image URL to small variant (160x160)
* Asset URLs: ..._l.jpg -> ..._s.webp, ..._m.webp -> ..._s.webp
*/
export function toSmallImageUrl(url) {
if (!url || typeof url !== "string") return url;
if (url.includes("_l.jpg") || url.includes("_l.jpeg")) {
return url.replace(/_l\.jpe?g/i, "_s.webp");
}
if (url.includes("_m.webp")) {
return url.replace("_m.webp", "_s.webp");
}
return url;
}
function apiBase() {
const raw =
(import.meta?.env?.VITE_API_BASE_URL || "")
@ -1043,3 +1058,40 @@ export async function fetchWeatherPosts() {
if (!res.ok) return [];
return res.json();
}
/**
* Change password for the authenticated user
* @param {string} oldPassword - Current password (optional but recommended)
* @param {string} newPassword - New password (min 8 chars)
*/
export async function changePassword(oldPassword, newPassword) {
const url = `${apiBase()}/change-password`;
try {
const res = await fetch(url, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({
old_password: oldPassword || "",
new_password: newPassword,
}),
});
const text = await res.text().catch(() => "");
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return {
ok: res.ok,
status: res.status,
json,
error: json?.error || (res.ok ? null : text || "Password change failed"),
message: json?.message || "",
};
} catch (err) {
console.warn("changePassword error:", err);
return { ok: false, status: 0, json: null, error: err?.message || "error" };
}
}

View File

@ -9,6 +9,7 @@ import {
removeFriend,
sendFriendRequest,
searchUsers,
toSmallImageUrl,
} from '../../api/client';
import { useAuth } from '../../auth/AuthContext';
import './Friends.css';
@ -228,7 +229,7 @@ export default function FriendsList({ onSelectUser, onClose }) {
>
<div className="friend-avatar">
{s.avatar_url ? (
<img src={s.avatar_url} alt={s.username} />
<img src={toSmallImageUrl(s.avatar_url)} alt={s.username} />
) : (
s.username[0]?.toUpperCase() || '?'
)}
@ -287,7 +288,7 @@ export default function FriendsList({ onSelectUser, onClose }) {
>
<div className="friend-avatar">
{request.avatar_url ? (
<img src={request.avatar_url} alt={request.username} />
<img src={toSmallImageUrl(request.avatar_url)} alt={request.username} />
) : (
request.username[0]?.toUpperCase() || '?'
)}

View File

@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import '../../styles/island.css';
import { fetchPosts, updateProfileUsername, uploadProfileAvatar, updateTimezone, getBrowserTimezone } from '../../api/client';
import { fetchPosts, updateProfileUsername, uploadProfileAvatar, updateTimezone, getBrowserTimezone, changePassword, toSmallImageUrl } from '../../api/client';
import { setUserTimezone as saveTimezoneLocal, getUserTimezone as getStoredTimezone } from '../../utils/timezone';
import { useAuth } from '../../auth/AuthContext';
import { trackEvent } from "../../utils/analytics";
@ -35,6 +35,14 @@ export default function IslandViewer({ island, onClose }) {
const [timezone, setTimezone] = useState(getStoredTimezone());
const [timezoneSaving, setTimezoneSaving] = useState(false);
const [timezoneInfo, setTimezoneInfo] = useState("");
// Password change
const [showPasswordForm, setShowPasswordForm] = useState(false);
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
const [passwordSuccess, setPasswordSuccess] = useState('');
const [passwordLoading, setPasswordLoading] = useState(false);
const islandUsername = localUsername || island?.username || "";
useEffect(() => {
@ -206,7 +214,7 @@ export default function IslandViewer({ island, onClose }) {
<div className="island-header-row">
<div className="island-header-avatar">
{avatarSrc ? (
<img src={avatarSrc} alt={islandUsername} />
<img src={toSmallImageUrl(avatarSrc)} alt={islandUsername} />
) : (
<span>{(islandUsername || "U")[0].toUpperCase()}</span>
)}
@ -222,7 +230,7 @@ export default function IslandViewer({ island, onClose }) {
<div className="island-profile-panel">
<div className="island-profile-avatar">
{avatarSrc ? (
<img src={avatarSrc} alt={islandUsername} />
<img src={toSmallImageUrl(avatarSrc)} alt={islandUsername} />
) : (
<div className="island-profile-letter">
{(islandUsername || "U")[0].toUpperCase()}
@ -399,6 +407,23 @@ export default function IslandViewer({ island, onClose }) {
</select>
</div>
{timezoneInfo ? <div className="island-profile-info">{timezoneInfo}</div> : null}
<div className="island-profile-row" style={{ marginTop: '16px' }}>
<button
type="button"
className="island-profile-btn"
onClick={() => {
setShowPasswordForm(true);
setPasswordError('');
setPasswordSuccess('');
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
}}
>
<i className="fa-solid fa-key" /> Change Password
</button>
</div>
</div>
) : null}
</div>
@ -472,6 +497,85 @@ export default function IslandViewer({ island, onClose }) {
</p>
</div>
</div>
{/* Password Change Modal */}
{showPasswordForm && (
<div className="password-modal-backdrop" onClick={() => setShowPasswordForm(false)}>
<div className="password-modal" onClick={(e) => e.stopPropagation()}>
<button className="password-modal-close" onClick={() => setShowPasswordForm(false)}>×</button>
<h3 className="password-modal-title">Change Password</h3>
<div className="password-modal-form">
<div className="password-modal-field">
<label>Current Password</label>
<input
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder="Enter current password"
/>
</div>
<div className="password-modal-field">
<label>New Password</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Min 8 characters"
/>
</div>
<div className="password-modal-field">
<label>Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
/>
</div>
{passwordError && <div className="password-modal-error">{passwordError}</div>}
{passwordSuccess && <div className="password-modal-success">{passwordSuccess}</div>}
<button
type="button"
className="password-modal-submit"
disabled={passwordLoading}
onClick={async () => {
setPasswordError('');
setPasswordSuccess('');
if (newPassword.length < 8) {
setPasswordError('Password must be at least 8 characters');
return;
}
if (newPassword !== confirmPassword) {
setPasswordError('Passwords do not match');
return;
}
setPasswordLoading(true);
const res = await changePassword(oldPassword, newPassword);
setPasswordLoading(false);
if (res.ok) {
setPasswordSuccess('Password changed!');
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
setTimeout(() => {
setShowPasswordForm(false);
setPasswordSuccess('');
}, 1500);
} else {
if (res.error === 'invalid_old_password') {
setPasswordError('Current password is incorrect');
} else {
setPasswordError(res.error || 'Failed to change password');
}
}
}}
>
{passwordLoading ? 'Changing...' : 'Change Password'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../../auth/AuthContext";
import { registerUser, updateProfileUsername, fetchFriendRequests } from "../../api/client";
import { registerUser, updateProfileUsername, fetchFriendRequests, toSmallImageUrl } from "../../api/client";
import AuthToast from "../Auth/AuthToast";
import { promptInstall, isRunningAsPWA, canInstall } from '../../pwaInstall';
import { trackEvent } from "../../utils/analytics";
@ -488,7 +488,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
<header className="topbar">
<div className="brand-stack">
<div className="brand-toprow">
<div className="logo-circle"><img src="/logo2026.png" alt="SocioWire" /></div>
<div className="logo-circle"><img src="/logo.png" alt="SocioWire" /></div>
<div className="title-block">
<div className="main-title">SOCIOWIRE.com</div>
<div className="sub-title">{t('topbar.wiredToLife')}</div>
@ -524,7 +524,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
onClick={() => onUserIsland && onUserIsland(username)}
>
<div className="sw-avatar">
{avatarUrl ? <img src={avatarUrl} alt={username} /> : <span>{initialLetter(username)}</span>}
{avatarUrl ? <img src={toSmallImageUrl(avatarUrl)} alt={username} /> : <span>{initialLetter(username)}</span>}
</div>
<div className="sw-usertext">
<div className="sw-userline">{t('common.online')}</div>

View File

@ -31,13 +31,30 @@ const avatarCache = new Map();
const avatarInflight = new Map();
const HEATMAP_ENABLED = false;
/**
* Convert avatar URL to small variant (160x160)
* Asset URLs: ..._l.jpg -> ..._s.webp, ..._m.webp -> ..._s.webp
*/
function toSmallAvatarUrl(url) {
if (!url || typeof url !== "string") return url;
// Convert _l.jpg or _l.jpeg to _s.webp
if (url.includes("_l.jpg") || url.includes("_l.jpeg")) {
return url.replace(/_l\.jpe?g/i, "_s.webp");
}
// Convert _m.webp to _s.webp
if (url.includes("_m.webp")) {
return url.replace("_m.webp", "_s.webp");
}
return url;
}
function applyAvatar(el, username, url) {
if (!el) return;
const initial = (username || "U")[0]?.toUpperCase() || "U";
el.innerHTML = "";
if (url) {
const img = document.createElement("img");
img.src = url;
img.src = toSmallAvatarUrl(url);
img.alt = username || "user";
el.appendChild(img);
} else {
@ -2589,7 +2606,7 @@ export function createIslandMarker(island, mapRef, markersRef, onIslandClick, th
transition: transform 0.2s ease, box-shadow 0.2s ease;
">
${island.avatar_url
? `<img src="${island.avatar_url}" style="width:100%;height:100%;object-fit:cover;" alt="${island.username}" />`
? `<img src="${toSmallAvatarUrl(island.avatar_url)}" style="width:100%;height:100%;object-fit:cover;" alt="${island.username}" />`
: `<div style="color:white;font-size:32px;font-weight:bold;">${island.username[0].toUpperCase()}</div>`
}
</div>

View File

@ -6,6 +6,7 @@ import {
sendFriendRequest,
removeFriend,
fetchFriends,
changePassword,
} from '../../api/client';
import { useAuth } from '../../auth/AuthContext';
import '../../styles/profile.css';
@ -38,6 +39,15 @@ export default function UserProfile({ username, onClose, onVisitIsland }) {
const [showFriends, setShowFriends] = useState(false);
const [friends, setFriends] = useState([]);
// Password change state
const [showPasswordForm, setShowPasswordForm] = useState(false);
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
const [passwordSuccess, setPasswordSuccess] = useState('');
const [passwordLoading, setPasswordLoading] = useState(false);
useEffect(() => {
if (!authenticated || isSelf || !username) return;
@ -100,6 +110,42 @@ export default function UserProfile({ username, onClose, onVisitIsland }) {
setFriends(res?.friends || []);
};
const handleChangePassword = async (e) => {
e.preventDefault();
setPasswordError('');
setPasswordSuccess('');
if (newPassword.length < 8) {
setPasswordError('Password must be at least 8 characters');
return;
}
if (newPassword !== confirmPassword) {
setPasswordError('Passwords do not match');
return;
}
setPasswordLoading(true);
const res = await changePassword(oldPassword, newPassword);
setPasswordLoading(false);
if (res.ok) {
setPasswordSuccess('Password changed successfully!');
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
setTimeout(() => {
setShowPasswordForm(false);
setPasswordSuccess('');
}, 2000);
} else {
if (res.error === 'invalid_old_password') {
setPasswordError('Current password is incorrect');
} else {
setPasswordError(res.error || 'Failed to change password');
}
}
};
const handleAvatarChange = async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
@ -250,6 +296,7 @@ export default function UserProfile({ username, onClose, onVisitIsland }) {
)}
{isSelf && (
<>
<button
className="user-profile-btn user-profile-btn-ghost"
onClick={() => {
@ -259,9 +306,72 @@ export default function UserProfile({ username, onClose, onVisitIsland }) {
>
<i className="fa-solid fa-user-group" /> Friends
</button>
<button
className="user-profile-btn user-profile-btn-ghost"
onClick={() => {
setShowPasswordForm(!showPasswordForm);
setPasswordError('');
setPasswordSuccess('');
}}
>
<i className="fa-solid fa-key" /> Change Password
</button>
</>
)}
</div>
{isSelf && showPasswordForm && (
<div className="user-profile-password-form">
<form onSubmit={handleChangePassword}>
<div className="user-profile-form-group">
<label>Current Password</label>
<input
type="password"
className="user-profile-input"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder="Enter current password"
/>
</div>
<div className="user-profile-form-group">
<label>New Password</label>
<input
type="password"
className="user-profile-input"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password (min 8 chars)"
required
/>
</div>
<div className="user-profile-form-group">
<label>Confirm New Password</label>
<input
type="password"
className="user-profile-input"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
required
/>
</div>
{passwordError && (
<div className="user-profile-error">{passwordError}</div>
)}
{passwordSuccess && (
<div className="user-profile-success">{passwordSuccess}</div>
)}
<button
type="submit"
className="user-profile-btn user-profile-btn-primary"
disabled={passwordLoading}
>
{passwordLoading ? 'Changing...' : 'Change Password'}
</button>
</form>
</div>
)}
{isSelf && showFriends && (
<div className="user-profile-friends">
{friends.length === 0 ? (
@ -280,6 +390,7 @@ export default function UserProfile({ username, onClose, onVisitIsland }) {
<div className="user-profile-footer">
<p className="user-profile-seed">Seed: {island.seed}</p>
<p className="user-profile-version">v2026.01.10b</p>
</div>
</>
) : (

View File

@ -510,3 +510,181 @@ body[data-theme="light"] .island-viewer-close {
body[data-theme="light"] .island-viewer-close:hover {
background: rgba(30, 58, 138, 0.3);
}
/* Password Form */
.island-password-form {
margin-top: 0.75rem;
padding: 0.75rem;
border-radius: 12px;
background: rgba(15, 23, 42, 0.5);
border: 1px solid rgba(148, 163, 184, 0.2);
}
.island-password-form .island-profile-row {
margin-bottom: 0.5rem;
}
.island-password-form .island-profile-row label {
min-width: 100px;
}
.island-profile-btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);
border-color: rgba(99, 102, 241, 0.6);
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
}
.island-profile-btn-primary:hover:not(:disabled) {
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.5);
}
body[data-theme="light"] .island-password-form {
background: rgba(255, 255, 255, 0.5);
}
body[data-theme="light"] .island-profile-btn-primary {
background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
}
/* Password Change Modal */
.password-modal-backdrop {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
animation: fadeIn 0.2s ease;
}
.password-modal {
position: relative;
width: 90%;
max-width: 380px;
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(56, 189, 248, 0.2);
animation: slideUp 0.25s ease;
}
.password-modal-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
border: none;
color: white;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.password-modal-close:hover {
background: rgba(255, 255, 255, 0.2);
}
.password-modal-title {
color: #f1f5f9;
font-size: 1.25rem;
margin: 0 0 1.25rem 0;
font-weight: 700;
}
.password-modal-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.password-modal-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.password-modal-field label {
color: rgba(226, 232, 240, 0.8);
font-size: 0.8rem;
font-weight: 600;
}
.password-modal-field input {
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.3);
color: #e2e8f0;
border-radius: 10px;
padding: 0.6rem 0.75rem;
font-size: 0.9rem;
}
.password-modal-field input:focus {
outline: none;
border-color: rgba(56, 189, 248, 0.5);
}
.password-modal-error {
color: #f87171;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
padding: 0.5rem 0.75rem;
border-radius: 8px;
font-size: 0.8rem;
}
.password-modal-success {
color: #86efac;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
padding: 0.5rem 0.75rem;
border-radius: 8px;
font-size: 0.8rem;
}
.password-modal-submit {
background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);
border: none;
color: white;
padding: 0.75rem 1rem;
border-radius: 10px;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
margin-top: 0.5rem;
}
.password-modal-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.password-modal-submit:hover:not(:disabled) {
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4);
}
body[data-theme="light"] .password-modal {
background: linear-gradient(135deg, #e0e7ff 0%, #f5f3ff 100%);
border-color: rgba(99, 102, 241, 0.3);
}
body[data-theme="light"] .password-modal-title {
color: #1e3a8a;
}
body[data-theme="light"] .password-modal-field label {
color: rgba(30, 58, 138, 0.8);
}
body[data-theme="light"] .password-modal-field input {
background: rgba(255, 255, 255, 0.8);
color: #1e293b;
border-color: rgba(99, 102, 241, 0.3);
}

View File

@ -240,6 +240,12 @@ body[data-theme="blue"] .user-profile-container{
margin: 0;
}
.user-profile-version {
color: rgba(255, 255, 255, 0.3);
font-size: 0.7rem;
margin: 4px 0 0 0;
}
/* Friends list in profile */
.user-profile-friends {
margin-top: 0.75rem;
@ -382,3 +388,76 @@ body[data-theme="light"] .user-profile-close:hover {
body[data-theme="light"] .user-profile-footer {
border-top-color: rgba(30, 58, 138, 0.1);
}
/* Password Change Form */
.user-profile-password-form {
margin-top: 0.75rem;
padding: 1rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.user-profile-form-group {
margin-bottom: 0.75rem;
}
.user-profile-form-group label {
display: block;
color: rgba(255, 255, 255, 0.8);
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.user-profile-form-group .user-profile-input {
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
}
.user-profile-error {
color: #fca5a5;
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
padding: 0.5rem 0.75rem;
border-radius: 8px;
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
.user-profile-success {
color: #86efac;
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.3);
padding: 0.5rem 0.75rem;
border-radius: 8px;
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
.user-profile-password-form .user-profile-btn-primary {
width: 100%;
justify-content: center;
margin-top: 0.5rem;
}
body[data-theme="light"] .user-profile-password-form {
background: rgba(255, 255, 255, 0.5);
}
body[data-theme="light"] .user-profile-form-group label {
color: rgba(30, 58, 138, 0.8);
}
body[data-theme="light"] .user-profile-error {
color: #dc2626;
background: rgba(239, 68, 68, 0.1);
}
body[data-theme="light"] .user-profile-success {
color: #16a34a;
background: rgba(34, 197, 94, 0.1);
}