feat: Add photo upload component for user and child profiles
- Created reusable PhotoUpload component with avatar preview - Added photo upload to child create/edit dialog - Added profile photo upload to settings page - Show photo preview with fallback icon - Display camera button for future file upload integration - Support URL paste for immediate photo display - Updated API types to support photoUrl field 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import { LanguageSelector } from '@/components/settings/LanguageSelector';
|
|||||||
import { MeasurementUnitSelector } from '@/components/settings/MeasurementUnitSelector';
|
import { MeasurementUnitSelector } from '@/components/settings/MeasurementUnitSelector';
|
||||||
import { TimeZoneSelector } from '@/components/settings/TimeZoneSelector';
|
import { TimeZoneSelector } from '@/components/settings/TimeZoneSelector';
|
||||||
import { TimeFormatSelector } from '@/components/settings/TimeFormatSelector';
|
import { TimeFormatSelector } from '@/components/settings/TimeFormatSelector';
|
||||||
|
import { PhotoUpload } from '@/components/common/PhotoUpload';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ export default function SettingsPage() {
|
|||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
const { user, logout, refreshUser } = useAuth();
|
const { user, logout, refreshUser } = useAuth();
|
||||||
const [name, setName] = useState(user?.name || '');
|
const [name, setName] = useState(user?.name || '');
|
||||||
|
const [photoUrl, setPhotoUrl] = useState(user?.photoUrl || '');
|
||||||
const [timezone, setTimezone] = useState(user?.timezone || 'UTC');
|
const [timezone, setTimezone] = useState(user?.timezone || 'UTC');
|
||||||
const [timeFormat, setTimeFormat] = useState<'12h' | '24h'>(user?.preferences?.timeFormat || '12h');
|
const [timeFormat, setTimeFormat] = useState<'12h' | '24h'>(user?.preferences?.timeFormat || '12h');
|
||||||
const [settings, setSettings] = useState({
|
const [settings, setSettings] = useState({
|
||||||
@@ -50,11 +52,14 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}, [user?.preferences]);
|
}, [user?.preferences]);
|
||||||
|
|
||||||
// Sync name and timezone state when user data changes
|
// Sync name, photo, and timezone state when user data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.name) {
|
if (user?.name) {
|
||||||
setName(user.name);
|
setName(user.name);
|
||||||
}
|
}
|
||||||
|
if (user?.photoUrl) {
|
||||||
|
setPhotoUrl(user.photoUrl);
|
||||||
|
}
|
||||||
if (user?.timezone) {
|
if (user?.timezone) {
|
||||||
setTimezone(user.timezone);
|
setTimezone(user.timezone);
|
||||||
}
|
}
|
||||||
@@ -74,6 +79,7 @@ export default function SettingsPage() {
|
|||||||
try {
|
try {
|
||||||
const response = await usersApi.updateProfile({
|
const response = await usersApi.updateProfile({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
|
photoUrl: photoUrl || undefined,
|
||||||
timezone,
|
timezone,
|
||||||
preferences: {
|
preferences: {
|
||||||
...settings,
|
...settings,
|
||||||
@@ -135,6 +141,13 @@ export default function SettingsPage() {
|
|||||||
{t('profile.title')}
|
{t('profile.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
|
||||||
|
<PhotoUpload
|
||||||
|
label="Profile Photo"
|
||||||
|
value={photoUrl}
|
||||||
|
onChange={setPhotoUrl}
|
||||||
|
disabled={isLoading}
|
||||||
|
size={100}
|
||||||
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={t('profile.name')}
|
label={t('profile.name')}
|
||||||
value={name}
|
value={name}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Child, CreateChildData } from '@/lib/api/children';
|
import { Child, CreateChildData } from '@/lib/api/children';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import { PhotoUpload } from '@/components/common/PhotoUpload';
|
||||||
|
|
||||||
interface ChildDialogProps {
|
interface ChildDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -146,13 +147,12 @@ export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false
|
|||||||
<MenuItem value="other">{t('gender.other')}</MenuItem>
|
<MenuItem value="other">{t('gender.other')}</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
|
|
||||||
<TextField
|
<PhotoUpload
|
||||||
label={t('dialog.photoUrl')}
|
label={t('dialog.photoUrl')}
|
||||||
value={formData.photoUrl}
|
value={formData.photoUrl || ''}
|
||||||
onChange={handleChange('photoUrl')}
|
onChange={(url) => setFormData({ ...formData, photoUrl: url })}
|
||||||
fullWidth
|
|
||||||
placeholder={t('dialog.photoPlaceholder')}
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
size={80}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
110
maternal-web/components/common/PhotoUpload.tsx
Normal file
110
maternal-web/components/common/PhotoUpload.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Avatar,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { PhotoCamera, Person } from '@mui/icons-material';
|
||||||
|
|
||||||
|
interface PhotoUploadProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (url: string) => void;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhotoUpload({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
disabled = false,
|
||||||
|
size = 100
|
||||||
|
}: PhotoUploadProps) {
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
|
const handleImageError = () => {
|
||||||
|
setImageError(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageLoad = () => {
|
||||||
|
setImageError(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, alignItems: 'center' }}>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
border: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 2,
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ position: 'relative' }}>
|
||||||
|
<Avatar
|
||||||
|
src={!imageError && value ? value : undefined}
|
||||||
|
sx={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
bgcolor: 'primary.light',
|
||||||
|
fontSize: size / 3,
|
||||||
|
}}
|
||||||
|
onError={handleImageError}
|
||||||
|
onLoad={handleImageLoad}
|
||||||
|
>
|
||||||
|
{!value || imageError ? <Person sx={{ fontSize: size / 2 }} /> : null}
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -4,
|
||||||
|
right: -4,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
border: 2,
|
||||||
|
borderColor: 'divider',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
// Future: Open file picker for actual upload
|
||||||
|
// For now, user can paste URL below
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PhotoCamera fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Photo URL"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
placeholder="https://example.com/photo.jpg"
|
||||||
|
disabled={disabled}
|
||||||
|
helperText="Paste an image URL or upload a photo"
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ export interface UserPreferences {
|
|||||||
|
|
||||||
export interface UpdateProfileData {
|
export interface UpdateProfileData {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
photoUrl?: string;
|
||||||
|
timezone?: string;
|
||||||
preferences?: UserPreferences;
|
preferences?: UserPreferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user