413 lines
17 KiB
JavaScript
413 lines
17 KiB
JavaScript
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;
|