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>
605 lines
16 KiB
TypeScript
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>
|
|
);
|
|
};
|