(null);
const [showBackgroundPicker, setShowBackgroundPicker] = useState(false);
+ const [backgroundUploading, setBackgroundUploading] = useState(false);
useEffect(() => {
if (
@@ -1044,6 +1045,7 @@ export default function CardModal({
return;
}
+ setBackgroundUploading(true);
try {
const { url } = await uploadBackground(file);
await saveBackground(url);
@@ -1051,6 +1053,7 @@ export default function CardModal({
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to upload image');
} finally {
+ setBackgroundUploading(false);
e.target.value = '';
}
};
@@ -1280,9 +1283,9 @@ export default function CardModal({
: 'bg-background'
}`}
style={
- isImageHeaderBackground
+ isImageHeaderBackground && background
? {
- backgroundImage: `url(${background})`,
+ backgroundImage: `url("${String(background).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
@@ -1369,6 +1372,7 @@ export default function CardModal({
removeBackground={removeBackground}
onImageUpload={handleImageUpload}
onClose={() => setShowBackgroundPicker(false)}
+ uploadingImage={backgroundUploading}
/>
diff --git a/frontend/components/CardModal/CardModalBackgroundPicker.tsx b/frontend/components/CardModal/CardModalBackgroundPicker.tsx
index 32da813..e546956 100644
--- a/frontend/components/CardModal/CardModalBackgroundPicker.tsx
+++ b/frontend/components/CardModal/CardModalBackgroundPicker.tsx
@@ -15,6 +15,8 @@ export type CardModalBackgroundPickerProps = {
removeBackground: () => Promise
;
onImageUpload: (e: React.ChangeEvent) => void;
onClose: () => void;
+ /** True while an image is being uploaded (disables file input, shows loading). */
+ uploadingImage?: boolean;
};
export function CardModalBackgroundPicker({
@@ -25,6 +27,7 @@ export function CardModalBackgroundPicker({
removeBackground,
onImageUpload,
onClose,
+ uploadingImage = false,
}: CardModalBackgroundPickerProps) {
return (
@@ -47,15 +50,16 @@ export function CardModalBackgroundPicker({
type='file'
accept='image/*'
onChange={onImageUpload}
+ disabled={uploadingImage}
className='flex-1'
id='background-image-upload-header'
/>
-
diff --git a/frontend/e2e/playwright-report/index.html b/frontend/e2e/playwright-report/index.html
deleted file mode 100644
index 815e715..0000000
--- a/frontend/e2e/playwright-report/index.html
+++ /dev/null
@@ -1,85 +0,0 @@
-
-
-
-
-
-
-
-
- Playwright Test Report
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/frontend/lib/actions/users.ts b/frontend/lib/actions/users.ts
index cbb0d88..25ee919 100644
--- a/frontend/lib/actions/users.ts
+++ b/frontend/lib/actions/users.ts
@@ -120,52 +120,67 @@ export async function updateUser(id: string, input: UpdateUserInput): Promise {
+function getUploadBaseUrl(): string {
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/graphql';
- const baseUrl = apiUrl.replace(/\/graphql\/?$/, '') || 'http://localhost:4000';
+ const base = (apiUrl || '').trim().replace(/\/graphql\/?$/i, '');
+ return base || 'http://localhost:4000';
+}
+
+async function uploadFile(
+ endpoint: 'avatar' | 'background',
+ field: 'avatar' | 'background',
+ file: File,
+): Promise<{ url: string }> {
+ const baseUrl = getUploadBaseUrl();
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
+ if (!token?.trim()) {
+ throw new Error('Vous devez être connecté pour envoyer une image.');
+ }
const formData = new FormData();
- formData.append('avatar', file);
- const res = await fetch(`${baseUrl}/api/upload/avatar`, {
- method: 'POST',
- headers: token ? { Authorization: `Bearer ${token}` } : {},
- body: formData,
- credentials: 'include',
- });
+ formData.append(field, file);
+ const uploadUrl = `${baseUrl.replace(/\/$/, '')}/api/upload/${endpoint}`;
+ let res: Response;
+ try {
+ res = await fetch(uploadUrl, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}` },
+ body: formData,
+ credentials: 'include',
+ });
+ } catch (networkError) {
+ const msg = networkError instanceof Error ? networkError.message : 'Erreur réseau';
+ throw new Error(`Impossible de contacter le serveur (${uploadUrl}): ${msg}`);
+ }
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
const msg = err?.message;
- const text = Array.isArray(msg) ? msg.join(', ') : typeof msg === 'string' ? msg : 'Upload failed';
- throw new Error(text);
+ let text = Array.isArray(msg) ? msg.join(', ') : typeof msg === 'string' ? msg : res.statusText;
+ if (res.status === 401) {
+ text = 'Session expirée ou non autorisée. Reconnectez-vous puis réessayez.';
+ } else if (res.status === 404) {
+ text = "Point d’upload introuvable. Vérifiez que NEXT_PUBLIC_API_URL pointe vers le backend.";
+ } else if (res.status === 413) {
+ text = 'Fichier trop volumineux.';
+ }
+ throw new Error(text || 'Échec de l’upload.');
}
- return res.json();
+ const data = await res.json().catch(() => null);
+ if (data && typeof data.url === 'string') return { url: data.url };
+ throw new Error('Réponse du serveur invalide (URL manquante).');
+}
+
+/**
+ * Upload avatar image. Backend saves the file and returns the new avatar URL.
+ */
+export async function uploadAvatar(file: File): Promise<{ url: string }> {
+ return uploadFile('avatar', 'avatar', file);
}
/**
* Upload background image (board or card). Backend saves to GCS or disk and returns the public URL.
*/
export async function uploadBackground(file: File): Promise<{ url: string }> {
- const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/graphql';
- const baseUrl = apiUrl.replace(/\/graphql\/?$/, '') || 'http://localhost:4000';
- const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
- const formData = new FormData();
- formData.append('background', file);
- const res = await fetch(`${baseUrl}/api/upload/background`, {
- method: 'POST',
- headers: token ? { Authorization: `Bearer ${token}` } : {},
- body: formData,
- credentials: 'include',
- });
- if (!res.ok) {
- const err = await res.json().catch(() => ({ message: res.statusText }));
- const msg = err?.message;
- const text = Array.isArray(msg) ? msg.join(', ') : typeof msg === 'string' ? msg : 'Upload failed';
- throw new Error(text);
- }
- return res.json();
+ return uploadFile('background', 'background', file);
}
/**
diff --git a/frontend/next.config.ts b/frontend/next.config.ts
index a9a85a9..44dd9b3 100644
--- a/frontend/next.config.ts
+++ b/frontend/next.config.ts
@@ -1,19 +1,27 @@
import type { NextConfig } from "next";
+// Allow images from API host when uploads are served by backend (no GCS)
+function getApiImagePatterns(): { protocol: 'http' | 'https'; hostname: string; pathname: string }[] {
+ const apiUrl = process.env.NEXT_PUBLIC_API_URL;
+ if (!apiUrl?.trim()) return [];
+ try {
+ const u = new URL(apiUrl.trim());
+ if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') return [];
+ return [
+ { protocol: u.protocol === 'https:' ? 'https' : 'http', hostname: u.hostname, pathname: '/**' },
+ ];
+ } catch {
+ return [];
+ }
+}
+
const nextConfig: NextConfig = {
output: 'standalone',
images: {
remotePatterns: [
- {
- protocol: 'https',
- hostname: 'storage.googleapis.com',
- pathname: '/**',
- },
- {
- protocol: 'http',
- hostname: 'localhost',
- pathname: '/**',
- },
+ { protocol: 'https', hostname: 'storage.googleapis.com', pathname: '/**' },
+ { protocol: 'http', hostname: 'localhost', pathname: '/**' },
+ ...getApiImagePatterns(),
],
},
};