Feat add profile photo (#580)
* Add profile photo support * Add translations * fixup! Add translations
This commit is contained in:
parent
49d22789e7
commit
c3bf5f5522
29 changed files with 569 additions and 29 deletions
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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": "حالات متكررة"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Забавени проекти",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -259,7 +259,17 @@
|
|||
"name": "Όνομα",
|
||||
"surname": "Επώνυμο",
|
||||
"enterName": "Εισάγετε το όνομά σας",
|
||||
"enterSurname": "Εισάγετε το επώνυμό σας"
|
||||
"enterSurname": "Εισάγετε το επώνυμό σας",
|
||||
"avatar": "Φωτογραφία προφίλ",
|
||||
"uploadAvatar": "Ανέβασμα φωτογραφίας",
|
||||
"removeAvatar": "Αφαίρεση φωτογραφίας",
|
||||
"avatarDescription": "Ανεβάστε μια φωτογραφία προφίλ (μέγιστο 5MB)",
|
||||
"avatarUploadError": "Παρακαλώ ανεβάστε ένα αρχείο εικόνας",
|
||||
"avatarSizeError": "Η εικόνα πρέπει να είναι μικρότερη από 5MB",
|
||||
"avatarUploadSuccess": "Η φωτογραφία ανέβηκε με επιτυχία!",
|
||||
"avatarRemoveSuccess": "Η φωτογραφία αφαιρέθηκε με επιτυχία!",
|
||||
"avatarUploadFailed": "Αποτυχία ανεβάσματος φωτογραφίας",
|
||||
"avatarRemoveFailed": "Αποτυχία αφαίρεσης φωτογραφίας"
|
||||
},
|
||||
"errors": {
|
||||
"required": "Αυτό το πεδίο είναι υποχρεωτικό",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "進行中のものがないので、こちらから始めてみませんか",
|
||||
|
|
|
|||
|
|
@ -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": "정체된 프로젝트",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Приостановленные проекты",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "停滞项目",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue