Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"react-markdown": "^9.0.3",
"react-query": "^3.39.3",
"react-router-dom": "^6.22.3",
"sonner": "^2.0.6",
"sort-by": "^1.2.0",
"tailwind-merge": "catalog:frontend",
"tailwindcss-animate": "catalog:frontend",
Expand Down
2 changes: 2 additions & 0 deletions apps/client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { Toaster } from 'sonner';

import '@/i18n/i18n';
import '/node_modules/flag-icons/css/flag-icons.min.css';
Expand All @@ -16,6 +17,7 @@ function App() {
<Routes />
<Analytics />
</LoadingApplication>
<Toaster />
</div>
</QueryClientProvider>
);
Expand Down
158 changes: 158 additions & 0 deletions apps/client/src/components/ProfilePictureUpload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import React, { useState, useRef } from 'react';
import AvatarEditor from 'react-avatar-editor';
import Dropzone from 'react-dropzone';
import * as uuid from 'uuid';
import { CircleUser, Upload, X } from 'lucide-react';
import { useMutation, useQueryClient } from 'react-query';

import { Avatar } from '@boilerplate/ui/components/avatar';
import { Button } from '@boilerplate/ui/components/button';
import { Card, CardContent } from '@boilerplate/ui/components/card';

import { AuthenticatedImage } from '@/components/authenticated-image';
import { useUser } from '@/store/async-store';
import api from '@/repository';

interface ProfilePictureUploadProps {
onUploadComplete?: () => void;
}

export const ProfilePictureUpload = ({ onUploadComplete }: ProfilePictureUploadProps) => {
const { data: user } = useUser();
const queryClient = useQueryClient();
const [image, setImage] = useState<File>();
const [isEditing, setIsEditing] = useState(false);
const editorRef = useRef<AvatarEditor>(null);
const changeUuid = useRef<string>(uuid.v4());

const uploadMutation = useMutation({
mutationFn: async () => {
if (!image || !editorRef.current) {
throw new Error('No image or editor reference');
}

const formData = new FormData();
const imageBlob = await new Promise<Blob>((res, rej) => {
if (editorRef.current === null) {
return rej(new Error('Editor reference is null'));
}
editorRef.current.getImageScaledToCanvas().toBlob(
(imageBlob) => {
if (!imageBlob) return rej(new Error('Image blob is null'));
res(imageBlob);
},
'image/webp',
0.8,
);
});

formData.append('file', imageBlob);
const response = await api.patch(`/user/profile-picture/${changeUuid.current}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
onSuccess: () => {
// Invalidate user query to refresh profile picture
queryClient.invalidateQueries(['user']);
setImage(undefined);
setIsEditing(false);
changeUuid.current = uuid.v4(); // Generate new UUID for next upload
onUploadComplete?.();
},
onError: (error) => {
console.error('Error uploading profile picture:', error);
},
});

const handleCancel = () => {
setImage(undefined);
setIsEditing(false);
};

const handleUpload = () => {
uploadMutation.mutate();
};

return (
<div className="flex flex-col items-center space-y-4">
{/* Current Profile Picture */}
<Avatar className="h-32 w-32">
<AuthenticatedImage
key={user?.profilePictureId}
fileUuid={user?.profilePictureId}
className="aspect-square h-full w-full"
fallback={<CircleUser className="h-20 w-20" />}
/>
</Avatar>

{/* Upload Section */}
{!isEditing && (
<Dropzone
onDrop={(dropped) => {
setImage(dropped[0] as File);
setIsEditing(true);
}}
accept={{ 'image/*': [] }}
multiple={false}
>
{({ getRootProps, getInputProps, isDragActive }) => (
<div
{...getRootProps()}
className={`
border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors
${isDragActive ? 'border-primary bg-primary/10' : 'border-gray-300 hover:border-primary'}
`}
>
<input {...getInputProps()} />
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-2" />
<p className="text-sm text-gray-600">
{isDragActive ? 'Drop your image here' : 'Click or drag to upload new profile picture'}
</p>
</div>
)}
</Dropzone>
)}

{/* Image Editor */}
{isEditing && image && (
<Card className="w-full max-w-sm">
<CardContent className="p-4">
<div className="flex flex-col items-center space-y-4">
<div className="relative">
<AvatarEditor
ref={editorRef}
image={image}
width={200}
height={200}
border={20}
borderRadius={100}
scale={1}
className="rounded-full"
/>
</div>
<div className="flex space-x-2">
<Button
variant="outline"
onClick={handleCancel}
disabled={uploadMutation.isLoading}
>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button
onClick={handleUpload}
disabled={uploadMutation.isLoading}
>
{uploadMutation.isLoading ? 'Uploading...' : 'Upload'}
</Button>
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
};
56 changes: 56 additions & 0 deletions apps/client/src/forms/profile-edit-form.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { TFunction } from 'i18next';
import { z } from 'zod';
import { formOptions } from '@tanstack/react-form/nextjs';

const createProfileEditFormSchema = (t: TFunction) =>
z.object({
name: z.string().min(1, t('formik.nameRequired')),
currentPassword: z.string().optional(),
newPassword: z.string().optional(),
confirmPassword: z.string().optional(),
}).refine((data) => {
// If any password field is filled, all must be filled
const hasPasswordField = data.currentPassword || data.newPassword || data.confirmPassword;
if (hasPasswordField) {
return data.currentPassword && data.newPassword && data.confirmPassword;
}
return true;
}, {
message: t('formik.passwordFieldsRequired'),
path: ['currentPassword'],
}).refine((data) => {
// If password fields are filled, new password and confirm must match
if (data.newPassword && data.confirmPassword) {
return data.newPassword === data.confirmPassword;
}
return true;
}, {
message: t('formik.passwordsDoNotMatch'),
path: ['confirmPassword'],
}).refine((data) => {
// New password must be different from current password
if (data.currentPassword && data.newPassword) {
return data.currentPassword !== data.newPassword;
}
return true;
}, {
message: t('formik.newPasswordMustBeDifferent'),
path: ['newPassword'],
});

export type ProfileEditFormValues = z.infer<ReturnType<typeof createProfileEditFormSchema>>;

export const createInitialProfileEditFormValues = (name: string = ''): ProfileEditFormValues => ({
name,
currentPassword: '',
newPassword: '',
confirmPassword: '',
});

export const profileEditFormOptions = (t: TFunction, initialValues: ProfileEditFormValues) =>
formOptions({
defaultValues: initialValues,
validators: {
onSubmit: createProfileEditFormSchema(t),
},
});
26 changes: 25 additions & 1 deletion apps/client/src/i18n/de/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
"formik.nameRequired": "Name ist erforderlich",
"formik.passwordRequired": "Passwort ist erforderlich",
"formik.passwordsMustMatch": "Beide Passwörter müssen identisch sein",
"formik.passwordsDoNotMatch": "Passwörter stimmen nicht überein",
"formik.passwordFieldsRequired": "Alle Passwort-Felder sind erforderlich",
"formik.newPasswordMustBeDifferent": "Neues Passwort muss sich vom aktuellen unterscheiden",
"formik.rememberMeRequired": "Eingeloggt bleiben ist erforderlich",
"formik.privacyPolicy": "Sie müssen die Allgemeinen Geschäftsbedingungen akzeptieren",
"general": "Allgemein",
Expand Down Expand Up @@ -71,5 +74,26 @@
"toggleNavigationMenu": "Navigationsmenü öffnen",
"toggleUserMenu": "Benutzerkontomenü öffnen",
"toLogin": "Zum Login",
"version": "Version"
"version": "Version",
"profile.picture": "Profilbild",
"profile.pictureDescription": "Aktualisiere dein Profilbild",
"profile.information": "Profil-Informationen",
"profile.informationDescription": "Aktualisiere deine persönlichen Daten und dein Passwort",
"profile.name": "Name",
"profile.namePlaceholder": "Gib deinen Namen ein",
"profile.email": "E-Mail",
"profile.changePassword": "Passwort ändern",
"profile.currentPassword": "Aktuelles Passwort",
"profile.currentPasswordPlaceholder": "Gib dein aktuelles Passwort ein",
"profile.newPassword": "Neues Passwort",
"profile.newPasswordPlaceholder": "Gib dein neues Passwort ein",
"profile.confirmPassword": "Passwort bestätigen",
"profile.confirmPasswordPlaceholder": "Bestätige dein neues Passwort",
"profile.saveChanges": "Änderungen speichern",
"profile.saving": "Speichern...",
"profile.nameUpdated": "Name erfolgreich aktualisiert",
"profile.nameUpdateError": "Fehler beim Aktualisieren des Namens",
"profile.passwordChanged": "Passwort erfolgreich geändert",
"profile.passwordChangeError": "Fehler beim Ändern des Passworts",
"profile.noChanges": "Keine Änderungen zu speichern"
}
28 changes: 26 additions & 2 deletions apps/client/src/i18n/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@
"formik.emailInvalid": "Enter a valid email",
"formik.emailRequired": "Email is required",
"formik.nameRequired": "Name is required",
"formik.passwordRequired": "PAssword is required",
"formik.passwordRequired": "Password is required",
"formik.passwordsMustMatch": "Both passwords must be identical",
"formik.passwordsDoNotMatch": "Passwords do not match",
"formik.passwordFieldsRequired": "All password fields are required",
"formik.newPasswordMustBeDifferent": "New password must be different from current password",
"formik.rememberMeRequired": "Remember me is required",
"formik.privacyPolicy": "You must accept the terms and conditions",
"general": "General",
Expand Down Expand Up @@ -71,5 +74,26 @@
"toggleNavigationMenu": "Toggle navigation menu",
"toggleUserMenu": "Toggle user menu",
"toLogin": "Go to login",
"version": "Version"
"version": "Version",
"profile.picture": "Profile Picture",
"profile.pictureDescription": "Update your profile picture",
"profile.information": "Profile Information",
"profile.informationDescription": "Update your personal information and password",
"profile.name": "Name",
"profile.namePlaceholder": "Enter your name",
"profile.email": "Email",
"profile.changePassword": "Change Password",
"profile.currentPassword": "Current Password",
"profile.currentPasswordPlaceholder": "Enter your current password",
"profile.newPassword": "New Password",
"profile.newPasswordPlaceholder": "Enter your new password",
"profile.confirmPassword": "Confirm Password",
"profile.confirmPasswordPlaceholder": "Confirm your new password",
"profile.saveChanges": "Save Changes",
"profile.saving": "Saving...",
"profile.nameUpdated": "Name updated successfully",
"profile.nameUpdateError": "Failed to update name",
"profile.passwordChanged": "Password changed successfully",
"profile.passwordChangeError": "Failed to change password",
"profile.noChanges": "No changes to save"
}
Loading