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>
This commit is contained in:
604
frontend/src/components/ChildProfilePanel.tsx
Normal file
604
frontend/src/components/ChildProfilePanel.tsx
Normal file
@@ -0,0 +1,604 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user