Feat add profile photo (#580)

* Add profile photo support

* Add translations

* fixup! Add translations
This commit is contained in:
Chris 2025-11-19 09:00:58 +02:00 committed by GitHub
parent 49d22789e7
commit c3bf5f5522
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 569 additions and 29 deletions

View file

@ -22,6 +22,7 @@ router.get('/current_user', async (req, res) => {
'language',
'appearance',
'timezone',
'avatar_image',
],
});
if (user) {
@ -35,6 +36,7 @@ router.get('/current_user', async (req, res) => {
language: user.language,
appearance: user.appearance,
timezone: user.timezone,
avatar_image: user.avatar_image,
is_admin: admin,
},
});

View file

@ -12,6 +12,9 @@ const {
serializeApiToken,
} = require('../services/apiTokenService');
const { apiKeyManagementLimiter } = require('../middleware/rateLimiter');
const multer = require('multer');
const path = require('path');
const fs = require('fs').promises;
router.use((req, res, next) => {
const userId = getAuthenticatedUserId(req);
@ -33,6 +36,46 @@ const VALID_FREQUENCIES = [
'12h',
];
// Configure multer for avatar uploads
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(__dirname, '../uploads/avatars');
try {
await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir);
} catch (error) {
cb(error, uploadDir);
}
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const ext = path.extname(file.originalname);
cb(null, `avatar-${req.authUserId}-${uniqueSuffix}${ext}`);
},
});
const fileFilter = (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const extname = allowedTypes.test(
path.extname(file.originalname).toLowerCase()
);
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('Only image files (JPEG, PNG, GIF, WebP) are allowed!'));
}
};
const upload = multer({
storage: storage,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB limit
},
fileFilter: fileFilter,
});
router.get('/users', async (req, res) => {
try {
const users = await User.findAll({
@ -249,6 +292,87 @@ router.patch('/profile', async (req, res) => {
}
});
router.post('/profile/avatar', upload.single('avatar'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const user = await User.findByPk(req.authUserId);
if (!user) {
// Clean up uploaded file
await fs.unlink(req.file.path).catch(() => {});
return res.status(404).json({ error: 'User not found' });
}
// Delete old avatar file if it exists
if (user.avatar_image) {
const oldAvatarPath = path.join(
__dirname,
'../uploads/avatars',
path.basename(user.avatar_image)
);
await fs.unlink(oldAvatarPath).catch(() => {
// Ignore errors if file doesn't exist
});
}
// Store relative path in database
const avatarUrl = `/uploads/avatars/${path.basename(req.file.path)}`;
await user.update({ avatar_image: avatarUrl });
res.json({
success: true,
avatar_image: avatarUrl,
message: 'Avatar uploaded successfully',
});
} catch (error) {
// Clean up uploaded file on error
if (req.file) {
await fs.unlink(req.file.path).catch(() => {});
}
logError('Error uploading avatar:', error);
res.status(500).json({
error: 'Failed to upload avatar',
details: error.message,
});
}
});
router.delete('/profile/avatar', async (req, res) => {
try {
const user = await User.findByPk(req.authUserId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Delete avatar file if it exists
if (user.avatar_image) {
const avatarPath = path.join(
__dirname,
'../uploads/avatars',
path.basename(user.avatar_image)
);
await fs.unlink(avatarPath).catch(() => {
// Ignore errors if file doesn't exist
});
}
await user.update({ avatar_image: null });
res.json({
success: true,
message: 'Avatar removed successfully',
});
} catch (error) {
logError('Error removing avatar:', error);
res.status(500).json({
error: 'Failed to remove avatar',
details: error.message,
});
}
});
router.post('/profile/change-password', async (req, res) => {
try {
const { currentPassword, newPassword } = req.body;

View file

@ -17,7 +17,7 @@ interface NavbarProps {
toggleDarkMode: () => void;
currentUser: {
email: string;
avatarUrl?: string;
avatar_image?: string;
is_admin?: boolean;
};
setCurrentUser: React.Dispatch<React.SetStateAction<any>>;
@ -216,9 +216,9 @@ const Navbar: React.FC<NavbarProps> = ({
className="flex items-center focus:outline-none"
aria-label="User Menu"
>
{currentUser?.avatarUrl ? (
{currentUser?.avatar_image ? (
<img
src={currentUser.avatarUrl}
src={getApiPath(currentUser.avatar_image)}
alt="User Avatar"
className="h-8 w-8 rounded-full object-cover border-2 border-green-500"
/>

View file

@ -26,6 +26,8 @@ import {
MoonIcon,
KeyIcon,
TrashIcon,
PhotoIcon,
UserCircleIcon,
} from '@heroicons/react/24/outline';
import TelegramIcon from '../Icons/TelegramIcon';
import { useToast } from '../Shared/ToastContext';
@ -171,6 +173,9 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
const [apiKeyToDelete, setApiKeyToDelete] = useState<ApiKeySummary | null>(
null
);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [removeAvatar, setRemoveAvatar] = useState(false);
const forceUpdate = useCallback(() => {
setUpdateKey((prevKey) => prevKey + 1);
@ -792,6 +797,74 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
}
};
const handleAvatarSelect = (file: File) => {
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
showErrorToast(
t('profile.avatarUploadError', 'Please upload an image file')
);
return;
}
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
showErrorToast(
t('profile.avatarSizeError', 'Image must be smaller than 5MB')
);
return;
}
// Store file for later upload
setAvatarFile(file);
setRemoveAvatar(false);
// Create preview URL
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
};
const handleAvatarRemove = () => {
setAvatarFile(null);
setAvatarPreview(null);
setRemoveAvatar(true);
};
const uploadAvatar = async (file: File): Promise<string> => {
const formData = new FormData();
formData.append('avatar', file);
const response = await fetch(getApiPath('profile/avatar'), {
method: 'POST',
credentials: 'include',
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to upload avatar');
}
const data = await response.json();
return data.avatar_image;
};
const deleteAvatar = async (): Promise<void> => {
const response = await fetch(getApiPath('profile/avatar'), {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to remove avatar');
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
@ -835,6 +908,21 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
}
const updatedProfile: Profile = await response.json();
// Handle avatar upload or deletion
if (avatarFile) {
// Upload new avatar
const avatarUrl = await uploadAvatar(avatarFile);
updatedProfile.avatar_image = avatarUrl;
setAvatarFile(null);
setAvatarPreview(null);
} else if (removeAvatar && formData.avatar_image) {
// Delete avatar
await deleteAvatar();
updatedProfile.avatar_image = null;
setRemoveAvatar(false);
}
setProfile(updatedProfile);
// Update formData to reflect the saved changes, preserving any fields not in response
@ -938,6 +1026,13 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
)
: t('profile.successMessage', 'Profile updated successfully!');
showSuccessToast(successMessage);
// Reload page if avatar changed to update navbar
if (avatarFile || removeAvatar) {
setTimeout(() => {
window.location.reload();
}, 1000);
}
} catch (err) {
showErrorToast((err as Error).message);
}
@ -1052,6 +1147,65 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
)}
</h3>
{/* Avatar Upload Section */}
<div className="mb-8 flex flex-col items-center">
<div className="relative">
{avatarPreview || formData.avatar_image ? (
<img
src={
avatarPreview ||
getApiPath(
formData.avatar_image
)
}
alt="Avatar"
className="w-32 h-32 rounded-full object-cover border-4 border-blue-500"
/>
) : (
<div className="w-32 h-32 rounded-full border-4 border-gray-300 dark:border-gray-600 bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<UserCircleIcon className="w-20 h-20 text-gray-400 dark:text-gray-500" />
</div>
)}
<label
htmlFor="avatar-upload"
className="absolute bottom-0 right-0 bg-blue-500 hover:bg-blue-600 text-white rounded-full p-2 cursor-pointer transition-colors"
>
<PhotoIcon className="w-5 h-5" />
<input
id="avatar-upload"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file =
e.target.files?.[0];
if (file) {
handleAvatarSelect(file);
}
}}
/>
</label>
</div>
{(formData.avatar_image || avatarPreview) && (
<button
type="button"
onClick={handleAvatarRemove}
className="mt-3 text-sm text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
{t(
'profile.removeAvatar',
'Remove Avatar'
)}
</button>
)}
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
{t(
'profile.avatarDescription',
'Upload a profile photo (max 5MB)'
)}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">

View file

@ -305,7 +305,17 @@
"name": "الاسم",
"surname": "الكنية",
"enterName": "أدخل اسمك",
"enterSurname": "أدخل كنيتك"
"enterSurname": "أدخل كنيتك",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "المشاريع المتوقفة",
@ -1009,7 +1019,9 @@
"dueDate": "تاريخ الاستحقاق",
"noCriteriaSet": "لم يتم تعيين أي معايير محددة",
"priorityLabel": "الأولوية:",
"dueLabel": "استحقاق:"
"dueLabel": "استحقاق:",
"tags": "علامات",
"recurring": "متكرر"
},
"search": {
"placeholder": "ابحث عن المهام، المشاريع، الملاحظات...",
@ -1041,6 +1053,14 @@
"project": "مشروع",
"area": "منطقة",
"note": "ملاحظة"
},
"thatAre": "، التي هي",
"extras": "إضافات",
"recurringFilter": {
"label": "متكرر",
"recurring": "قوالب متكررة",
"nonRecurring": "غير متكرر",
"instances": "حالات متكررة"
}
}
}

View file

@ -305,7 +305,17 @@
"name": "Име",
"surname": "Фамилия",
"enterName": "Въведете вашето име",
"enterSurname": "Въведете вашата фамилия"
"enterSurname": "Въведете вашата фамилия",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "Забавени проекти",

View file

@ -305,7 +305,17 @@
"name": "Navn",
"surname": "Efternavn",
"enterName": "Indtast dit navn",
"enterSurname": "Indtast dit efternavn"
"enterSurname": "Indtast dit efternavn",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "Stagnerede Projekter",

View file

@ -531,7 +531,17 @@
"name": "Name",
"surname": "Nachname",
"enterName": "Geben Sie Ihren Namen ein",
"enterSurname": "Geben Sie Ihren Nachnamen ein"
"enterSurname": "Geben Sie Ihren Nachnamen ein",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"nextTask": {
"suggestion": "Da nichts in Bearbeitung ist, wie wäre es, mit diesem zu beginnen",

View file

@ -259,7 +259,17 @@
"name": "Όνομα",
"surname": "Επώνυμο",
"enterName": "Εισάγετε το όνομά σας",
"enterSurname": "Εισάγετε το επώνυμό σας"
"enterSurname": "Εισάγετε το επώνυμό σας",
"avatar": "Φωτογραφία προφίλ",
"uploadAvatar": "Ανέβασμα φωτογραφίας",
"removeAvatar": "Αφαίρεση φωτογραφίας",
"avatarDescription": "Ανεβάστε μια φωτογραφία προφίλ (μέγιστο 5MB)",
"avatarUploadError": "Παρακαλώ ανεβάστε ένα αρχείο εικόνας",
"avatarSizeError": "Η εικόνα πρέπει να είναι μικρότερη από 5MB",
"avatarUploadSuccess": "Η φωτογραφία ανέβηκε με επιτυχία!",
"avatarRemoveSuccess": "Η φωτογραφία αφαιρέθηκε με επιτυχία!",
"avatarUploadFailed": "Αποτυχία ανεβάσματος φωτογραφίας",
"avatarRemoveFailed": "Αποτυχία αφαίρεσης φωτογραφίας"
},
"errors": {
"required": "Αυτό το πεδίο είναι υποχρεωτικό",

View file

@ -305,7 +305,17 @@
"aiProductivityFeatures": "AI & Productivity Features",
"botSetup": "Bot Setup",
"passwordChangeNote": "Password changes will be saved when you click \"Save Changes\" at the bottom of the form.",
"passwordChangeOptional": "Leave password fields empty to update other settings without changing your password."
"passwordChangeOptional": "Leave password fields empty to update other settings without changing your password.",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "Stalled Projects",

View file

@ -259,7 +259,17 @@
"name": "Nombre",
"surname": "Apellido",
"enterName": "Introduce tu nombre",
"enterSurname": "Introduce tu apellido"
"enterSurname": "Introduce tu apellido",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"errors": {
"required": "Este campo es obligatorio",

View file

@ -305,7 +305,17 @@
"name": "Nimi",
"surname": "Sukunimi",
"enterName": "Syötä nimesi",
"enterSurname": "Syötä sukunimesi"
"enterSurname": "Syötä sukunimesi",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "Jumiutuneet projektit",

View file

@ -305,7 +305,17 @@
"name": "Nom",
"surname": "Prénom",
"enterName": "Entrez votre nom",
"enterSurname": "Entrez votre prénom"
"enterSurname": "Entrez votre prénom",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "Projets Bloqués",

View file

@ -305,7 +305,17 @@
"name": "Nama",
"surname": "Nama Belakang",
"enterName": "Masukkan nama Anda",
"enterSurname": "Masukkan nama belakang Anda"
"enterSurname": "Masukkan nama belakang Anda",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "Proyek Terhenti",

View file

@ -305,7 +305,17 @@
"name": "Nome",
"surname": "Cognome",
"enterName": "Inserisci il tuo nome",
"enterSurname": "Inserisci il tuo cognome"
"enterSurname": "Inserisci il tuo cognome",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "Progetti Bloccati",

View file

@ -305,7 +305,17 @@
"name": "名前",
"surname": "姓",
"enterName": "名前を入力してください",
"enterSurname": "姓を入力してください"
"enterSurname": "姓を入力してください",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"nextTask": {
"suggestion": "進行中のものがないので、こちらから始めてみませんか",

View file

@ -305,7 +305,17 @@
"name": "이름",
"surname": "성",
"enterName": "이름을 입력하세요",
"enterSurname": "성을 입력하세요"
"enterSurname": "성을 입력하세요",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "정체된 프로젝트",

View file

@ -305,7 +305,17 @@
"name": "Naam",
"surname": "Achternaam",
"enterName": "Voer uw naam in",
"enterSurname": "Voer uw achternaam in"
"enterSurname": "Voer uw achternaam in",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "Stagnerende Projecten",

View file

@ -305,7 +305,17 @@
"name": "Navn",
"surname": "Etternavn",
"enterName": "Skriv inn navnet ditt",
"enterSurname": "Skriv inn etternavnet ditt"
"enterSurname": "Skriv inn etternavnet ditt",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "Stagnerte prosjekter",

View file

@ -305,7 +305,17 @@
"name": "Imię",
"surname": "Nazwisko",
"enterName": "Wprowadź swoje imię",
"enterSurname": "Wprowadź swoje nazwisko"
"enterSurname": "Wprowadź swoje nazwisko",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "Zatrzymane projekty",

View file

@ -305,7 +305,17 @@
"name": "Nome",
"surname": "Sobrenome",
"enterName": "Digite seu nome",
"enterSurname": "Digite seu sobrenome"
"enterSurname": "Digite seu sobrenome",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "Projetos Parados",

View file

@ -305,7 +305,17 @@
"name": "Nume",
"surname": "Prenume",
"enterName": "Introduceți numele dvs.",
"enterSurname": "Introduceți prenumele dvs."
"enterSurname": "Introduceți prenumele dvs.",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "Proiecte Blocate",

View file

@ -305,7 +305,17 @@
"name": "Имя",
"surname": "Фамилия",
"enterName": "Введите ваше имя",
"enterSurname": "Введите вашу фамилию"
"enterSurname": "Введите вашу фамилию",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "Приостановленные проекты",

View file

@ -305,7 +305,17 @@
"name": "Ime",
"surname": "Priimek",
"enterName": "Vnesite svoje ime",
"enterSurname": "Vnesite svoj priimek"
"enterSurname": "Vnesite svoj priimek",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "Zastali projekti",

View file

@ -305,7 +305,17 @@
"name": "Namn",
"surname": "Efternamn",
"enterName": "Ange ditt namn",
"enterSurname": "Ange ditt efternamn"
"enterSurname": "Ange ditt efternamn",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "Stagnerade projekt",

View file

@ -305,7 +305,17 @@
"name": "İsim",
"surname": "Soyisim",
"enterName": "İsminizi girin",
"enterSurname": "Soyisminizi girin"
"enterSurname": "Soyisminizi girin",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "Duraklayan Projeler",

View file

@ -583,7 +583,17 @@
"name": "Ім'я",
"surname": "Прізвище",
"enterName": "Введіть ваше ім'я",
"enterSurname": "Введіть ваше прізвище"
"enterSurname": "Введіть ваше прізвище",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"task": {
"suggestions": {

View file

@ -305,7 +305,17 @@
"name": "Tên",
"surname": "Họ",
"enterName": "Nhập tên của bạn",
"enterSurname": "Nhập họ của bạn"
"enterSurname": "Nhập họ của bạn",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "Dự án Đang Tạm Dừng",

View file

@ -305,7 +305,17 @@
"name": "姓名",
"surname": "姓氏",
"enterName": "输入您的姓名",
"enterSurname": "输入您的姓氏"
"enterSurname": "输入您的姓氏",
"avatar": "Profile Photo",
"uploadAvatar": "Upload Photo",
"removeAvatar": "Remove Avatar",
"avatarDescription": "Upload a profile photo (max 5MB)",
"avatarUploadError": "Please upload an image file",
"avatarSizeError": "Image must be smaller than 5MB",
"avatarUploadSuccess": "Avatar uploaded successfully!",
"avatarRemoveSuccess": "Avatar removed successfully!",
"avatarUploadFailed": "Failed to upload avatar",
"avatarRemoveFailed": "Failed to remove avatar"
},
"productivity": {
"stalledProjects": "停滞项目",