Files
FamilyPlanner/frontend/src/components/ChildProfilePanel.tsx
philippe fdd72c1135 Initial commit: Family Planner application
Complete family planning application with:
- React frontend with TypeScript
- Node.js/Express backend with TypeScript
- Python ingestion service for document processing
- Planning ingestion service with LLM integration
- Shared UI components and type definitions
- OAuth integration for calendar synchronization
- Comprehensive documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 10:43:33 +02:00

605 lines
16 KiB
TypeScript

import {
ChangeEvent,
FormEvent,
useEffect,
useMemo,
useRef,
useState
} from "react";
import styled from "styled-components";
import { ChildProfile } from "@family-planner/types";
import {
CreateChildPayload,
UpdateChildPayload,
useChildren
} from "../state/ChildrenContext";
import { uploadAvatar, listAvatars } from "../services/api-client";
type ChildProfilePanelProps = {
mode?: "create" | "edit";
child?: ChildProfile | null;
onCancel?: () => void;
};
type AvatarSelection =
| { source: "upload"; file: File; previewUrl: string; name: string }
| { source: "gallery"; url: string; name?: string };
type GalleryAvatar = {
filename: string;
url: string;
};
const Panel = styled.aside`
flex: 1;
padding: 24px;
border-radius: 18px;
background: rgba(29, 36, 66, 0.92);
border: 1px solid rgba(126, 136, 180, 0.22);
display: flex;
flex-direction: column;
gap: 20px;
`;
const Title = styled.h2`
margin: 0;
`;
const Description = styled.p`
margin: 0;
color: var(--text-muted);
`;
const Form = styled.form`
display: flex;
flex-direction: column;
gap: 18px;
`;
const Label = styled.label`
display: flex;
flex-direction: column;
gap: 8px;
font-weight: 600;
`;
const Row = styled.div`
display: flex;
gap: 12px;
flex-wrap: wrap;
`;
const BaseInput = styled.input`
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.28);
background: rgba(16, 22, 52, 0.9);
color: #ffffff;
`;
const TextArea = styled.textarea`
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.28);
min-height: 96px;
background: rgba(16, 22, 52, 0.9);
color: #ffffff;
`;
const SubmitButton = styled.button<{ $loading?: boolean }>`
padding: 12px 16px;
border-radius: 12px;
border: none;
background: ${({ $loading }) =>
$loading ? "rgba(85, 98, 255, 0.25)" : "linear-gradient(135deg, #5562ff, #7d6cff)"};
color: #ffffff;
font-weight: 600;
cursor: ${({ $loading }) => ($loading ? "progress" : "pointer")};
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: opacity 0.2s ease;
opacity: ${({ $loading }) => ($loading ? 0.7 : 1)};
`;
const SecondaryButton = styled.button`
padding: 12px 16px;
border-radius: 12px;
border: 1px solid rgba(126, 136, 180, 0.4);
background: rgba(16, 22, 52, 0.9);
color: #d9dcff;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-1px);
}
`;
const AvatarSection = styled.section`
display: flex;
flex-direction: column;
gap: 16px;
`;
const AvatarHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
`;
const AvatarPreview = styled.div`
display: flex;
align-items: center;
gap: 14px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(12, 18, 42, 0.8);
border: 1px solid rgba(126, 136, 180, 0.24);
`;
const AvatarImage = styled.img`
width: 64px;
height: 64px;
border-radius: 18px;
object-fit: cover;
border: 2px solid rgba(126, 136, 180, 0.35);
`;
const AvatarFallback = styled.div<{ $color: string }>`
width: 64px;
height: 64px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.4rem;
background: ${({ $color }) => $color};
color: #040411;
`;
const AvatarInfo = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
color: var(--text-muted);
`;
const AvatarPicker = styled.div`
border-radius: 16px;
background: rgba(10, 14, 34, 0.6);
border: 1px solid rgba(148, 156, 210, 0.24);
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
`;
const GalleryGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 12px;
`;
const GalleryItem = styled.button<{ $selected?: boolean }>`
border: ${({ $selected }) => ($selected ? "2px solid #7d6cff" : "1px solid rgba(126,136,180,0.3)")};
border-radius: 14px;
background: rgba(16, 22, 52, 0.85);
padding: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, border 0.2s ease;
&:hover {
transform: translateY(-2px);
}
`;
const GalleryThumbnail = styled.img`
width: 100%;
height: 100%;
border-radius: 10px;
object-fit: cover;
`;
const Helper = styled.span`
font-size: 0.85rem;
color: var(--text-muted);
`;
const ErrorText = styled.span`
font-size: 0.85rem;
color: #ff7b8a;
`;
const StatusMessage = styled.div`
padding: 12px;
border-radius: 12px;
background: rgba(12, 18, 42, 0.7);
border: 1px solid rgba(126, 136, 180, 0.24);
color: var(--text-muted);
font-size: 0.9rem;
`;
const DEFAULT_COLOR = "#5562ff";
export const ChildProfilePanel = ({
mode = "create",
child,
onCancel
}: ChildProfilePanelProps) => {
const isEdit = mode === "edit" && !!child;
const { createChild, updateChild } = useChildren();
const [fullName, setFullName] = useState<string>(child?.fullName ?? "");
const [colorHex, setColorHex] = useState<string>(child?.colorHex ?? DEFAULT_COLOR);
const [email, setEmail] = useState<string>(child?.email ?? "");
const [notes, setNotes] = useState<string>(child?.notes ?? "");
const [avatarSelection, setAvatarSelection] = useState<AvatarSelection | null>(null);
const [removeExistingAvatar, setRemoveExistingAvatar] = useState<boolean>(false);
const [avatarPickerOpen, setAvatarPickerOpen] = useState<boolean>(false);
const [gallery, setGallery] = useState<GalleryAvatar[]>([]);
const [galleryLoading, setGalleryLoading] = useState<boolean>(false);
const [galleryError, setGalleryError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (avatarSelection?.source === "upload") {
return () => {
URL.revokeObjectURL(avatarSelection.previewUrl);
};
}
return undefined;
}, [avatarSelection]);
useEffect(() => {
if (!gallery.length) {
setGalleryLoading(true);
listAvatars()
.then((items) => {
setGallery(items);
setGalleryError(null);
})
.catch(() => {
setGalleryError("Impossible de charger la galerie locale.");
})
.finally(() => setGalleryLoading(false));
}
}, [gallery.length]);
useEffect(() => {
if (isEdit && child) {
setFullName(child.fullName);
setColorHex(child.colorHex);
setEmail(child.email ?? "");
setNotes(child.notes ?? "");
setAvatarSelection(null);
setRemoveExistingAvatar(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setError(null);
} else if (!isEdit) {
setFullName("");
setColorHex(DEFAULT_COLOR);
setEmail("");
setNotes("");
setAvatarSelection(null);
setRemoveExistingAvatar(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setError(null);
}
}, [isEdit, child]);
const initials = useMemo(() => {
return fullName
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0))
.join("")
.slice(0, 2)
.toUpperCase();
}, [fullName]);
const currentAvatarUrl = useMemo(() => {
if (avatarSelection) {
return avatarSelection.source === "upload" ? avatarSelection.previewUrl : avatarSelection.url;
}
if (removeExistingAvatar) {
return null;
}
return child?.avatar?.url ?? null;
}, [avatarSelection, child?.avatar, removeExistingAvatar]);
const currentAvatarLabel = useMemo(() => {
if (avatarSelection) {
return avatarSelection.source === "upload"
? avatarSelection.name
: avatarSelection.name ?? "Avatar local";
}
if (removeExistingAvatar) {
return "Aucun avatar selectionne";
}
return child?.avatar?.name ?? "Aucun avatar selectionne";
}, [avatarSelection, child?.avatar, removeExistingAvatar]);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
if (!file.type.startsWith("image/")) {
setError("Le fichier doit etre une image (png, jpg, svg...).");
return;
}
setAvatarSelection({
source: "upload",
file,
previewUrl: URL.createObjectURL(file),
name: file.name
});
setRemoveExistingAvatar(false);
setError(null);
};
const handleSelectGallery = (item: GalleryAvatar) => {
setAvatarSelection({
source: "gallery",
url: item.url,
name: item.filename
});
setRemoveExistingAvatar(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleClearAvatar = () => {
if (avatarSelection?.source === "upload") {
URL.revokeObjectURL(avatarSelection.previewUrl);
}
setAvatarSelection(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setRemoveExistingAvatar(isEdit);
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isSubmitting) {
return;
}
if (!fullName.trim()) {
setError("Merci de saisir le nom complet de l enfant.");
return;
}
const payload: CreateChildPayload | UpdateChildPayload = {
fullName: fullName.trim(),
colorHex,
email: email.trim() ? email.trim() : undefined,
notes: notes.trim() ? notes.trim() : undefined
};
setIsSubmitting(true);
setError(null);
try {
let avatarPayload: ChildProfile["avatar"] | null | undefined = undefined;
if (avatarSelection?.source === "upload") {
const uploaded = await uploadAvatar(avatarSelection.file);
avatarPayload = {
kind: "custom",
url: uploaded.url,
name: avatarSelection.name
};
URL.revokeObjectURL(avatarSelection.previewUrl);
setAvatarSelection({
source: "gallery",
url: uploaded.url,
name: avatarSelection.name
});
} else if (avatarSelection?.source === "gallery") {
avatarPayload = {
kind: "custom",
url: avatarSelection.url,
name: avatarSelection.name
};
} else if (isEdit && child?.avatar && !removeExistingAvatar) {
avatarPayload = child.avatar;
} else if (removeExistingAvatar) {
avatarPayload = null;
}
if (avatarPayload !== undefined) {
payload.avatar = avatarPayload ?? undefined;
}
if (isEdit && child) {
await updateChild(child.id, payload as UpdateChildPayload);
onCancel?.();
} else {
await createChild(payload as CreateChildPayload);
setFullName("");
setColorHex(DEFAULT_COLOR);
setEmail("");
setNotes("");
setAvatarSelection(null);
setRemoveExistingAvatar(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
} catch (err) {
setError("Impossible d enregistrer pour le moment. Merci de reessayer.");
} finally {
setIsSubmitting(false);
}
};
return (
<Panel>
<Title>{isEdit ? "Modifier l enfant" : "Ajouter un enfant"}</Title>
<Description>
{isEdit
? "Ajuste le profil, la couleur ou l avatar. Les modifications sont visibles partout."
: "Cree rapidement un nouveau profil en renseignant email et avatar pour automatiser les agendas."}
</Description>
<Form onSubmit={handleSubmit}>
<Label>
Prenom et nom
<BaseInput
type="text"
placeholder="Ex: Alice Durand"
value={fullName}
onChange={(event) => setFullName(event.target.value)}
/>
</Label>
<Row>
<Label style={{ flex: "1 1 180px" }}>
Adresse email
<BaseInput
type="email"
placeholder="prenom@exemple.com"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</Label>
<Label style={{ width: "120px" }}>
Couleur
<BaseInput
type="color"
value={colorHex}
onChange={(event) => setColorHex(event.target.value)}
/>
</Label>
</Row>
<Label>
Notes
<TextArea
placeholder="Infos importantes, allergies..."
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</Label>
<AvatarSection>
<AvatarHeader>
<strong>Avatar</strong>
<Row>
<SecondaryButton
type="button"
onClick={() => setAvatarPickerOpen((open) => !open)}
>
{avatarPickerOpen ? "Fermer" : "Choisir un avatar"}
</SecondaryButton>
{(avatarSelection || (isEdit && child?.avatar && !removeExistingAvatar)) && (
<SecondaryButton type="button" onClick={handleClearAvatar}>
Retirer l avatar
</SecondaryButton>
)}
</Row>
</AvatarHeader>
<AvatarPreview>
{currentAvatarUrl ? (
<AvatarImage src={currentAvatarUrl} alt={currentAvatarLabel ?? "Avatar"} />
) : (
<AvatarFallback $color={colorHex}>{initials || "?"}</AvatarFallback>
)}
<AvatarInfo>
<span>{currentAvatarLabel}</span>
<Helper>
Les avatars importes sont stockes dans le dossier `backend/public/avatars/`.
</Helper>
</AvatarInfo>
</AvatarPreview>
{avatarPickerOpen ? (
<AvatarPicker>
<div>
<strong>Importer un nouvel avatar</strong>
<Label>
<BaseInput
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
/>
</Label>
<Helper>
Formats acceptes: png, jpg, svg. Taille conseillee 512x512. Les images importees sont
stockees localement.
</Helper>
</div>
<div>
<strong>Galerie locale</strong>
{galleryLoading ? (
<StatusMessage>Chargement de la galerie...</StatusMessage>
) : galleryError ? (
<StatusMessage>{galleryError}</StatusMessage>
) : gallery.length === 0 ? (
<StatusMessage>
Aucune image dans `backend/public/avatars/`. Ajoute des fichiers pour les proposer ici.
</StatusMessage>
) : (
<GalleryGrid>
{gallery.map((item) => (
<GalleryItem
key={item.filename}
$selected={
avatarSelection?.source === "gallery" && avatarSelection.url === item.url
}
type="button"
onClick={() => handleSelectGallery(item)}
>
<GalleryThumbnail src={item.url} alt={item.filename} />
</GalleryItem>
))}
</GalleryGrid>
)}
</div>
</AvatarPicker>
) : null}
</AvatarSection>
{error ? <ErrorText>{error}</ErrorText> : null}
<Row>
<SubmitButton type="submit" disabled={isSubmitting} $loading={isSubmitting}>
{isSubmitting
? "Enregistrement..."
: isEdit
? "Mettre a jour le profil"
: "Enregistrer le profil"}
</SubmitButton>
{isEdit ? (
<SecondaryButton
type="button"
onClick={() => {
onCancel?.();
}}
>
Annuler
</SecondaryButton>
) : null}
</Row>
</Form>
</Panel>
);
};