Louis Mazin 12d3a95811 nice
2026-09-25 19:30:07 +02:00

413 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useRef, useState } from 'react';
import LoadingCard from '@/components/LoadingCard';
import MainCard from '@/components/MainCard';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
AlertCircle,
CheckCircle2,
FileText,
FolderOpen,
KeyRound,
Loader2,
RefreshCw,
User,
} from 'lucide-react';
import { useAuth } from '../context/useAuth';
import api from '../api/axios';
const FILES_ENDPOINT = '/file/files/';
const getErrorText = (value) => {
if (typeof value === 'string') return value;
if (Array.isArray(value)) return value.map(getErrorText).filter(Boolean).join(' ');
return '';
};
const getRequestError = (error, fallback) => {
const data = error.response?.data;
return (
getErrorText(data?.current_password) ||
getErrorText(data?.new_password) ||
getErrorText(data?.confirm_password) ||
getErrorText(data?.non_field_errors) ||
getErrorText(data?.detail) ||
getErrorText(data?.error) ||
getErrorText(Array.isArray(data) ? data : null) ||
fallback
);
};
const parseFilesResponse = (data) => {
const items = Array.isArray(data) ? data : data?.results;
if (!Array.isArray(items)) {
throw new Error('Réponse de fichiers invalide.');
}
// La pagination reste sur le même endpoint Axios, même si Django
// renvoie une URL absolue dans "next".
const next = typeof data?.next === 'string' && data.next
? `${FILES_ENDPOINT}${new URL(data.next, 'https://localhost').search}`
: null;
return {
items,
next,
total: Array.isArray(data)
? data.length
: Number.isInteger(data?.count) && data.count >= 0
? data.count
: null,
};
};
const formatDate = (value) => {
if (!value) return 'Non renseignée';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return 'Non renseignée';
return date.toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
};
const ProfileField = ({ label, children }) => (
<div className="grid gap-1.5 py-3.5 sm:grid-cols-2 sm:gap-4">
<dt className="text-sm text-muted-foreground">{label}</dt>
<dd className="min-w-0 break-words text-sm font-medium sm:text-right">
{children || 'Non renseigné'}
</dd>
</div>
);
const ProfileContent = ({ user }) => {
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
const [passwordSuccess, setPasswordSuccess] = useState('');
const [savingPassword, setSavingPassword] = useState(false);
const passwordRequestPending = useRef(false);
const [filesState, setFilesState] = useState({
items: [],
next: null,
total: null,
loading: true,
loadingMore: false,
error: '',
});
const filesRequest = useRef(null);
const loadFiles = useCallback(async (nextPage = null) => {
filesRequest.current?.abort();
const controller = new AbortController();
filesRequest.current = controller;
const append = Boolean(nextPage);
setFilesState((previous) => ({
...previous,
loading: !append,
loadingMore: append,
error: '',
}));
try {
const response = await api.get(nextPage || FILES_ENDPOINT, {
signal: controller.signal,
});
const page = parseFilesResponse(response.data);
if (controller.signal.aborted) return;
setFilesState((previous) => ({
items: append
? Array.from(new Map(
[...previous.items, ...page.items].map((file) => [file.id, file]),
).values())
: page.items,
next: page.next,
total: page.total,
loading: false,
loadingMore: false,
error: '',
}));
} catch (error) {
if (controller.signal.aborted) return;
setFilesState((previous) => ({
...previous,
loading: false,
loadingMore: false,
error: getRequestError(error, 'Impossible de charger tes fichiers. Réessaie dans un instant.'),
}));
}
}, []);
useEffect(() => {
loadFiles();
return () => filesRequest.current?.abort();
}, [loadFiles]);
const resetPasswordForm = () => {
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setPasswordError('');
};
const handlePasswordDialogChange = (open) => {
if (passwordRequestPending.current) return;
resetPasswordForm();
if (open) setPasswordSuccess('');
setPasswordDialogOpen(open);
};
const handleChangePassword = async (event) => {
event.preventDefault();
if (passwordRequestPending.current) return;
setPasswordError('');
setPasswordSuccess('');
if (!currentPassword || !newPassword || !confirmPassword) {
setPasswordError('Remplis tous les champs.');
return;
}
if (newPassword !== confirmPassword) {
setPasswordError('Les nouveaux mots de passe ne correspondent pas.');
return;
}
passwordRequestPending.current = true;
setSavingPassword(true);
try {
await api.post('/users/change-password/', {
current_password: currentPassword,
new_password: newPassword,
confirm_password: confirmPassword,
});
resetPasswordForm();
setPasswordDialogOpen(false);
setPasswordSuccess('Ton mot de passe a bien été modifié.');
} catch (error) {
setPasswordError(getRequestError(error, 'Impossible de modifier le mot de passe.'));
} finally {
passwordRequestPending.current = false;
setSavingPassword(false);
}
};
const fullName = [user.first_name, user.last_name].filter(Boolean).join(' ').trim();
const displayName = fullName || user.username || 'Mon profil';
const initials = [user.first_name?.trim()?.[0], user.last_name?.trim()?.[0]]
.filter(Boolean).join('').toUpperCase() || user.username?.[0]?.toUpperCase() || '?';
const filesBusy = filesState.loading || filesState.loadingMore;
return (
<MainCard title="Mon espace" icon={User}>
<div className="h-full min-h-0 w-full overflow-y-auto">
<div className="mx-auto w-full max-w-6xl space-y-6 p-4 sm:p-6">
<header className="flex items-center gap-4">
<div
aria-hidden="true"
className="flex size-14 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-xl font-semibold text-primary sm:size-16"
>
{initials}
</div>
<div className="min-w-0 space-y-2">
<p className="break-words text-xl font-semibold tracking-tight sm:text-2xl">
{displayName}
</p>
<Badge variant={user.is_admin ? 'default' : 'secondary'}>
{user.is_admin ? 'Administrateur' : 'Utilisateur'}
</Badge>
</div>
</header>
<div className="grid items-start gap-5 lg:grid-cols-2">
<div className="min-w-0 space-y-5">
<section aria-labelledby="profile-details-title" className="rounded-xl border border-border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
<h2 id="profile-details-title" className="text-lg font-semibold tracking-tight">
Informations personnelles
</h2>
<p className="mt-1 text-sm text-muted-foreground">Les informations associées à ton compte.</p>
<dl className="mt-3 divide-y divide-border">
<ProfileField label="Prénom">{user.first_name}</ProfileField>
<ProfileField label="Nom">{user.last_name}</ProfileField>
<ProfileField label="Nom d’utilisateur">{user.username}</ProfileField>
<ProfileField label="Date d’inscription">{formatDate(user.date_joined)}</ProfileField>
</dl>
</section>
<section aria-labelledby="profile-security-title" className="rounded-xl border border-border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
<div className="flex items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<KeyRound className="size-5" aria-hidden="true" />
</span>
<div className="min-w-0 space-y-1">
<h2 id="profile-security-title" className="text-lg font-semibold tracking-tight">Mot de passe</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
Modifie le mot de passe utilisé pour te connecter.
</p>
</div>
</div>
<Button type="button" variant="outline" className="mt-4 w-full sm:w-auto" onClick={() => handlePasswordDialogChange(true)}>
Modifier le mot de passe
</Button>
{passwordSuccess && (
<div role="status" className="mt-4 flex items-start gap-2 rounded-lg bg-emerald-500/10 p-3 text-sm text-emerald-700 dark:text-emerald-300">
<CheckCircle2 className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>{passwordSuccess}</p>
</div>
)}
</section>
</div>
<section aria-labelledby="profile-files-title" className="min-w-0 rounded-xl border border-border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<h2 id="profile-files-title" className="text-lg font-semibold tracking-tight">Mes fichiers</h2>
<p className="text-sm text-muted-foreground">Les documents mis à ta disposition.</p>
</div>
<Button type="button" variant="ghost" size="icon" aria-label="Actualiser les fichiers" title="Actualiser les fichiers" disabled={filesBusy} onClick={() => loadFiles()}>
<RefreshCw className={`size-4 ${filesState.loading ? 'animate-spin motion-reduce:animate-none' : ''}`} aria-hidden="true" />
</Button>
</div>
{filesState.error && (
<div role="alert" className="mt-4 space-y-3 rounded-lg border border-destructive/20 bg-destructive/5 p-3">
<div className="flex items-start gap-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>{filesState.error}</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => loadFiles()} disabled={filesBusy}>Réessayer</Button>
</div>
)}
{filesState.loading && filesState.items.length === 0 && (
<div role="status" className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
<Loader2 className="size-5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
Chargement des fichiers…
</div>
)}
{!filesState.loading && !filesState.error && filesState.items.length === 0 && (
<div className="mt-4 flex flex-col items-center rounded-lg border border-dashed border-border px-4 py-10 text-center">
<FolderOpen className="mb-3 size-8 text-muted-foreground" aria-hidden="true" />
<p className="text-sm font-medium">Aucun fichier pour le moment</p>
<p className="mt-1 max-w-xs text-sm leading-relaxed text-muted-foreground">
Les documents ajoutés à ton espace apparaîtront ici.
</p>
</div>
)}
{filesState.items.length > 0 && (
<>
<ul className="mt-4 divide-y divide-border" aria-busy={filesBusy}>
{filesState.items.map((file) => (
<li key={file.id} className="flex items-start gap-3 py-3.5">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<FileText className="size-5" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1 space-y-1.5">
<p className="break-words text-sm font-medium leading-relaxed">{file.name || 'Fichier sans nom'}</p>
<Badge variant="secondary" className="h-auto max-w-full whitespace-normal break-words text-left text-xs">
{typeof file.type === 'string' && file.type.trim() ? file.type : 'Sans catégorie'}
</Badge>
</div>
</li>
))}
</ul>
<div className="mt-3 space-y-3 border-t border-border pt-4">
<p className="text-xs text-muted-foreground" role="status">
{filesState.items.length} fichier{filesState.items.length > 1 ? 's' : ''} affiché{filesState.items.length > 1 ? 's' : ''}
{filesState.total !== null && filesState.total > filesState.items.length ? ` sur ${filesState.total}` : ''}
</p>
{filesState.next && (
<Button type="button" variant="outline" className="w-full" disabled={filesBusy} onClick={() => loadFiles(filesState.next)}>
{filesState.loadingMore && <Loader2 className="size-4 animate-spin motion-reduce:animate-none" aria-hidden="true" />}
{filesState.loadingMore ? 'Chargement…' : 'Afficher plus'}
</Button>
)}
</div>
</>
)}
</section>
</div>
</div>
</div>
<Dialog open={passwordDialogOpen} onOpenChange={handlePasswordDialogChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Modifier le mot de passe</DialogTitle>
<DialogDescription>Saisis ton mot de passe actuel, puis le nouveau deux fois.</DialogDescription>
</DialogHeader>
<form onSubmit={handleChangePassword} className="space-y-4" aria-busy={savingPassword}>
<div className="space-y-2">
<Label htmlFor="current-password">Mot de passe actuel</Label>
<Input id="current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} disabled={savingPassword} required />
</div>
<div className="space-y-2">
<Label htmlFor="new-password">Nouveau mot de passe</Label>
<Input id="new-password" type="password" autoComplete="new-password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} disabled={savingPassword} required />
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirmer le nouveau mot de passe</Label>
<Input id="confirm-password" type="password" autoComplete="new-password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} disabled={savingPassword} required />
</div>
{passwordError && <p role="alert" className="text-sm text-destructive">{passwordError}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => handlePasswordDialogChange(false)} disabled={savingPassword}>Annuler</Button>
<Button type="submit" disabled={savingPassword}>
{savingPassword && <Loader2 className="size-4 animate-spin motion-reduce:animate-none" aria-hidden="true" />}
{savingPassword ? 'Modification…' : 'Enregistrer'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</MainCard>
);
};
const MyspacePage = () => {
const { user } = useAuth();
if (!user) {
return <LoadingCard icon={User} title="Profil" message="Chargement du profil…" />;
}
// Réinitialise les formulaires et les fichiers si le compte change.
return <ProfileContent key={user.id ?? user.username} user={user} />;
};
export default MyspacePage;